diff --git a/.core_files.yaml b/.core_files.yaml index b1870654be0..5e9b1d50def 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -24,6 +24,7 @@ base_platforms: &base_platforms - homeassistant/components/datetime/** - homeassistant/components/device_tracker/** - homeassistant/components/diagnostics/** + - homeassistant/components/event/** - homeassistant/components/fan/** - homeassistant/components/geo_location/** - homeassistant/components/humidifier/** diff --git a/.coveragerc b/.coveragerc index 442432dd71c..fb1869b2489 100644 --- a/.coveragerc +++ b/.coveragerc @@ -82,6 +82,7 @@ omit = homeassistant/components/arwn/sensor.py homeassistant/components/aseko_pool_live/__init__.py homeassistant/components/aseko_pool_live/binary_sensor.py + homeassistant/components/aseko_pool_live/coordinator.py homeassistant/components/aseko_pool_live/entity.py homeassistant/components/aseko_pool_live/sensor.py homeassistant/components/asterisk_cdr/mailbox.py @@ -229,6 +230,10 @@ omit = homeassistant/components/dublin_bus_transport/sensor.py homeassistant/components/dunehd/__init__.py homeassistant/components/dunehd/media_player.py + homeassistant/components/duotecno/__init__.py + homeassistant/components/duotecno/entity.py + homeassistant/components/duotecno/switch.py + homeassistant/components/duotecno/cover.py homeassistant/components/dwd_weather_warnings/const.py homeassistant/components/dwd_weather_warnings/coordinator.py homeassistant/components/dwd_weather_warnings/sensor.py @@ -260,6 +265,11 @@ omit = homeassistant/components/eight_sleep/__init__.py homeassistant/components/eight_sleep/binary_sensor.py homeassistant/components/eight_sleep/sensor.py + homeassistant/components/electric_kiwi/__init__.py + homeassistant/components/electric_kiwi/api.py + homeassistant/components/electric_kiwi/oauth2.py + homeassistant/components/electric_kiwi/sensor.py + homeassistant/components/electric_kiwi/coordinator.py homeassistant/components/eliqonline/sensor.py homeassistant/components/elkm1/__init__.py homeassistant/components/elkm1/alarm_control_panel.py @@ -304,11 +314,8 @@ omit = homeassistant/components/escea/__init__.py homeassistant/components/escea/climate.py homeassistant/components/escea/discovery.py - homeassistant/components/esphome/__init__.py homeassistant/components/esphome/bluetooth/* - homeassistant/components/esphome/camera.py - homeassistant/components/esphome/domain_data.py - homeassistant/components/esphome/entry_data.py + homeassistant/components/esphome/manager.py homeassistant/components/etherscan/sensor.py homeassistant/components/eufy/* homeassistant/components/eufylife_ble/__init__.py @@ -316,12 +323,16 @@ omit = homeassistant/components/everlights/light.py homeassistant/components/evohome/* homeassistant/components/ezviz/__init__.py + homeassistant/components/ezviz/alarm_control_panel.py homeassistant/components/ezviz/binary_sensor.py + homeassistant/components/ezviz/button.py homeassistant/components/ezviz/camera.py + homeassistant/components/ezviz/image.py homeassistant/components/ezviz/light.py homeassistant/components/ezviz/coordinator.py homeassistant/components/ezviz/number.py homeassistant/components/ezviz/entity.py + homeassistant/components/ezviz/select.py homeassistant/components/ezviz/sensor.py homeassistant/components/ezviz/switch.py homeassistant/components/ezviz/update.py @@ -594,6 +605,7 @@ omit = homeassistant/components/keymitt_ble/entity.py homeassistant/components/keymitt_ble/switch.py homeassistant/components/keymitt_ble/coordinator.py + homeassistant/components/kitchen_sink/weather.py homeassistant/components/kiwi/lock.py homeassistant/components/kodi/__init__.py homeassistant/components/kodi/browse_media.py @@ -651,6 +663,7 @@ omit = homeassistant/components/lookin/light.py homeassistant/components/lookin/media_player.py homeassistant/components/lookin/sensor.py + homeassistant/components/loqed/sensor.py homeassistant/components/luci/device_tracker.py homeassistant/components/luftdaten/sensor.py homeassistant/components/lupusec/* @@ -698,13 +711,14 @@ omit = homeassistant/components/metoffice/sensor.py homeassistant/components/metoffice/weather.py homeassistant/components/microsoft/tts.py - homeassistant/components/miflora/sensor.py homeassistant/components/mikrotik/hub.py homeassistant/components/mill/climate.py homeassistant/components/mill/sensor.py homeassistant/components/minecraft_server/__init__.py + homeassistant/components/minecraft_server/binary_sensor.py + homeassistant/components/minecraft_server/entity.py + homeassistant/components/minecraft_server/sensor.py homeassistant/components/minio/minio_helper.py - homeassistant/components/mitemp_bt/sensor.py homeassistant/components/mjpeg/camera.py homeassistant/components/mjpeg/util.py homeassistant/components/mochad/__init__.py @@ -755,7 +769,6 @@ omit = homeassistant/components/neato/switch.py homeassistant/components/neato/vacuum.py homeassistant/components/nederlandse_spoorwegen/sensor.py - homeassistant/components/nest/legacy/* homeassistant/components/netdata/sensor.py homeassistant/components/netgear/__init__.py homeassistant/components/netgear/button.py @@ -858,6 +871,9 @@ omit = homeassistant/components/openweathermap/sensor.py homeassistant/components/openweathermap/weather_update_coordinator.py homeassistant/components/opnsense/__init__.py + homeassistant/components/opower/__init__.py + homeassistant/components/opower/coordinator.py + homeassistant/components/opower/sensor.py homeassistant/components/opnsense/device_tracker.py homeassistant/components/opple/light.py homeassistant/components/oru/* @@ -989,6 +1005,7 @@ omit = homeassistant/components/reolink/light.py homeassistant/components/reolink/number.py homeassistant/components/reolink/select.py + homeassistant/components/reolink/sensor.py homeassistant/components/reolink/siren.py homeassistant/components/reolink/switch.py homeassistant/components/reolink/update.py @@ -1314,6 +1331,7 @@ omit = homeassistant/components/tradfri/sensor.py homeassistant/components/tradfri/switch.py homeassistant/components/trafikverket_train/__init__.py + homeassistant/components/trafikverket_train/coordinator.py homeassistant/components/trafikverket_train/sensor.py homeassistant/components/trafikverket_weatherstation/__init__.py homeassistant/components/trafikverket_weatherstation/coordinator.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 042eb94b195..27e2d2e5ad0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,42 +7,46 @@ "containerEnv": { "DEVCONTAINER": "1" }, "appPort": ["8123:8123"], "runArgs": ["-e", "GIT_EDITOR=code --wait"], - "extensions": [ - "ms-python.vscode-pylance", - "visualstudioexptteam.vscodeintellicode", - "redhat.vscode-yaml", - "esbenp.prettier-vscode", - "GitHub.vscode-pull-request-github" - ], - // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json - "settings": { - "python.pythonPath": "/usr/local/bin/python", - "python.linting.enabled": true, - "python.linting.pylintEnabled": true, - "python.formatting.blackPath": "/usr/local/bin/black", - "python.linting.pycodestylePath": "/usr/local/bin/pycodestyle", - "python.linting.pydocstylePath": "/usr/local/bin/pydocstyle", - "python.linting.mypyPath": "/usr/local/bin/mypy", - "python.linting.pylintPath": "/usr/local/bin/pylint", - "python.formatting.provider": "black", - "python.testing.pytestArgs": ["--no-cov"], - "editor.formatOnPaste": false, - "editor.formatOnSave": true, - "editor.formatOnType": true, - "files.trimTrailingWhitespace": true, - "terminal.integrated.profiles.linux": { - "zsh": { - "path": "/usr/bin/zsh" + "customizations": { + "vscode": { + "extensions": [ + "ms-python.vscode-pylance", + "visualstudioexptteam.vscodeintellicode", + "redhat.vscode-yaml", + "esbenp.prettier-vscode", + "GitHub.vscode-pull-request-github" + ], + // Please keep this file in sync with settings in home-assistant/.vscode/settings.default.json + "settings": { + "python.pythonPath": "/usr/local/bin/python", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.formatting.blackPath": "/usr/local/bin/black", + "python.linting.pycodestylePath": "/usr/local/bin/pycodestyle", + "python.linting.pydocstylePath": "/usr/local/bin/pydocstyle", + "python.linting.mypyPath": "/usr/local/bin/mypy", + "python.linting.pylintPath": "/usr/local/bin/pylint", + "python.formatting.provider": "black", + "python.testing.pytestArgs": ["--no-cov"], + "editor.formatOnPaste": false, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "files.trimTrailingWhitespace": true, + "terminal.integrated.profiles.linux": { + "zsh": { + "path": "/usr/bin/zsh" + } + }, + "terminal.integrated.defaultProfile.linux": "zsh", + "yaml.customTags": [ + "!input scalar", + "!secret scalar", + "!include_dir_named scalar", + "!include_dir_list scalar", + "!include_dir_merge_list scalar", + "!include_dir_merge_named scalar" + ] } - }, - "terminal.integrated.defaultProfile.linux": "zsh", - "yaml.customTags": [ - "!input scalar", - "!secret scalar", - "!include_dir_named scalar", - "!include_dir_list scalar", - "!include_dir_merge_list scalar", - "!include_dir_merge_named scalar" - ] + } } } diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 237fc2888ab..80291c73e61 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -59,15 +59,15 @@ body: attributes: label: Integration causing the issue description: > - The name of the integration. For example: Automation, Philips Hue + The name of the integration, for example Automation or Philips Hue. - type: input id: integration_link attributes: label: Link to integration documentation on our website placeholder: "https://www.home-assistant.io/integrations/..." description: | - Providing a link [to the documentation][docs] helps us categorize the - issue, while also providing a useful reference for others. + Providing a link [to the documentation][docs] helps us categorize the issue and might speed up the + investigation by automatically informing a contributor, while also providing a useful reference for others. [docs]: https://www.home-assistant.io/integrations diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 2ee32ca9dbc..47e3e765b72 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -124,7 +124,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bcc19bfb55d..4561e8a53e1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,9 +32,9 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.7 - DEFAULT_PYTHON: "3.10" - ALL_PYTHON_VERSIONS: "['3.10', '3.11']" + HA_SHORT_VERSION: 2023.8 + DEFAULT_PYTHON: "3.11" + ALL_PYTHON_VERSIONS: "['3.11']" # 10.3 is the oldest supported version # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022) # 10.6 is the current long-term-support @@ -209,7 +209,7 @@ jobs: uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -253,7 +253,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -299,7 +299,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -348,7 +348,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -443,7 +443,7 @@ jobs: uses: actions/checkout@v3.5.3 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -492,9 +492,9 @@ jobs: python -m venv venv . venv/bin/activate python --version - pip install --cache-dir=$PIP_CACHE -U "pip>=21.3.1,<23.2" setuptools wheel - pip install --cache-dir=$PIP_CACHE -r requirements_all.txt - pip install --cache-dir=$PIP_CACHE -r requirements_test.txt + PIP_CACHE_DIR=$PIP_CACHE pip install -U "pip>=21.3.1" setuptools wheel + PIP_CACHE_DIR=$PIP_CACHE pip install -r requirements_all.txt + PIP_CACHE_DIR=$PIP_CACHE pip install -r requirements_test.txt pip install -e . --config-settings editable_mode=compat hassfest: @@ -511,7 +511,7 @@ jobs: uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -543,7 +543,7 @@ jobs: uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -576,7 +576,7 @@ jobs: uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -620,7 +620,7 @@ jobs: uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -702,7 +702,7 @@ jobs: uses: actions/checkout@v3.5.3 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -827,7 +827,7 @@ jobs: uses: actions/checkout@v3.5.3 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -934,7 +934,7 @@ jobs: uses: actions/checkout@v3.5.3 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1019,6 +1019,7 @@ jobs: with: | fail_ci_if_error: true flags: full-suite + token: ${{ env.CODECOV_TOKEN }} attempt_limit: 5 attempt_delay: 30000 - name: Upload coverage to Codecov (partial coverage) @@ -1028,5 +1029,6 @@ jobs: action: codecov/codecov-action@v3.1.3 with: | fail_ci_if_error: true + token: ${{ env.CODECOV_TOKEN }} attempt_limit: 5 attempt_delay: 30000 diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index dd1f3d061a9..1d77ac8f130 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v3.5.3 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.1 + uses: actions/setup-python@v4.7.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d6cd3f43b10..b7b351b755f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.272 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.280 hooks: - id: ruff args: - --fix - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.7.0 hooks: - id: black args: @@ -17,8 +17,8 @@ repos: hooks: - id: codespell args: - - --ignore-words-list=additionals,alle,alot,ba,bre,bund,currenty,datas,dof,dur,ether,farenheit,falsy,fo,haa,hass,hist,iam,iff,iif,incomfort,ines,ist,lightsensor,mut,nam,nd,pres,pullrequests,referer,resset,rime,ser,serie,sur,te,technik,ue,uint,unsecure,visability,wan,wanna,withing,zar - - --skip="./.*,*.csv,*.json" + - --ignore-words-list=additionals,alle,alot,bund,currenty,datas,farenheit,falsy,fo,haa,hass,iif,incomfort,ines,ist,nam,nd,pres,pullrequests,resset,rime,ser,serie,te,technik,ue,unsecure,withing,zar + - --skip="./.*,*.csv,*.json,*.ambr" - --quiet-level=2 exclude_types: [csv, json] exclude: ^tests/fixtures/|homeassistant/generated/ @@ -35,7 +35,7 @@ repos: - --branch=master - --branch=rc - repo: https://github.com/adrienverge/yamllint.git - rev: v1.28.0 + rev: v1.32.0 hooks: - id: yamllint - repo: https://github.com/pre-commit/mirrors-prettier diff --git a/.strict-typing b/.strict-typing index 67ebca7aea7..dffeb08e014 100644 --- a/.strict-typing +++ b/.strict-typing @@ -108,11 +108,13 @@ homeassistant.components.dsmr.* homeassistant.components.dunehd.* homeassistant.components.efergy.* homeassistant.components.electrasmart.* +homeassistant.components.electric_kiwi.* homeassistant.components.elgato.* homeassistant.components.elkm1.* homeassistant.components.emulated_hue.* homeassistant.components.energy.* homeassistant.components.esphome.* +homeassistant.components.event.* homeassistant.components.evil_genius_labs.* homeassistant.components.fan.* homeassistant.components.fastdotcom.* diff --git a/CODEOWNERS b/CODEOWNERS index 3f8f27187f3..10acd5dd65a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -195,8 +195,8 @@ build.json @home-assistant/supervisor /tests/components/camera/ @home-assistant/core /homeassistant/components/cast/ @emontnemery /tests/components/cast/ @emontnemery -/homeassistant/components/cert_expiry/ @Cereal2nd @jjlawren -/tests/components/cert_expiry/ @Cereal2nd @jjlawren +/homeassistant/components/cert_expiry/ @jjlawren +/tests/components/cert_expiry/ @jjlawren /homeassistant/components/circuit/ @braam /homeassistant/components/cisco_ios/ @fbradyirl /homeassistant/components/cisco_mobility_express/ @fbradyirl @@ -277,8 +277,6 @@ build.json @home-assistant/supervisor /tests/components/discord/ @tkdrob /homeassistant/components/discovergy/ @jpbede /tests/components/discovergy/ @jpbede -/homeassistant/components/discovery/ @home-assistant/core -/tests/components/discovery/ @home-assistant/core /homeassistant/components/dlink/ @tkdrob /tests/components/dlink/ @tkdrob /homeassistant/components/dlna_dmr/ @StevenLooman @chishm @@ -299,6 +297,8 @@ build.json @home-assistant/supervisor /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/dynalite/ @ziv1234 @@ -321,6 +321,8 @@ build.json @home-assistant/supervisor /tests/components/eight_sleep/ @mezz64 @raman325 /homeassistant/components/electrasmart/ @jafar-atili /tests/components/electrasmart/ @jafar-atili +/homeassistant/components/electric_kiwi/ @mikey0000 +/tests/components/electric_kiwi/ @mikey0000 /homeassistant/components/elgato/ @frenck /tests/components/elgato/ @frenck /homeassistant/components/elkm1/ @gwww @bdraco @@ -360,6 +362,8 @@ build.json @home-assistant/supervisor /tests/components/esphome/ @OttoWinter @jesserockz @bdraco /homeassistant/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99 +/homeassistant/components/event/ @home-assistant/core +/tests/components/event/ @home-assistant/core /homeassistant/components/evil_genius_labs/ @balloob /tests/components/evil_genius_labs/ @balloob /homeassistant/components/evohome/ @zxdavb @@ -425,6 +429,8 @@ build.json @home-assistant/supervisor /tests/components/fully_kiosk/ @cgarwood /homeassistant/components/garages_amsterdam/ @klaasnicolaas /tests/components/garages_amsterdam/ @klaasnicolaas +/homeassistant/components/gardena_bluetooth/ @elupus +/tests/components/gardena_bluetooth/ @elupus /homeassistant/components/gdacs/ @exxamalte /tests/components/gdacs/ @exxamalte /homeassistant/components/generic/ @davet2001 @@ -745,7 +751,6 @@ build.json @home-assistant/supervisor /tests/components/meteoclimatic/ @adrianmo /homeassistant/components/metoffice/ @MrHarcombe @avee87 /tests/components/metoffice/ @MrHarcombe @avee87 -/homeassistant/components/miflora/ @danielhiversen @basnijholt /homeassistant/components/mikrotik/ @engrbm87 /tests/components/mikrotik/ @engrbm87 /homeassistant/components/mill/ @danielhiversen @@ -889,6 +894,7 @@ build.json @home-assistant/supervisor /homeassistant/components/openhome/ @bazwilliams /tests/components/openhome/ @bazwilliams /homeassistant/components/opensky/ @joostlek +/tests/components/opensky/ @joostlek /homeassistant/components/opentherm_gw/ @mvn23 /tests/components/opentherm_gw/ @mvn23 /homeassistant/components/openuv/ @bachya @@ -897,6 +903,8 @@ build.json @home-assistant/supervisor /tests/components/openweathermap/ @fabaff @freekode @nzapponi /homeassistant/components/opnsense/ @mtreinish /tests/components/opnsense/ @mtreinish +/homeassistant/components/opower/ @tronikos +/tests/components/opower/ @tronikos /homeassistant/components/oralb/ @bdraco @Lash-L /tests/components/oralb/ @bdraco @Lash-L /homeassistant/components/oru/ @bvlaicu @@ -914,6 +922,8 @@ build.json @home-assistant/supervisor /tests/components/panel_iframe/ @home-assistant/frontend /homeassistant/components/peco/ @IceBotYT /tests/components/peco/ @IceBotYT +/homeassistant/components/pegel_online/ @mib1185 +/tests/components/pegel_online/ @mib1185 /homeassistant/components/persistent_notification/ @home-assistant/core /tests/components/persistent_notification/ @home-assistant/core /homeassistant/components/philips_js/ @elupus @@ -1000,8 +1010,8 @@ build.json @home-assistant/supervisor /tests/components/rapt_ble/ @sairon /homeassistant/components/raspberry_pi/ @home-assistant/core /tests/components/raspberry_pi/ @home-assistant/core -/homeassistant/components/rdw/ @frenck -/tests/components/rdw/ @frenck +/homeassistant/components/rdw/ @frenck @joostlek +/tests/components/rdw/ @frenck @joostlek /homeassistant/components/recollect_waste/ @bachya /tests/components/recollect_waste/ @bachya /homeassistant/components/recorder/ @home-assistant/core diff --git a/build.yaml b/build.yaml index a181e9d1548..882fa31f121 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.06.1 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.06.1 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.06.1 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.06.1 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.06.1 + 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 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/docs/screenshot-integrations.png b/docs/screenshot-integrations.png index bc304f11b16..f169e4486a6 100644 Binary files a/docs/screenshot-integrations.png and b/docs/screenshot-integrations.png differ diff --git a/homeassistant/backports/enum.py b/homeassistant/backports/enum.py index 35562877bab..871244b4567 100644 --- a/homeassistant/backports/enum.py +++ b/homeassistant/backports/enum.py @@ -1,32 +1,15 @@ -"""Enum backports from standard lib.""" +"""Enum backports from standard lib. + +This file contained the backport of the StrEnum of Python 3.11. + +Since we have dropped support for Python 3.10, we can remove this backport. +This file is kept for now to avoid breaking custom components that might +import it. +""" from __future__ import annotations -from enum import Enum -from typing import Any +from enum import StrEnum -from typing_extensions import Self - - -class StrEnum(str, Enum): - """Partial backport of Python 3.11's StrEnum for our basic use cases.""" - - def __new__(cls, value: str, *args: Any, **kwargs: Any) -> Self: - """Create a new StrEnum instance.""" - if not isinstance(value, str): - raise TypeError(f"{value!r} is not a string") - return super().__new__(cls, value, *args, **kwargs) - - def __str__(self) -> str: - """Return self.value.""" - return str(self.value) - - @staticmethod - def _generate_next_value_( - name: str, start: int, count: int, last_values: list[Any] - ) -> Any: - """Make `auto()` explicitly unsupported. - - We may revisit this when it's very clear that Python 3.11's - `StrEnum.auto()` behavior will no longer change. - """ - raise TypeError("auto() is not supported by this implementation") +__all__ = [ + "StrEnum", +] diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py index c7ab0d08693..83d66a39f71 100644 --- a/homeassistant/backports/functools.py +++ b/homeassistant/backports/functools.py @@ -3,27 +3,24 @@ from __future__ import annotations from collections.abc import Callable from types import GenericAlias -from typing import Any, Generic, TypeVar, overload - -from typing_extensions import Self +from typing import Any, Generic, Self, TypeVar, overload _T = TypeVar("_T") -_R = TypeVar("_R") -class cached_property(Generic[_T, _R]): # pylint: disable=invalid-name +class cached_property(Generic[_T]): # pylint: disable=invalid-name """Backport of Python 3.12's cached_property. Includes https://github.com/python/cpython/pull/101890/files """ - def __init__(self, func: Callable[[_T], _R]) -> None: + def __init__(self, func: Callable[[Any], _T]) -> None: """Initialize.""" - self.func = func - self.attrname: Any = None + self.func: Callable[[Any], _T] = func + self.attrname: str | None = None self.__doc__ = func.__doc__ - def __set_name__(self, owner: type[_T], name: str) -> None: + def __set_name__(self, owner: type[Any], name: str) -> None: """Set name.""" if self.attrname is None: self.attrname = name @@ -34,14 +31,16 @@ class cached_property(Generic[_T, _R]): # pylint: disable=invalid-name ) @overload - def __get__(self, instance: None, owner: type[_T]) -> Self: + def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ... @overload - def __get__(self, instance: _T, owner: type[_T]) -> _R: + def __get__(self, instance: Any, owner: type[Any] | None = None) -> _T: ... - def __get__(self, instance: _T | None, owner: type[_T] | None = None) -> _R | Self: + def __get__( + self, instance: Any | None, owner: type[Any] | None = None + ) -> _T | Self: """Get.""" if instance is None: return self diff --git a/homeassistant/brands/u_tec.json b/homeassistant/brands/u_tec.json index f0c2cf8a691..2ce4be9a7d9 100644 --- a/homeassistant/brands/u_tec.json +++ b/homeassistant/brands/u_tec.json @@ -1,5 +1,5 @@ { "domain": "u_tec", "name": "U-tec", - "integrations": ["ultraloq"] + "iot_standards": ["zwave"] } diff --git a/homeassistant/components/abode/services.yaml b/homeassistant/components/abode/services.yaml index 843cc123c69..f9d4e73a4e5 100644 --- a/homeassistant/components/abode/services.yaml +++ b/homeassistant/components/abode/services.yaml @@ -1,10 +1,6 @@ capture_image: - name: Capture image - description: Request a new image capture from a camera device. fields: entity_id: - name: Entity - description: Entity id of the camera to request an image. required: true selector: entity: @@ -12,31 +8,21 @@ capture_image: domain: camera change_setting: - name: Change setting - description: Change an Abode system setting. fields: setting: - name: Setting - description: Setting to change. required: true example: beeper_mute selector: text: value: - name: Value - description: Value of the setting. required: true example: "1" selector: text: trigger_automation: - name: Trigger automation - description: Trigger an Abode automation. fields: entity_id: - name: Entity - description: Entity id of the automation to trigger. required: true selector: entity: diff --git a/homeassistant/components/abode/strings.json b/homeassistant/components/abode/strings.json index b974007707e..4b98b69eb19 100644 --- a/homeassistant/components/abode/strings.json +++ b/homeassistant/components/abode/strings.json @@ -15,7 +15,7 @@ } }, "reauth_confirm": { - "title": "Fill in your Abode login information", + "title": "[%key:component::abode::config::step::user::title%]", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" @@ -31,5 +31,41 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "services": { + "capture_image": { + "name": "Capture image", + "description": "Request a new image capture from a camera device.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Entity id of the camera to request an image." + } + } + }, + "change_setting": { + "name": "Change setting", + "description": "Change an Abode system setting.", + "fields": { + "setting": { + "name": "Setting", + "description": "Setting to change." + }, + "value": { + "name": "Value", + "description": "Value of the setting." + } + } + }, + "trigger_automation": { + "name": "Trigger automation", + "description": "Trigger an Abode automation.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Entity id of the automation to trigger." + } + } + } } } diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 658b5d368d0..3a834261af5 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -3,7 +3,7 @@ "name": "AccuWeather", "codeowners": ["@bieniu"], "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/accuweather/", + "documentation": "https://www.home-assistant.io/integrations/accuweather", "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["accuweather"], diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 5a85b4a4c38..9eca5e772b0 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -25,7 +25,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AccuWeatherDataUpdateCoordinator @@ -50,7 +49,7 @@ PARALLEL_UPDATES = 1 class AccuWeatherSensorDescriptionMixin: """Mixin for AccuWeather sensor.""" - value_fn: Callable[[dict[str, Any]], StateType] + value_fn: Callable[[dict[str, Any]], str | int | float | None] @dataclass @@ -59,7 +58,7 @@ class AccuWeatherSensorDescription( ): """Class describing AccuWeather sensor entities.""" - attr_fn: Callable[[dict[str, Any]], dict[str, StateType]] = lambda _: {} + attr_fn: Callable[[dict[str, Any]], dict[str, Any]] = lambda _: {} FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( @@ -428,7 +427,7 @@ class AccuWeatherSensor( self.forecast_day = forecast_day @property - def native_value(self) -> StateType: + def native_value(self) -> str | int | float | None: """Return the state.""" return self.entity_description.value_fn(self._sensor_data) diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 20cb12179ee..30dae28c408 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -14,6 +14,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, + ATTR_FORECAST_UV_INDEX, ATTR_FORECAST_WIND_BEARING, Forecast, WeatherEntity, @@ -147,6 +148,11 @@ class AccuWeatherEntity( """Return the visibility.""" return cast(float, self.coordinator.data["Visibility"][API_METRIC][ATTR_VALUE]) + @property + def uv_index(self) -> float: + """Return the UV index.""" + return cast(float, self.coordinator.data["UVIndex"]) + @property def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" @@ -172,6 +178,7 @@ class AccuWeatherEntity( ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item["WindGustDay"][ATTR_SPEED][ ATTR_VALUE ], + 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 diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index 5397fed5e1d..40ff869c43b 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -import telnetlib +import telnetlib # pylint: disable=deprecated-module from typing import Final import voluptuous as vol diff --git a/homeassistant/components/adguard/services.yaml b/homeassistant/components/adguard/services.yaml index 5e4c2a157de..f38dc4ed866 100644 --- a/homeassistant/components/adguard/services.yaml +++ b/homeassistant/components/adguard/services.yaml @@ -1,65 +1,43 @@ add_url: - name: Add url - description: Add a new filter subscription to AdGuard Home. fields: name: - name: Name - description: The name of the filter subscription. required: true example: Example selector: text: url: - name: Url - description: The filter URL to subscribe to, containing the filter rules. required: true example: https://www.example.com/filter/1.txt selector: text: remove_url: - name: Remove url - description: Removes a filter subscription from AdGuard Home. fields: url: - name: Url - description: The filter subscription URL to remove. required: true example: https://www.example.com/filter/1.txt selector: text: enable_url: - name: Enable url - description: Enables a filter subscription in AdGuard Home. fields: url: - name: Url - description: The filter subscription URL to enable. required: true example: https://www.example.com/filter/1.txt selector: text: disable_url: - name: Disable url - description: Disables a filter subscription in AdGuard Home. fields: url: - name: Url - description: The filter subscription URL to disable. required: true example: https://www.example.com/filter/1.txt selector: text: refresh: - name: Refresh - description: Refresh all filter subscriptions in AdGuard Home. fields: force: - name: Force - description: Force update (bypasses AdGuard Home throttling). "true" to force, or "false" to omit for a regular refresh. default: false selector: boolean: diff --git a/homeassistant/components/adguard/strings.json b/homeassistant/components/adguard/strings.json index bde73e82b37..e34a7c88229 100644 --- a/homeassistant/components/adguard/strings.json +++ b/homeassistant/components/adguard/strings.json @@ -72,5 +72,61 @@ "name": "Query log" } } + }, + "services": { + "add_url": { + "name": "Add URL", + "description": "Add a new filter subscription to AdGuard Home.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "The name of the filter subscription." + }, + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "The filter URL to subscribe to, containing the filter rules." + } + } + }, + "remove_url": { + "name": "Remove URL", + "description": "Removes a filter subscription from AdGuard Home.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "The filter subscription URL to remove." + } + } + }, + "enable_url": { + "name": "Enable URL", + "description": "Enables a filter subscription in AdGuard Home.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "The filter subscription URL to enable." + } + } + }, + "disable_url": { + "name": "Disable URL", + "description": "Disables a filter subscription in AdGuard Home.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "The filter subscription URL to disable." + } + } + }, + "refresh": { + "name": "Refresh", + "description": "Refresh all filter subscriptions in AdGuard Home.", + "fields": { + "force": { + "name": "Force", + "description": "Force update (bypasses AdGuard Home throttling). \"true\" to force, or \"false\" to omit for a regular refresh." + } + } + } } } diff --git a/homeassistant/components/ads/services.yaml b/homeassistant/components/ads/services.yaml index 53c514bb587..e2d5c60ada2 100644 --- a/homeassistant/components/ads/services.yaml +++ b/homeassistant/components/ads/services.yaml @@ -1,19 +1,13 @@ # Describes the format for available ADS services write_data_by_name: - name: Write data by name - description: Write a value to the connected ADS device. fields: adsvar: - name: ADS variable - description: The name of the variable to write to. required: true example: ".global_var" selector: text: adstype: - name: ADS type - description: The data type of the variable to write to. required: true selector: select: @@ -25,8 +19,6 @@ write_data_by_name: - "udint" - "uint" value: - name: Value - description: The value to write to the variable. required: true selector: number: diff --git a/homeassistant/components/ads/strings.json b/homeassistant/components/ads/strings.json new file mode 100644 index 00000000000..fd34973a21d --- /dev/null +++ b/homeassistant/components/ads/strings.json @@ -0,0 +1,22 @@ +{ + "services": { + "write_data_by_name": { + "name": "Write data by name", + "description": "Write a value to the connected ADS device.", + "fields": { + "adsvar": { + "name": "ADS variable", + "description": "The name of the variable to write to." + }, + "adstype": { + "name": "ADS type", + "description": "The data type of the variable to write to." + }, + "value": { + "name": "Value", + "description": "The value to write to the variable." + } + } + } + } +} diff --git a/homeassistant/components/advantage_air/services.yaml b/homeassistant/components/advantage_air/services.yaml index 6bd3bf815d6..cb93ef568fc 100644 --- a/homeassistant/components/advantage_air/services.yaml +++ b/homeassistant/components/advantage_air/services.yaml @@ -1,14 +1,10 @@ set_time_to: - name: Set Time To - description: Control timers to turn the system on or off after a set number of minutes target: entity: integration: advantage_air domain: sensor fields: minutes: - name: Minutes - description: Minutes until action required: true selector: number: diff --git a/homeassistant/components/advantage_air/strings.json b/homeassistant/components/advantage_air/strings.json index 76ecb174f6d..de8bde6897e 100644 --- a/homeassistant/components/advantage_air/strings.json +++ b/homeassistant/components/advantage_air/strings.json @@ -13,7 +13,19 @@ "port": "[%key:common::config_flow::data::port%]" }, "description": "Connect to the API of your Advantage Air wall mounted tablet.", - "title": "Connect" + "title": "[%key:common::action::connect%]" + } + } + }, + "services": { + "set_time_to": { + "name": "Set time to", + "description": "Controls timers to turn the system on or off after a set number of minutes.", + "fields": { + "minutes": { + "name": "Minutes", + "description": "Minutes until action." + } } } } diff --git a/homeassistant/components/aftership/services.yaml b/homeassistant/components/aftership/services.yaml index 62e339dbda8..2950d5162dd 100644 --- a/homeassistant/components/aftership/services.yaml +++ b/homeassistant/components/aftership/services.yaml @@ -1,43 +1,29 @@ # Describes the format for available aftership services add_tracking: - name: Add tracking - description: Add new tracking number to Aftership. fields: tracking_number: - name: Tracking number - description: Tracking number for the new tracking required: true example: "123456789" selector: text: title: - name: Title - description: A custom title for the new tracking example: "Laptop" selector: text: slug: - name: Slug - description: Slug (carrier) of the new tracking example: "USPS" selector: text: remove_tracking: - name: Remove tracking - description: Remove a tracking number from Aftership. fields: tracking_number: - name: Tracking number - description: Tracking number of the tracking to remove required: true example: "123456789" selector: text: slug: - name: Slug - description: Slug (carrier) of the tracking to remove example: "USPS" selector: text: diff --git a/homeassistant/components/aftership/strings.json b/homeassistant/components/aftership/strings.json new file mode 100644 index 00000000000..a7ccdd48202 --- /dev/null +++ b/homeassistant/components/aftership/strings.json @@ -0,0 +1,36 @@ +{ + "services": { + "add_tracking": { + "name": "Add tracking", + "description": "Adds a new tracking number to Aftership.", + "fields": { + "tracking_number": { + "name": "Tracking number", + "description": "Tracking number for the new tracking." + }, + "title": { + "name": "Title", + "description": "A custom title for the new tracking." + }, + "slug": { + "name": "Slug", + "description": "Slug (carrier) of the new tracking." + } + } + }, + "remove_tracking": { + "name": "Remove tracking", + "description": "Removes a tracking number from Aftership.", + "fields": { + "tracking_number": { + "name": "[%key:component::aftership::services::add_tracking::fields::tracking_number::name%]", + "description": "Tracking number of the tracking to remove." + }, + "slug": { + "name": "[%key:component::aftership::services::add_tracking::fields::slug::name%]", + "description": "Slug (carrier) of the tracking to remove." + } + } + } + } +} diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py index 632b2e29d57..dc8038862c6 100644 --- a/homeassistant/components/agent_dvr/alarm_control_panel.py +++ b/homeassistant/components/agent_dvr/alarm_control_panel.py @@ -47,14 +47,16 @@ class AgentBaseStation(AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT ) + _attr_has_entity_name = True + _attr_name = None def __init__(self, client): """Initialize the alarm control panel.""" self._client = client - self._attr_name = f"{client.name} {CONST_ALARM_CONTROL_PANEL_NAME}" self._attr_unique_id = f"{client.unique}_CP" self._attr_device_info = DeviceInfo( identifiers={(AGENT_DOMAIN, client.unique)}, + name=f"{client.name} {CONST_ALARM_CONTROL_PANEL_NAME}", manufacturer="Agent", model=CONST_ALARM_CONTROL_PANEL_NAME, sw_version=client.version, diff --git a/homeassistant/components/agent_dvr/camera.py b/homeassistant/components/agent_dvr/camera.py index e485940034f..d49a1ac387e 100644 --- a/homeassistant/components/agent_dvr/camera.py +++ b/homeassistant/components/agent_dvr/camera.py @@ -72,12 +72,13 @@ class AgentCamera(MjpegCamera): _attr_attribution = ATTRIBUTION _attr_should_poll = True # Cameras default to False _attr_supported_features = CameraEntityFeature.ON_OFF + _attr_has_entity_name = True + _attr_name = None def __init__(self, device): """Initialize as a subclass of MjpegCamera.""" self.device = device self._removed = False - self._attr_name = f"{device.client.name} {device.name}" self._attr_unique_id = f"{device._client.unique}_{device.typeID}_{device.id}" super().__init__( name=device.name, @@ -88,7 +89,7 @@ class AgentCamera(MjpegCamera): identifiers={(AGENT_DOMAIN, self.unique_id)}, manufacturer="Agent", model="Camera", - name=self.name, + name=f"{device.client.name} {device.name}", sw_version=device.client.version, ) diff --git a/homeassistant/components/agent_dvr/manifest.json b/homeassistant/components/agent_dvr/manifest.json index 0c9c829631a..9a6c528c336 100644 --- a/homeassistant/components/agent_dvr/manifest.json +++ b/homeassistant/components/agent_dvr/manifest.json @@ -3,7 +3,7 @@ "name": "Agent DVR", "codeowners": ["@ispysoftware"], "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/agent_dvr/", + "documentation": "https://www.home-assistant.io/integrations/agent_dvr", "iot_class": "local_polling", "loggers": ["agent"], "requirements": ["agent-py==0.0.23"] diff --git a/homeassistant/components/agent_dvr/services.yaml b/homeassistant/components/agent_dvr/services.yaml index 206b32cb526..6256cfaac1e 100644 --- a/homeassistant/components/agent_dvr/services.yaml +++ b/homeassistant/components/agent_dvr/services.yaml @@ -1,38 +1,28 @@ start_recording: - name: Start recording - description: Enable continuous recording. target: entity: integration: agent_dvr domain: camera stop_recording: - name: Stop recording - description: Disable continuous recording. target: entity: integration: agent_dvr domain: camera enable_alerts: - name: Enable alerts - description: Enable alerts target: entity: integration: agent_dvr domain: camera disable_alerts: - name: Disable alerts - description: Disable alerts target: entity: integration: agent_dvr domain: camera snapshot: - name: Snapshot - description: Take a photo target: entity: integration: agent_dvr diff --git a/homeassistant/components/agent_dvr/strings.json b/homeassistant/components/agent_dvr/strings.json index 127fbb69b33..77167b8294b 100644 --- a/homeassistant/components/agent_dvr/strings.json +++ b/homeassistant/components/agent_dvr/strings.json @@ -16,5 +16,27 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "services": { + "start_recording": { + "name": "Start recording", + "description": "Enables continuous recording." + }, + "stop_recording": { + "name": "Stop recording", + "description": "Disables continuous recording." + }, + "enable_alerts": { + "name": "Enable alerts", + "description": "Enables alerts." + }, + "disable_alerts": { + "name": "Disable alerts", + "description": "Disables alerts." + }, + "snapshot": { + "name": "Snapshot", + "description": "Takes a photo." + } } } diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index 85b8b22043a..f52bdca4b86 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -90,7 +90,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: str(longitude), ), ): - device_entry = device_registry.async_get_device({old_ids}) # type: ignore[arg-type] + device_entry = device_registry.async_get_device(identifiers={old_ids}) # type: ignore[arg-type] if device_entry and entry.entry_id in device_entry.config_entries: new_ids = (DOMAIN, f"{latitude}-{longitude}") device_registry.async_update_device( diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 7ec58ccd8e5..33ee8bbe4c9 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -17,7 +17,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", - "wrong_location": "No Airly measuring stations in this area." + "wrong_location": "[%key:component::airly::config::error::wrong_location%]" } }, "system_health": { diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index 39dbef48647..67bce66e167 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -2,7 +2,7 @@ import logging from pyairnow import WebServiceAPI -from pyairnow.errors import AirNowError, InvalidKeyError +from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError import voluptuous as vol from homeassistant import config_entries, core, exceptions @@ -35,6 +35,8 @@ async def validate_input(hass: core.HomeAssistant, data): raise InvalidAuth from exc except AirNowError as exc: raise CannotConnect from exc + except EmptyResponseError as exc: + raise InvalidLocation from exc if not test_data: raise InvalidLocation diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index aed12596176..072f0988c19 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -14,7 +14,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_location": "No results found for that location", + "invalid_location": "No results found for that location, try changing the location or station radius.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index e06324f93ec..8c78bbfb58d 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -3,7 +3,20 @@ "name": "Airthings BLE", "bluetooth": [ { - "manufacturer_id": 820 + "manufacturer_id": 820, + "service_uuid": "b42e1f6e-ade7-11e4-89d3-123b93f75cba" + }, + { + "manufacturer_id": 820, + "service_uuid": "b42e4a8e-ade7-11e4-89d3-123b93f75cba" + }, + { + "manufacturer_id": 820, + "service_uuid": "b42e1c08-ade7-11e4-89d3-123b93f75cba" + }, + { + "manufacturer_id": 820, + "service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba" } ], "codeowners": ["@vincegio"], diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index e7d73ec0f1c..52e234505c1 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -84,6 +84,9 @@ async def async_setup_entry( class AirtouchAC(CoordinatorEntity, ClimateEntity): """Representation of an AirTouch 4 ac.""" + _attr_has_entity_name = True + _attr_name = None + _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE ) @@ -107,7 +110,7 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity): """Return device info for this device.""" return DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, - name=self.name, + name=f"AC {self._ac_number}", manufacturer="Airtouch", model="Airtouch 4", ) @@ -122,11 +125,6 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity): """Return the current temperature.""" return self._unit.Temperature - @property - def name(self): - """Return the name of the climate device.""" - return f"AC {self._ac_number}" - @property def fan_mode(self): """Return fan mode of the AC this group belongs to.""" @@ -200,6 +198,8 @@ class AirtouchAC(CoordinatorEntity, ClimateEntity): class AirtouchGroup(CoordinatorEntity, ClimateEntity): """Representation of an AirTouch 4 group.""" + _attr_has_entity_name = True + _attr_name = None _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_hvac_modes = AT_GROUP_MODES @@ -224,7 +224,7 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity): identifiers={(DOMAIN, self.unique_id)}, manufacturer="Airtouch", model="Airtouch 4", - name=self.name, + name=self._unit.GroupName, ) @property @@ -242,11 +242,6 @@ class AirtouchGroup(CoordinatorEntity, ClimateEntity): """Return Max Temperature for AC of this group.""" return self._airtouch.acs[self._unit.BelongsToAc].MaxSetpoint - @property - def name(self): - """Return the name of the climate device.""" - return self._unit.GroupName - @property def current_temperature(self): """Return the current temperature.""" diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json index 0ba99c0984a..397a41bf24b 100644 --- a/homeassistant/components/airvisual/strings.json +++ b/homeassistant/components/airvisual/strings.json @@ -11,7 +11,7 @@ } }, "geography_by_name": { - "title": "Configure a Geography", + "title": "[%key:component::airvisual::config::step::geography_by_coords::title%]", "description": "Use the AirVisual cloud API to monitor a city/state/country.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]", @@ -45,7 +45,7 @@ "options": { "step": { "init": { - "title": "Configure AirVisual", + "title": "[%key:component::airvisual::config::step::user::title%]", "data": { "show_on_map": "Show monitored geography on the map" } diff --git a/homeassistant/components/airvisual_pro/__init__.py b/homeassistant/components/airvisual_pro/__init__.py index b146651b6e6..5bbbb0e895d 100644 --- a/homeassistant/components/airvisual_pro/__init__.py +++ b/homeassistant/components/airvisual_pro/__init__.py @@ -60,6 +60,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Get data from the device.""" try: data = await node.async_get_latest_measurements() + data["history"] = {} + if data["settings"].get("follow_mode") == "device": + history = await node.async_get_history(include_trends=False) + data["history"] = history.get("measurements", [])[-1] except InvalidAuthenticationError as err: raise ConfigEntryAuthFailed("Invalid Samba password") from err except NodeConnectionError as err: diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py index 59de2ae630c..188647b7338 100644 --- a/homeassistant/components/airvisual_pro/sensor.py +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -30,7 +30,9 @@ from .const import DOMAIN class AirVisualProMeasurementKeyMixin: """Define an entity description mixin to include a measurement key.""" - value_fn: Callable[[dict[str, Any], dict[str, Any], dict[str, Any]], float | int] + value_fn: Callable[ + [dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]], float | int + ] @dataclass @@ -43,75 +45,81 @@ class AirVisualProMeasurementDescription( SENSOR_DESCRIPTIONS = ( AirVisualProMeasurementDescription( key="air_quality_index", - name="Air quality index", device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda settings, status, measurements: measurements[ + value_fn=lambda settings, status, measurements, history: measurements[ async_get_aqi_locale(settings) ], ), + AirVisualProMeasurementDescription( + key="outdoor_air_quality_index", + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda settings, status, measurements, history: int( + history.get( + f'Outdoor {"AQI(US)" if settings["is_aqi_usa"] else "AQI(CN)"}', -1 + ) + ), + translation_key="outdoor_air_quality_index", + ), AirVisualProMeasurementDescription( key="battery_level", - name="Battery", device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda settings, status, measurements: status["battery"], + value_fn=lambda settings, status, measurements, history: status["battery"], ), AirVisualProMeasurementDescription( key="carbon_dioxide", - name="C02", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda settings, status, measurements: measurements["co2"], + value_fn=lambda settings, status, measurements, history: measurements["co2"], ), AirVisualProMeasurementDescription( key="humidity", - name="Humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda settings, status, measurements: measurements["humidity"], + value_fn=lambda settings, status, measurements, history: measurements[ + "humidity" + ], ), AirVisualProMeasurementDescription( key="particulate_matter_0_1", - name="PM 0.1", - device_class=SensorDeviceClass.PM1, + translation_key="pm01", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda settings, status, measurements: measurements["pm0_1"], + value_fn=lambda settings, status, measurements, history: measurements["pm0_1"], ), AirVisualProMeasurementDescription( key="particulate_matter_1_0", - name="PM 1.0", - device_class=SensorDeviceClass.PM10, + device_class=SensorDeviceClass.PM1, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda settings, status, measurements: measurements["pm1_0"], + value_fn=lambda settings, status, measurements, history: measurements["pm1_0"], ), AirVisualProMeasurementDescription( key="particulate_matter_2_5", - name="PM 2.5", device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda settings, status, measurements: measurements["pm2_5"], + value_fn=lambda settings, status, measurements, history: measurements["pm2_5"], ), AirVisualProMeasurementDescription( key="temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda settings, status, measurements: measurements["temperature_C"], + value_fn=lambda settings, status, measurements, history: measurements[ + "temperature_C" + ], ), AirVisualProMeasurementDescription( key="voc", - name="VOC", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda settings, status, measurements: measurements["voc"], + value_fn=lambda settings, status, measurements, history: measurements["voc"], ), ) @@ -150,4 +158,5 @@ class AirVisualProSensor(AirVisualProEntity, SensorEntity): self.coordinator.data["settings"], self.coordinator.data["status"], self.coordinator.data["measurements"], + self.coordinator.data["history"], ) diff --git a/homeassistant/components/airvisual_pro/strings.json b/homeassistant/components/airvisual_pro/strings.json index f06f120885e..b5c68371fdf 100644 --- a/homeassistant/components/airvisual_pro/strings.json +++ b/homeassistant/components/airvisual_pro/strings.json @@ -24,5 +24,15 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "pm01": { + "name": "PM0.1" + }, + "outdoor_air_quality_index": { + "name": "Outdoor air quality index" + } + } } } diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index 052318b6b10..765eec2d288 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -4,7 +4,14 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any, Final -from aioairzone_cloud.const import AZD_PROBLEMS, AZD_WARNINGS, AZD_ZONES +from aioairzone_cloud.const import ( + AZD_ACTIVE, + AZD_AIDOOS, + AZD_ERRORS, + AZD_PROBLEMS, + AZD_WARNINGS, + AZD_ZONES, +) from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -18,7 +25,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import AirzoneUpdateCoordinator -from .entity import AirzoneEntity, AirzoneZoneEntity +from .entity import AirzoneAidooEntity, AirzoneEntity, AirzoneZoneEntity @dataclass @@ -28,7 +35,27 @@ class AirzoneBinarySensorEntityDescription(BinarySensorEntityDescription): attributes: dict[str, str] | None = None +AIDOO_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]] = ( + AirzoneBinarySensorEntityDescription( + device_class=BinarySensorDeviceClass.RUNNING, + key=AZD_ACTIVE, + ), + 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, + key=AZD_ACTIVE, + ), AirzoneBinarySensorEntityDescription( attributes={ "warnings": AZD_WARNINGS, @@ -48,6 +75,18 @@ async def async_setup_entry( binary_sensors: list[AirzoneBinarySensor] = [] + for aidoo_id, aidoo_data in coordinator.data.get(AZD_AIDOOS, {}).items(): + for description in AIDOO_BINARY_SENSOR_TYPES: + if description.key in aidoo_data: + binary_sensors.append( + AirzoneAidooBinarySensor( + coordinator, + description, + aidoo_id, + aidoo_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: @@ -85,6 +124,27 @@ class AirzoneBinarySensor(AirzoneEntity, BinarySensorEntity): } +class AirzoneAidooBinarySensor(AirzoneAidooEntity, AirzoneBinarySensor): + """Define an Airzone Cloud Aidoo binary sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: AirzoneBinarySensorEntityDescription, + aidoo_id: str, + aidoo_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, aidoo_id, aidoo_data) + + self._attr_unique_id = f"{aidoo_id}_{description.key}" + self.entity_description = description + + self._async_update_attrs() + + class AirzoneZoneBinarySensor(AirzoneZoneEntity, AirzoneBinarySensor): """Define an Airzone Cloud Zone binary sensor.""" diff --git a/homeassistant/components/airzone_cloud/diagnostics.py b/homeassistant/components/airzone_cloud/diagnostics.py index a86f95d6187..0bce3251d5a 100644 --- a/homeassistant/components/airzone_cloud/diagnostics.py +++ b/homeassistant/components/airzone_cloud/diagnostics.py @@ -7,6 +7,7 @@ from typing import Any from aioairzone_cloud.const import ( API_CITY, API_GROUP_ID, + API_GROUPS, API_LOCATION_ID, API_OLD_ID, API_PIN, @@ -29,7 +30,6 @@ from .coordinator import AirzoneUpdateCoordinator TO_REDACT_API = [ API_CITY, - API_GROUP_ID, API_LOCATION_ID, API_OLD_ID, API_PIN, @@ -58,11 +58,17 @@ def gather_ids(api_data: dict[str, Any]) -> dict[str, Any]: ids[dev_id] = f"device{dev_idx}" dev_idx += 1 + group_idx = 1 inst_idx = 1 - for inst_id in api_data[RAW_INSTALLATIONS]: + for inst_id, inst_data in api_data[RAW_INSTALLATIONS].items(): if inst_id not in ids: ids[inst_id] = f"installation{inst_idx}" inst_idx += 1 + for group in inst_data[API_GROUPS]: + group_id = group[API_GROUP_ID] + if group_id not in ids: + ids[group_id] = f"group{group_idx}" + group_idx += 1 ws_idx = 1 for ws_id in api_data[RAW_WEBSERVERS]: diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 8602dfa14cf..289565f0473 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.2.0"] + "requirements": ["aioairzone-cloud==0.2.1"] } diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index 90fbf849389..c33838029b4 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -141,6 +141,8 @@ class AirzoneSensor(AirzoneEntity, SensorEntity): class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): """Define an Airzone Cloud Aidoo sensor.""" + _attr_has_entity_name = True + def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -151,7 +153,6 @@ class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): """Initialize.""" super().__init__(coordinator, aidoo_id, aidoo_data) - self._attr_has_entity_name = True self._attr_unique_id = f"{aidoo_id}_{description.key}" self.entity_description = description @@ -161,6 +162,8 @@ class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): """Define an Airzone Cloud WebServer sensor.""" + _attr_has_entity_name = True + def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -171,7 +174,6 @@ class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): """Initialize.""" super().__init__(coordinator, ws_id, ws_data) - self._attr_has_entity_name = True self._attr_unique_id = f"{ws_id}_{description.key}" self.entity_description = description @@ -181,6 +183,8 @@ class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): """Define an Airzone Cloud Zone sensor.""" + _attr_has_entity_name = True + def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -191,7 +195,6 @@ class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): """Initialize.""" super().__init__(coordinator, zone_id, zone_data) - self._attr_has_entity_name = True self._attr_unique_id = f"{zone_id}_{description.key}" self.entity_description = description diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py index e6e628f834d..f14a1ce66e0 100644 --- a/homeassistant/components/alarm_control_panel/const.py +++ b/homeassistant/components/alarm_control_panel/const.py @@ -1,9 +1,7 @@ """Provides the constants needed for component.""" -from enum import IntFlag +from enum import IntFlag, StrEnum from typing import Final -from homeassistant.backports.enum import StrEnum - DOMAIN: Final = "alarm_control_panel" ATTR_CHANGED_BY: Final = "changed_by" diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index 0bf3952c4ed..f7a3854b6b3 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -1,99 +1,83 @@ # Describes the format for available alarm control panel services alarm_disarm: - name: Disarm - description: Send the alarm the command for disarm. target: entity: domain: alarm_control_panel fields: code: - name: Code - description: An optional code to disarm the alarm control panel with. example: "1234" selector: text: alarm_arm_custom_bypass: - name: Arm with custom bypass - description: Send arm custom bypass command. target: entity: domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS fields: code: - name: Code - description: An optional code to arm custom bypass the alarm control panel with. example: "1234" selector: text: alarm_arm_home: - name: Arm home - description: Send the alarm the command for arm home. target: entity: domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME fields: code: - name: Code - description: An optional code to arm home the alarm control panel with. example: "1234" selector: text: alarm_arm_away: - name: Arm away - description: Send the alarm the command for arm away. target: entity: domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY fields: code: - name: Code - description: An optional code to arm away the alarm control panel with. example: "1234" selector: text: alarm_arm_night: - name: Arm night - description: Send the alarm the command for arm night. target: entity: domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT fields: code: - name: Code - description: An optional code to arm night the alarm control panel with. example: "1234" selector: text: alarm_arm_vacation: - name: Arm vacation - description: Send the alarm the command for arm vacation. target: entity: domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION fields: code: - name: Code - description: An optional code to arm vacation the alarm control panel with. example: "1234" selector: text: alarm_trigger: - name: Trigger - description: Send the alarm the command for trigger. target: entity: domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.TRIGGER fields: code: - name: Code - description: An optional code to trigger the alarm control panel with. example: "1234" selector: text: diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 4025bbd4cc4..deaab6d75ee 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -63,10 +63,76 @@ } } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "alarm_disarm": { + "name": "Disarm", + "description": "Disarms the alarm.", + "fields": { + "code": { + "name": "Code", + "description": "Code to disarm the alarm." + } + } + }, + "alarm_arm_custom_bypass": { + "name": "Arm with custom bypass", + "description": "Arms the alarm while allowing to bypass a custom area.", + "fields": { + "code": { + "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", + "description": "Code to arm the alarm." + } + } + }, + "alarm_arm_home": { + "name": "Arm home", + "description": "Sets the alarm to: _armed, but someone is home_.", + "fields": { + "code": { + "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", + "description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]" + } + } + }, + "alarm_arm_away": { + "name": "Arm away", + "description": "Sets the alarm to: _armed, no one home_.", + "fields": { + "code": { + "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", + "description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]" + } + } + }, + "alarm_arm_night": { + "name": "Arm night", + "description": "Sets the alarm to: _armed for the night_.", + "fields": { + "code": { + "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", + "description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]" + } + } + }, + "alarm_arm_vacation": { + "name": "Arm vacation", + "description": "Sets the alarm to: _armed for vacation_.", + "fields": { + "code": { + "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", + "description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]" + } + } + }, + "alarm_trigger": { + "name": "Trigger", + "description": "Enables an external alarm trigger.", + "fields": { + "code": { + "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", + "description": "[%key:component::alarm_control_panel::services::alarm_arm_custom_bypass::fields::code::description%]" + } + } } } } diff --git a/homeassistant/components/alarmdecoder/services.yaml b/homeassistant/components/alarmdecoder/services.yaml index 9d50eae07e6..91a6000e683 100644 --- a/homeassistant/components/alarmdecoder/services.yaml +++ b/homeassistant/components/alarmdecoder/services.yaml @@ -1,30 +1,22 @@ alarm_keypress: - name: Key press - description: Send custom keypresses to the alarm. target: entity: integration: alarmdecoder domain: alarm_control_panel fields: keypress: - name: Key press - description: "String to send to the alarm panel." required: true example: "*71" selector: text: alarm_toggle_chime: - name: Toggle Chime - description: Send the alarm the toggle chime command. target: entity: integration: alarmdecoder domain: alarm_control_panel fields: code: - name: Code - description: A code to toggle the alarm control panel chime with. required: true example: 1234 selector: diff --git a/homeassistant/components/alarmdecoder/strings.json b/homeassistant/components/alarmdecoder/strings.json index 33b33749048..d7ac882bb82 100644 --- a/homeassistant/components/alarmdecoder/strings.json +++ b/homeassistant/components/alarmdecoder/strings.json @@ -20,7 +20,9 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, - "create_entry": { "default": "Successfully connected to AlarmDecoder." }, + "create_entry": { + "default": "Successfully connected to AlarmDecoder." + }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } @@ -35,7 +37,7 @@ } }, "arm_settings": { - "title": "Configure AlarmDecoder", + "title": "[%key:component::alarmdecoder::options::step::init::title%]", "data": { "auto_bypass": "Auto Bypass on Arm", "code_arm_required": "Code Required for Arming", @@ -43,14 +45,14 @@ } }, "zone_select": { - "title": "Configure AlarmDecoder", + "title": "[%key:component::alarmdecoder::options::step::init::title%]", "description": "Enter the zone number you'd like to to add, edit, or remove.", "data": { "zone_number": "Zone Number" } }, "zone_details": { - "title": "Configure AlarmDecoder", + "title": "[%key:component::alarmdecoder::options::step::init::title%]", "description": "Enter details for zone {zone_number}. To delete zone {zone_number}, leave Zone Name blank.", "data": { "zone_name": "Zone Name", @@ -68,5 +70,27 @@ "loop_rfid": "RF Loop cannot be used without RF Serial.", "loop_range": "RF Loop must be an integer between 1 and 4." } + }, + "services": { + "alarm_keypress": { + "name": "Key press", + "description": "Sends custom keypresses to the alarm.", + "fields": { + "keypress": { + "name": "[%key:component::alarmdecoder::services::alarm_keypress::name%]", + "description": "String to send to the alarm panel." + } + } + }, + "alarm_toggle_chime": { + "name": "Toggle chime", + "description": "Sends the alarm the toggle chime command.", + "fields": { + "code": { + "name": "Code", + "description": "Code to toggle the alarm control panel chime with." + } + } + } } } diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index d7d495b55bf..9b3fb0f29c8 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -25,16 +25,17 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import Event, HassJob, HomeAssistant +from homeassistant.core import HassJob, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import ( + EventStateChangedData, async_track_point_in_time, async_track_state_change_event, ) from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.util.dt import now from .const import ( @@ -196,11 +197,13 @@ class Alert(Entity): return STATE_ON return STATE_IDLE - async def watched_entity_change(self, event: Event) -> None: + async def watched_entity_change( + self, event: EventType[EventStateChangedData] + ) -> None: """Determine if the alert should start or stop.""" - if (to_state := event.data.get("new_state")) is None: + if (to_state := event.data["new_state"]) is None: return - LOGGER.debug("Watched entity (%s) has changed", event.data.get("entity_id")) + LOGGER.debug("Watched entity (%s) has changed", event.data["entity_id"]) if to_state.state == self._alert_state and not self._firing: await self.begin_alerting() if to_state.state != self._alert_state and self._firing: diff --git a/homeassistant/components/alert/services.yaml b/homeassistant/components/alert/services.yaml index 3242a9cedb4..e1d842f5bc4 100644 --- a/homeassistant/components/alert/services.yaml +++ b/homeassistant/components/alert/services.yaml @@ -1,20 +1,14 @@ toggle: - name: Toggle - description: Toggle alert's notifications. target: entity: domain: alert turn_off: - name: Turn off - description: Silence alert's notifications. target: entity: domain: alert turn_on: - name: Turn on - description: Reset alert's notifications. target: entity: domain: alert diff --git a/homeassistant/components/alert/strings.json b/homeassistant/components/alert/strings.json index 4d948b2f4d1..f8c1b2ede72 100644 --- a/homeassistant/components/alert/strings.json +++ b/homeassistant/components/alert/strings.json @@ -9,5 +9,19 @@ "on": "[%key:common::state::active%]" } } + }, + "services": { + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles alert's notifications." + }, + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Silences alert's notifications." + }, + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Resets alert's notifications." + } } } diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 6ed071b8b9e..9a805b43c4f 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -14,6 +14,7 @@ from homeassistant.components import ( camera, climate, cover, + event, fan, group, humidifier, @@ -527,6 +528,26 @@ class CoverCapabilities(AlexaEntity): yield Alexa(self.entity) +@ENTITY_ADAPTERS.register(event.DOMAIN) +class EventCapabilities(AlexaEntity): + """Class to represent doorbel event capabilities.""" + + def default_display_categories(self) -> list[str] | None: + """Return the display categories for this entity.""" + attrs = self.entity.attributes + device_class: event.EventDeviceClass | None = attrs.get(ATTR_DEVICE_CLASS) + if device_class == event.EventDeviceClass.DOORBELL: + return [DisplayCategory.DOORBELL] + return None + + def interfaces(self) -> Generator[AlexaCapability, None, None]: + """Yield the supported interfaces.""" + if self.default_display_categories() is not None: + yield AlexaDoorbellEventSource(self.entity) + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + @ENTITY_ADAPTERS.register(light.DOMAIN) class LightCapabilities(AlexaEntity): """Class to represent Light capabilities.""" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index eb23b09627e..c1b99b017e5 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -857,14 +857,55 @@ async def async_api_adjust_target_temp( temp_delta = temperature_from_object( hass, directive.payload["targetSetpointDelta"], interval=True ) - target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta - - if target_temp < min_temp or target_temp > max_temp: - raise AlexaTempRangeError(hass, target_temp, min_temp, max_temp) - - data = {ATTR_ENTITY_ID: entity.entity_id, ATTR_TEMPERATURE: target_temp} response = directive.response() + + 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: + 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) + + target_temp_low = float(current_target_temp_low) + temp_delta + if target_temp_low < min_temp or target_temp_low > max_temp: + raise AlexaTempRangeError(hass, target_temp_low, min_temp, max_temp) + + data = { + ATTR_ENTITY_ID: entity.entity_id, + climate.ATTR_TARGET_TEMP_HIGH: target_temp_high, + climate.ATTR_TARGET_TEMP_LOW: target_temp_low, + } + + response.add_context_property( + { + "name": "upperSetpoint", + "namespace": "Alexa.ThermostatController", + "value": {"value": target_temp_high, "scale": API_TEMP_UNITS[unit]}, + } + ) + response.add_context_property( + { + "name": "lowerSetpoint", + "namespace": "Alexa.ThermostatController", + "value": {"value": target_temp_low, "scale": API_TEMP_UNITS[unit]}, + } + ) + else: + target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta + + if target_temp < min_temp or target_temp > max_temp: + raise AlexaTempRangeError(hass, target_temp, min_temp, max_temp) + + data = {ATTR_ENTITY_ID: entity.entity_id, ATTR_TEMPERATURE: target_temp} + response.add_context_property( + { + "name": "targetSetpoint", + "namespace": "Alexa.ThermostatController", + "value": {"value": target_temp, "scale": API_TEMP_UNITS[unit]}, + } + ) + await hass.services.async_call( entity.domain, climate.SERVICE_SET_TEMPERATURE, @@ -872,13 +913,6 @@ async def async_api_adjust_target_temp( blocking=False, context=context, ) - response.add_context_property( - { - "name": "targetSetpoint", - "namespace": "Alexa.ThermostatController", - "value": {"value": target_temp, "scale": API_TEMP_UNITS[unit]}, - } - ) return response diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index ebab3bcee8c..04bb561560f 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, cast 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.helpers.aiohttp_client import async_get_clientsession @@ -91,8 +92,10 @@ async def async_enable_proactive_mode(hass, smart_home_config): return if should_doorbell: - if new_state.state == STATE_ON and ( - old_state is None or old_state.state != STATE_ON + if ( + new_state.domain == event.DOMAIN + or new_state.state == STATE_ON + and (old_state is None or old_state.state != STATE_ON) ): await async_send_doorbell_event_message( hass, smart_home_config, alexa_changed_entity diff --git a/homeassistant/components/amberelectric/__init__.py b/homeassistant/components/amberelectric/__init__.py index b6901e1b81b..9d9eef49b36 100644 --- a/homeassistant/components/amberelectric/__init__.py +++ b/homeassistant/components/amberelectric/__init__.py @@ -4,9 +4,10 @@ from amberelectric import Configuration from amberelectric.api import amber_api from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant -from .const import CONF_API_TOKEN, CONF_SITE_ID, DOMAIN, PLATFORMS +from .const import CONF_SITE_ID, DOMAIN, PLATFORMS from .coordinator import AmberUpdateCoordinator diff --git a/homeassistant/components/amberelectric/strings.json b/homeassistant/components/amberelectric/strings.json index 5235a8bf325..ccdc2374142 100644 --- a/homeassistant/components/amberelectric/strings.json +++ b/homeassistant/components/amberelectric/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "data": { - "api_token": "API Token", + "api_token": "[%key:common::config_flow::data::api_token%]", "site_id": "Site ID" }, "description": "Go to {api_url} to generate an API key" diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py index 516ed319d01..cf8b40916f3 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -154,17 +154,18 @@ class AmbiclimateEntity(ClimateEntity): _attr_target_temperature_step = 1 _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] + _attr_has_entity_name = True + _attr_name = None def __init__(self, heater, store): """Initialize the thermostat.""" self._heater = heater self._store = store self._attr_unique_id = heater.device_id - self._attr_name = heater.name self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, manufacturer="Ambiclimate", - name=self.name, + name=heater.name, ) async def async_set_temperature(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/ambiclimate/services.yaml b/homeassistant/components/ambiclimate/services.yaml index e5532ae82f9..bf72d18b259 100644 --- a/homeassistant/components/ambiclimate/services.yaml +++ b/homeassistant/components/ambiclimate/services.yaml @@ -1,53 +1,34 @@ # Describes the format for available services for ambiclimate set_comfort_mode: - name: Set comfort mode - description: > - Enable comfort mode on your AC. fields: name: - description: > - String with device name. required: true example: Bedroom selector: text: send_comfort_feedback: - name: Send comfort feedback - description: > - Send feedback for comfort mode. fields: name: - description: > - String with device name. required: true example: Bedroom selector: text: value: - description: > - Send any of the following comfort values: too_hot, too_warm, bit_warm, comfortable, bit_cold, too_cold, freezing required: true example: bit_warm selector: text: set_temperature_mode: - name: Set temperature mode - description: > - Enable temperature mode on your AC. fields: name: - description: > - String with device name. required: true example: Bedroom selector: text: value: - description: > - Target value in celsius required: true example: 22 selector: diff --git a/homeassistant/components/ambiclimate/strings.json b/homeassistant/components/ambiclimate/strings.json index c51c25a2f61..2b55f7bebb6 100644 --- a/homeassistant/components/ambiclimate/strings.json +++ b/homeassistant/components/ambiclimate/strings.json @@ -18,5 +18,45 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "access_token": "Unknown error generating an access token." } + }, + "services": { + "set_comfort_mode": { + "name": "Set comfort mode", + "description": "Enables comfort mode on your AC.", + "fields": { + "name": { + "name": "Device name", + "description": "String with device name." + } + } + }, + "send_comfort_feedback": { + "name": "Send comfort feedback", + "description": "Sends feedback for comfort mode.", + "fields": { + "name": { + "name": "[%key:component::ambiclimate::services::set_comfort_mode::fields::name::name%]", + "description": "[%key:component::ambiclimate::services::set_comfort_mode::fields::name::description%]" + }, + "value": { + "name": "Comfort value", + "description": "Send any of the following comfort values: too_hot, too_warm, bit_warm, comfortable, bit_cold, too_cold, freezing\n." + } + } + }, + "set_temperature_mode": { + "name": "Set temperature mode", + "description": "Enables temperature mode on your AC.", + "fields": { + "name": { + "name": "[%key:component::ambiclimate::services::set_comfort_mode::fields::name::name%]", + "description": "[%key:component::ambiclimate::services::set_comfort_mode::fields::name::description%]" + }, + "value": { + "name": "Temperature", + "description": "Target value in celsius." + } + } + } } } diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 8fea717e6bb..ce07741c37f 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -210,9 +210,10 @@ class AmcrestChecker(ApiWrapper): self, *args: Any, **kwargs: Any ) -> AsyncIterator[httpx.Response]: """amcrest.ApiWrapper.command wrapper to catch errors.""" - async with self._async_command_wrapper(): - async with super().async_stream_command(*args, **kwargs) as ret: - yield ret + async with self._async_command_wrapper(), super().async_stream_command( + *args, **kwargs + ) as ret: + yield ret @asynccontextmanager async def _async_command_wrapper(self) -> AsyncIterator[None]: diff --git a/homeassistant/components/amcrest/services.yaml b/homeassistant/components/amcrest/services.yaml index b79a333101b..cdcaf0e2c04 100644 --- a/homeassistant/components/amcrest/services.yaml +++ b/homeassistant/components/amcrest/services.yaml @@ -1,82 +1,53 @@ enable_recording: - name: Enable recording - description: Enable continuous recording to camera storage. fields: entity_id: - name: Entity - description: "Name(s) of the cameras, or 'all' for all cameras." example: "camera.house_front" selector: text: disable_recording: - name: Disable recording - description: Disable continuous recording to camera storage. fields: entity_id: - name: Entity - description: "Name(s) of the cameras, or 'all' for all cameras." example: "camera.house_front" selector: text: enable_audio: - name: Enable audio - description: Enable audio stream. fields: entity_id: - name: Entity - description: "Name(s) of the cameras, or 'all' for all cameras." example: "camera.house_front" selector: text: disable_audio: - name: Disable audio - description: Disable audio stream. fields: entity_id: - name: Entity - description: "Name(s) of the cameras, or 'all' for all cameras." example: "camera.house_front" selector: text: enable_motion_recording: - name: Enable motion recording - description: Enable recording a clip to camera storage when motion is detected. fields: entity_id: - name: Entity - description: "Name(s) of the cameras, or 'all' for all cameras." example: "camera.house_front" selector: text: disable_motion_recording: - name: Disable motion recording - description: Disable recording a clip to camera storage when motion is detected. fields: entity_id: - name: Entity - description: "Name(s) of the cameras, or 'all' for all cameras." example: "camera.house_front" selector: text: goto_preset: - name: Go to preset - description: Move camera to PTZ preset. fields: entity_id: - description: "Name(s) of the cameras, or 'all' for all cameras." selector: entity: integration: amcrest domain: camera preset: - name: Preset - description: Preset number. required: true selector: number: @@ -84,18 +55,12 @@ goto_preset: max: 1000 set_color_bw: - name: Set color - description: Set camera color mode. fields: entity_id: - name: Entity - description: "Name(s) of the cameras, or 'all' for all cameras." example: "camera.house_front" selector: text: color_bw: - name: Color - description: Color mode. selector: select: options: @@ -104,40 +69,26 @@ set_color_bw: - "color" start_tour: - name: Start tour - description: Start camera's PTZ tour function. fields: entity_id: - name: Entity - description: "Name(s) of the cameras, or 'all' for all cameras." example: "camera.house_front" selector: text: stop_tour: - name: Stop tour - description: Stop camera's PTZ tour function. fields: entity_id: - name: Entity - description: "Name(s) of the cameras, or 'all' for all cameras." example: "camera.house_front" selector: text: ptz_control: - name: PTZ control - description: Move (Pan/Tilt) and/or Zoom a PTZ camera. fields: entity_id: - name: Entity - description: "Name of the camera, or 'all' for all cameras." example: "camera.house_front" selector: text: movement: - name: Movement - description: "Direction to move the camera." required: true selector: select: @@ -153,8 +104,6 @@ ptz_control: - "zoom_in" - "zoom_out" travel_time: - name: Travel time - description: "Travel time in fractional seconds: from 0 to 1." default: .2 selector: number: diff --git a/homeassistant/components/amcrest/strings.json b/homeassistant/components/amcrest/strings.json new file mode 100644 index 00000000000..816511bf05e --- /dev/null +++ b/homeassistant/components/amcrest/strings.json @@ -0,0 +1,130 @@ +{ + "services": { + "enable_recording": { + "name": "Enable recording", + "description": "Enables continuous recording to camera storage.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name(s) of the cameras, or 'all' for all cameras." + } + } + }, + "disable_recording": { + "name": "Disable recording", + "description": "Disables continuous recording to camera storage.", + "fields": { + "entity_id": { + "name": "[%key:component::amcrest::services::enable_recording::fields::entity_id::name%]", + "description": "[%key:component::amcrest::services::enable_recording::fields::entity_id::description%]" + } + } + }, + "enable_audio": { + "name": "Enable audio", + "description": "Enables audio stream.", + "fields": { + "entity_id": { + "name": "[%key:component::amcrest::services::enable_recording::fields::entity_id::name%]", + "description": "[%key:component::amcrest::services::enable_recording::fields::entity_id::description%]" + } + } + }, + "disable_audio": { + "name": "Disable audio", + "description": "Disables audio stream.", + "fields": { + "entity_id": { + "name": "[%key:component::amcrest::services::enable_recording::fields::entity_id::name%]", + "description": "[%key:component::amcrest::services::enable_recording::fields::entity_id::description%]" + } + } + }, + "enable_motion_recording": { + "name": "Enables motion recording", + "description": "Enables recording a clip to camera storage when motion is detected.", + "fields": { + "entity_id": { + "name": "[%key:component::amcrest::services::enable_recording::fields::entity_id::name%]", + "description": "[%key:component::amcrest::services::enable_recording::fields::entity_id::description%]" + } + } + }, + "disable_motion_recording": { + "name": "Disables motion recording", + "description": "Disable recording a clip to camera storage when motion is detected.", + "fields": { + "entity_id": { + "name": "[%key:component::amcrest::services::enable_recording::fields::entity_id::name%]", + "description": "[%key:component::amcrest::services::enable_recording::fields::entity_id::description%]" + } + } + }, + "goto_preset": { + "name": "Go to preset", + "description": "Moves camera to PTZ preset.", + "fields": { + "entity_id": { + "name": "[%key:component::amcrest::services::enable_recording::fields::entity_id::name%]", + "description": "[%key:component::amcrest::services::enable_recording::fields::entity_id::description%]" + }, + "preset": { + "name": "Preset", + "description": "Preset number." + } + } + }, + "set_color_bw": { + "name": "Set color", + "description": "Sets camera color mode.", + "fields": { + "entity_id": { + "name": "[%key:component::amcrest::services::enable_recording::fields::entity_id::name%]", + "description": "[%key:component::amcrest::services::enable_recording::fields::entity_id::description%]" + }, + "color_bw": { + "name": "Color", + "description": "Color mode." + } + } + }, + "start_tour": { + "name": "Start tour", + "description": "Starts camera's PTZ tour function.", + "fields": { + "entity_id": { + "name": "[%key:component::amcrest::services::enable_recording::fields::entity_id::name%]", + "description": "[%key:component::amcrest::services::enable_recording::fields::entity_id::description%]" + } + } + }, + "stop_tour": { + "name": "Stop tour", + "description": "Stops camera's PTZ tour function.", + "fields": { + "entity_id": { + "name": "[%key:component::amcrest::services::enable_recording::fields::entity_id::name%]", + "description": "[%key:component::amcrest::services::enable_recording::fields::entity_id::description%]" + } + } + }, + "ptz_control": { + "name": "PTZ control", + "description": "Moves (pan/tilt) and/or zoom a PTZ camera.", + "fields": { + "entity_id": { + "name": "[%key:component::amcrest::services::enable_recording::fields::entity_id::name%]", + "description": "[%key:component::amcrest::services::enable_recording::fields::entity_id::description%]" + }, + "movement": { + "name": "Movement", + "description": "Direction to move the camera." + }, + "travel_time": { + "name": "Travel time", + "description": "Travel time in fractional seconds: from 0 to 1." + } + } + } + } +} diff --git a/homeassistant/components/android_ip_webcam/strings.json b/homeassistant/components/android_ip_webcam/strings.json index 6f6639cecb4..db21a690984 100644 --- a/homeassistant/components/android_ip_webcam/strings.json +++ b/homeassistant/components/android_ip_webcam/strings.json @@ -17,11 +17,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Android IP Webcam YAML configuration is being removed", - "description": "Configuring Android IP Webcam using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Android IP Webcam YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index f4fbe4a498f..4f927f242df 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -2,13 +2,15 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine -from datetime import datetime +from datetime import timedelta import functools +import hashlib import logging from typing import Any, Concatenate, ParamSpec, TypeVar from androidtv.constants import APPS, KEYS from androidtv.exceptions import LockNotAcquiredException +from androidtv.setup_async import AndroidTVAsync, FireTVAsync import voluptuous as vol from homeassistant.components import persistent_notification @@ -34,6 +36,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC 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 from . import ADB_PYTHON_EXCEPTIONS, ADB_TCP_EXCEPTIONS, get_androidtv_mac from .const import ( @@ -64,6 +67,8 @@ ATTR_DEVICE_PATH = "device_path" ATTR_HDMI_INPUT = "hdmi_input" ATTR_LOCAL_PATH = "local_path" +MIN_TIME_BETWEEN_SCREENCAPS = timedelta(seconds=60) + SERVICE_ADB_COMMAND = "adb_command" SERVICE_DOWNLOAD = "download" SERVICE_LEARN_SENDEVENT = "learn_sendevent" @@ -88,13 +93,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Android Debug Bridge entity.""" - aftv = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV] + aftv: AndroidTVAsync | FireTVAsync = hass.data[DOMAIN][entry.entry_id][ANDROID_DEV] device_class = aftv.DEVICE_CLASS device_type = ( PREFIX_ANDROIDTV if device_class == DEVICE_ANDROIDTV else PREFIX_FIRETV ) # CONF_NAME may be present in entry.data for configuration imported from YAML - device_name = entry.data.get(CONF_NAME) or f"{device_type} {entry.data[CONF_HOST]}" + device_name: str = entry.data.get( + CONF_NAME, f"{device_type} {entry.data[CONF_HOST]}" + ) device_args = [ aftv, @@ -171,8 +178,11 @@ def adb_decorator( except LockNotAcquiredException: # If the ADB lock could not be acquired, skip this command _LOGGER.info( - "ADB command not executed because the connection is currently" - " in use" + ( + "ADB command %s not executed because the connection is" + " currently in use" + ), + func.__name__, ) return None except self.exceptions as err: @@ -204,23 +214,27 @@ class ADBDevice(MediaPlayerEntity): """Representation of an Android or Fire TV device.""" _attr_device_class = MediaPlayerDeviceClass.TV + _attr_has_entity_name = True + _attr_name = None def __init__( self, - aftv, - name, - dev_type, - unique_id, - entry_id, - entry_data, - ): + aftv: AndroidTVAsync | FireTVAsync, + name: str, + dev_type: str, + unique_id: str, + entry_id: str, + entry_data: dict[str, Any], + ) -> None: """Initialize the Android / Fire TV device.""" self.aftv = aftv - self._attr_name = name self._attr_unique_id = unique_id self._entry_id = entry_id self._entry_data = entry_data + self._media_image: tuple[bytes | None, str | None] = None, None + self._attr_media_image_hash = None + info = aftv.device_properties model = info.get(ATTR_MODEL) self._attr_device_info = DeviceInfo( @@ -235,13 +249,13 @@ class ADBDevice(MediaPlayerEntity): if mac := get_androidtv_mac(info): self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_NETWORK_MAC, mac)} - self._app_id_to_name = {} - self._app_name_to_id = {} + self._app_id_to_name: dict[str, str] = {} + self._app_name_to_id: dict[str, str] = {} self._get_sources = DEFAULT_GET_SOURCES self._exclude_unnamed_apps = DEFAULT_EXCLUDE_UNNAMED_APPS self._screencap = DEFAULT_SCREENCAP - self.turn_on_command = None - self.turn_off_command = None + self.turn_on_command: str | None = None + self.turn_off_command: str | None = None # ADB exceptions to catch if not aftv.adb_server_ip: @@ -260,7 +274,7 @@ class ADBDevice(MediaPlayerEntity): # The number of consecutive failed connect attempts self._failed_connect_count = 0 - def _process_config(self): + def _process_config(self) -> None: """Load the config options.""" _LOGGER.debug("Loading configuration options") options = self._entry_data[ANDROID_DEV_OPT] @@ -297,34 +311,39 @@ class ADBDevice(MediaPlayerEntity): ) ) - @property - def media_image_hash(self) -> str | None: - """Hash value for media image.""" - return f"{datetime.now().timestamp()}" if self._screencap else None - @adb_decorator() - async def _adb_screencap(self): + async def _adb_screencap(self) -> bytes | None: """Take a screen capture from the device.""" return await self.aftv.adb_screencap() - async def async_get_media_image(self) -> tuple[bytes | None, str | None]: - """Fetch current playing image.""" + async def _async_get_screencap(self, prev_app_id: str | None = None) -> None: + """Take a screen capture from the device when enabled.""" if ( not self._screencap or self.state in {MediaPlayerState.OFF, None} or not self.available ): - return None, None + self._media_image = None, None + self._attr_media_image_hash = None + else: + force: bool = prev_app_id is not None + if force: + force = prev_app_id != self._attr_app_id + await self._adb_get_screencap(no_throttle=force) - media_data = await self._adb_screencap() - if media_data: - return media_data, "image/png" + @Throttle(MIN_TIME_BETWEEN_SCREENCAPS) + async def _adb_get_screencap(self, **kwargs) -> None: + """Take a screen capture from the device every 60 seconds.""" + if media_data := await self._adb_screencap(): + self._media_image = media_data, "image/png" + self._attr_media_image_hash = hashlib.sha256(media_data).hexdigest()[:16] + else: + self._media_image = None, None + self._attr_media_image_hash = None - # If an exception occurred and the device is no longer available, write the state - if not self.available: - self.async_write_ha_state() - - return None, None + async def async_get_media_image(self) -> tuple[bytes | None, str | None]: + """Fetch current playing image.""" + return self._media_image @adb_decorator() async def async_media_play(self) -> None: @@ -382,7 +401,7 @@ class ADBDevice(MediaPlayerEntity): await self.aftv.stop_app(self._app_name_to_id.get(source_, source_)) @adb_decorator() - async def adb_command(self, command): + async def adb_command(self, command: str) -> None: """Send an ADB command to an Android / Fire TV device.""" if key := KEYS.get(command): await self.aftv.adb_shell(f"input keyevent {key}") @@ -407,7 +426,7 @@ class ADBDevice(MediaPlayerEntity): return @adb_decorator() - async def learn_sendevent(self): + async def learn_sendevent(self) -> None: """Translate a key press on a remote to ADB 'sendevent' commands.""" output = await self.aftv.learn_sendevent() if output: @@ -426,7 +445,7 @@ class ADBDevice(MediaPlayerEntity): _LOGGER.info("%s", msg) @adb_decorator() - async def service_download(self, device_path, local_path): + async def service_download(self, device_path: str, local_path: str) -> None: """Download a file from your Android / Fire TV device to your Home Assistant instance.""" if not self.hass.config.is_allowed_path(local_path): _LOGGER.warning("'%s' is not secure to load data from!", local_path) @@ -435,7 +454,7 @@ class ADBDevice(MediaPlayerEntity): await self.aftv.adb_pull(local_path, device_path) @adb_decorator() - async def service_upload(self, device_path, local_path): + async def service_upload(self, device_path: str, local_path: str) -> None: """Upload a file from your Home Assistant instance to an Android / Fire TV device.""" if not self.hass.config.is_allowed_path(local_path): _LOGGER.warning("'%s' is not secure to load data from!", local_path) @@ -460,6 +479,7 @@ class AndroidTVDevice(ADBDevice): | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_STEP ) + aftv: AndroidTVAsync @adb_decorator(override_available=True) async def async_update(self) -> None: @@ -477,6 +497,7 @@ class AndroidTVDevice(ADBDevice): if not self.available: return + prev_app_id = self._attr_app_id # Get the updated state and attributes. ( state, @@ -492,7 +513,7 @@ class AndroidTVDevice(ADBDevice): if self._attr_state is None: self._attr_available = False - if running_apps: + if running_apps and self._attr_app_id: self._attr_source = self._attr_app_name = self._app_id_to_name.get( self._attr_app_id, self._attr_app_id ) @@ -506,6 +527,8 @@ class AndroidTVDevice(ADBDevice): else: self._attr_source_list = None + await self._async_get_screencap(prev_app_id) + @adb_decorator() async def async_media_stop(self) -> None: """Send stop command.""" @@ -549,6 +572,7 @@ class FireTVDevice(ADBDevice): | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.STOP ) + aftv: FireTVAsync @adb_decorator(override_available=True) async def async_update(self) -> None: @@ -566,6 +590,7 @@ class FireTVDevice(ADBDevice): if not self.available: return + prev_app_id = self._attr_app_id # Get the `state`, `current_app`, `running_apps` and `hdmi_input`. ( state, @@ -578,7 +603,7 @@ class FireTVDevice(ADBDevice): if self._attr_state is None: self._attr_available = False - if running_apps: + if running_apps and self._attr_app_id: self._attr_source = self._app_id_to_name.get( self._attr_app_id, self._attr_app_id ) @@ -592,6 +617,8 @@ class FireTVDevice(ADBDevice): else: self._attr_source_list = None + await self._async_get_screencap(prev_app_id) + @adb_decorator() async def async_media_stop(self) -> None: """Send stop (back) command.""" diff --git a/homeassistant/components/androidtv/services.yaml b/homeassistant/components/androidtv/services.yaml index 4482f50f3e2..41f7dbfea8f 100644 --- a/homeassistant/components/androidtv/services.yaml +++ b/homeassistant/components/androidtv/services.yaml @@ -1,67 +1,49 @@ # Describes the format for available Android and Fire TV services adb_command: - name: ADB command - description: Send an ADB command to an Android / Fire TV device. target: entity: integration: androidtv domain: media_player fields: command: - name: Command - description: Either a key command or an ADB shell command. required: true example: "HOME" selector: text: download: - name: Download - description: Download a file from your Android / Fire TV device to your Home Assistant instance. target: entity: integration: androidtv domain: media_player fields: device_path: - name: Device path - description: The filepath on the Android / Fire TV device. required: true example: "/storage/emulated/0/Download/example.txt" selector: text: local_path: - name: Local path - description: The filepath on your Home Assistant instance. required: true example: "/config/www/example.txt" selector: text: upload: - name: Upload - description: Upload a file from your Home Assistant instance to an Android / Fire TV device. target: entity: integration: androidtv domain: media_player fields: device_path: - name: Device path - description: The filepath on the Android / Fire TV device. required: true example: "/storage/emulated/0/Download/example.txt" selector: text: local_path: - name: Local path - description: The filepath on your Home Assistant instance. required: true example: "/config/www/example.txt" selector: text: learn_sendevent: - name: Learn sendevent - description: Translate a key press on a remote into ADB 'sendevent' commands. You must press one button on the remote within 8 seconds of calling this service. target: entity: integration: androidtv diff --git a/homeassistant/components/androidtv/strings.json b/homeassistant/components/androidtv/strings.json index e7d06a9f624..7949c066916 100644 --- a/homeassistant/components/androidtv/strings.json +++ b/homeassistant/components/androidtv/strings.json @@ -50,7 +50,7 @@ "title": "Configure Android state detection rules", "description": "Configure detection rule for application id {rule_id}", "data": { - "rule_id": "Application ID", + "rule_id": "[%key:component::androidtv::options::step::apps::data::app_id%]", "rule_values": "List of state detection rules (see documentation)", "rule_delete": "Check to delete this rule" } @@ -59,5 +59,49 @@ "error": { "invalid_det_rules": "Invalid state detection rules" } + }, + "services": { + "adb_command": { + "name": "ADB command", + "description": "Sends an ADB command to an Android / Fire TV device.", + "fields": { + "command": { + "name": "Command", + "description": "Either a key command or an ADB shell command." + } + } + }, + "download": { + "name": "Download", + "description": "Downloads a file from your Android / Fire TV device to your Home Assistant instance.", + "fields": { + "device_path": { + "name": "Device path", + "description": "The filepath on the Android / Fire TV device." + }, + "local_path": { + "name": "Local path", + "description": "The filepath on your Home Assistant instance." + } + } + }, + "upload": { + "name": "Upload", + "description": "Uploads a file from your Home Assistant instance to an Android / Fire TV device.", + "fields": { + "device_path": { + "name": "[%key:component::androidtv::services::download::fields::device_path::name%]", + "description": "[%key:component::androidtv::services::download::fields::device_path::description%]" + }, + "local_path": { + "name": "[%key:component::androidtv::services::download::fields::local_path::name%]", + "description": "[%key:component::androidtv::services::download::fields::local_path::description%]" + } + } + }, + "learn_sendevent": { + "name": "Learn sendevent", + "description": "Translates a key press on a remote into ADB 'sendevent' commands. You must press one button on the remote within 8 seconds of calling this service." + } } } diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index 9299b1ed0b0..4c58f82b8e7 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import DOMAIN -from .helpers import create_api +from .helpers import create_api, get_enable_ime _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,7 @@ PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Android TV Remote from a config entry.""" - api = create_api(hass, entry.data[CONF_HOST]) + api = create_api(hass, entry.data[CONF_HOST], get_enable_ime(entry)) @callback def is_available_updated(is_available: bool) -> None: @@ -76,6 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) ) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -87,3 +88,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api.disconnect() return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index f7e1078d3fa..b8399fd7ba2 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -15,11 +15,12 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac -from .const import DOMAIN -from .helpers import create_api +from .const import CONF_ENABLE_IME, DOMAIN +from .helpers import create_api, get_enable_ime STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -55,7 +56,7 @@ class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: self.host = user_input["host"] assert self.host - api = create_api(self.hass, self.host) + api = create_api(self.hass, self.host, enable_ime=False) try: self.name, self.mac = await api.async_get_name_and_mac() assert self.mac @@ -75,7 +76,7 @@ class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_start_pair(self) -> FlowResult: """Start pairing with the Android TV. Navigate to the pair flow to enter the PIN shown on screen.""" assert self.host - self.api = create_api(self.hass, self.host) + self.api = create_api(self.hass, self.host, enable_ime=False) await self.api.async_generate_cert_if_missing() await self.api.async_start_pairing() return await self.async_step_pair() @@ -186,3 +187,38 @@ class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={CONF_NAME: self.name}, errors=errors, ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """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: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_ENABLE_IME, + default=get_enable_ime(self.config_entry), + ): bool, + } + ), + ) diff --git a/homeassistant/components/androidtv_remote/const.py b/homeassistant/components/androidtv_remote/const.py index 82f494b81aa..44d7098adc1 100644 --- a/homeassistant/components/androidtv_remote/const.py +++ b/homeassistant/components/androidtv_remote/const.py @@ -4,3 +4,6 @@ from __future__ import annotations from typing import Final DOMAIN: Final = "androidtv_remote" + +CONF_ENABLE_IME: Final = "enable_ime" +CONF_ENABLE_IME_DEFAULT_VALUE: Final = True diff --git a/homeassistant/components/androidtv_remote/helpers.py b/homeassistant/components/androidtv_remote/helpers.py index 0bc1f1b904f..41b056269f2 100644 --- a/homeassistant/components/androidtv_remote/helpers.py +++ b/homeassistant/components/androidtv_remote/helpers.py @@ -3,11 +3,14 @@ from __future__ import annotations from androidtvremote2 import AndroidTVRemote +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.storage import STORAGE_DIR +from .const import CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE -def create_api(hass: HomeAssistant, host: str) -> AndroidTVRemote: + +def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRemote: """Create an AndroidTVRemote instance.""" return AndroidTVRemote( client_name="Home Assistant", @@ -15,4 +18,10 @@ def create_api(hass: HomeAssistant, host: str) -> AndroidTVRemote: keyfile=hass.config.path(STORAGE_DIR, "androidtv_remote_key.pem"), host=host, loop=hass.loop, + enable_ime=enable_ime, ) + + +def get_enable_ime(entry: ConfigEntry) -> bool: + """Get value of enable_ime option or its default value.""" + return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index c728ea0a682..cb7a969379e 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_push", "loggers": ["androidtvremote2"], "quality_scale": "platinum", - "requirements": ["androidtvremote2==0.0.9"], + "requirements": ["androidtvremote2==0.0.13"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index 983c604370b..dbbf6a2d383 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -34,5 +34,14 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "options": { + "step": { + "init": { + "data": { + "enable_ime": "Enable IME. Needed for getting the current app. Disable for devices that show 'Use keyboard on mobile device screen' instead of the on screen keyboard." + } + } + } } } diff --git a/homeassistant/components/anova/strings.json b/homeassistant/components/anova/strings.json index b14246a392d..b7762732303 100644 --- a/homeassistant/components/anova/strings.json +++ b/homeassistant/components/anova/strings.json @@ -29,7 +29,7 @@ "name": "State" }, "mode": { - "name": "Mode" + "name": "[%key:common::config_flow::data::mode%]" }, "target_temperature": { "name": "Target temperature" diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index 3fb8bf00b8a..bfe6fe6c80c 100644 --- a/homeassistant/components/apcupsd/__init__.py +++ b/homeassistant/components/apcupsd/__init__.py @@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) @@ -79,16 +80,6 @@ class APCUPSdData: return self.status[model_key] return None - @property - def sw_version(self) -> str | None: - """Return the software version of the APCUPSd, if available.""" - return self.status.get("VERSION") - - @property - def hw_version(self) -> str | None: - """Return the firmware version of the UPS, if available.""" - return self.status.get("FIRMWARE") - @property def serial_no(self) -> str | None: """Return the unique serial number of the UPS, if available.""" @@ -99,6 +90,21 @@ class APCUPSdData: """Return the STATFLAG indicating the status of the UPS, if available.""" return self.status.get("STATFLAG") + @property + def device_info(self) -> DeviceInfo | None: + """Return the DeviceInfo of this APC UPS for the sensors, if serial number is available.""" + if self.serial_no is None: + return None + + return DeviceInfo( + identifiers={(DOMAIN, self.serial_no)}, + model=self.model, + manufacturer="APC", + name=self.name if self.name is not None else "APC UPS", + hw_version=self.status.get("FIRMWARE"), + sw_version=self.status.get("VERSION"), + ) + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self, **kwargs: Any) -> None: """Fetch the latest status from APCUPSd. diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index d45ad561d8d..bac8d18d58b 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -9,7 +9,6 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, VALUE_ONLINE, APCUPSdData @@ -53,13 +52,8 @@ class OnlineStatus(BinarySensorEntity): # Set up unique id and device info if serial number is available. if (serial_no := data_service.serial_no) is not None: self._attr_unique_id = f"{serial_no}_{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, serial_no)}, - model=data_service.model, - manufacturer="APC", - hw_version=data_service.hw_version, - sw_version=data_service.sw_version, - ) + self._attr_device_info = data_service.device_info + self.entity_description = description self._data_service = data_service diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 8b7034357df..745be7e2d63 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -21,7 +21,6 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, APCUPSdData @@ -496,13 +495,7 @@ class APCUPSdSensor(SensorEntity): # Set up unique id and device info if serial number is available. if (serial_no := data_service.serial_no) is not None: self._attr_unique_id = f"{serial_no}_{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, serial_no)}, - model=data_service.model, - manufacturer="APC", - hw_version=data_service.hw_version, - sw_version=data_service.sw_version, - ) + self._attr_device_info = data_service.device_info self.entity_description = description self._data_service = data_service diff --git a/homeassistant/components/apcupsd/strings.json b/homeassistant/components/apcupsd/strings.json index aef33a6f8bf..c7ebf8a0a3b 100644 --- a/homeassistant/components/apcupsd/strings.json +++ b/homeassistant/components/apcupsd/strings.json @@ -16,11 +16,5 @@ "description": "Enter the host and port on which the apcupsd NIS is being served." } } - }, - "issues": { - "deprecated_yaml": { - "title": "The APC UPS Daemon YAML configuration is being removed", - "description": "Configuring APC UPS Daemon using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the APC UPS Daemon YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 6538bd345de..b465a6b7037 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import ( URL_API, URL_API_COMPONENTS, URL_API_CONFIG, + URL_API_CORE_STATE, URL_API_ERROR_LOG, URL_API_EVENTS, URL_API_SERVICES, @@ -55,6 +56,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the API with the HTTP interface.""" hass.http.register_view(APIStatusView) + hass.http.register_view(APICoreStateView) hass.http.register_view(APIEventStream) hass.http.register_view(APIConfigView) hass.http.register_view(APIStatesView) @@ -84,6 +86,24 @@ class APIStatusView(HomeAssistantView): return self.json_message("API running.") +class APICoreStateView(HomeAssistantView): + """View to handle core state requests.""" + + url = URL_API_CORE_STATE + name = "api:core:state" + + @ha.callback + def get(self, request: web.Request) -> web.Response: + """Retrieve the current core state. + + This API is intended to be a fast and lightweight way to check if the + Home Assistant core is running. Its primary use case is for supervisor + to check if Home Assistant is running. + """ + hass: HomeAssistant = request.app["hass"] + return self.json({"state": hass.state.value}) + + class APIEventStream(HomeAssistantView): """View to handle EventStream requests.""" diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index 440ca5e6c9f..c1d35c94b4f 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -88,14 +88,18 @@ class AppleTVEntity(Entity): """Device that sends commands to an Apple TV.""" _attr_should_poll = False + _attr_has_entity_name = True + _attr_name = None def __init__(self, name, identifier, manager): """Initialize device.""" self.atv = None self.manager = manager - self._attr_name = name self._attr_unique_id = identifier - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, identifier)}) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + name=name, + ) async def async_added_to_hass(self): """Handle when an entity is about to be added to Home Assistant.""" diff --git a/homeassistant/components/apple_tv/strings.json b/homeassistant/components/apple_tv/strings.json index e5948a54a8d..8730ffe01d5 100644 --- a/homeassistant/components/apple_tv/strings.json +++ b/homeassistant/components/apple_tv/strings.json @@ -6,7 +6,7 @@ "title": "Set up a new Apple TV", "description": "Start by entering the device name (e.g. Kitchen or Bedroom) or IP address of the Apple TV you want to add.\n\nIf you cannot see your device or experience any issues, try specifying the device IP address.", "data": { - "device_input": "Device" + "device_input": "[%key:common::config_flow::data::device%]" } }, "reconfigure": { diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 9a56f5d91eb..04dcef05202 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apprise", "iot_class": "cloud_push", "loggers": ["apprise"], - "requirements": ["apprise==1.4.0"] + "requirements": ["apprise==1.4.5"] } diff --git a/homeassistant/components/aseko_pool_live/__init__.py b/homeassistant/components/aseko_pool_live/__init__.py index 70a66251bdc..b09682fcaf9 100644 --- a/homeassistant/components/aseko_pool_live/__init__.py +++ b/homeassistant/components/aseko_pool_live/__init__.py @@ -1,19 +1,18 @@ """The Aseko Pool Live integration.""" from __future__ import annotations -from datetime import timedelta import logging -from aioaseko import APIUnavailable, MobileAccount, Unit, Variable +from aioaseko import APIUnavailable, MobileAccount from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +from .coordinator import AsekoDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -49,28 +48,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class AsekoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Variable]]): - """Class to manage fetching Aseko unit data from single endpoint.""" - - def __init__(self, hass: HomeAssistant, unit: Unit) -> None: - """Initialize global Aseko unit data updater.""" - self._unit = unit - - if self._unit.name: - name = self._unit.name - else: - name = f"{self._unit.type}-{self._unit.serial_number}" - - super().__init__( - hass, - _LOGGER, - name=name, - update_interval=timedelta(minutes=2), - ) - - async def _async_update_data(self) -> dict[str, Variable]: - """Fetch unit data.""" - await self._unit.get_state() - return {variable.type: variable for variable in self._unit.variables} diff --git a/homeassistant/components/aseko_pool_live/binary_sensor.py b/homeassistant/components/aseko_pool_live/binary_sensor.py index f67ea58bfc4..8178e243279 100644 --- a/homeassistant/components/aseko_pool_live/binary_sensor.py +++ b/homeassistant/components/aseko_pool_live/binary_sensor.py @@ -15,8 +15,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AsekoDataUpdateCoordinator from .const import DOMAIN +from .coordinator import AsekoDataUpdateCoordinator from .entity import AsekoEntity @@ -31,7 +31,7 @@ class AsekoBinarySensorDescriptionMixin: class AsekoBinarySensorEntityDescription( BinarySensorEntityDescription, AsekoBinarySensorDescriptionMixin ): - """Describes a Aseko binary sensor entity.""" + """Describes an Aseko binary sensor entity.""" UNIT_BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = ( diff --git a/homeassistant/components/aseko_pool_live/coordinator.py b/homeassistant/components/aseko_pool_live/coordinator.py new file mode 100644 index 00000000000..383ab7116b6 --- /dev/null +++ b/homeassistant/components/aseko_pool_live/coordinator.py @@ -0,0 +1,37 @@ +"""The Aseko Pool Live integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from aioaseko import Unit, Variable + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class AsekoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Variable]]): + """Class to manage fetching Aseko unit data from single endpoint.""" + + def __init__(self, hass: HomeAssistant, unit: Unit) -> None: + """Initialize global Aseko unit data updater.""" + self._unit = unit + + if self._unit.name: + name = self._unit.name + else: + name = f"{self._unit.type}-{self._unit.serial_number}" + + super().__init__( + hass, + _LOGGER, + name=name, + update_interval=timedelta(minutes=2), + ) + + async def _async_update_data(self) -> dict[str, Variable]: + """Fetch unit data.""" + await self._unit.get_state() + return {variable.type: variable for variable in self._unit.variables} diff --git a/homeassistant/components/aseko_pool_live/entity.py b/homeassistant/components/aseko_pool_live/entity.py index 58974bcc326..9cc402e014c 100644 --- a/homeassistant/components/aseko_pool_live/entity.py +++ b/homeassistant/components/aseko_pool_live/entity.py @@ -4,8 +4,8 @@ from aioaseko import Unit from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AsekoDataUpdateCoordinator from .const import DOMAIN +from .coordinator import AsekoDataUpdateCoordinator class AsekoEntity(CoordinatorEntity[AsekoDataUpdateCoordinator]): diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py index 74051ef454f..09c4af31428 100644 --- a/homeassistant/components/aseko_pool_live/sensor.py +++ b/homeassistant/components/aseko_pool_live/sensor.py @@ -12,8 +12,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AsekoDataUpdateCoordinator from .const import DOMAIN +from .coordinator import AsekoDataUpdateCoordinator from .entity import AsekoEntity @@ -36,7 +36,7 @@ async def async_setup_entry( class VariableSensorEntity(AsekoEntity, SensorEntity): """Representation of a unit variable sensor entity.""" - attr_state_class = SensorStateClass.MEASUREMENT + _attr_state_class = SensorStateClass.MEASUREMENT def __init__( self, unit: Unit, variable: Variable, coordinator: AsekoDataUpdateCoordinator diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 891fc639fee..1be9ddbb14f 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -4,12 +4,12 @@ from __future__ import annotations import asyncio from collections.abc import AsyncIterable, Callable, Iterable from dataclasses import asdict, dataclass, field +from enum import StrEnum import logging from typing import Any, cast import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.components import conversation, media_source, stt, tts, websocket_api from homeassistant.components.tts.media_source import ( generate_media_source_id as tts_generate_media_source_id, diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index a737490f22f..cb19811d650 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -2,11 +2,10 @@ from __future__ import annotations from dataclasses import dataclass, field +from enum import StrEnum import webrtcvad -from homeassistant.backports.enum import StrEnum - _SAMPLE_RATE = 16000 diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index ea3aacf43a4..4e6d44a8868 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -111,7 +111,7 @@ async def websocket_run( if start_stage == PipelineStage.STT: # Audio pipeline that will receive audio as binary websocket messages - audio_queue: "asyncio.Queue[bytes]" = asyncio.Queue() + audio_queue: asyncio.Queue[bytes] = asyncio.Queue() incoming_sample_rate = msg["input"]["sample_rate"] async def stt_stream() -> AsyncGenerator[bytes, None]: diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py new file mode 100644 index 00000000000..9e6da0ea8f7 --- /dev/null +++ b/homeassistant/components/asuswrt/bridge.py @@ -0,0 +1,273 @@ +"""aioasuswrt and pyasuswrt bridge classes.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections import namedtuple +import logging +from typing import Any, cast + +from aioasuswrt.asuswrt import AsusWrt as AsusWrtLegacy + +from homeassistant.const import ( + CONF_HOST, + CONF_MODE, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .const import ( + CONF_DNSMASQ, + CONF_INTERFACE, + CONF_REQUIRE_IP, + CONF_SSH_KEY, + DEFAULT_DNSMASQ, + DEFAULT_INTERFACE, + KEY_METHOD, + KEY_SENSORS, + PROTOCOL_TELNET, + SENSORS_BYTES, + SENSORS_LOAD_AVG, + SENSORS_RATES, + SENSORS_TEMPERATURES, +) + +SENSORS_TYPE_BYTES = "sensors_bytes" +SENSORS_TYPE_COUNT = "sensors_count" +SENSORS_TYPE_LOAD_AVG = "sensors_load_avg" +SENSORS_TYPE_RATES = "sensors_rates" +SENSORS_TYPE_TEMPERATURES = "sensors_temperatures" + +WrtDevice = namedtuple("WrtDevice", ["ip", "name", "connected_to"]) + +_LOGGER = logging.getLogger(__name__) + + +def _get_dict(keys: list, values: list) -> dict[str, Any]: + """Create a dict from a list of keys and values.""" + return dict(zip(keys, values)) + + +class AsusWrtBridge(ABC): + """The Base Bridge abstract class.""" + + @staticmethod + def get_bridge( + hass: HomeAssistant, conf: dict[str, Any], options: dict[str, Any] | None = None + ) -> AsusWrtBridge: + """Get Bridge instance.""" + return AsusWrtLegacyBridge(conf, options) + + def __init__(self, host: str) -> None: + """Initialize Bridge.""" + self._host = host + self._firmware: str | None = None + self._label_mac: str | None = None + self._model: str | None = None + + @property + def host(self) -> str: + """Return hostname.""" + return self._host + + @property + def firmware(self) -> str | None: + """Return firmware information.""" + return self._firmware + + @property + def label_mac(self) -> str | None: + """Return label mac information.""" + return self._label_mac + + @property + def model(self) -> str | None: + """Return model information.""" + return self._model + + @property + @abstractmethod + def is_connected(self) -> bool: + """Get connected status.""" + + @abstractmethod + async def async_connect(self) -> None: + """Connect to the device.""" + + @abstractmethod + async def async_disconnect(self) -> None: + """Disconnect to the device.""" + + @abstractmethod + async def async_get_connected_devices(self) -> dict[str, WrtDevice]: + """Get list of connected devices.""" + + @abstractmethod + async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: + """Return a dictionary of available sensors for this bridge.""" + + +class AsusWrtLegacyBridge(AsusWrtBridge): + """The Bridge that use legacy library.""" + + def __init__( + self, conf: dict[str, Any], options: dict[str, Any] | None = None + ) -> None: + """Initialize Bridge.""" + super().__init__(conf[CONF_HOST]) + self._protocol: str = conf[CONF_PROTOCOL] + self._api: AsusWrtLegacy = self._get_api(conf, options) + + @staticmethod + def _get_api( + conf: dict[str, Any], options: dict[str, Any] | None = None + ) -> AsusWrtLegacy: + """Get the AsusWrtLegacy API.""" + opt = options or {} + + return AsusWrtLegacy( + conf[CONF_HOST], + conf.get(CONF_PORT), + conf[CONF_PROTOCOL] == PROTOCOL_TELNET, + conf[CONF_USERNAME], + conf.get(CONF_PASSWORD, ""), + conf.get(CONF_SSH_KEY, ""), + conf[CONF_MODE], + opt.get(CONF_REQUIRE_IP, True), + interface=opt.get(CONF_INTERFACE, DEFAULT_INTERFACE), + dnsmasq=opt.get(CONF_DNSMASQ, DEFAULT_DNSMASQ), + ) + + @property + def is_connected(self) -> bool: + """Get connected status.""" + return cast(bool, self._api.is_connected) + + async def async_connect(self) -> None: + """Connect to the device.""" + await self._api.connection.async_connect() + + # get main router properties + if self._label_mac is None: + await self._get_label_mac() + if self._firmware is None: + await self._get_firmware() + if self._model is None: + await self._get_model() + + async def async_disconnect(self) -> None: + """Disconnect to the device.""" + if self._api is not None and self._protocol == PROTOCOL_TELNET: + self._api.connection.disconnect() + + async def async_get_connected_devices(self) -> dict[str, WrtDevice]: + """Get list of connected devices.""" + try: + api_devices = await self._api.async_get_connected_devices() + except OSError as exc: + raise UpdateFailed(exc) from exc + return { + format_mac(mac): WrtDevice(dev.ip, dev.name, None) + for mac, dev in api_devices.items() + } + + async def _get_nvram_info(self, info_type: str) -> dict[str, Any]: + """Get AsusWrt router info from nvram.""" + info = {} + try: + info = await self._api.async_get_nvram(info_type) + except OSError as exc: + _LOGGER.warning( + "Error calling method async_get_nvram(%s): %s", info_type, exc + ) + + return info + + async def _get_label_mac(self) -> None: + """Get label mac information.""" + label_mac = await self._get_nvram_info("LABEL_MAC") + if label_mac and "label_mac" in label_mac: + self._label_mac = format_mac(label_mac["label_mac"]) + + async def _get_firmware(self) -> None: + """Get firmware information.""" + firmware = await self._get_nvram_info("FIRMWARE") + if firmware and "firmver" in firmware: + firmver: str = firmware["firmver"] + if "buildno" in firmware: + firmver += f" (build {firmware['buildno']})" + self._firmware = firmver + + async def _get_model(self) -> None: + """Get model information.""" + model = await self._get_nvram_info("MODEL") + if model and "model" in model: + self._model = model["model"] + + async def async_get_available_sensors(self) -> dict[str, dict[str, Any]]: + """Return a dictionary of available sensors for this bridge.""" + sensors_temperatures = await self._get_available_temperature_sensors() + sensors_types = { + SENSORS_TYPE_BYTES: { + KEY_SENSORS: SENSORS_BYTES, + KEY_METHOD: self._get_bytes, + }, + SENSORS_TYPE_LOAD_AVG: { + KEY_SENSORS: SENSORS_LOAD_AVG, + KEY_METHOD: self._get_load_avg, + }, + SENSORS_TYPE_RATES: { + KEY_SENSORS: SENSORS_RATES, + KEY_METHOD: self._get_rates, + }, + SENSORS_TYPE_TEMPERATURES: { + KEY_SENSORS: sensors_temperatures, + KEY_METHOD: self._get_temperatures, + }, + } + return sensors_types + + async def _get_available_temperature_sensors(self) -> list[str]: + """Check which temperature information is available on the router.""" + availability = await self._api.async_find_temperature_commands() + return [SENSORS_TEMPERATURES[i] for i in range(3) if availability[i]] + + async def _get_bytes(self) -> dict[str, Any]: + """Fetch byte information from the router.""" + try: + datas = await self._api.async_get_bytes_total() + except (IndexError, OSError, ValueError) as exc: + raise UpdateFailed(exc) from exc + + return _get_dict(SENSORS_BYTES, datas) + + async def _get_rates(self) -> dict[str, Any]: + """Fetch rates information from the router.""" + try: + rates = await self._api.async_get_current_transfer_rates() + except (IndexError, OSError, ValueError) as exc: + raise UpdateFailed(exc) from exc + + return _get_dict(SENSORS_RATES, rates) + + async def _get_load_avg(self) -> dict[str, Any]: + """Fetch load average information from the router.""" + try: + avg = await self._api.async_get_loadavg() + except (IndexError, OSError, ValueError) as exc: + raise UpdateFailed(exc) from exc + + return _get_dict(SENSORS_LOAD_AVG, avg) + + async def _get_temperatures(self) -> dict[str, Any]: + """Fetch temperatures information from the router.""" + try: + temperatures: dict[str, Any] = await self._api.async_get_temperature() + except (OSError, ValueError) as exc: + raise UpdateFailed(exc) from exc + + return temperatures diff --git a/homeassistant/components/asuswrt/config_flow.py b/homeassistant/components/asuswrt/config_flow.py index 6b0056b14fa..56569d4f23b 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -25,13 +25,13 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaFlowFormStep, SchemaOptionsFlowHandler, ) +from .bridge import AsusWrtBridge from .const import ( CONF_DNSMASQ, CONF_INTERFACE, @@ -47,7 +47,6 @@ from .const import ( PROTOCOL_SSH, PROTOCOL_TELNET, ) -from .router import get_api, get_nvram_info LABEL_MAC = "LABEL_MAC" @@ -143,16 +142,15 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - @staticmethod async def _async_check_connection( - user_input: dict[str, Any] + self, user_input: dict[str, Any] ) -> tuple[str, str | None]: """Attempt to connect the AsusWrt router.""" host: str = user_input[CONF_HOST] - api = get_api(user_input) + api = AsusWrtBridge.get_bridge(self.hass, user_input) try: - await api.connection.async_connect() + await api.async_connect() except OSError: _LOGGER.error("Error connecting to the AsusWrt router at %s", host) @@ -168,14 +166,9 @@ class AsusWrtFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.error("Error connecting to the AsusWrt router at %s", host) return RESULT_CONN_ERROR, None - label_mac = await get_nvram_info(api, LABEL_MAC) - conf_protocol = user_input[CONF_PROTOCOL] - if conf_protocol == PROTOCOL_TELNET: - api.connection.disconnect() + unique_id = api.label_mac + await api.async_disconnect() - unique_id = None - if label_mac and "label_mac" in label_mac: - unique_id = format_mac(label_mac["label_mac"]) return RESULT_SUCCESS, unique_id async def async_step_user( diff --git a/homeassistant/components/asuswrt/const.py b/homeassistant/components/asuswrt/const.py index f80643f078d..1733d4c09c3 100644 --- a/homeassistant/components/asuswrt/const.py +++ b/homeassistant/components/asuswrt/const.py @@ -13,6 +13,10 @@ DEFAULT_DNSMASQ = "/var/lib/misc" DEFAULT_INTERFACE = "eth0" DEFAULT_TRACK_UNKNOWN = False +KEY_COORDINATOR = "coordinator" +KEY_METHOD = "method" +KEY_SENSORS = "sensors" + MODE_AP = "ap" MODE_ROUTER = "router" diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 4291c21d0ed..8f7229bf5ad 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -6,22 +6,12 @@ from datetime import datetime, timedelta import logging from typing import Any -from aioasuswrt.asuswrt import AsusWrt, Device as WrtDevice - from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, DOMAIN as TRACKER_DOMAIN, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_MODE, - CONF_PASSWORD, - CONF_PORT, - CONF_PROTOCOL, - CONF_USERNAME, -) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er @@ -30,57 +20,37 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util +from homeassistant.util import dt as dt_util, slugify +from .bridge import AsusWrtBridge, WrtDevice from .const import ( CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP, - CONF_SSH_KEY, CONF_TRACK_UNKNOWN, DEFAULT_DNSMASQ, DEFAULT_INTERFACE, DEFAULT_TRACK_UNKNOWN, DOMAIN, - PROTOCOL_TELNET, - SENSORS_BYTES, + KEY_COORDINATOR, + KEY_METHOD, + KEY_SENSORS, SENSORS_CONNECTED_DEVICE, - SENSORS_LOAD_AVG, - SENSORS_RATES, - SENSORS_TEMPERATURES, ) CONF_REQ_RELOAD = [CONF_DNSMASQ, CONF_INTERFACE, CONF_REQUIRE_IP] -DEFAULT_NAME = "Asuswrt" - -KEY_COORDINATOR = "coordinator" -KEY_SENSORS = "sensors" SCAN_INTERVAL = timedelta(seconds=30) -SENSORS_TYPE_BYTES = "sensors_bytes" SENSORS_TYPE_COUNT = "sensors_count" -SENSORS_TYPE_LOAD_AVG = "sensors_load_avg" -SENSORS_TYPE_RATES = "sensors_rates" -SENSORS_TYPE_TEMPERATURES = "sensors_temperatures" _LOGGER = logging.getLogger(__name__) -def _get_dict(keys: list, values: list) -> dict[str, Any]: - """Create a dict from a list of keys and values.""" - ret_dict: dict[str, Any] = dict.fromkeys(keys) - - for index, key in enumerate(ret_dict): - ret_dict[key] = values[index] - - return ret_dict - - class AsusWrtSensorDataHandler: """Data handler for AsusWrt sensor.""" - def __init__(self, hass: HomeAssistant, api: AsusWrt) -> None: + def __init__(self, hass: HomeAssistant, api: AsusWrtBridge) -> None: """Initialize a AsusWrt sensor data handler.""" self._hass = hass self._api = api @@ -90,42 +60,6 @@ class AsusWrtSensorDataHandler: """Return number of connected devices.""" return {SENSORS_CONNECTED_DEVICE[0]: self._connected_devices} - async def _get_bytes(self) -> dict[str, Any]: - """Fetch byte information from the router.""" - try: - datas = await self._api.async_get_bytes_total() - except (OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc - - return _get_dict(SENSORS_BYTES, datas) - - async def _get_rates(self) -> dict[str, Any]: - """Fetch rates information from the router.""" - try: - rates = await self._api.async_get_current_transfer_rates() - except (OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc - - return _get_dict(SENSORS_RATES, rates) - - async def _get_load_avg(self) -> dict[str, Any]: - """Fetch load average information from the router.""" - try: - avg = await self._api.async_get_loadavg() - except (OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc - - return _get_dict(SENSORS_LOAD_AVG, avg) - - async def _get_temperatures(self) -> dict[str, Any]: - """Fetch temperatures information from the router.""" - try: - temperatures: dict[str, Any] = await self._api.async_get_temperature() - except (OSError, ValueError) as exc: - raise UpdateFailed(exc) from exc - - return temperatures - def update_device_count(self, conn_devices: int) -> bool: """Update connected devices attribute.""" if self._connected_devices == conn_devices: @@ -134,19 +68,17 @@ class AsusWrtSensorDataHandler: return True async def get_coordinator( - self, sensor_type: str, should_poll: bool = True + self, + sensor_type: str, + update_method: Callable[[], Any] | None = None, ) -> DataUpdateCoordinator: """Get the coordinator for a specific sensor type.""" + should_poll = True if sensor_type == SENSORS_TYPE_COUNT: + should_poll = False method = self._get_connected_devices - elif sensor_type == SENSORS_TYPE_BYTES: - method = self._get_bytes - elif sensor_type == SENSORS_TYPE_LOAD_AVG: - method = self._get_load_avg - elif sensor_type == SENSORS_TYPE_RATES: - method = self._get_rates - elif sensor_type == SENSORS_TYPE_TEMPERATURES: - method = self._get_temperatures + elif update_method is not None: + method = update_method else: raise RuntimeError(f"Invalid sensor type: {sensor_type}") @@ -226,12 +158,6 @@ class AsusWrtRouter: self.hass = hass self._entry = entry - self._api: AsusWrt = None - self._protocol: str = entry.data[CONF_PROTOCOL] - self._host: str = entry.data[CONF_HOST] - self._model: str = "Asus Router" - self._sw_v: str | None = None - self._devices: dict[str, AsusWrtDevInfo] = {} self._connected_devices: int = 0 self._connect_error: bool = False @@ -248,26 +174,57 @@ class AsusWrtRouter: } self._options.update(entry.options) + self._api: AsusWrtBridge = AsusWrtBridge.get_bridge( + self.hass, dict(self._entry.data), self._options + ) + + def _migrate_entities_unique_id(self) -> None: + """Migrate router entities to new unique id format.""" + _ENTITY_MIGRATION_ID = { + "sensor_connected_device": "Devices Connected", + "sensor_rx_bytes": "Download", + "sensor_tx_bytes": "Upload", + "sensor_rx_rates": "Download Speed", + "sensor_tx_rates": "Upload Speed", + "sensor_load_avg1": "Load Avg (1m)", + "sensor_load_avg5": "Load Avg (5m)", + "sensor_load_avg15": "Load Avg (15m)", + "2.4GHz": "2.4GHz Temperature", + "5.0GHz": "5GHz Temperature", + "CPU": "CPU Temperature", + } + + entity_reg = er.async_get(self.hass) + router_entries = er.async_entries_for_config_entry( + entity_reg, self._entry.entry_id + ) + + migrate_entities: dict[str, str] = {} + for entry in router_entries: + if entry.domain == TRACKER_DOMAIN: + continue + old_unique_id = entry.unique_id + if not old_unique_id.startswith(DOMAIN): + continue + for new_id, old_id in _ENTITY_MIGRATION_ID.items(): + if old_unique_id.endswith(old_id): + migrate_entities[entry.entity_id] = slugify( + f"{self.unique_id}_{new_id}" + ) + break + + for entity_id, unique_id in migrate_entities.items(): + entity_reg.async_update_entity(entity_id, new_unique_id=unique_id) + async def setup(self) -> None: """Set up a AsusWrt router.""" - self._api = get_api(dict(self._entry.data), self._options) - try: - await self._api.connection.async_connect() - except OSError as exp: - raise ConfigEntryNotReady from exp - + await self._api.async_connect() + except OSError as exc: + raise ConfigEntryNotReady from exc if not self._api.is_connected: raise ConfigEntryNotReady - # System - model = await get_nvram_info(self._api, "MODEL") - if model and "model" in model: - self._model = model["model"] - firmware = await get_nvram_info(self._api, "FIRMWARE") - if firmware and "firmver" in firmware and "buildno" in firmware: - self._sw_v = f"{firmware['firmver']} (build {firmware['buildno']})" - # Load tracked entities from registry entity_reg = er.async_get(self.hass) track_entries = er.async_entries_for_config_entry( @@ -295,6 +252,9 @@ class AsusWrtRouter: self._devices[device_mac] = AsusWrtDevInfo(device_mac, entry.original_name) + # Migrate entities to new unique id format + self._migrate_entities_unique_id() + # Update devices await self.update_devices() @@ -312,24 +272,24 @@ class AsusWrtRouter: async def update_devices(self) -> None: """Update AsusWrt devices tracker.""" new_device = False - _LOGGER.debug("Checking devices for ASUS router %s", self._host) + _LOGGER.debug("Checking devices for ASUS router %s", self.host) try: - api_devices = await self._api.async_get_connected_devices() - except OSError as exc: + wrt_devices = await self._api.async_get_connected_devices() + except UpdateFailed as exc: if not self._connect_error: self._connect_error = True _LOGGER.error( "Error connecting to ASUS router %s for device update: %s", - self._host, + self.host, exc, ) return if self._connect_error: self._connect_error = False - _LOGGER.info("Reconnected to ASUS router %s", self._host) + _LOGGER.info("Reconnected to ASUS router %s", self.host) - self._connected_devices = len(api_devices) + self._connected_devices = len(wrt_devices) consider_home: int = self._options.get( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() ) @@ -337,7 +297,6 @@ class AsusWrtRouter: CONF_TRACK_UNKNOWN, DEFAULT_TRACK_UNKNOWN ) - wrt_devices = {format_mac(mac): dev for mac, dev in api_devices.items()} for device_mac, device in self._devices.items(): dev_info = wrt_devices.pop(device_mac, None) device.update(dev_info, consider_home) @@ -363,19 +322,14 @@ class AsusWrtRouter: self._sensors_data_handler = AsusWrtSensorDataHandler(self.hass, self._api) self._sensors_data_handler.update_device_count(self._connected_devices) - sensors_types: dict[str, list[str]] = { - SENSORS_TYPE_BYTES: SENSORS_BYTES, - SENSORS_TYPE_COUNT: SENSORS_CONNECTED_DEVICE, - SENSORS_TYPE_LOAD_AVG: SENSORS_LOAD_AVG, - SENSORS_TYPE_RATES: SENSORS_RATES, - SENSORS_TYPE_TEMPERATURES: await self._get_available_temperature_sensors(), - } + sensors_types = await self._api.async_get_available_sensors() + sensors_types[SENSORS_TYPE_COUNT] = {KEY_SENSORS: SENSORS_CONNECTED_DEVICE} - for sensor_type, sensor_names in sensors_types.items(): - if not sensor_names: + for sensor_type, sensor_def in sensors_types.items(): + if not (sensor_names := sensor_def.get(KEY_SENSORS)): continue coordinator = await self._sensors_data_handler.get_coordinator( - sensor_type, sensor_type != SENSORS_TYPE_COUNT + sensor_type, update_method=sensor_def.get(KEY_METHOD) ) self._sensors_coordinator[sensor_type] = { KEY_COORDINATOR: coordinator, @@ -392,31 +346,10 @@ class AsusWrtRouter: if self._sensors_data_handler.update_device_count(self._connected_devices): await coordinator.async_refresh() - async def _get_available_temperature_sensors(self) -> list[str]: - """Check which temperature information is available on the router.""" - try: - availability = await self._api.async_find_temperature_commands() - available_sensors = [ - SENSORS_TEMPERATURES[i] for i in range(3) if availability[i] - ] - except Exception as exc: # pylint: disable=broad-except - _LOGGER.debug( - ( - "Failed checking temperature sensor availability for ASUS router" - " %s. Exception: %s" - ), - self._host, - exc, - ) - return [] - - return available_sensors - async def close(self) -> None: """Close the connection.""" - if self._api is not None and self._protocol == PROTOCOL_TELNET: - self._api.connection.disconnect() - self._api = None + if self._api is not None: + await self._api.async_disconnect() for func in self._on_close: func() @@ -443,14 +376,17 @@ class AsusWrtRouter: @property def device_info(self) -> DeviceInfo: """Return the device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id or "AsusWRT")}, - name=self._host, - model=self._model, + info = DeviceInfo( + identifiers={(DOMAIN, self._entry.unique_id or "AsusWRT")}, + name=self.host, + model=self._api.model or "Asus Router", manufacturer="Asus", - sw_version=self._sw_v, - configuration_url=f"http://{self._host}", + configuration_url=f"http://{self.host}", ) + if self._api.firmware: + info["sw_version"] = self._api.firmware + + return info @property def signal_device_new(self) -> str: @@ -465,17 +401,12 @@ class AsusWrtRouter: @property def host(self) -> str: """Return router hostname.""" - return self._host + return self._api.host @property - def unique_id(self) -> str | None: + def unique_id(self) -> str: """Return router unique id.""" - return self._entry.unique_id - - @property - def name(self) -> str: - """Return router name.""" - return self._host if self.unique_id else DEFAULT_NAME + return self._entry.unique_id or self._entry.entry_id @property def devices(self) -> dict[str, AsusWrtDevInfo]: @@ -486,32 +417,3 @@ class AsusWrtRouter: def sensors_coordinator(self) -> dict[str, Any]: """Return sensors coordinators.""" return self._sensors_coordinator - - -async def get_nvram_info(api: AsusWrt, info_type: str) -> dict[str, Any]: - """Get AsusWrt router info from nvram.""" - info = {} - try: - info = await api.async_get_nvram(info_type) - except OSError as exc: - _LOGGER.warning("Error calling method async_get_nvram(%s): %s", info_type, exc) - - return info - - -def get_api(conf: dict[str, Any], options: dict[str, Any] | None = None) -> AsusWrt: - """Get the AsusWrt API.""" - opt = options or {} - - return AsusWrt( - conf[CONF_HOST], - conf.get(CONF_PORT), - conf[CONF_PROTOCOL] == PROTOCOL_TELNET, - conf[CONF_USERNAME], - conf.get(CONF_PASSWORD, ""), - conf.get(CONF_SSH_KEY, ""), - conf[CONF_MODE], - opt.get(CONF_REQUIRE_IP, True), - interface=opt.get(CONF_INTERFACE, DEFAULT_INTERFACE), - dnsmasq=opt.get(CONF_DNSMASQ, DEFAULT_DNSMASQ), - ) diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 95724ec3bb5..4f9ec0af411 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -22,17 +22,20 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) +from homeassistant.util import slugify from .const import ( DATA_ASUSWRT, DOMAIN, + KEY_COORDINATOR, + KEY_SENSORS, SENSORS_BYTES, SENSORS_CONNECTED_DEVICE, SENSORS_LOAD_AVG, SENSORS_RATES, SENSORS_TEMPERATURES, ) -from .router import KEY_COORDINATOR, KEY_SENSORS, AsusWrtRouter +from .router import AsusWrtRouter @dataclass @@ -47,14 +50,14 @@ UNIT_DEVICES = "Devices" CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( AsusWrtSensorEntityDescription( key=SENSORS_CONNECTED_DEVICE[0], - name="Devices Connected", + translation_key="devices_connected", icon="mdi:router-network", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UNIT_DEVICES, ), AsusWrtSensorEntityDescription( key=SENSORS_RATES[0], - name="Download Speed", + translation_key="download_speed", icon="mdi:download-network", device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, @@ -65,7 +68,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( ), AsusWrtSensorEntityDescription( key=SENSORS_RATES[1], - name="Upload Speed", + translation_key="upload_speed", icon="mdi:upload-network", device_class=SensorDeviceClass.DATA_RATE, state_class=SensorStateClass.MEASUREMENT, @@ -76,7 +79,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( ), AsusWrtSensorEntityDescription( key=SENSORS_BYTES[0], - name="Download", + translation_key="download", icon="mdi:download", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.GIGABYTES, @@ -87,7 +90,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( ), AsusWrtSensorEntityDescription( key=SENSORS_BYTES[1], - name="Upload", + translation_key="upload", icon="mdi:upload", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfInformation.GIGABYTES, @@ -98,7 +101,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( ), AsusWrtSensorEntityDescription( key=SENSORS_LOAD_AVG[0], - name="Load Avg (1m)", + translation_key="load_avg_1m", icon="mdi:cpu-32-bit", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -107,7 +110,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( ), AsusWrtSensorEntityDescription( key=SENSORS_LOAD_AVG[1], - name="Load Avg (5m)", + translation_key="load_avg_5m", icon="mdi:cpu-32-bit", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -116,7 +119,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( ), AsusWrtSensorEntityDescription( key=SENSORS_LOAD_AVG[2], - name="Load Avg (15m)", + translation_key="load_avg_15m", icon="mdi:cpu-32-bit", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -125,7 +128,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( ), AsusWrtSensorEntityDescription( key=SENSORS_TEMPERATURES[0], - name="2.4GHz Temperature", + translation_key="24ghz_temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -135,7 +138,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( ), AsusWrtSensorEntityDescription( key=SENSORS_TEMPERATURES[1], - name="5GHz Temperature", + translation_key="5ghz_temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -145,7 +148,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( ), AsusWrtSensorEntityDescription( key=SENSORS_TEMPERATURES[2], - name="CPU Temperature", + translation_key="cpu_temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -180,6 +183,9 @@ async def async_setup_entry( class AsusWrtSensor(CoordinatorEntity, SensorEntity): """Representation of a AsusWrt sensor.""" + entity_description: AsusWrtSensorEntityDescription + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinator, @@ -188,13 +194,9 @@ class AsusWrtSensor(CoordinatorEntity, SensorEntity): ) -> None: """Initialize a AsusWrt sensor.""" super().__init__(coordinator) - self.entity_description: AsusWrtSensorEntityDescription = description + self.entity_description = description - self._attr_name = f"{router.name} {description.name}" - if router.unique_id: - self._attr_unique_id = f"{DOMAIN} {router.unique_id} {description.name}" - else: - self._attr_unique_id = f"{DOMAIN} {self.name}" + self._attr_unique_id = slugify(f"{router.unique_id}_{description.key}") self._attr_device_info = router.device_info self._attr_extra_state_attributes = {"hostname": router.host} diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index f6ccb5a7c9c..52b9f919434 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -36,11 +36,48 @@ "data": { "consider_home": "Seconds to wait before considering a device away", "track_unknown": "Track unknown / unnamed devices", - "interface": "The interface that you want statistics from (e.g. eth0,eth1 etc)", + "interface": "The interface that you want statistics from (e.g. eth0, eth1 etc)", "dnsmasq": "The location in the router of the dnsmasq.leases files", "require_ip": "Devices must have IP (for access point mode)" } } } + }, + "entity": { + "sensor": { + "devices_connected": { + "name": "Devices connected" + }, + "download_speed": { + "name": "Download speed" + }, + "upload_speed": { + "name": "Upload speed" + }, + "download": { + "name": "Download" + }, + "upload": { + "name": "Upload" + }, + "load_avg_1m": { + "name": "Average load (1m)" + }, + "load_avg_5m": { + "name": "Average load (5m)" + }, + "load_avg_15m": { + "name": "Average load (15m)" + }, + "24ghz_temperature": { + "name": "2.4GHz Temperature" + }, + "5ghz_temperature": { + "name": "5GHz Temperature" + }, + "cpu_temperature": { + "name": "CPU Temperature" + } + } } } diff --git a/homeassistant/components/atag/manifest.json b/homeassistant/components/atag/manifest.json index 2a279840a9e..c45d8c42546 100644 --- a/homeassistant/components/atag/manifest.json +++ b/homeassistant/components/atag/manifest.json @@ -3,7 +3,7 @@ "name": "Atag", "codeowners": ["@MatsNL"], "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/atag/", + "documentation": "https://www.home-assistant.io/integrations/atag", "iot_class": "local_polling", "loggers": ["pyatag"], "requirements": ["pyatag==0.3.5.3"] diff --git a/homeassistant/components/atlanticcityelectric/__init__.py b/homeassistant/components/atlanticcityelectric/__init__.py new file mode 100644 index 00000000000..2a6ada2bf05 --- /dev/null +++ b/homeassistant/components/atlanticcityelectric/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Atlantic City Electric.""" diff --git a/homeassistant/components/atlanticcityelectric/manifest.json b/homeassistant/components/atlanticcityelectric/manifest.json new file mode 100644 index 00000000000..e6055d66462 --- /dev/null +++ b/homeassistant/components/atlanticcityelectric/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "atlanticcityelectric", + "name": "Atlantic City Electric", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index ca4e799f16b..0dbc4c8f7d6 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.5.1", "yalexs-ble==2.1.18"] + "requirements": ["yalexs==1.5.1", "yalexs-ble==2.2.3"] } diff --git a/homeassistant/components/aussie_broadband/strings.json b/homeassistant/components/aussie_broadband/strings.json index 90e4f094ee6..276844a8806 100644 --- a/homeassistant/components/aussie_broadband/strings.json +++ b/homeassistant/components/aussie_broadband/strings.json @@ -35,9 +35,9 @@ "options": { "step": { "init": { - "title": "Select Services", + "title": "[%key:component::aussie_broadband::config::step::service::title%]", "data": { - "services": "Services" + "services": "[%key:component::aussie_broadband::config::step::service::data::services%]" } } }, diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index ec8431366ab..e2614af6a3e 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -92,14 +92,15 @@ async def fetch_redirect_uris(hass: HomeAssistant, url: str) -> list[str]: parser = LinkTagParser("redirect_uri") chunks = 0 try: - async with aiohttp.ClientSession() as session: - async with session.get(url, timeout=5) as resp: - async for data in resp.content.iter_chunked(1024): - parser.feed(data.decode()) - chunks += 1 + async with aiohttp.ClientSession() as session, session.get( + url, timeout=5 + ) as resp: + async for data in resp.content.iter_chunked(1024): + parser.feed(data.decode()) + chunks += 1 - if chunks == 10: - break + if chunks == 10: + break except asyncio.TimeoutError: _LOGGER.error("Timeout while looking up redirect_uri %s", url) diff --git a/homeassistant/components/automation/services.yaml b/homeassistant/components/automation/services.yaml index 62d0988d770..6b3afdca335 100644 --- a/homeassistant/components/automation/services.yaml +++ b/homeassistant/components/automation/services.yaml @@ -1,46 +1,32 @@ # Describes the format for available automation services turn_on: - name: Turn on - description: Enable an automation. target: entity: domain: automation turn_off: - name: Turn off - description: Disable an automation. target: entity: domain: automation fields: stop_actions: - name: Stop actions - description: Stop currently running actions. default: true selector: boolean: toggle: - name: Toggle - description: Toggle (enable / disable) an automation. target: entity: domain: automation trigger: - name: Trigger - description: Trigger the actions of an automation. target: entity: domain: automation fields: skip_condition: - name: Skip conditions - description: Whether or not the conditions will be skipped. default: true selector: boolean: reload: - name: Reload - description: Reload the automation configuration. diff --git a/homeassistant/components/automation/strings.json b/homeassistant/components/automation/strings.json index 4e433119a2a..31bd812a947 100644 --- a/homeassistant/components/automation/strings.json +++ b/homeassistant/components/automation/strings.json @@ -38,11 +38,45 @@ "fix_flow": { "step": { "confirm": { - "title": "{name} uses an unknown service", + "title": "[%key:component::automation::issues::service_not_found::title%]", "description": "The automation \"{name}\" (`{entity_id}`) has an action that calls an unknown service: `{service}`.\n\nThis error prevents the automation from running correctly. Maybe this service is no longer available, or perhaps a typo caused it.\n\nTo fix this error, [edit the automation]({edit}) and remove the action that calls this service.\n\nClick on SUBMIT below to confirm you have fixed this automation." } } } } + }, + "services": { + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Enables an automation." + }, + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Disables an automation.", + "fields": { + "stop_actions": { + "name": "Stop actions", + "description": "Stops currently running actions." + } + } + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles (enable / disable) an automation." + }, + "trigger": { + "name": "Trigger", + "description": "Triggers the actions of an automation.", + "fields": { + "skip_condition": { + "name": "Skip conditions", + "description": "Defines whether or not the conditions will be skipped." + } + } + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads the automation configuration." + } } } diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index c593c4fa419..53e2c3c9fe5 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -35,10 +35,16 @@ class AxisCamera(AxisEntity, MjpegCamera): _attr_supported_features = CameraEntityFeature.STREAM + _still_image_url: str + _mjpeg_url: str + _stream_source: str + def __init__(self, device: AxisNetworkDevice) -> None: """Initialize Axis Communications camera component.""" AxisEntity.__init__(self, device) + self._generate_sources() + MjpegCamera.__init__( self, username=device.username, @@ -46,41 +52,52 @@ class AxisCamera(AxisEntity, MjpegCamera): mjpeg_url=self.mjpeg_source, still_image_url=self.image_source, authentication=HTTP_DIGEST_AUTHENTICATION, + unique_id=f"{device.unique_id}-camera", ) - self._attr_unique_id = f"{device.unique_id}-camera" - async def async_added_to_hass(self) -> None: """Subscribe camera events.""" self.async_on_remove( async_dispatcher_connect( - self.hass, self.device.signal_new_address, self._new_address + self.hass, self.device.signal_new_address, self._generate_sources ) ) await super().async_added_to_hass() - def _new_address(self) -> None: - """Set new device address for video stream.""" - self._mjpeg_url = self.mjpeg_source - self._still_image_url = self.image_source + def _generate_sources(self) -> None: + """Generate sources. + + Additionally used when device change IP address. + """ + image_options = self.generate_options(skip_stream_profile=True) + self._still_image_url = f"http://{self.device.host}:{self.device.port}/axis-cgi/jpg/image.cgi{image_options}" + + mjpeg_options = self.generate_options() + self._mjpeg_url = f"http://{self.device.host}:{self.device.port}/axis-cgi/mjpg/video.cgi{mjpeg_options}" + + stream_options = self.generate_options(add_video_codec_h264=True) + self._stream_source = f"rtsp://{self.device.username}:{self.device.password}@{self.device.host}/axis-media/media.amp{stream_options}" + + self.device.additional_diagnostics["camera_sources"] = { + "Image": self._still_image_url, + "MJPEG": self._mjpeg_url, + "Stream": f"rtsp://user:pass@{self.device.host}/axis-media/media.amp{stream_options}", + } @property def image_source(self) -> str: """Return still image URL for device.""" - options = self.generate_options(skip_stream_profile=True) - return f"http://{self.device.host}:{self.device.port}/axis-cgi/jpg/image.cgi{options}" + return self._still_image_url @property def mjpeg_source(self) -> str: """Return mjpeg URL for device.""" - options = self.generate_options() - return f"http://{self.device.host}:{self.device.port}/axis-cgi/mjpg/video.cgi{options}" + return self._mjpeg_url async def stream_source(self) -> str: """Return the stream source.""" - options = self.generate_options(add_video_codec_h264=True) - return f"rtsp://{self.device.username}:{self.device.password}@{self.device.host}/axis-media/media.amp{options}" + return self._stream_source def generate_options( self, skip_stream_profile: bool = False, add_video_codec_h264: bool = False diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index f53e69fba9f..8f3c8b9a8b6 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -62,6 +62,8 @@ class AxisNetworkDevice: self.fw_version = api.vapix.firmware_version self.product_type = api.vapix.product_type + self.additional_diagnostics: dict[str, Any] = {} + @property def host(self): """Return the host address of this device.""" diff --git a/homeassistant/components/axis/diagnostics.py b/homeassistant/components/axis/diagnostics.py index 277f24513de..20dfedd717b 100644 --- a/homeassistant/components/axis/diagnostics.py +++ b/homeassistant/components/axis/diagnostics.py @@ -21,7 +21,7 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" device: AxisNetworkDevice = hass.data[AXIS_DOMAIN][config_entry.entry_id] - diag: dict[str, Any] = {} + diag: dict[str, Any] = device.additional_diagnostics.copy() diag["config"] = async_redact_data(config_entry.as_dict(), REDACT_CONFIG) diff --git a/homeassistant/components/azure_devops/strings.json b/homeassistant/components/azure_devops/strings.json index 8dfd203c84b..ad8ebaa016e 100644 --- a/homeassistant/components/azure_devops/strings.json +++ b/homeassistant/components/azure_devops/strings.json @@ -18,7 +18,7 @@ }, "reauth": { "data": { - "personal_access_token": "Personal Access Token (PAT)" + "personal_access_token": "[%key:component::azure_devops::config::step::user::data::personal_access_token%]" }, "description": "Authentication failed for {project_url}. Please enter your current credentials.", "title": "Reauthentication" diff --git a/homeassistant/components/backup/services.yaml b/homeassistant/components/backup/services.yaml index d001c57ef5c..900aa39dd6e 100644 --- a/homeassistant/components/backup/services.yaml +++ b/homeassistant/components/backup/services.yaml @@ -1,3 +1 @@ create: - name: Create backup - description: Create a new backup. diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json new file mode 100644 index 00000000000..6ad3416b1b9 --- /dev/null +++ b/homeassistant/components/backup/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "create": { + "name": "Create backup", + "description": "Creates a new backup." + } + } +} diff --git a/homeassistant/components/baf/strings.json b/homeassistant/components/baf/strings.json index cb322320675..5143b519d27 100644 --- a/homeassistant/components/baf/strings.json +++ b/homeassistant/components/baf/strings.json @@ -60,7 +60,7 @@ "name": "Wi-Fi SSID" }, "ip_address": { - "name": "IP Address" + "name": "[%key:common::config_flow::data::ip%]" } }, "switch": { diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 06baef1bd0e..49965a38b77 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -27,12 +27,13 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConditionError, TemplateError from homeassistant.helpers import condition import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( + EventStateChangedData, TrackTemplate, TrackTemplateResult, TrackTemplateResultInfo, @@ -41,7 +42,7 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.template import Template, result_as_boolean -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from . import DOMAIN, PLATFORMS from .const import ( @@ -231,16 +232,20 @@ class BayesianBinarySensor(BinarySensorEntity): """ @callback - def async_threshold_sensor_state_listener(event: Event) -> None: + def async_threshold_sensor_state_listener( + event: EventType[EventStateChangedData], + ) -> None: """Handle sensor state changes. When a state changes, we must update our list of current observations, then calculate the new probability. """ - entity: str = event.data[CONF_ENTITY_ID] + entity_id = event.data["entity_id"] - self.current_observations.update(self._record_entity_observations(entity)) + self.current_observations.update( + self._record_entity_observations(entity_id) + ) self.async_set_context(event.context) self._recalculate_and_write_state() @@ -254,14 +259,13 @@ class BayesianBinarySensor(BinarySensorEntity): @callback def _async_template_result_changed( - event: Event | None, updates: list[TrackTemplateResult] + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], ) -> None: track_template_result = updates.pop() template = track_template_result.template result = track_template_result.result - entity: str | None = ( - None if event is None else event.data.get(CONF_ENTITY_ID) - ) + entity_id = None if event is None else event.data["entity_id"] if isinstance(result, TemplateError): _LOGGER.error( "TemplateError('%s') while processing template '%s' in entity '%s'", @@ -278,8 +282,8 @@ class BayesianBinarySensor(BinarySensorEntity): observation.observed = observed # in some cases a template may update because of the absence of an entity - if entity is not None: - observation.entity_id = entity + if entity_id is not None: + observation.entity_id = entity_id self.current_observations[observation.id] = observation diff --git a/homeassistant/components/bayesian/services.yaml b/homeassistant/components/bayesian/services.yaml index c1dc891805a..c983a105c93 100644 --- a/homeassistant/components/bayesian/services.yaml +++ b/homeassistant/components/bayesian/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload all bayesian entities diff --git a/homeassistant/components/bayesian/strings.json b/homeassistant/components/bayesian/strings.json index 338795624cd..9ebccedc88d 100644 --- a/homeassistant/components/bayesian/strings.json +++ b/homeassistant/components/bayesian/strings.json @@ -8,5 +8,11 @@ "description": "In the Bayesian integration `prob_given_false` is now a required configuration variable as there was no mathematical rationale for the previous default value. Please add this to your `configuration.yml` for `bayesian/{entity}`. These observations will be ignored until you do.", "title": "Manual YAML addition required for Bayesian" } + }, + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads bayesian sensors from the YAML-configuration." + } } } diff --git a/homeassistant/components/bge/__init__.py b/homeassistant/components/bge/__init__.py new file mode 100644 index 00000000000..a9bb8803f09 --- /dev/null +++ b/homeassistant/components/bge/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Baltimore Gas and Electric (BGE).""" diff --git a/homeassistant/components/bge/manifest.json b/homeassistant/components/bge/manifest.json new file mode 100644 index 00000000000..7cce2b5cf1a --- /dev/null +++ b/homeassistant/components/bge/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "bge", + "name": "Baltimore Gas and Electric (BGE)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 1c2d6d779fb..79e20c6f571 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -3,12 +3,12 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +from enum import StrEnum import logging from typing import Literal, final import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index b9c9b19a93c..b86c013f104 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -52,8 +52,8 @@ "is_no_vibration": "{entity_name} is not detecting vibration", "is_open": "{entity_name} is open", "is_not_open": "{entity_name} is closed", - "is_on": "{entity_name} is on", - "is_off": "{entity_name} is off" + "is_on": "[%key:common::device_automation::condition_type::is_on%]", + "is_off": "[%key:common::device_automation::condition_type::is_off%]" }, "trigger_type": { "bat_low": "{entity_name} battery low", @@ -106,8 +106,8 @@ "no_vibration": "{entity_name} stopped detecting vibration", "opened": "{entity_name} opened", "not_opened": "{entity_name} closed", - "turned_on": "{entity_name} turned on", - "turned_off": "{entity_name} turned off" + "turned_on": "[%key:common::device_automation::trigger_type::turned_on%]", + "turned_off": "[%key:common::device_automation::trigger_type::turned_off%]" } }, "entity_component": { @@ -237,6 +237,13 @@ "on": "Plugged in" } }, + "power": { + "name": "Power", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" + } + }, "presence": { "name": "Presence", "state": { @@ -300,25 +307,5 @@ "on": "[%key:common::state::open%]" } } - }, - "device_class": { - "co": "carbon monoxide", - "cold": "cold", - "gas": "gas", - "heat": "heat", - "moisture": "moisture", - "motion": "motion", - "occupancy": "occupancy", - "power": "power", - "problem": "problem", - "smoke": "smoke", - "sound": "sound", - "vibration": "vibration" - }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" - } } } diff --git a/homeassistant/components/blackbird/services.yaml b/homeassistant/components/blackbird/services.yaml index 7b3096c25e4..00425c93eb6 100644 --- a/homeassistant/components/blackbird/services.yaml +++ b/homeassistant/components/blackbird/services.yaml @@ -1,10 +1,6 @@ set_all_zones: - name: Set all zones - description: Set all Blackbird zones to a single source. fields: entity_id: - name: Entity - description: Name of any blackbird zone. required: true example: "media_player.zone_1" selector: @@ -12,8 +8,6 @@ set_all_zones: integration: blackbird domain: media_player source: - name: Source - description: Name of source to switch to. required: true example: "Source 1" selector: diff --git a/homeassistant/components/blackbird/strings.json b/homeassistant/components/blackbird/strings.json new file mode 100644 index 00000000000..93c0e6ef23d --- /dev/null +++ b/homeassistant/components/blackbird/strings.json @@ -0,0 +1,18 @@ +{ + "services": { + "set_all_zones": { + "name": "Set all zones", + "description": "Sets all Blackbird zones to a single source.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name of any blackbird zone." + }, + "source": { + "name": "Source", + "description": "Name of source to switch to." + } + } + } + } +} diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index 3d51ba2f7bb..95f4d33f91f 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -1,62 +1,41 @@ # Describes the format for available Blink services blink_update: - name: Update - description: Force a refresh. - trigger_camera: - name: Trigger camera - description: Request camera to take new image. target: entity: integration: blink domain: camera save_video: - name: Save video - description: Save last recorded video clip to local file. fields: name: - name: Name - description: Name of camera to grab video from. required: true example: "Living Room" selector: text: filename: - name: File name - description: Filename to writable path (directory may need to be included in allowlist_external_dirs in config) required: true example: "/tmp/video.mp4" selector: text: save_recent_clips: - name: Save recent clips - description: 'Save all recent video clips to local directory with file pattern "%Y%m%d_%H%M%S_{name}.mp4"' fields: name: - name: Name - description: Name of camera to grab recent clips from. required: true example: "Living Room" selector: text: file_path: - name: Output directory - description: Directory name of writable path (directory may need to be included in allowlist_external_dirs in config) required: true example: "/tmp" selector: text: send_pin: - name: Send pin - description: Send a new PIN to blink for 2FA. fields: pin: - name: Pin - description: PIN received from blink. Leave empty if you only received a verification email. example: "abc123" selector: text: diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index 61c9a21af37..85556bbcd5a 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -10,7 +10,9 @@ }, "2fa": { "title": "Two-factor authentication", - "data": { "2fa": "Two-factor code" }, + "data": { + "2fa": "Two-factor code" + }, "description": "Enter the PIN sent via email or SMS" } }, @@ -46,5 +48,53 @@ "name": "Camera armed" } } + }, + "services": { + "blink_update": { + "name": "Update", + "description": "Forces a refresh." + }, + "trigger_camera": { + "name": "Trigger camera", + "description": "Requests camera to take new image." + }, + "save_video": { + "name": "Save video", + "description": "Saves last recorded video clip to local file.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "Name of camera to grab video from." + }, + "filename": { + "name": "File name", + "description": "Filename to writable path (directory may need to be included in allowlist_external_dirs in config)." + } + } + }, + "save_recent_clips": { + "name": "Save recent clips", + "description": "Saves all recent video clips to local directory with file pattern \"%Y%m%d_%H%M%S_{name}.mp4\".", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "Name of camera to grab recent clips from." + }, + "file_path": { + "name": "Output directory", + "description": "Directory name of writable path (directory may need to be included in allowlist_external_dirs in config)." + } + } + }, + "send_pin": { + "name": "Send pin", + "description": "Sends a new PIN to blink for 2FA.", + "fields": { + "pin": { + "name": "Pin", + "description": "PIN received from blink. Leave empty if you only received a verification email." + } + } + } } } diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 69e115470ad..91984cf6247 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -694,7 +694,7 @@ class BluesoundPlayer(MediaPlayerEntity): for source in [ x for x in self._services_items - if x["type"] == "LocalMusic" or x["type"] == "RadioService" + if x["type"] in ("LocalMusic", "RadioService") ]: sources.append(source["title"]) diff --git a/homeassistant/components/bluesound/services.yaml b/homeassistant/components/bluesound/services.yaml index 7c04cc00f39..7ab69a82124 100644 --- a/homeassistant/components/bluesound/services.yaml +++ b/homeassistant/components/bluesound/services.yaml @@ -1,54 +1,36 @@ join: - name: Join - description: Group player together. fields: master: - name: Master - description: Entity ID of the player that should become the master of the group. required: true selector: entity: integration: bluesound domain: media_player entity_id: - name: Entity - description: Name of entity that will coordinate the grouping. Platform dependent. selector: entity: integration: bluesound domain: media_player unjoin: - name: Unjoin - description: Unjoin the player from a group. fields: entity_id: - name: Entity - description: Name of entity that will be unjoined from their group. Platform dependent. selector: entity: integration: bluesound domain: media_player set_sleep_timer: - name: Set sleep timer - description: "Set a Bluesound timer. It will increase timer in steps: 15, 30, 45, 60, 90, 0" fields: entity_id: - name: Entity - description: Name(s) of entities that will have a timer set. selector: entity: integration: bluesound domain: media_player clear_sleep_timer: - name: Clear sleep timer - description: Clear a Bluesound timer. fields: entity_id: - name: Entity - description: Name(s) of entities that will have the timer cleared. selector: entity: integration: bluesound diff --git a/homeassistant/components/bluesound/strings.json b/homeassistant/components/bluesound/strings.json new file mode 100644 index 00000000000..f41c34a7449 --- /dev/null +++ b/homeassistant/components/bluesound/strings.json @@ -0,0 +1,48 @@ +{ + "services": { + "join": { + "name": "Join", + "description": "Group player together.", + "fields": { + "master": { + "name": "Master", + "description": "Entity ID of the player that should become the master of the group." + }, + "entity_id": { + "name": "Entity", + "description": "Name of entity that will coordinate the grouping. Platform dependent." + } + } + }, + "unjoin": { + "name": "Unjoin", + "description": "Unjoin the player from a group.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name of entity that will be unjoined from their group. Platform dependent." + } + } + }, + "set_sleep_timer": { + "name": "Set sleep timer", + "description": "Set a Bluesound timer. It will increase timer in steps: 15, 30, 45, 60, 90, 0.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name(s) of entities that will have a timer set." + } + } + }, + "clear_sleep_timer": { + "name": "Clear sleep timer", + "description": "Clear a Bluesound timer.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name(s) of entities that will have the timer cleared." + } + } + } + } +} diff --git a/homeassistant/components/bluetooth/advertisement_tracker.py b/homeassistant/components/bluetooth/advertisement_tracker.py index 3936435f84e..b6a70e32865 100644 --- a/homeassistant/components/bluetooth/advertisement_tracker.py +++ b/homeassistant/components/bluetooth/advertisement_tracker.py @@ -18,6 +18,8 @@ TRACKER_BUFFERING_WOBBLE_SECONDS = 5 class AdvertisementTracker: """Tracker to determine the interval that a device is advertising.""" + __slots__ = ("intervals", "sources", "_timings") + def __init__(self) -> None: """Initialize the tracker.""" self.intervals: dict[str, float] = {} diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index e8de285138e..455619182ab 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -303,7 +303,19 @@ class BaseHaRemoteScanner(BaseHaScanner): ) -> None: """Call the registered callback.""" self._last_detection = advertisement_monotonic_time - if prev_discovery := self._discovered_device_advertisement_datas.get(address): + try: + prev_discovery = self._discovered_device_advertisement_datas[address] + except KeyError: + # We expect this is the rare case and since py3.11+ has + # near zero cost try on success, and we can avoid .get() + # which is slower than [] we use the try/except pattern. + device = BLEDevice( + address=address, + name=local_name, + details=self._details | details, + rssi=rssi, # deprecated, will be removed in newer bleak + ) + else: # Merge the new data with the old data # to function the same as BlueZ which # merges the dicts on PropertiesChanged @@ -344,13 +356,6 @@ class BaseHaRemoteScanner(BaseHaScanner): device.details = self._details | details # pylint: disable-next=protected-access device._rssi = rssi # deprecated, will be removed in newer bleak - else: - device = BLEDevice( - address=address, - name=local_name, - details=self._details | details, - rssi=rssi, # deprecated, will be removed in newer bleak - ) advertisement_data = AdvertisementData( local_name=None if local_name == "" else local_name, diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index f1221290c74..ce778e0309b 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -102,6 +102,28 @@ def _dispatch_bleak_callback( class BluetoothManager: """Manage Bluetooth.""" + __slots__ = ( + "hass", + "_integration_matcher", + "_cancel_unavailable_tracking", + "_cancel_logging_listener", + "_advertisement_tracker", + "_unavailable_callbacks", + "_connectable_unavailable_callbacks", + "_callback_index", + "_bleak_callbacks", + "_all_history", + "_connectable_history", + "_non_connectable_scanners", + "_connectable_scanners", + "_adapters", + "_sources", + "_bluetooth_adapters", + "storage", + "slot_manager", + "_debug", + ) + def __init__( self, hass: HomeAssistant, @@ -413,23 +435,20 @@ class BluetoothManager: # Pre-filter noisy apple devices as they can account for 20-35% of the # traffic on a typical network. - advertisement_data = service_info.advertisement - manufacturer_data = advertisement_data.manufacturer_data if ( - len(manufacturer_data) == 1 - and (apple_data := manufacturer_data.get(APPLE_MFR_ID)) - and apple_data[0] not in APPLE_START_BYTES_WANTED - and not advertisement_data.service_data + (manufacturer_data := service_info.manufacturer_data) + and APPLE_MFR_ID in manufacturer_data + and manufacturer_data[APPLE_MFR_ID][0] not in APPLE_START_BYTES_WANTED + and len(manufacturer_data) == 1 + and not service_info.service_data ): return - device = service_info.device - address = device.address + address = service_info.device.address all_history = self._all_history connectable = service_info.connectable connectable_history = self._connectable_history old_connectable_service_info = connectable and connectable_history.get(address) - source = service_info.source # This logic is complex due to the many combinations of scanners # that are supported. @@ -544,13 +563,17 @@ class BluetoothManager: "%s: %s %s match: %s", self._async_describe_source(service_info), address, - advertisement_data, + service_info.advertisement, matched_domains, ) - if connectable or old_connectable_service_info: + if (connectable or old_connectable_service_info) and ( + bleak_callbacks := self._bleak_callbacks + ): # Bleak callbacks must get a connectable device - for callback_filters in self._bleak_callbacks: + device = service_info.device + advertisement_data = service_info.advertisement + for callback_filters in bleak_callbacks: _dispatch_bleak_callback(*callback_filters, device, advertisement_data) for match in self._callback_index.match_callbacks(service_info): diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index dbe8ac3f1ab..bc07e2b94ae 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,10 +15,10 @@ "quality_scale": "internal", "requirements": [ "bleak==0.20.2", - "bleak-retry-connector==3.0.2", - "bluetooth-adapters==0.15.3", - "bluetooth-auto-recovery==1.2.0", - "bluetooth-data-tools==1.3.0", - "dbus-fast==1.86.0" + "bleak-retry-connector==3.1.1", + "bluetooth-adapters==0.16.0", + "bluetooth-auto-recovery==1.2.1", + "bluetooth-data-tools==1.6.1", + "dbus-fast==1.87.5" ] } diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index 911862a4221..35efbdf3cbe 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -53,6 +53,7 @@ NEED_RESET_ERRORS = [ "org.bluez.Error.Failed", "org.bluez.Error.InProgress", "org.bluez.Error.NotReady", + "not found", ] # When the adapter is still initializing, the scanner will raise an exception diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index cae88ef24c1..4b168126251 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -5,7 +5,7 @@ "user": { "description": "Choose a device to set up", "data": { - "address": "Device" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index ed2bfb5ffac..0c41b58c63d 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -3,7 +3,6 @@ from __future__ import annotations from abc import ABC, abstractmethod import logging -from typing import cast from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -70,7 +69,7 @@ class BasePassiveBluetoothCoordinator(ABC): if service_info := async_last_service_info( self.hass, self.address, self.connectable ): - return cast(str, service_info.name) # for compat this can be a pyobjc + return service_info.name return self._last_name @property diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 67e401cd40a..2ae036080f8 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -6,13 +6,18 @@ from collections.abc import Callable import contextlib from dataclasses import dataclass from functools import partial +import inspect import logging from typing import TYPE_CHECKING, Any, Final from bleak import BleakClient, BleakError from bleak.backends.client import BaseBleakClient, get_platform_client_backend_type from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementDataCallback, BaseBleakScanner +from bleak.backends.scanner import ( + AdvertisementData, + AdvertisementDataCallback, + BaseBleakScanner, +) from bleak_retry_connector import ( NO_RSSI_VALUE, ble_device_description, @@ -58,6 +63,7 @@ class HaBleakScannerWrapper(BaseBleakScanner): self._detection_cancel: CALLBACK_TYPE | None = None self._mapped_filters: dict[str, set[str]] = {} self._advertisement_data_callback: AdvertisementDataCallback | None = None + self._background_tasks: set[asyncio.Task] = set() remapped_kwargs = { "detection_callback": detection_callback, "service_uuids": service_uuids or [], @@ -128,12 +134,24 @@ class HaBleakScannerWrapper(BaseBleakScanner): """Set up the detection callback.""" if self._advertisement_data_callback is None: return + callback = self._advertisement_data_callback self._cancel_callback() super().register_detection_callback(self._advertisement_data_callback) assert models.MANAGER is not None - assert self._callback is not None + + if not inspect.iscoroutinefunction(callback): + detection_callback = callback + else: + + def detection_callback( + ble_device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + task = asyncio.create_task(callback(ble_device, advertisement_data)) + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + self._detection_cancel = models.MANAGER.async_register_bleak_callback( - self._callback, self._mapped_filters + detection_callback, self._mapped_filters ) def __del__(self) -> None: diff --git a/homeassistant/components/bluetooth_le_tracker/manifest.json b/homeassistant/components/bluetooth_le_tracker/manifest.json index 9c13bcc8c94..79f885cad18 100644 --- a/homeassistant/components/bluetooth_le_tracker/manifest.json +++ b/homeassistant/components/bluetooth_le_tracker/manifest.json @@ -4,6 +4,5 @@ "codeowners": [], "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bluetooth_le_tracker", - "iot_class": "local_push", - "loggers": [] + "iot_class": "local_push" } diff --git a/homeassistant/components/bluetooth_tracker/services.yaml b/homeassistant/components/bluetooth_tracker/services.yaml index 3150403dbf1..91b8669505b 100644 --- a/homeassistant/components/bluetooth_tracker/services.yaml +++ b/homeassistant/components/bluetooth_tracker/services.yaml @@ -1,3 +1 @@ update: - name: Update - description: Trigger manual tracker update diff --git a/homeassistant/components/bluetooth_tracker/strings.json b/homeassistant/components/bluetooth_tracker/strings.json new file mode 100644 index 00000000000..bf22845d054 --- /dev/null +++ b/homeassistant/components/bluetooth_tracker/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "update": { + "name": "Update", + "description": "Triggers manual tracker update." + } + } +} diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index fc91f8eb72e..08e4fb007b7 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["bond_async"], "quality_scale": "platinum", - "requirements": ["bond-async==0.1.23"], + "requirements": ["bond-async==0.2.1"], "zeroconf": ["_bond._tcp.local."] } diff --git a/homeassistant/components/bond/services.yaml b/homeassistant/components/bond/services.yaml index 6be18eaa1ef..bda0bc5835f 100644 --- a/homeassistant/components/bond/services.yaml +++ b/homeassistant/components/bond/services.yaml @@ -1,13 +1,9 @@ # Describes the format for available bond services set_fan_speed_tracked_state: - name: Set fan speed tracked state - description: Sets the tracked fan speed for a bond fan fields: entity_id: - description: Name(s) of entities to set the tracked fan speed. example: "fan.living_room_fan" - name: Entity required: true selector: entity: @@ -15,8 +11,6 @@ set_fan_speed_tracked_state: domain: fan speed: required: true - name: Fan Speed - description: Fan Speed as %. example: 50 selector: number: @@ -26,13 +20,9 @@ set_fan_speed_tracked_state: mode: slider set_switch_power_tracked_state: - name: Set switch power tracked state - description: Sets the tracked power state of a bond switch fields: entity_id: - description: Name(s) of entities to set the tracked power state of. example: "switch.whatever" - name: Entity required: true selector: entity: @@ -40,20 +30,14 @@ set_switch_power_tracked_state: domain: switch power_state: required: true - name: Power state - description: Power state example: true selector: boolean: set_light_power_tracked_state: - name: Set light power tracked state - description: Sets the tracked power state of a bond light fields: entity_id: - description: Name(s) of entities to set the tracked power state of. example: "light.living_room_lights" - name: Entity required: true selector: entity: @@ -61,20 +45,14 @@ set_light_power_tracked_state: domain: light power_state: required: true - name: Power state - description: Power state example: true selector: boolean: set_light_brightness_tracked_state: - name: Set light brightness tracked state - description: Sets the tracked brightness state of a bond light fields: entity_id: - description: Name(s) of entities to set the tracked brightness state of. example: "light.living_room_lights" - name: Entity required: true selector: entity: @@ -82,8 +60,6 @@ set_light_brightness_tracked_state: domain: light brightness: required: true - name: Brightness - description: Brightness example: 50 selector: number: @@ -93,24 +69,18 @@ set_light_brightness_tracked_state: mode: slider start_increasing_brightness: - name: Start increasing brightness - description: "Start increasing the brightness of the light. (deprecated)" target: entity: integration: bond domain: light start_decreasing_brightness: - name: Start decreasing brightness - description: "Start decreasing the brightness of the light. (deprecated)" target: entity: integration: bond domain: light stop: - name: Stop - description: "Stop any in-progress action and empty the queue. (deprecated)" target: entity: integration: bond diff --git a/homeassistant/components/bond/strings.json b/homeassistant/components/bond/strings.json index e923ded939e..4c7c224bc44 100644 --- a/homeassistant/components/bond/strings.json +++ b/homeassistant/components/bond/strings.json @@ -24,5 +24,75 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "services": { + "set_fan_speed_tracked_state": { + "name": "Set fan speed tracked state", + "description": "Sets the tracked fan speed for a bond fan.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name(s) of entities to set the tracked fan speed." + }, + "speed": { + "name": "Fan Speed", + "description": "Fan Speed as %." + } + } + }, + "set_switch_power_tracked_state": { + "name": "Set switch power tracked state", + "description": "Sets the tracked power state of a bond switch.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name(s) of entities to set the tracked power state of." + }, + "power_state": { + "name": "Power state", + "description": "Power state." + } + } + }, + "set_light_power_tracked_state": { + "name": "Set light power tracked state", + "description": "Sets the tracked power state of a bond light.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "[%key:component::bond::services::set_switch_power_tracked_state::fields::entity_id::description%]" + }, + "power_state": { + "name": "[%key:component::bond::services::set_switch_power_tracked_state::fields::power_state::name%]", + "description": "[%key:component::bond::services::set_switch_power_tracked_state::fields::power_state::description%]" + } + } + }, + "set_light_brightness_tracked_state": { + "name": "Set light brightness tracked state", + "description": "Sets the tracked brightness state of a bond light.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name(s) of entities to set the tracked brightness state of." + }, + "brightness": { + "name": "Brightness", + "description": "Brightness." + } + } + }, + "start_increasing_brightness": { + "name": "Start increasing brightness", + "description": "Start increasing the brightness of the light. (deprecated)." + }, + "start_decreasing_brightness": { + "name": "Start decreasing brightness", + "description": "Start decreasing the brightness of the light. (deprecated)." + }, + "stop": { + "name": "[%key:common::action::stop%]", + "description": "Stop any in-progress action and empty the queue. (deprecated)." + } } } diff --git a/homeassistant/components/bosch_shc/entity.py b/homeassistant/components/bosch_shc/entity.py index de3e2f9d3ea..3cf92a8adcc 100644 --- a/homeassistant/components/bosch_shc/entity.py +++ b/homeassistant/components/bosch_shc/entity.py @@ -15,9 +15,7 @@ async def async_remove_devices( ) -> None: """Get item that is removed from session.""" dev_registry = get_dev_reg(hass) - device = dev_registry.async_get_device( - identifiers={(DOMAIN, entity.device_id)}, connections=set() - ) + device = dev_registry.async_get_device(identifiers={(DOMAIN, entity.device_id)}) if device is not None: dev_registry.async_update_device(device.id, remove_config_entry_id=entry_id) diff --git a/homeassistant/components/braviatv/button.py b/homeassistant/components/braviatv/button.py index b382d97a2ae..1f6c9961c51 100644 --- a/homeassistant/components/braviatv/button.py +++ b/homeassistant/components/braviatv/button.py @@ -36,7 +36,6 @@ class BraviaTVButtonDescription( BUTTONS: tuple[BraviaTVButtonDescription, ...] = ( BraviaTVButtonDescription( key="reboot", - translation_key="restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=lambda coordinator: coordinator.async_reboot_device(), diff --git a/homeassistant/components/braviatv/const.py b/homeassistant/components/braviatv/const.py index 5925a97422a..34b621802f9 100644 --- a/homeassistant/components/braviatv/const.py +++ b/homeassistant/components/braviatv/const.py @@ -1,10 +1,9 @@ """Constants for Bravia TV integration.""" from __future__ import annotations +from enum import StrEnum from typing import Final -from homeassistant.backports.enum import StrEnum - ATTR_CID: Final = "cid" ATTR_MAC: Final = "macAddr" ATTR_MANUFACTURER: Final = "Sony" diff --git a/homeassistant/components/braviatv/strings.json b/homeassistant/components/braviatv/strings.json index aacaf81465b..8f8e728cb9d 100644 --- a/homeassistant/components/braviatv/strings.json +++ b/homeassistant/components/braviatv/strings.json @@ -15,14 +15,14 @@ } }, "pin": { - "title": "Authorize Sony Bravia TV", + "title": "[%key:component::braviatv::config::step::authorize::title%]", "description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Deregister remote device.", "data": { "pin": "[%key:common::config_flow::data::pin%]" } }, "psk": { - "title": "Authorize Sony Bravia TV", + "title": "[%key:component::braviatv::config::step::authorize::title%]", "description": "To set up PSK on your TV, go to: Settings -> Network -> Home Network Setup -> IP Control. Set «Authentication» to «Normal and Pre-Shared Key» or «Pre-Shared Key» and define your Pre-Shared-Key string (e.g. sony). \n\nThen enter your PSK here.", "data": { "pin": "PSK" @@ -47,9 +47,6 @@ }, "entity": { "button": { - "restart": { - "name": "[%key:component::button::entity_component::restart::name%]" - }, "terminate_apps": { "name": "Terminate apps" } diff --git a/homeassistant/components/broadlink/device.py b/homeassistant/components/broadlink/device.py index 87d8cf398fb..69e1161a65c 100644 --- a/homeassistant/components/broadlink/device.py +++ b/homeassistant/components/broadlink/device.py @@ -80,7 +80,9 @@ class BroadlinkDevice: """ device_registry = dr.async_get(hass) assert entry.unique_id - device_entry = device_registry.async_get_device({(DOMAIN, entry.unique_id)}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, entry.unique_id)} + ) assert device_entry device_registry.async_update_device(device_entry.id, name=entry.title) await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index 3ee3fe7609f..e24c941c514 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -12,7 +12,7 @@ "description": "Do you want to add the printer {model} with serial number `{serial_number}` to Home Assistant?", "title": "Discovered Brother Printer", "data": { - "type": "Type of the printer" + "type": "[%key:component::brother::config::step::user::data::type%]" } } }, @@ -44,7 +44,7 @@ "name": "Duplex unit page counter" }, "drum_remaining_life": { - "name": "Drum remaining life" + "name": "Drum remaining lifetime" }, "drum_remaining_pages": { "name": "Drum remaining pages" @@ -53,7 +53,7 @@ "name": "Drum page counter" }, "black_drum_remaining_life": { - "name": "Black drum remaining life" + "name": "Black drum remaining lifetime" }, "black_drum_remaining_pages": { "name": "Black drum remaining pages" @@ -62,7 +62,7 @@ "name": "Black drum page counter" }, "cyan_drum_remaining_life": { - "name": "Cyan drum remaining life" + "name": "Cyan drum remaining lifetime" }, "cyan_drum_remaining_pages": { "name": "Cyan drum remaining pages" @@ -71,7 +71,7 @@ "name": "Cyan drum page counter" }, "magenta_drum_remaining_life": { - "name": "Magenta drum remaining life" + "name": "Magenta drum remaining lifetime" }, "magenta_drum_remaining_pages": { "name": "Magenta drum remaining pages" @@ -80,7 +80,7 @@ "name": "Magenta drum page counter" }, "yellow_drum_remaining_life": { - "name": "Yellow drum remaining life" + "name": "Yellow drum remaining lifetime" }, "yellow_drum_remaining_pages": { "name": "Yellow drum remaining pages" @@ -89,19 +89,19 @@ "name": "Yellow drum page counter" }, "belt_unit_remaining_life": { - "name": "Belt unit remaining life" + "name": "Belt unit remaining lifetime" }, "fuser_remaining_life": { - "name": "Fuser remaining life" + "name": "Fuser remaining lifetime" }, "laser_remaining_life": { - "name": "Laser remaining life" + "name": "Laser remaining lifetime" }, "pf_kit_1_remaining_life": { - "name": "PF Kit 1 remaining life" + "name": "PF Kit 1 remaining lifetime" }, "pf_kit_mp_remaining_life": { - "name": "PF Kit MP remaining life" + "name": "PF Kit MP remaining lifetime" }, "black_toner_remaining": { "name": "Black toner remaining" diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index 5512bcd1176..add558ff48b 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -45,12 +45,17 @@ async def async_setup_platform( async_create_issue( hass, - DOMAIN, - "deprecated_yaml", + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", breaks_in_ha_version="2023.11.0", is_fixable=False, + issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Brottsplatskartan", + }, ) hass.async_create_task( diff --git a/homeassistant/components/brottsplatskartan/strings.json b/homeassistant/components/brottsplatskartan/strings.json index 8d9677a0af4..f10120f7884 100644 --- a/homeassistant/components/brottsplatskartan/strings.json +++ b/homeassistant/components/brottsplatskartan/strings.json @@ -16,12 +16,6 @@ } } }, - "issues": { - "deprecated_yaml": { - "title": "The Brottsplatskartan YAML configuration is being removed", - "description": "Configuring Brottsplatskartan using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Brottsplatskartan YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - }, "selector": { "areas": { "options": { diff --git a/homeassistant/components/browser/services.yaml b/homeassistant/components/browser/services.yaml index dd3ddd095cc..c2192911eea 100644 --- a/homeassistant/components/browser/services.yaml +++ b/homeassistant/components/browser/services.yaml @@ -1,10 +1,6 @@ browse_url: - name: Browse - description: Open a URL in the default browser on the host machine of Home Assistant. fields: url: - name: URL - description: The URL to open. required: true example: "https://www.home-assistant.io" selector: diff --git a/homeassistant/components/browser/strings.json b/homeassistant/components/browser/strings.json new file mode 100644 index 00000000000..9083ba93795 --- /dev/null +++ b/homeassistant/components/browser/strings.json @@ -0,0 +1,14 @@ +{ + "services": { + "browse_url": { + "name": "Browse", + "description": "Opens a URL in the default browser on the host machine of Home Assistant.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "The URL to open." + } + } + } + } +} diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index 3fb328ab7fb..9f916e5751f 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -60,6 +60,10 @@ class BruntDevice( Contains the common logic for all Brunt devices. """ + _attr_has_entity_name = True + _attr_name = None + _attr_device_class = CoverDeviceClass.BLIND + _attr_attribution = ATTRIBUTION _attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -83,12 +87,9 @@ class BruntDevice( self._remove_update_listener = None - self._attr_name = self._thing.name - self._attr_device_class = CoverDeviceClass.BLIND - self._attr_attribution = ATTRIBUTION self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, # type: ignore[arg-type] - name=self._attr_name, + name=self._thing.name, via_device=(DOMAIN, self._entry_id), manufacturer="Brunt", sw_version=self._thing.fw_version, diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index dc403611da2..39eab6e7e0a 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -106,6 +106,10 @@ class BSBLANClimate( @property def current_temperature(self) -> float | None: """Return the current temperature.""" + if self.coordinator.data.current_temperature.value == "---": + # device returns no current temperature + return None + return float(self.coordinator.data.current_temperature.value) @property diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 0e945d13d48..5abb888513d 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@liudger"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bsblan", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["bsblan"], "requirements": ["python-bsblan==0.5.11"] diff --git a/homeassistant/components/bthome/config_flow.py b/homeassistant/components/bthome/config_flow.py index 6514f2c5396..a728efdf05a 100644 --- a/homeassistant/components/bthome/config_flow.py +++ b/homeassistant/components/bthome/config_flow.py @@ -80,7 +80,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN): if len(bindkey) != 32: errors["bindkey"] = "expected_32_characters" else: - self._discovered_device.bindkey = bytes.fromhex(bindkey) + self._discovered_device.set_bindkey(bytes.fromhex(bindkey)) # If we got this far we already know supported will # return true so we don't bother checking that again diff --git a/homeassistant/components/bthome/logbook.py b/homeassistant/components/bthome/logbook.py new file mode 100644 index 00000000000..4111777375d --- /dev/null +++ b/homeassistant/components/bthome/logbook.py @@ -0,0 +1,43 @@ +"""Describe bthome logbook events.""" +from __future__ import annotations + +from collections.abc import Callable + +from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import async_get +from homeassistant.helpers.typing import EventType + +from .const import ( + BTHOME_BLE_EVENT, + DOMAIN, + BTHomeBleEvent, +) + + +@callback +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[ + [str, str, Callable[[EventType[BTHomeBleEvent]], dict[str, str]]], None + ], +) -> None: + """Describe logbook events.""" + dr = async_get(hass) + + @callback + def async_describe_bthome_event(event: EventType[BTHomeBleEvent]) -> dict[str, str]: + """Describe bthome logbook event.""" + data = event.data + device = dr.async_get(data["device_id"]) + name = device and device.name or f'BTHome {data["address"]}' + if properties := data["event_properties"]: + message = f"{data['event_class']} {data['event_type']}: {properties}" + else: + message = f"{data['event_class']} {data['event_type']}" + return { + LOGBOOK_ENTRY_NAME: name, + LOGBOOK_ENTRY_MESSAGE: message, + } + + async_describe_event(DOMAIN, BTHOME_BLE_EVENT, async_describe_bthome_event) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index b38c1d3829b..418c7b8e3e3 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==2.12.1"] + "requirements": ["bthome-ble==3.0.0"] } diff --git a/homeassistant/components/buienradar/strings.json b/homeassistant/components/buienradar/strings.json index bac4e63e288..f254f7602f8 100644 --- a/homeassistant/components/buienradar/strings.json +++ b/homeassistant/components/buienradar/strings.json @@ -38,7 +38,7 @@ "name": "Barometer" }, "barometerfcnamenl": { - "name": "Barometer" + "name": "[%key:component::buienradar::entity::sensor::barometerfcname::name%]" }, "condition": { "name": "Condition", diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 0e2790a2e85..901acdcdec1 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -3,12 +3,12 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta +from enum import StrEnum import logging from typing import final import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 diff --git a/homeassistant/components/button/services.yaml b/homeassistant/components/button/services.yaml index 245368f9d5b..2f4d2c6fafe 100644 --- a/homeassistant/components/button/services.yaml +++ b/homeassistant/components/button/services.yaml @@ -1,6 +1,4 @@ press: - name: Press - description: Press the button entity. target: entity: domain: button diff --git a/homeassistant/components/button/strings.json b/homeassistant/components/button/strings.json index 006959d1b4c..f552e9ae12b 100644 --- a/homeassistant/components/button/strings.json +++ b/homeassistant/components/button/strings.json @@ -16,16 +16,16 @@ "name": "Identify" }, "restart": { - "name": "Restart" + "name": "[%key:common::action::restart%]" }, "update": { "name": "Update" } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "press": { + "name": "Press", + "description": "Press the button entity." } } } diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index d56b2b0ddfa..c85f0d2bff1 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -455,7 +455,7 @@ def extract_offset(summary: str, offset_prefix: str) -> tuple[str, datetime.time if search and search.group(1): time = search.group(1) if ":" not in time: - if time[0] == "+" or time[0] == "-": + if time[0] in ("+", "-"): time = f"{time[0]}0:{time[1:]}" else: time = f"0:{time}" diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index af69882bba5..712d6ad8823 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -1,78 +1,54 @@ create_event: - name: Create event - description: Add a new calendar event. target: entity: domain: calendar + supported_features: + - calendar.CalendarEntityFeature.CREATE_EVENT fields: summary: - name: Summary - description: Defines the short summary or subject for the event required: true example: "Department Party" selector: text: description: - name: Description - description: A more complete description of the event than that provided by the summary. example: "Meeting to provide technical review for 'Phoenix' design." selector: text: start_date_time: - name: Start time - description: The date and time the event should start. example: "2022-03-22 20:00:00" selector: datetime: end_date_time: - name: End time - description: The date and time the event should end. example: "2022-03-22 22:00:00" selector: datetime: start_date: - name: Start date - description: The date the all-day event should start. example: "2022-03-22" selector: date: end_date: - name: End date - description: The date the all-day event should end (exclusive). example: "2022-03-23" selector: date: in: - name: In - description: Days or weeks that you want to create the event in. example: '{"days": 2} or {"weeks": 2}' location: - name: Location - description: The location of the event. example: "Conference Room - F123, Bldg. 002" selector: text: list_events: - name: List event - description: List events on a calendar within a time range. target: entity: domain: calendar fields: start_date_time: - name: Start time - description: Return active events after this time (exclusive). When not set, defaults to now. example: "2022-03-22 20:00:00" selector: datetime: end_date_time: - name: End time - description: Return active events before this time (exclusive). Cannot be used with 'duration'. example: "2022-03-22 22:00:00" selector: datetime: duration: - name: Duration - description: Return active events from start_date_time until the specified duration. selector: duration: diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index b28f741c381..81334c12379 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -33,10 +33,62 @@ } } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "create_event": { + "name": "Create event", + "description": "Adds a new calendar event.", + "fields": { + "summary": { + "name": "Summary", + "description": "Defines the short summary or subject for the event." + }, + "description": { + "name": "Description", + "description": "A more complete description of the event than the one provided by the summary." + }, + "start_date_time": { + "name": "Start time", + "description": "The date and time the event should start." + }, + "end_date_time": { + "name": "End time", + "description": "The date and time the event should end." + }, + "start_date": { + "name": "Start date", + "description": "The date the all-day event should start." + }, + "end_date": { + "name": "End date", + "description": "The date the all-day event should end (exclusive)." + }, + "in": { + "name": "In", + "description": "Days or weeks that you want to create the event in." + }, + "location": { + "name": "Location", + "description": "The location of the event." + } + } + }, + "list_events": { + "name": "List event", + "description": "Lists events on a calendar within a time range.", + "fields": { + "start_date_time": { + "name": "Start time", + "description": "Returns active events after this time (exclusive). When not set, defaults to now." + }, + "end_date_time": { + "name": "End time", + "description": "Returns active events before this time (exclusive). Cannot be used with 'duration'." + }, + "duration": { + "name": "Duration", + "description": "Returns active events from start_date_time until the specified duration." + } + } } } } diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index b22e2996f7e..277aa10075e 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -514,8 +514,8 @@ class Camera(Entity): @property def available(self) -> bool: """Return True if entity is available.""" - if self.stream and not self.stream.available: - return self.stream.available + if (stream := self.stream) and not stream.available: + return False return super().available async def async_create_stream(self) -> Stream | None: diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index ab5832e48ab..f745f60b51a 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -1,8 +1,7 @@ """Constants for Camera component.""" +from enum import StrEnum from typing import Final -from homeassistant.backports.enum import StrEnum - DOMAIN: Final = "camera" DATA_CAMERA_PREFS: Final = "camera_prefs" diff --git a/homeassistant/components/camera/manifest.json b/homeassistant/components/camera/manifest.json index a0ae9d925a8..b1df158a260 100644 --- a/homeassistant/components/camera/manifest.json +++ b/homeassistant/components/camera/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/camera", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.6.7"] + "requirements": ["PyTurboJPEG==1.7.1"] } diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index 024bb927508..55ac9f2bfeb 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -1,65 +1,47 @@ # Describes the format for available camera services turn_off: - name: Turn off - description: Turn off camera. target: entity: domain: camera turn_on: - name: Turn on - description: Turn on camera. target: entity: domain: camera enable_motion_detection: - name: Enable motion detection - description: Enable the motion detection in a camera. target: entity: domain: camera disable_motion_detection: - name: Disable motion detection - description: Disable the motion detection in a camera. target: entity: domain: camera snapshot: - name: Take snapshot - description: Take a snapshot from a camera. target: entity: domain: camera fields: filename: - name: Filename - description: Template of a Filename. Variable is entity_id. required: true example: "/tmp/snapshot_{{ entity_id.name }}.jpg" selector: text: play_stream: - name: Play stream - description: Play camera stream on supported media player. target: entity: domain: camera fields: media_player: - name: Media Player - description: Name(s) of media player to stream to. required: true selector: entity: domain: media_player format: - name: Format - description: Stream format supported by media player. default: "hls" selector: select: @@ -67,22 +49,16 @@ play_stream: - "hls" record: - name: Record - description: Record live camera feed. target: entity: domain: camera fields: filename: - name: Filename - description: Template of a Filename. Variable is entity_id. Must be mp4. required: true example: "/tmp/snapshot_{{ entity_id.name }}.mp4" selector: text: duration: - name: Duration - description: Target recording length. default: 30 selector: number: @@ -90,10 +66,6 @@ record: max: 3600 unit_of_measurement: seconds lookback: - name: Lookback - description: - Target lookback period to include in addition to duration. Only - available if there is currently an active HLS stream. default: 0 selector: number: diff --git a/homeassistant/components/camera/strings.json b/homeassistant/components/camera/strings.json index f67097516b4..90b053ec087 100644 --- a/homeassistant/components/camera/strings.json +++ b/homeassistant/components/camera/strings.json @@ -25,8 +25,8 @@ "motion_detection": { "name": "Motion detection", "state": { - "true": "Enabled", - "false": "Disabled" + "true": "[%key:common::state::enabled%]", + "false": "[%key:common::state::disabled%]" } }, "model_name": { @@ -35,10 +35,65 @@ } } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Turns off the camera." + }, + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Turns on the camera." + }, + "enable_motion_detection": { + "name": "Enable motion detection", + "description": "Enables the motion detection." + }, + "disable_motion_detection": { + "name": "Disable motion detection", + "description": "Disables the motion detection." + }, + "snapshot": { + "name": "Take snapshot", + "description": "Takes a snapshot from a camera.", + "fields": { + "filename": { + "name": "Filename", + "description": "Template of a filename. Variable available is `entity_id`." + } + } + }, + "play_stream": { + "name": "Play stream", + "description": "Plays the camera stream on a supported media player.", + "fields": { + "media_player": { + "name": "Media player", + "description": "Media players to stream to." + }, + "format": { + "name": "Format", + "description": "Stream format supported by the media player." + } + } + }, + "record": { + "name": "Record", + "description": "Creates a recording of a live camera feed.", + "fields": { + "filename": { + "name": "[%key:component::camera::services::snapshot::fields::filename::name%]", + "description": "Template of a filename. Variable available is `entity_id`. Must be mp4." + }, + "duration": { + "name": "Duration", + "description": "Planned duration of the recording. The actual duration may vary." + }, + "lookback": { + "name": "Lookback", + "description": "Planned lookback period to include in the recording (in addition to the duration). Only available if there is currently an active HLS stream. The actual length of the lookback period may vary." + } + } } - } + }, + "selector": {} } diff --git a/homeassistant/components/cast/services.yaml b/homeassistant/components/cast/services.yaml index f0fbcf4a8d7..e2e23ad40a2 100644 --- a/homeassistant/components/cast/services.yaml +++ b/homeassistant/components/cast/services.yaml @@ -1,25 +1,17 @@ show_lovelace_view: - name: Show lovelace view - description: Show a Lovelace view on a Chromecast. fields: entity_id: - name: Entity - description: Media Player entity to show the Lovelace view on. required: true selector: entity: integration: cast domain: media_player dashboard_path: - name: Dashboard path - description: The URL path of the Lovelace dashboard to show. required: true example: lovelace-cast selector: text: view_path: - name: View Path - description: The path of the Lovelace view to show. example: downstairs selector: text: diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json index 719465e98ca..ce622e48aae 100644 --- a/homeassistant/components/cast/strings.json +++ b/homeassistant/components/cast/strings.json @@ -22,10 +22,10 @@ "options": { "step": { "basic_options": { - "title": "Google Cast configuration", - "description": "Known Hosts - A comma-separated list of hostnames or IP-addresses of cast devices, use if mDNS discovery is not working.", + "title": "[%key:component::cast::config::step::config::title%]", + "description": "[%key:component::cast::config::step::config::description%]", "data": { - "known_hosts": "Known hosts" + "known_hosts": "[%key:component::cast::config::step::config::data::known_hosts%]" } }, "advanced_options": { @@ -38,7 +38,27 @@ } }, "error": { - "invalid_known_hosts": "Known hosts must be a comma separated list of hosts." + "invalid_known_hosts": "[%key:component::cast::config::error::invalid_known_hosts%]" + } + }, + "services": { + "show_lovelace_view": { + "name": "Show dashboard view", + "description": "Shows a dashboard view on a Chromecast device.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Media player entity to show the dashboard view on." + }, + "dashboard_path": { + "name": "Dashboard path", + "description": "The URL path of the dashboard to show." + }, + "view_path": { + "name": "View path", + "description": "The path of the dashboard view to show." + } + } } } } diff --git a/homeassistant/components/cert_expiry/manifest.json b/homeassistant/components/cert_expiry/manifest.json index 5125f69d03a..df135b65bbe 100644 --- a/homeassistant/components/cert_expiry/manifest.json +++ b/homeassistant/components/cert_expiry/manifest.json @@ -1,7 +1,7 @@ { "domain": "cert_expiry", "name": "Certificate Expiry", - "codeowners": ["@Cereal2nd", "@jjlawren"], + "codeowners": ["@jjlawren"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cert_expiry", "iot_class": "cloud_polling" diff --git a/homeassistant/components/channels/services.yaml b/homeassistant/components/channels/services.yaml index 5aa2f1ebda7..73ac6675ccf 100644 --- a/homeassistant/components/channels/services.yaml +++ b/homeassistant/components/channels/services.yaml @@ -1,30 +1,22 @@ seek_forward: - name: Seek forward - description: Seek forward by a set number of seconds. target: entity: integration: channels domain: media_player seek_backward: - name: Seek backward - description: Seek backward by a set number of seconds. target: entity: integration: channels domain: media_player seek_by: - name: Seek by - description: Seek by an inputted number of seconds. target: entity: integration: channels domain: media_player fields: seconds: - name: Seconds - description: Number of seconds to seek by. Negative numbers seek backwards. required: true selector: number: diff --git a/homeassistant/components/channels/strings.json b/homeassistant/components/channels/strings.json new file mode 100644 index 00000000000..0eceed8a8e0 --- /dev/null +++ b/homeassistant/components/channels/strings.json @@ -0,0 +1,22 @@ +{ + "services": { + "seek_forward": { + "name": "Seek forward", + "description": "Seeks forward by a set number of seconds." + }, + "seek_backward": { + "name": "Seek backward", + "description": "Seeks backward by a set number of seconds." + }, + "seek_by": { + "name": "Seek by", + "description": "Seeks by an inputted number of seconds.", + "fields": { + "seconds": { + "name": "Seconds", + "description": "Number of seconds to seek by. Negative numbers seek backwards." + } + } + } + } +} diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index e62cb1143b5..907ff84491b 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -239,11 +239,12 @@ class ClimateEntity(Entity): @property def state(self) -> str | None: """Return the current state.""" - if self.hvac_mode is None: + hvac_mode = self.hvac_mode + if hvac_mode is None: return None - if not isinstance(self.hvac_mode, HVACMode): - return HVACMode(self.hvac_mode).value - return self.hvac_mode.value + if not isinstance(hvac_mode, HVACMode): + return HVACMode(hvac_mode).value + return hvac_mode.value @property def precision(self) -> float: @@ -258,18 +259,18 @@ class ClimateEntity(Entity): def capability_attributes(self) -> dict[str, Any] | None: """Return the capability attributes.""" supported_features = self.supported_features + temperature_unit = self.temperature_unit + precision = self.precision + hass = self.hass + data: dict[str, Any] = { ATTR_HVAC_MODES: self.hvac_modes, - ATTR_MIN_TEMP: show_temp( - self.hass, self.min_temp, self.temperature_unit, self.precision - ), - ATTR_MAX_TEMP: show_temp( - self.hass, self.max_temp, self.temperature_unit, self.precision - ), + ATTR_MIN_TEMP: show_temp(hass, self.min_temp, temperature_unit, precision), + ATTR_MAX_TEMP: show_temp(hass, self.max_temp, temperature_unit, precision), } - if self.target_temperature_step: - data[ATTR_TARGET_TEMP_STEP] = self.target_temperature_step + if target_temperature_step := self.target_temperature_step: + data[ATTR_TARGET_TEMP_STEP] = target_temperature_step if supported_features & ClimateEntityFeature.TARGET_HUMIDITY: data[ATTR_MIN_HUMIDITY] = self.min_humidity @@ -291,39 +292,34 @@ class ClimateEntity(Entity): def state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" supported_features = self.supported_features + temperature_unit = self.temperature_unit + precision = self.precision + hass = self.hass + data: dict[str, str | float | None] = { ATTR_CURRENT_TEMPERATURE: show_temp( - self.hass, - self.current_temperature, - self.temperature_unit, - self.precision, + hass, self.current_temperature, temperature_unit, precision ), } if supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: data[ATTR_TEMPERATURE] = show_temp( - self.hass, + hass, self.target_temperature, - self.temperature_unit, - self.precision, + temperature_unit, + precision, ) if supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: data[ATTR_TARGET_TEMP_HIGH] = show_temp( - self.hass, - self.target_temperature_high, - self.temperature_unit, - self.precision, + hass, self.target_temperature_high, temperature_unit, precision ) data[ATTR_TARGET_TEMP_LOW] = show_temp( - self.hass, - self.target_temperature_low, - self.temperature_unit, - self.precision, + hass, self.target_temperature_low, temperature_unit, precision ) - if self.current_humidity is not None: - data[ATTR_CURRENT_HUMIDITY] = self.current_humidity + if (current_humidity := self.current_humidity) is not None: + data[ATTR_CURRENT_HUMIDITY] = current_humidity if supported_features & ClimateEntityFeature.TARGET_HUMIDITY: data[ATTR_HUMIDITY] = self.target_humidity @@ -331,8 +327,8 @@ class ClimateEntity(Entity): if supported_features & ClimateEntityFeature.FAN_MODE: data[ATTR_FAN_MODE] = self.fan_mode - if self.hvac_action: - data[ATTR_HVAC_ACTION] = self.hvac_action + if hvac_action := self.hvac_action: + data[ATTR_HVAC_ACTION] = hvac_action if supported_features & ClimateEntityFeature.PRESET_MODE: data[ATTR_PRESET_MODE] = self.preset_mode diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 41d4646aeae..23c76c151d7 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -1,8 +1,6 @@ """Provides the constants needed for component.""" -from enum import IntFlag - -from homeassistant.backports.enum import StrEnum +from enum import IntFlag, StrEnum class HVACMode(StrEnum): diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml index 33e114c87f5..405bb735b66 100644 --- a/homeassistant/components/climate/services.yaml +++ b/homeassistant/components/climate/services.yaml @@ -1,8 +1,6 @@ # Describes the format for available climate services set_aux_heat: - name: Turn on/off auxiliary heater - description: Turn auxiliary heater on/off for climate device. target: entity: domain: climate @@ -10,15 +8,11 @@ set_aux_heat: - climate.ClimateEntityFeature.AUX_HEAT fields: aux_heat: - name: Auxiliary heating - description: New value of auxiliary heater. required: true selector: boolean: set_preset_mode: - name: Set preset mode - description: Set preset mode for climate device. target: entity: domain: climate @@ -26,16 +20,12 @@ set_preset_mode: - climate.ClimateEntityFeature.PRESET_MODE fields: preset_mode: - name: Preset mode - description: New value of preset mode. required: true example: "away" selector: text: set_temperature: - name: Set temperature - description: Set target temperature of climate device. target: entity: domain: climate @@ -44,8 +34,6 @@ set_temperature: - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE fields: temperature: - name: Temperature - description: New target temperature for HVAC. filter: supported_features: - climate.ClimateEntityFeature.TARGET_TEMPERATURE @@ -56,8 +44,6 @@ set_temperature: step: 0.1 mode: box target_temp_high: - name: Target temperature high - description: New target high temperature for HVAC. filter: supported_features: - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -69,8 +55,6 @@ set_temperature: step: 0.1 mode: box target_temp_low: - name: Target temperature low - description: New target low temperature for HVAC. filter: supported_features: - climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -82,29 +66,18 @@ set_temperature: step: 0.1 mode: box hvac_mode: - name: HVAC mode - description: HVAC operation mode to set temperature to. selector: select: options: - - label: "Off" - value: "off" - - label: "Auto" - value: "auto" - - label: "Cool" - value: "cool" - - label: "Dry" - value: "dry" - - label: "Fan Only" - value: "fan_only" - - label: "Heat/Cool" - value: "heat_cool" - - label: "Heat" - value: "heat" - + - "off" + - "auto" + - "cool" + - "dry" + - "fan_only" + - "heat_cool" + - "heat" + translation_key: hvac_mode set_humidity: - name: Set target humidity - description: Set target humidity of climate device. target: entity: domain: climate @@ -112,8 +85,6 @@ set_humidity: - climate.ClimateEntityFeature.TARGET_HUMIDITY fields: humidity: - name: Humidity - description: New target humidity for climate device. required: true selector: number: @@ -122,8 +93,6 @@ set_humidity: unit_of_measurement: "%" set_fan_mode: - name: Set fan mode - description: Set fan operation for climate device. target: entity: domain: climate @@ -131,44 +100,29 @@ set_fan_mode: - climate.ClimateEntityFeature.FAN_MODE fields: fan_mode: - name: Fan mode - description: New value of fan mode. required: true example: "low" selector: text: set_hvac_mode: - name: Set HVAC mode - description: Set HVAC operation mode for climate device. target: entity: domain: climate fields: hvac_mode: - name: HVAC mode - description: New value of operation mode. selector: select: options: - - label: "Off" - value: "off" - - label: "Auto" - value: "auto" - - label: "Cool" - value: "cool" - - label: "Dry" - value: "dry" - - label: "Fan Only" - value: "fan_only" - - label: "Heat/Cool" - value: "heat_cool" - - label: "Heat" - value: "heat" - + - "off" + - "auto" + - "cool" + - "dry" + - "fan_only" + - "heat_cool" + - "heat" + translation_key: hvac_mode set_swing_mode: - name: Set swing mode - description: Set swing operation for climate device. target: entity: domain: climate @@ -176,23 +130,17 @@ set_swing_mode: - climate.ClimateEntityFeature.SWING_MODE fields: swing_mode: - name: Swing mode - description: New value of swing mode. required: true example: "horizontal" selector: text: turn_on: - name: Turn on - description: Turn climate device on. target: entity: domain: climate turn_off: - name: Turn off - description: Turn climate device off. target: entity: domain: climate diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 73ac4d6fbc4..c517bfd7a20 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -28,9 +28,15 @@ "fan_only": "Fan only" }, "state_attributes": { - "aux_heat": { "name": "Aux heat" }, - "current_humidity": { "name": "Current humidity" }, - "current_temperature": { "name": "Current temperature" }, + "aux_heat": { + "name": "Aux heat" + }, + "current_humidity": { + "name": "Current humidity" + }, + "current_temperature": { + "name": "Current temperature" + }, "fan_mode": { "name": "Fan mode", "state": { @@ -49,11 +55,13 @@ "fan_modes": { "name": "Fan modes" }, - "humidity": { "name": "Target humidity" }, + "humidity": { + "name": "Target humidity" + }, "hvac_action": { "name": "Current action", "state": { - "off": "Off", + "off": "[%key:common::state::off%]", "preheating": "Preheating", "heating": "Heating", "cooling": "Cooling", @@ -65,10 +73,18 @@ "hvac_modes": { "name": "HVAC modes" }, - "max_humidity": { "name": "Max target humidity" }, - "max_temp": { "name": "Max target temperature" }, - "min_humidity": { "name": "Min target humidity" }, - "min_temp": { "name": "Min target temperature" }, + "max_humidity": { + "name": "Max target humidity" + }, + "max_temp": { + "name": "Max target temperature" + }, + "min_humidity": { + "name": "Min target humidity" + }, + "min_temp": { + "name": "Min target temperature" + }, "preset_mode": { "name": "Preset", "state": { @@ -98,17 +114,124 @@ "swing_modes": { "name": "Swing modes" }, - "target_temp_high": { "name": "Upper target temperature" }, - "target_temp_low": { "name": "Lower target temperature" }, - "target_temp_step": { "name": "Target temperature step" }, - "temperature": { "name": "Target temperature" } + "target_temp_high": { + "name": "Upper target temperature" + }, + "target_temp_low": { + "name": "Lower target temperature" + }, + "target_temp_step": { + "name": "Target temperature step" + }, + "temperature": { + "name": "Target temperature" + } } } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "set_aux_heat": { + "name": "Turn on/off auxiliary heater", + "description": "Turns auxiliary heater on/off.", + "fields": { + "aux_heat": { + "name": "Auxiliary heating", + "description": "New value of auxiliary heater." + } + } + }, + "set_preset_mode": { + "name": "Set preset mode", + "description": "Sets preset mode.", + "fields": { + "preset_mode": { + "name": "Preset mode", + "description": "Preset mode." + } + } + }, + "set_temperature": { + "name": "Set target temperature", + "description": "Sets target temperature.", + "fields": { + "temperature": { + "name": "Temperature", + "description": "Target temperature." + }, + "target_temp_high": { + "name": "Target temperature high", + "description": "High target temperature." + }, + "target_temp_low": { + "name": "Target temperature low", + "description": "Low target temperature." + }, + "hvac_mode": { + "name": "HVAC mode", + "description": "HVAC operation mode." + } + } + }, + "set_humidity": { + "name": "Set target humidity", + "description": "Sets target humidity.", + "fields": { + "humidity": { + "name": "Humidity", + "description": "Target humidity." + } + } + }, + "set_fan_mode": { + "name": "Set fan mode", + "description": "Sets fan operation mode.", + "fields": { + "fan_mode": { + "name": "Fan mode", + "description": "Fan operation mode." + } + } + }, + "set_hvac_mode": { + "name": "Set HVAC mode", + "description": "Sets HVAC operation mode.", + "fields": { + "hvac_mode": { + "name": "HVAC mode", + "description": "HVAC operation mode." + } + } + }, + "set_swing_mode": { + "name": "Set swing mode", + "description": "Sets swing operation mode.", + "fields": { + "swing_mode": { + "name": "Swing mode", + "description": "Swing operation mode." + } + } + }, + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Turns climate device on." + }, + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Turns climate device off." + } + }, + "selector": { + "hvac_mode": { + "options": { + "off": "Off", + "auto": "Auto", + "cool": "Cool", + "dry": "Dry", + "fan_only": "Fan only", + "heat_cool": "Heat/cool", + "heat": "Heat" + } } } } diff --git a/homeassistant/components/cloud/services.yaml b/homeassistant/components/cloud/services.yaml index 1b676ea6be9..b54d35d4221 100644 --- a/homeassistant/components/cloud/services.yaml +++ b/homeassistant/components/cloud/services.yaml @@ -1,9 +1,4 @@ # Describes the format for available cloud services remote_connect: - name: Remote connect - description: Make instance UI available outside over NabuCasa cloud - remote_disconnect: - name: Remote disconnect - description: Disconnect UI from NabuCasa cloud diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index a3cf7fe0457..aba2e770bc9 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -30,5 +30,15 @@ } } } + }, + "services": { + "remote_connect": { + "name": "Remote connect", + "description": "Makes the instance UI accessible from outside of the local network by using Home Assistant Cloud." + }, + "remote_disconnect": { + "name": "Remote disconnect", + "description": "Disconnects the Home Assistant UI from the Home Assistant Cloud. You will no longer be able to access your Home Assistant instance from outside your local network." + } } } diff --git a/homeassistant/components/cloudflare/services.yaml b/homeassistant/components/cloudflare/services.yaml index f9465e788d8..e800a3a3eee 100644 --- a/homeassistant/components/cloudflare/services.yaml +++ b/homeassistant/components/cloudflare/services.yaml @@ -1,3 +1 @@ update_records: - name: Update records - description: Manually trigger update to Cloudflare records diff --git a/homeassistant/components/cloudflare/strings.json b/homeassistant/components/cloudflare/strings.json index 89bc67feeed..080be414b5c 100644 --- a/homeassistant/components/cloudflare/strings.json +++ b/homeassistant/components/cloudflare/strings.json @@ -38,5 +38,11 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "services": { + "update_records": { + "name": "Update records", + "description": "Manually trigger update to Cloudflare records." + } } } diff --git a/homeassistant/components/co2signal/const.py b/homeassistant/components/co2signal/const.py index a1264acc9ff..1e0cbfe0f11 100644 --- a/homeassistant/components/co2signal/const.py +++ b/homeassistant/components/co2signal/const.py @@ -3,4 +3,4 @@ DOMAIN = "co2signal" CONF_COUNTRY_CODE = "country_code" -ATTRIBUTION = "Data provided by CO2signal" +ATTRIBUTION = "Data provided by Electricity Maps" diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index b4dc01d03aa..a0a3ee71a9c 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -1,6 +1,6 @@ { "domain": "co2signal", - "name": "CO2 Signal", + "name": "Electricity Maps", "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/co2signal", diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 9f133c0b0ca..ae22fb7b7ef 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -75,11 +75,11 @@ class CO2Sensor(CoordinatorEntity[CO2SignalCoordinator], SensorEntity): "country_code": coordinator.data["countryCode"], } self._attr_device_info = DeviceInfo( - configuration_url="https://www.electricitymap.org/", + configuration_url="https://www.electricitymaps.com/", entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinator.entry_id)}, - manufacturer="Tmrow.com", - name="CO2 signal", + manufacturer="Electricity Maps", + name="Electricity Maps", ) self._attr_unique_id = ( f"{coordinator.entry_id}_{description.unique_id or description.key}" diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index 05ea76f3179..01c5673d4b1 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -6,7 +6,7 @@ "location": "Get data for", "api_key": "[%key:common::config_flow::data::access_token%]" }, - "description": "Visit https://co2signal.com/ to request a token." + "description": "Visit https://electricitymaps.com/free-tier to request a token." }, "coordinates": { "data": { @@ -28,13 +28,17 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "api_ratelimit": "API Ratelimit exceeded" + "api_ratelimit": "[%key:component::co2signal::config::error::api_ratelimit%]" } }, "entity": { "sensor": { - "carbon_intensity": { "name": "CO2 intensity" }, - "fossil_fuel_percentage": { "name": "Grid fossil fuel percentage" } + "carbon_intensity": { + "name": "CO2 intensity" + }, + "fossil_fuel_percentage": { + "name": "Grid fossil fuel percentage" + } } } } diff --git a/homeassistant/components/color_extractor/services.yaml b/homeassistant/components/color_extractor/services.yaml index be278a59059..2fd0b0db815 100644 --- a/homeassistant/components/color_extractor/services.yaml +++ b/homeassistant/components/color_extractor/services.yaml @@ -1,25 +1,13 @@ turn_on: - name: Turn on - description: - Set the light RGB to the predominant color found in the image provided by - URL or file path. target: entity: domain: light fields: color_extract_url: - name: URL - description: - The URL of the image we want to extract RGB values from. Must be allowed - in allowlist_external_urls. example: https://www.example.com/images/logo.png selector: text: color_extract_path: - name: Path - description: - The full system path to the image we want to extract RGB values from. - Must be allowed in allowlist_external_dirs. example: /opt/images/logo.png selector: text: diff --git a/homeassistant/components/color_extractor/strings.json b/homeassistant/components/color_extractor/strings.json new file mode 100644 index 00000000000..3dc02f56030 --- /dev/null +++ b/homeassistant/components/color_extractor/strings.json @@ -0,0 +1,18 @@ +{ + "services": { + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Sets the light RGB to the predominant color found in the image provided by URL or file path.", + "fields": { + "color_extract_url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "The URL of the image we want to extract RGB values from. Must be allowed in allowlist_external_urls." + }, + "color_extract_path": { + "name": "[%key:common::config_flow::data::path%]", + "description": "The full system path to the image we want to extract RGB values from. Must be allowed in allowlist_external_dirs." + } + } + } + } +} diff --git a/homeassistant/components/comed/__init__.py b/homeassistant/components/comed/__init__.py new file mode 100644 index 00000000000..6808e129f87 --- /dev/null +++ b/homeassistant/components/comed/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Commonwealth Edison (ComEd).""" diff --git a/homeassistant/components/comed/manifest.json b/homeassistant/components/comed/manifest.json new file mode 100644 index 00000000000..355328481c3 --- /dev/null +++ b/homeassistant/components/comed/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "comed", + "name": "Commonwealth Edison (ComEd)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/command_line/services.yaml b/homeassistant/components/command_line/services.yaml index f4cec426860..c983a105c93 100644 --- a/homeassistant/components/command_line/services.yaml +++ b/homeassistant/components/command_line/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload all command_line entities diff --git a/homeassistant/components/command_line/strings.json b/homeassistant/components/command_line/strings.json index dab4a77a6ec..9fc0de2ab28 100644 --- a/homeassistant/components/command_line/strings.json +++ b/homeassistant/components/command_line/strings.json @@ -4,5 +4,11 @@ "title": "Command Line YAML configuration has moved", "description": "Configuring Command Line `{platform}` using YAML has moved.\n\nConsult the documentation to move your YAML configuration to integration key and restart Home Assistant to fix this issue." } + }, + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads command line configuration from the YAML-configuration." + } } } diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py index 4d6ff95b810..6abc5d3d5d0 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -17,10 +17,13 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from .const import ( CONF_COMPENSATION, @@ -124,10 +127,12 @@ class CompensationSensor(SensorEntity): return ret @callback - def _async_compensation_sensor_state_listener(self, event: Event) -> None: + def _async_compensation_sensor_state_listener( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle sensor state changes.""" new_state: State | None - if (new_state := event.data.get("new_state")) is None: + if (new_state := event.data["new_state"]) is None: return if self.native_unit_of_measurement is None and self._source_attribute is None: @@ -140,7 +145,7 @@ class CompensationSensor(SensorEntity): else: value = None if new_state.state == STATE_UNKNOWN else new_state.state try: - x_value = float(value) + x_value = float(value) # type: ignore[arg-type] if self._minimum is not None and x_value <= self._minimum[0]: y_value = self._minimum[1] elif self._maximum is not None and x_value >= self._maximum[0]: diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 5b82b5dae72..29dd56c11ec 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -195,7 +195,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.http.register_view(ConversationProcessView()) websocket_api.async_register_command(hass, websocket_process) websocket_api.async_register_command(hass, websocket_prepare) - websocket_api.async_register_command(hass, websocket_get_agent_info) websocket_api.async_register_command(hass, websocket_list_agents) websocket_api.async_register_command(hass, websocket_hass_agent_debug) @@ -249,29 +248,6 @@ async def websocket_prepare( connection.send_result(msg["id"]) -@websocket_api.websocket_command( - { - vol.Required("type"): "conversation/agent/info", - vol.Optional("agent_id"): agent_id_validator, - } -) -@websocket_api.async_response -async def websocket_get_agent_info( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Info about the agent in use.""" - agent = await _get_agent_manager(hass).async_get_agent(msg.get("agent_id")) - - connection.send_result( - msg["id"], - { - "attribution": agent.attribution, - }, - ) - - @websocket_api.websocket_command( { vol.Required("type"): "conversation/agent/list", @@ -346,7 +322,11 @@ async def websocket_hass_agent_debug( "intent": { "name": result.intent.name, }, - "entities": { + "slots": { # direct access to values + entity_key: entity.value + for entity_key, entity in result.entities.items() + }, + "details": { entity_key: { "name": entity.name, "value": entity.value, diff --git a/homeassistant/components/conversation/agent.py b/homeassistant/components/conversation/agent.py index 99b9c9392d8..2eae3631187 100644 --- a/homeassistant/components/conversation/agent.py +++ b/homeassistant/components/conversation/agent.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Literal, TypedDict +from typing import Any, Literal from homeassistant.core import Context from homeassistant.helpers import intent @@ -35,21 +35,9 @@ class ConversationResult: } -class Attribution(TypedDict): - """Attribution for a conversation agent.""" - - name: str - url: str - - class AbstractConversationAgent(ABC): """Abstract conversation agent.""" - @property - def attribution(self) -> Attribution | None: - """Return the attribution.""" - return None - @property @abstractmethod def supported_languages(self) -> list[str] | Literal["*"]: diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 336d6287f18..04aafc8a99d 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -11,7 +11,14 @@ from pathlib import Path import re from typing import IO, Any -from hassil.intents import Intents, ResponseType, SlotList, TextSlotList +from hassil.expression import Expression, ListReference, Sequence +from hassil.intents import ( + Intents, + ResponseType, + SlotList, + TextSlotList, + WildcardSlotList, +) from hassil.recognize import RecognizeResult, recognize_all from hassil.util import merge_dict from home_assistant_intents import get_domains_and_languages, get_intents @@ -32,7 +39,11 @@ from homeassistant.helpers import ( template, translation, ) -from homeassistant.helpers.event import async_track_state_added_domain +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_added_domain, +) +from homeassistant.helpers.typing import EventType from homeassistant.util.json import JsonObjectType, json_loads_object from .agent import AbstractConversationAgent, ConversationInput, ConversationResult @@ -44,7 +55,7 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] REGEX_TYPE = type(re.compile("")) TRIGGER_CALLBACK_TYPE = Callable[ # pylint: disable=invalid-name - [str], Awaitable[str | None] + [str, RecognizeResult], Awaitable[str | None] ] @@ -95,7 +106,7 @@ def async_setup(hass: core.HomeAssistant) -> None: async_should_expose(hass, DOMAIN, entity_id) @core.callback - def async_entity_state_listener(event: core.Event) -> None: + def async_entity_state_listener(event: EventType[EventStateChangedData]) -> None: """Set expose flag on new entities.""" async_should_expose(hass, DOMAIN, event.data["entity_id"]) @@ -653,6 +664,17 @@ class DefaultAgent(AbstractConversationAgent): } self._trigger_intents = Intents.from_dict(intents_dict) + + # Assume slot list references are wildcards + wildcard_names: set[str] = set() + for trigger_intent in self._trigger_intents.intents.values(): + for intent_data in trigger_intent.data: + for sentence in intent_data.sentences: + _collect_list_references(sentence, wildcard_names) + + for wildcard_name in wildcard_names: + self._trigger_intents.slot_lists[wildcard_name] = WildcardSlotList() + _LOGGER.debug("Rebuilt trigger intents: %s", intents_dict) def _unregister_trigger(self, trigger_data: TriggerData) -> None: @@ -678,14 +700,14 @@ class DefaultAgent(AbstractConversationAgent): assert self._trigger_intents is not None - matched_triggers: set[int] = set() + matched_triggers: dict[int, RecognizeResult] = {} for result in recognize_all(sentence, self._trigger_intents): trigger_id = int(result.intent.name) if trigger_id in matched_triggers: # Already matched a sentence from this trigger break - matched_triggers.add(trigger_id) + matched_triggers[trigger_id] = result if not matched_triggers: # Sentence did not match any trigger sentences @@ -695,14 +717,14 @@ class DefaultAgent(AbstractConversationAgent): "'%s' matched %s trigger(s): %s", sentence, len(matched_triggers), - matched_triggers, + list(matched_triggers), ) # Gather callback responses in parallel trigger_responses = await asyncio.gather( *( - self._trigger_sentences[trigger_id].callback(sentence) - for trigger_id in matched_triggers + self._trigger_sentences[trigger_id].callback(sentence, result) + for trigger_id, result in matched_triggers.items() ) ) @@ -729,3 +751,15 @@ def _make_error_result( response.async_set_error(error_code, response_text) return ConversationResult(response, conversation_id) + + +def _collect_list_references(expression: Expression, list_names: set[str]) -> None: + """Collect list reference names recursively.""" + if isinstance(expression, Sequence): + seq: Sequence = expression + for item in seq.items: + _collect_list_references(item, list_names) + elif isinstance(expression, ListReference): + # {list} + list_ref: ListReference = expression + list_names.add(list_ref.slot_name) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index aa2d0c32d16..1eb58e96ff9 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.6.28"] + "requirements": ["hassil==1.2.5", "home-assistant-intents==2023.7.25"] } diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml index 1a28044dcb5..7b6717eec6d 100644 --- a/homeassistant/components/conversation/services.yaml +++ b/homeassistant/components/conversation/services.yaml @@ -1,24 +1,16 @@ # Describes the format for available component services process: - name: Process - description: Launch a conversation from a transcribed text. fields: text: - name: Text - description: Transcribed text example: Turn all lights on required: true selector: text: language: - name: Language - description: Language of text. Defaults to server language example: NL selector: text: agent_id: - name: Agent - description: Assist engine to process your request example: homeassistant selector: conversation_agent: diff --git a/homeassistant/components/conversation/strings.json b/homeassistant/components/conversation/strings.json index dc6f2b5f52b..15e783c0d90 100644 --- a/homeassistant/components/conversation/strings.json +++ b/homeassistant/components/conversation/strings.json @@ -1 +1,23 @@ -{ "title": "Conversation" } +{ + "title": "Conversation", + "services": { + "process": { + "name": "Process", + "description": "Launches a conversation from a transcribed text.", + "fields": { + "text": { + "name": "Text", + "description": "Transcribed text input." + }, + "language": { + "name": "Language", + "description": "Language of text. Defaults to server language." + }, + "agent_id": { + "name": "Agent", + "description": "Conversation agent to process your request. The conversation agent is the brains of your assistant. It processes the incoming text commands." + } + } + } + } +} diff --git a/homeassistant/components/conversation/trigger.py b/homeassistant/components/conversation/trigger.py index b64b74c5fa6..71ddb5c1237 100644 --- a/homeassistant/components/conversation/trigger.py +++ b/homeassistant/components/conversation/trigger.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from hassil.recognize import PUNCTUATION +from hassil.recognize import PUNCTUATION, RecognizeResult import voluptuous as vol from homeassistant.const import CONF_COMMAND, CONF_PLATFORM @@ -49,12 +49,29 @@ async def async_attach_trigger( job = HassJob(action) @callback - async def call_action(sentence: str) -> str | None: + async def call_action(sentence: str, result: RecognizeResult) -> str | None: """Call action with right context.""" + + # Add slot values as extra trigger data + details = { + entity_name: { + "name": entity_name, + "text": entity.text.strip(), # remove whitespace + "value": entity.value.strip() + if isinstance(entity.value, str) + else entity.value, + } + for entity_name, entity in result.entities.items() + } + trigger_input: dict[str, Any] = { # Satisfy type checker **trigger_data, "platform": DOMAIN, "sentence": sentence, + "details": details, + "slots": { # direct access to values + entity_name: entity["value"] for entity_name, entity in details.items() + }, } # Wait for the automation to complete diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 6f3d48fc1bb..f946f29bdaa 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations import logging +from typing import Self -from typing_extensions import Self import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml index 835d39c9d2e..643fc223083 100644 --- a/homeassistant/components/counter/services.yaml +++ b/homeassistant/components/counter/services.yaml @@ -1,37 +1,27 @@ # Describes the format for available counter services decrement: - name: Decrement - description: Decrement a counter. target: entity: domain: counter increment: - name: Increment - description: Increment a counter. target: entity: domain: counter reset: - name: Reset - description: Reset a counter. target: entity: domain: counter set_value: - name: Set - description: Set the counter value target: entity: domain: counter fields: value: - name: Value required: true - description: The new counter value the entity should be set to. selector: number: min: 0 diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index 09592594659..53c87349836 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -32,11 +32,35 @@ "fix_flow": { "step": { "confirm": { - "title": "The counter configure service is being removed", - "description": "The counter service `counter.configure` is being removed and use of it has been detected. If you want to change the current value of a counter, use the new `counter.set_value` service instead.\n\nPlease remove the use of this service from your automations and scripts and select **submit** to close this issue." + "title": "[%key:component::counter::issues::deprecated_configure_service::title%]", + "description": "The counter service `counter.configure` is being removed and use of it has been detected. If you want to change the current value of a counter, use the new `counter.set_value` service instead.\n\nPlease remove this service from your automations and scripts and select **submit** to close this issue." } } } } + }, + "services": { + "decrement": { + "name": "Decrement", + "description": "Decrements a counter." + }, + "increment": { + "name": "Increment", + "description": "Increments a counter." + }, + "reset": { + "name": "Reset", + "description": "Resets a counter." + }, + "set_value": { + "name": "Set", + "description": "Sets the counter value.", + "fields": { + "value": { + "name": "Value", + "description": "The new counter value the entity should be set to." + } + } + } } } diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index a3965552b16..354b972e2b7 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -4,14 +4,13 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta -from enum import IntFlag +from enum import IntFlag, StrEnum import functools as ft import logging from typing import Any, ParamSpec, TypeVar, final import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_CLOSE_COVER, diff --git a/homeassistant/components/cover/services.yaml b/homeassistant/components/cover/services.yaml index 2f8e20464f3..9f9e37941e2 100644 --- a/homeassistant/components/cover/services.yaml +++ b/homeassistant/components/cover/services.yaml @@ -1,36 +1,35 @@ # Describes the format for available cover services open_cover: - name: Open - description: Open all or specified cover. target: entity: domain: cover + supported_features: + - cover.CoverEntityFeature.OPEN close_cover: - name: Close - description: Close all or specified cover. target: entity: domain: cover + supported_features: + - cover.CoverEntityFeature.CLOSE toggle: - name: Toggle - description: Toggle a cover open/closed. target: entity: domain: cover + supported_features: + - - cover.CoverEntityFeature.CLOSE + - cover.CoverEntityFeature.OPEN set_cover_position: - name: Set position - description: Move to specific position all or specified cover. target: entity: domain: cover + supported_features: + - cover.CoverEntityFeature.SET_POSITION fields: position: - name: Position - description: Position of the cover required: true selector: number: @@ -39,43 +38,42 @@ set_cover_position: unit_of_measurement: "%" stop_cover: - name: Stop - description: Stop all or specified cover. target: entity: domain: cover + supported_features: + - cover.CoverEntityFeature.STOP open_cover_tilt: - name: Open tilt - description: Open all or specified cover tilt. target: entity: domain: cover + supported_features: + - cover.CoverEntityFeature.OPEN_TILT close_cover_tilt: - name: Close tilt - description: Close all or specified cover tilt. target: entity: domain: cover + supported_features: + - cover.CoverEntityFeature.CLOSE_TILT toggle_cover_tilt: - name: Toggle tilt - description: Toggle a cover tilt open/closed. target: entity: domain: cover + supported_features: + - - cover.CoverEntityFeature.CLOSE_TILT + - cover.CoverEntityFeature.OPEN_TILT set_cover_tilt_position: - name: Set tilt position - description: Move to specific position all or specified cover tilt. target: entity: domain: cover + supported_features: + - cover.CoverEntityFeature.SET_TILT_POSITION fields: tilt_position: - name: Tilt position - description: Tilt position of the cover. required: true selector: number: @@ -84,8 +82,8 @@ set_cover_tilt_position: unit_of_measurement: "%" stop_cover_tilt: - name: Stop tilt - description: Stop all or specified cover. target: entity: domain: cover + supported_features: + - cover.CoverEntityFeature.STOP_TILT diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 663df02a824..979835fcfd2 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -77,10 +77,58 @@ "name": "Window" } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "open_cover": { + "name": "[%key:common::action::open%]", + "description": "Opens a cover." + }, + "close_cover": { + "name": "[%key:common::action::close%]", + "description": "Closes a cover." + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles a cover open/closed." + }, + "set_cover_position": { + "name": "Set position", + "description": "Moves a cover to a specific position.", + "fields": { + "position": { + "name": "Position", + "description": "Target position." + } + } + }, + "stop_cover": { + "name": "[%key:common::action::stop%]", + "description": "Stops the cover movement." + }, + "open_cover_tilt": { + "name": "Open tilt", + "description": "Tilts a cover open." + }, + "close_cover_tilt": { + "name": "Close tilt", + "description": "Tilts a cover to close." + }, + "toggle_cover_tilt": { + "name": "Toggle tilt", + "description": "Toggles a cover tilt open/closed." + }, + "set_cover_tilt_position": { + "name": "Set tilt position", + "description": "Moves a cover tilt to a specific position.", + "fields": { + "tilt_position": { + "name": "Tilt position", + "description": "Target tilt positition." + } + } + }, + "stop_cover_tilt": { + "name": "Stop tilt", + "description": "Stops a tilting cover movement." } } } diff --git a/homeassistant/components/cpuspeed/strings.json b/homeassistant/components/cpuspeed/strings.json index a64e1be7fcf..e82c6a0db12 100644 --- a/homeassistant/components/cpuspeed/strings.json +++ b/homeassistant/components/cpuspeed/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "CPU Speed", + "title": "[%key:component::cpuspeed::title%]", "description": "[%key:common::config_flow::description::confirm_setup%]" } }, diff --git a/homeassistant/components/crownstone/devices.py b/homeassistant/components/crownstone/devices.py index 427f88a1fb9..83aaac95393 100644 --- a/homeassistant/components/crownstone/devices.py +++ b/homeassistant/components/crownstone/devices.py @@ -12,6 +12,7 @@ class CrownstoneBaseEntity(Entity): """Base entity class for Crownstone devices.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, device: Crownstone) -> None: """Initialize the device.""" diff --git a/homeassistant/components/crownstone/light.py b/homeassistant/components/crownstone/light.py index c9cbeff90d5..a140de59017 100644 --- a/homeassistant/components/crownstone/light.py +++ b/homeassistant/components/crownstone/light.py @@ -71,6 +71,7 @@ class CrownstoneEntity(CrownstoneBaseEntity, LightEntity): """ _attr_icon = "mdi:power-socket-de" + _attr_name = None def __init__( self, crownstone_data: Crownstone, usb: CrownstoneUart | None = None @@ -79,7 +80,6 @@ class CrownstoneEntity(CrownstoneBaseEntity, LightEntity): super().__init__(crownstone_data) self.usb = usb # Entity class attributes - self._attr_name = str(self.device.name) self._attr_unique_id = f"{self.cloud_id}-{CROWNSTONE_SUFFIX}" @property diff --git a/homeassistant/components/crownstone/strings.json b/homeassistant/components/crownstone/strings.json index bcd818effb0..204f43768c7 100644 --- a/homeassistant/components/crownstone/strings.json +++ b/homeassistant/components/crownstone/strings.json @@ -53,22 +53,22 @@ "data": { "usb_path": "[%key:common::config_flow::data::usb_path%]" }, - "title": "Crownstone USB dongle configuration", + "title": "[%key:component::crownstone::config::step::usb_config::title%]", "description": "Select the serial port of the Crownstone USB dongle.\n\nLook for a device with VID 10C4 and PID EA60." }, "usb_manual_config": { "data": { "usb_manual_path": "[%key:common::config_flow::data::usb_path%]" }, - "title": "Crownstone USB dongle manual path", - "description": "Manually enter the path of a Crownstone USB dongle." + "title": "[%key:component::crownstone::config::step::usb_manual_config::title%]", + "description": "[%key:component::crownstone::config::step::usb_manual_config::description%]" }, "usb_sphere_config": { "data": { - "usb_sphere": "Crownstone Sphere" + "usb_sphere": "[%key:component::crownstone::config::step::usb_sphere_config::data::usb_sphere%]" }, - "title": "Crownstone USB Sphere", - "description": "Select a Crownstone Sphere where the USB is located." + "title": "[%key:component::crownstone::config::step::usb_sphere_config::title%]", + "description": "[%key:component::crownstone::config::step::usb_sphere_config::description%]" } } } diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index 2660a1a9d3a..ae5f1008820 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -55,7 +55,7 @@ class DaikinSensorEntityDescription(SensorEntityDescription, DaikinRequiredKeysM SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( DaikinSensorEntityDescription( key=ATTR_INSIDE_TEMPERATURE, - name="Inside temperature", + translation_key="inside_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -63,7 +63,7 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( ), DaikinSensorEntityDescription( key=ATTR_OUTSIDE_TEMPERATURE, - name="Outside temperature", + translation_key="outside_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -71,7 +71,6 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( ), DaikinSensorEntityDescription( key=ATTR_HUMIDITY, - name="Humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, @@ -79,7 +78,7 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( ), DaikinSensorEntityDescription( key=ATTR_TARGET_HUMIDITY, - name="Target humidity", + translation_key="target_humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, @@ -87,7 +86,7 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( ), DaikinSensorEntityDescription( key=ATTR_TOTAL_POWER, - name="Compressor estimated power consumption", + translation_key="compressor_estimated_power_consumption", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -95,7 +94,7 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( ), DaikinSensorEntityDescription( key=ATTR_COOL_ENERGY, - name="Cool energy consumption", + translation_key="cool_energy_consumption", icon="mdi:snowflake", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -104,7 +103,7 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( ), DaikinSensorEntityDescription( key=ATTR_HEAT_ENERGY, - name="Heat energy consumption", + translation_key="heat_energy_consumption", icon="mdi:fire", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -113,7 +112,7 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( ), DaikinSensorEntityDescription( key=ATTR_ENERGY_TODAY, - name="Energy consumption", + translation_key="energy_consumption", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -121,7 +120,7 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( ), DaikinSensorEntityDescription( key=ATTR_COMPRESSOR_FREQUENCY, - name="Compressor frequency", + translation_key="compressor_frequency", icon="mdi:fan", device_class=SensorDeviceClass.FREQUENCY, state_class=SensorStateClass.MEASUREMENT, @@ -131,7 +130,7 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = ( ), DaikinSensorEntityDescription( key=ATTR_TOTAL_ENERGY_TODAY, - name="Compressor energy consumption", + translation_key="compressor_energy_consumption", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json index 7848949831b..93ee636c726 100644 --- a/homeassistant/components/daikin/strings.json +++ b/homeassistant/components/daikin/strings.json @@ -21,5 +21,36 @@ "api_password": "Invalid authentication, use either API Key or Password.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "sensor": { + "inside_temperature": { + "name": "Inside temperature" + }, + "outside_temperature": { + "name": "Outside temperature" + }, + "target_humidity": { + "name": "Target humidity" + }, + "compressor_estimated_power_consumption": { + "name": "Compressor estimated power consumption" + }, + "cool_energy_consumption": { + "name": "Cool energy consumption" + }, + "heat_energy_consumption": { + "name": "Heat energy consumption" + }, + "energy_consumption": { + "name": "Energy consumption" + }, + "compressor_frequency": { + "name": "Compressor frequency" + }, + "compressor_energy_consumption": { + "name": "Compressor energy consumption" + } + } } } diff --git a/homeassistant/components/date/services.yaml b/homeassistant/components/date/services.yaml index 7ce1210f809..aebf5630205 100644 --- a/homeassistant/components/date/services.yaml +++ b/homeassistant/components/date/services.yaml @@ -1,13 +1,9 @@ set_value: - name: Set Date - description: Set the date for a date entity. target: entity: domain: date fields: date: - name: Date - description: The date to set. required: true example: "2022/11/01" selector: diff --git a/homeassistant/components/date/strings.json b/homeassistant/components/date/strings.json index f2d2e5ef8e1..9e88d3b5676 100644 --- a/homeassistant/components/date/strings.json +++ b/homeassistant/components/date/strings.json @@ -5,10 +5,16 @@ "name": "[%key:component::date::title%]" } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "set_value": { + "name": "Set date", + "description": "Sets the date.", + "fields": { + "date": { + "name": "Date", + "description": "The date to set." + } + } } } } diff --git a/homeassistant/components/datetime/services.yaml b/homeassistant/components/datetime/services.yaml index b5cce19e88b..fb6f798e9bd 100644 --- a/homeassistant/components/datetime/services.yaml +++ b/homeassistant/components/datetime/services.yaml @@ -1,13 +1,9 @@ set_value: - name: Set Date/Time - description: Set the date/time for a datetime entity. target: entity: domain: datetime fields: datetime: - name: Date & Time - description: The date/time to set. The time zone of the Home Assistant instance is assumed. required: true example: "2022/11/01 22:15" selector: diff --git a/homeassistant/components/datetime/strings.json b/homeassistant/components/datetime/strings.json index 3b97559018c..503d7a2ca9e 100644 --- a/homeassistant/components/datetime/strings.json +++ b/homeassistant/components/datetime/strings.json @@ -4,5 +4,17 @@ "_": { "name": "[%key:component::datetime::title%]" } + }, + "services": { + "set_value": { + "name": "Set date/time", + "description": "Sets the date/time for a datetime entity.", + "fields": { + "datetime": { + "name": "Date & Time", + "description": "The date/time to set. The time zone of the Home Assistant instance is assumed." + } + } + } } } diff --git a/homeassistant/components/debugpy/services.yaml b/homeassistant/components/debugpy/services.yaml index c864684226f..453b3af46bd 100644 --- a/homeassistant/components/debugpy/services.yaml +++ b/homeassistant/components/debugpy/services.yaml @@ -1,4 +1,2 @@ # Describes the format for available Remote Python Debugger services start: - name: Start - description: Start the Remote Python Debugger diff --git a/homeassistant/components/debugpy/strings.json b/homeassistant/components/debugpy/strings.json new file mode 100644 index 00000000000..2de92fc3827 --- /dev/null +++ b/homeassistant/components/debugpy/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "start": { + "name": "[%key:common::action::start%]", + "description": "Starts the Remote Python Debugger." + } + } +} diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index acbf5089f96..8eda93c2d46 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping +import logging from pprint import pformat from typing import Any, cast from urllib.parse import urlparse @@ -106,7 +107,8 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): except (asyncio.TimeoutError, ResponseError): self.bridges = [] - LOGGER.debug("Discovered deCONZ gateways %s", pformat(self.bridges)) + if LOGGER.isEnabledFor(logging.DEBUG): + LOGGER.debug("Discovered deCONZ gateways %s", pformat(self.bridges)) if self.bridges: hosts = [] @@ -215,7 +217,8 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered deCONZ bridge.""" - LOGGER.debug("deCONZ SSDP discovery %s", pformat(discovery_info)) + if LOGGER.isEnabledFor(logging.DEBUG): + LOGGER.debug("deCONZ SSDP discovery %s", pformat(discovery_info)) self.bridge_id = normalize_bridge_id(discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL]) parsed_url = urlparse(discovery_info.ssdp_location) @@ -248,7 +251,8 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): This flow is triggered by the discovery component. """ - LOGGER.debug("deCONZ HASSIO discovery %s", pformat(discovery_info.config)) + if LOGGER.isEnabledFor(logging.DEBUG): + LOGGER.debug("deCONZ HASSIO discovery %s", pformat(discovery_info.config)) self.bridge_id = normalize_bridge_id(discovery_info.config[CONF_SERIAL]) await self.async_set_unique_id(self.bridge_id) diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index 5cea4ca3b15..bcac6ac1e1d 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -168,14 +168,13 @@ async def async_remove_orphaned_entries_service(gateway: DeconzGateway) -> None: if gateway.api.config.mac: gateway_host = device_registry.async_get_device( connections={(CONNECTION_NETWORK_MAC, gateway.api.config.mac)}, - identifiers=set(), ) if gateway_host and gateway_host.id in devices_to_be_removed: devices_to_be_removed.remove(gateway_host.id) # Don't remove the Gateway service entry gateway_service = device_registry.async_get_device( - identifiers={(DOMAIN, gateway.api.config.bridge_id)}, connections=set() + identifiers={(DOMAIN, gateway.api.config.bridge_id)} ) if gateway_service and gateway_service.id in devices_to_be_removed: devices_to_be_removed.remove(gateway_service.id) diff --git a/homeassistant/components/deconz/services.yaml b/homeassistant/components/deconz/services.yaml index 9084728a216..d08312852b3 100644 --- a/homeassistant/components/deconz/services.yaml +++ b/homeassistant/components/deconz/services.yaml @@ -1,65 +1,33 @@ configure: - name: Configure - description: >- - Configure attributes of either a device endpoint in deCONZ - or the deCONZ service itself. fields: entity: - name: Entity - description: Represents a specific device endpoint in deCONZ. selector: entity: integration: deconz field: - name: Path - description: >- - String representing a full path to deCONZ endpoint (when - entity is not specified) or a subpath of the device path for the - entity (when entity is specified). example: '"/lights/1/state" or "/state"' selector: text: data: - name: Configuration payload - description: JSON object with what data you want to alter. required: true example: '{"on": true}' selector: object: bridgeid: - name: Bridge identifier - description: >- - Unique string for each deCONZ hardware. - It can be found as part of the integration name. - Useful if you run multiple deCONZ integrations. example: "00212EFFFF012345" selector: text: device_refresh: - name: Device refresh - description: Refresh available devices from deCONZ. fields: bridgeid: - name: Bridge identifier - description: >- - Unique string for each deCONZ hardware. - It can be found as part of the integration name. - Useful if you run multiple deCONZ integrations. example: "00212EFFFF012345" selector: text: remove_orphaned_entries: - name: Remove orphaned entries - description: Clean up device and entity registry entries orphaned by deCONZ. fields: bridgeid: - name: Bridge identifier - description: >- - Unique string for each deCONZ hardware. - It can be found as part of the integration name. - Useful if you run multiple deCONZ integrations. example: "00212EFFFF012345" selector: text: diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 45a19b0466d..e32ab875c28 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -79,14 +79,14 @@ "remote_rotate_from_side_6": "Device rotated from \"side 6\" to \"{subtype}\"" }, "trigger_subtype": { - "turn_on": "Turn on", - "turn_off": "Turn off", + "turn_on": "[%key:common::action::turn_on%]", + "turn_off": "[%key:common::action::turn_off%]", "dim_up": "Dim up", "dim_down": "Dim down", "left": "Left", "right": "Right", - "open": "Open", - "close": "Close", + "open": "[%key:common::action::open%]", + "close": "[%key:common::action::close%]", "both_buttons": "Both buttons", "top_buttons": "Top buttons", "bottom_buttons": "Bottom buttons", @@ -105,5 +105,49 @@ "side_5": "Side 5", "side_6": "Side 6" } + }, + "services": { + "configure": { + "name": "Configure", + "description": "Configures attributes of either a device endpoint in deCONZ or the deCONZ service itself.", + "fields": { + "entity": { + "name": "Entity", + "description": "Represents a specific device endpoint in deCONZ." + }, + "field": { + "name": "[%key:common::config_flow::data::path%]", + "description": "String representing a full path to deCONZ endpoint (when entity is not specified) or a subpath of the device path for the entity (when entity is specified)." + }, + "data": { + "name": "Configuration payload", + "description": "JSON object with what data you want to alter." + }, + "bridgeid": { + "name": "Bridge identifier", + "description": "Unique string for each deCONZ hardware. It can be found as part of the integration name. Useful if you run multiple deCONZ integrations." + } + } + }, + "device_refresh": { + "name": "Device refresh", + "description": "Refreshes available devices from deCONZ.", + "fields": { + "bridgeid": { + "name": "[%key:component::deconz::services::configure::fields::bridgeid::name%]", + "description": "[%key:component::deconz::services::configure::fields::bridgeid::description%]" + } + } + }, + "remove_orphaned_entries": { + "name": "Remove orphaned entries", + "description": "Cleans up device and entity registry entries orphaned by deCONZ.", + "fields": { + "bridgeid": { + "name": "[%key:component::deconz::services::configure::fields::bridgeid::name%]", + "description": "[%key:component::deconz::services::configure::fields::bridgeid::description%]" + } + } + } } } diff --git a/homeassistant/components/delmarva/__init__.py b/homeassistant/components/delmarva/__init__.py new file mode 100644 index 00000000000..2af337b64a4 --- /dev/null +++ b/homeassistant/components/delmarva/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Delmarva Power.""" diff --git a/homeassistant/components/delmarva/manifest.json b/homeassistant/components/delmarva/manifest.json new file mode 100644 index 00000000000..7f0de5c464a --- /dev/null +++ b/homeassistant/components/delmarva/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "delmarva", + "name": "Delmarva Power", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 246c952e219..04eba5f0586 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -30,6 +30,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.COVER, Platform.DATE, Platform.DATETIME, + Platform.EVENT, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, @@ -55,6 +56,7 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.IMAGE_PROCESSING, Platform.CALENDAR, Platform.DEVICE_TRACKER, + Platform.WEATHER, ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/demo/button.py b/homeassistant/components/demo/button.py index f7a653e1779..3c0498fefef 100644 --- a/homeassistant/components/demo/button.py +++ b/homeassistant/components/demo/button.py @@ -5,6 +5,7 @@ from homeassistant.components import persistent_notification from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN @@ -21,7 +22,6 @@ async def async_setup_entry( DemoButton( unique_id="push", device_name="Push", - icon="mdi:gesture-tap-button", ), ] ) @@ -38,18 +38,17 @@ class DemoButton(ButtonEntity): self, unique_id: str, device_name: str, - icon: str, ) -> None: """Initialize the Demo button entity.""" self._attr_unique_id = unique_id - self._attr_icon = icon - self._attr_device_info = { - "identifiers": {(DOMAIN, unique_id)}, - "name": device_name, - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device_name, + ) async def async_press(self) -> None: """Send out a persistent notification.""" persistent_notification.async_create( self.hass, "Button pressed", title="Button" ) + self.hass.bus.async_fire("demo_button_pressed") diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index 340a4b306cb..bfc2cd1a2e7 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -14,6 +14,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN @@ -63,7 +64,7 @@ async def async_setup_entry( aux=False, target_temp_high=None, target_temp_low=None, - hvac_modes=[cls.value for cls in HVACMode if cls != HVACMode.HEAT_COOL], + hvac_modes=[cls for cls in HVACMode if cls != HVACMode.HEAT_COOL], ), DemoClimate( unique_id="climate_3", @@ -82,7 +83,7 @@ async def async_setup_entry( aux=None, target_temp_high=24, target_temp_low=21, - hvac_modes=[cls.value for cls in HVACMode if cls != HVACMode.HEAT], + hvac_modes=[cls for cls in HVACMode if cls != HVACMode.HEAT], ), ] ) @@ -152,10 +153,10 @@ class DemoClimate(ClimateEntity): self._swing_modes = ["auto", "1", "2", "3", "off"] self._target_temperature_high = target_temp_high self._target_temperature_low = target_temp_low - self._attr_device_info = { - "identifiers": {(DOMAIN, unique_id)}, - "name": device_name, - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device_name, + ) @property def unique_id(self) -> str: diff --git a/homeassistant/components/demo/event.py b/homeassistant/components/demo/event.py new file mode 100644 index 00000000000..e9d26d9f54d --- /dev/null +++ b/homeassistant/components/demo/event.py @@ -0,0 +1,47 @@ +"""Demo platform that offers a fake event entity.""" +from __future__ import annotations + +from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the demo event platform.""" + async_add_entities([DemoEvent()]) + + +class DemoEvent(EventEntity): + """Representation of a demo event entity.""" + + _attr_device_class = EventDeviceClass.BUTTON + _attr_event_types = ["pressed"] + _attr_has_entity_name = True + _attr_name = "Button press" + _attr_should_poll = False + _attr_translation_key = "push" + _attr_unique_id = "push" + + def __init__(self) -> None: + """Initialize the Demo event entity.""" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, "push")}, + ) + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self.hass.bus.async_listen("demo_button_pressed", self._async_handle_event) + + @callback + def _async_handle_event(self, _: Event) -> None: + """Handle the demo button event.""" + self._trigger_event("pressed") + self.async_write_ha_state() diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index 26689582fae..a1f7504762a 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -25,7 +25,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import StateType from . import DOMAIN @@ -149,11 +148,11 @@ class DemoSensor(SensorEntity): self, unique_id: str, device_name: str | None, - state: StateType, + state: float | int | str | None, device_class: SensorDeviceClass, state_class: SensorStateClass | None, unit_of_measurement: str | None, - battery: StateType, + battery: int | None, options: list[str] | None = None, translation_key: str | None = None, ) -> None: @@ -189,7 +188,7 @@ class DemoSumSensor(RestoreSensor): device_class: SensorDeviceClass, state_class: SensorStateClass | None, unit_of_measurement: str | None, - battery: StateType, + battery: int | None, suggested_entity_id: str, ) -> None: """Initialize the sensor.""" diff --git a/homeassistant/components/demo/services.yaml b/homeassistant/components/demo/services.yaml index a09b4498035..300ea37f805 100644 --- a/homeassistant/components/demo/services.yaml +++ b/homeassistant/components/demo/services.yaml @@ -1,3 +1 @@ randomize_device_tracker_data: - name: Randomize device tracker data - description: Demonstrates using a device tracker to see where devices are located diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json index add04c236e7..555760a5af9 100644 --- a/homeassistant/components/demo/strings.json +++ b/homeassistant/components/demo/strings.json @@ -1,46 +1,5 @@ { "title": "Demo", - "issues": { - "bad_psu": { - "title": "The power supply is not stable", - "fix_flow": { - "step": { - "confirm": { - "title": "The power supply needs to be replaced", - "description": "Press SUBMIT to confirm the power supply has been replaced" - } - } - } - }, - "out_of_blinker_fluid": { - "title": "The blinker fluid is empty and needs to be refilled", - "fix_flow": { - "step": { - "confirm": { - "title": "Blinker fluid needs to be refilled", - "description": "Press SUBMIT when blinker fluid has been refilled" - } - } - } - }, - "cold_tea": { - "title": "The tea is cold", - "fix_flow": { - "step": {}, - "abort": { - "not_tea_time": "Can not re-heat the tea at this time" - } - } - }, - "transmogrifier_deprecated": { - "title": "The transmogrifier component is deprecated", - "description": "The transmogrifier component is now deprecated due to the lack of local control available in the new API" - }, - "unfixable_problem": { - "title": "This is not a fixable problem", - "description": "This issue is never going to give up." - } - }, "options": { "step": { "init": { @@ -81,7 +40,18 @@ "2": "2", "3": "3", "auto": "Auto", - "off": "Off" + "off": "[%key:common::state::off%]" + } + } + } + } + }, + "event": { + "push": { + "state_attributes": { + "event_type": { + "state": { + "pressed": "Pressed" } } } @@ -100,7 +70,7 @@ "thermostat_mode": { "name": "Thermostat mode", "state": { - "away": "Away", + "away": "[%key:common::state::not_home%]", "comfort": "Comfort", "eco": "Eco", "sleep": "Sleep" @@ -116,5 +86,11 @@ } } } + }, + "services": { + "randomize_device_tracker_data": { + "name": "Randomize device tracker data", + "description": "Demonstrates using a device tracker to see where devices are located." + } } } diff --git a/homeassistant/components/demo/water_heater.py b/homeassistant/components/demo/water_heater.py index a21f492c439..0ab175691f8 100644 --- a/homeassistant/components/demo/water_heater.py +++ b/homeassistant/components/demo/water_heater.py @@ -15,6 +15,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType SUPPORT_FLAGS_HEATER = ( WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF | WaterHeaterEntityFeature.OPERATION_MODE | WaterHeaterEntityFeature.AWAY_MODE ) @@ -103,3 +104,11 @@ class DemoWaterHeater(WaterHeaterEntity): """Turn away mode off.""" self._attr_is_away_mode_on = False self.schedule_update_ha_state() + + def turn_on(self, **kwargs: Any) -> None: + """Turn on water heater.""" + self.set_operation_mode("eco") + + def turn_off(self, **kwargs: Any) -> None: + """Turn off water heater.""" + self.set_operation_mode("off") diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index e64d0bcc28d..887a9212335 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -1,7 +1,7 @@ """Demo platform that offers fake meteorological data.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, @@ -20,11 +20,13 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY_VARIANT, Forecast, WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util @@ -45,6 +47,8 @@ CONDITION_CLASSES: dict[str, list[str]] = { ATTR_CONDITION_EXCEPTIONAL: [], } +WEATHER_UPDATE_INTERVAL = timedelta(minutes=30) + async def async_setup_entry( hass: HomeAssistant, @@ -83,6 +87,8 @@ def setup_platform( [ATTR_CONDITION_RAINY, 15, 18, 7, 0], [ATTR_CONDITION_FOG, 0.2, 21, 12, 100], ], + None, + None, ), DemoWeather( "North", @@ -103,6 +109,24 @@ def setup_platform( [ATTR_CONDITION_SUNNY, 0.3, -14, -19, 0], [ATTR_CONDITION_SUNNY, 0, -9, -12, 0], ], + [ + [ATTR_CONDITION_SUNNY, 2, -10, -15, 60], + [ATTR_CONDITION_SUNNY, 1, -13, -14, 25], + [ATTR_CONDITION_SUNNY, 0, -18, -22, 70], + [ATTR_CONDITION_SUNNY, 0.1, -23, -23, 90], + [ATTR_CONDITION_SUNNY, 4, -19, -20, 40], + [ATTR_CONDITION_SUNNY, 0.3, -14, -19, 0], + [ATTR_CONDITION_SUNNY, 0, -9, -12, 0], + ], + [ + [ATTR_CONDITION_SNOWY, 2, -10, -15, 60, True], + [ATTR_CONDITION_PARTLYCLOUDY, 1, -13, -14, 25, False], + [ATTR_CONDITION_SUNNY, 0, -18, -22, 70, True], + [ATTR_CONDITION_SUNNY, 0.1, -23, -23, 90, False], + [ATTR_CONDITION_SNOWY, 4, -19, -20, 40, True], + [ATTR_CONDITION_SUNNY, 0.3, -14, -19, 0, False], + [ATTR_CONDITION_SUNNY, 0, -9, -12, 0, True], + ], ), ] ) @@ -125,10 +149,13 @@ class DemoWeather(WeatherEntity): temperature_unit: str, pressure_unit: str, wind_speed_unit: str, - forecast: list[list], + forecast_daily: list[list] | None, + forecast_hourly: list[list] | None, + forecast_twice_daily: list[list] | None, ) -> None: """Initialize the Demo weather.""" self._attr_name = f"Demo Weather {name}" + self._attr_unique_id = f"demo-weather-{name.lower()}" self._condition = condition self._native_temperature = temperature self._native_temperature_unit = temperature_unit @@ -137,7 +164,40 @@ class DemoWeather(WeatherEntity): self._native_pressure_unit = pressure_unit self._native_wind_speed = wind_speed self._native_wind_speed_unit = wind_speed_unit - self._forecast = forecast + self._forecast_daily = forecast_daily + self._forecast_hourly = forecast_hourly + self._forecast_twice_daily = forecast_twice_daily + self._attr_supported_features = 0 + if self._forecast_daily: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_DAILY + if self._forecast_hourly: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_HOURLY + if self._forecast_twice_daily: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_TWICE_DAILY + + async def async_added_to_hass(self) -> None: + """Set up a timer updating the forecasts.""" + + async def update_forecasts(_: datetime) -> None: + if self._forecast_daily: + self._forecast_daily = ( + self._forecast_daily[1:] + self._forecast_daily[:1] + ) + if self._forecast_hourly: + self._forecast_hourly = ( + self._forecast_hourly[1:] + self._forecast_hourly[:1] + ) + if self._forecast_twice_daily: + self._forecast_twice_daily = ( + self._forecast_twice_daily[1:] + self._forecast_twice_daily[:1] + ) + await self.async_update_listeners(None) + + self.async_on_remove( + async_track_time_interval( + self.hass, update_forecasts, WEATHER_UPDATE_INTERVAL + ) + ) @property def native_temperature(self) -> float: @@ -181,13 +241,13 @@ class DemoWeather(WeatherEntity): k for k, v in CONDITION_CLASSES.items() if self._condition.lower() in v ][0] - @property - def forecast(self) -> list[Forecast]: - """Return the forecast.""" + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast.""" reftime = dt_util.now().replace(hour=16, minute=00) forecast_data = [] - for entry in self._forecast: + assert self._forecast_daily is not None + for entry in self._forecast_daily: data_dict = Forecast( datetime=reftime.isoformat(), condition=entry[0], @@ -196,7 +256,48 @@ class DemoWeather(WeatherEntity): templow=entry[3], precipitation_probability=entry[4], ) - reftime = reftime + timedelta(hours=4) + reftime = reftime + timedelta(hours=24) + forecast_data.append(data_dict) + + return forecast_data + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the hourly forecast.""" + reftime = dt_util.now().replace(hour=16, minute=00) + + forecast_data = [] + assert self._forecast_hourly is not None + for entry in self._forecast_hourly: + data_dict = Forecast( + datetime=reftime.isoformat(), + condition=entry[0], + precipitation=entry[1], + temperature=entry[2], + templow=entry[3], + precipitation_probability=entry[4], + ) + reftime = reftime + timedelta(hours=1) + forecast_data.append(data_dict) + + return forecast_data + + async def async_forecast_twice_daily(self) -> list[Forecast]: + """Return the twice daily forecast.""" + reftime = dt_util.now().replace(hour=11, minute=00) + + forecast_data = [] + assert self._forecast_twice_daily is not None + for entry in self._forecast_twice_daily: + data_dict = Forecast( + datetime=reftime.isoformat(), + condition=entry[0], + precipitation=entry[1], + temperature=entry[2], + templow=entry[3], + precipitation_probability=entry[4], + is_daytime=entry[5], + ) + reftime = reftime + timedelta(hours=12) forecast_data.append(data_dict) return forecast_data diff --git a/homeassistant/components/denon/media_player.py b/homeassistant/components/denon/media_player.py index 60da3df393e..b3b9e1a98ef 100644 --- a/homeassistant/components/denon/media_player.py +++ b/homeassistant/components/denon/media_player.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -import telnetlib +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index 660e4c770b0..b3c36ed39d2 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==0.11.2"], + "requirements": ["denonavr==0.11.3"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index eab4c1df3a6..67368596439 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -256,7 +256,7 @@ class DenonDevice(MediaPlayerEntity): return # Some updates trigger multiple events like one for artist and one for title for one change # We skip every event except the last one - if event == "NS" and not parameter.startswith("E4"): + if event == "NSE" and not parameter.startswith("4"): return if event == "TA" and not parameter.startwith("ANNAME"): return diff --git a/homeassistant/components/denonavr/services.yaml b/homeassistant/components/denonavr/services.yaml index ee35732e311..9c53ff9994a 100644 --- a/homeassistant/components/denonavr/services.yaml +++ b/homeassistant/components/denonavr/services.yaml @@ -1,36 +1,27 @@ # Describes the format for available denonavr services get_command: - name: Get command - description: "Send a generic HTTP get command." target: entity: integration: denonavr domain: media_player fields: command: - name: Command - description: Endpoint of the command, including associated parameters. example: "/goform/formiPhoneAppDirect.xml?RCKSK0410370" required: true selector: text: set_dynamic_eq: - name: Set dynamic equalizer - description: "Enable or disable DynamicEQ." target: entity: integration: denonavr domain: media_player fields: dynamic_eq: - description: "True/false for enable/disable." default: true selector: boolean: update_audyssey: - name: Update audyssey - description: "Update Audyssey settings." target: entity: integration: denonavr diff --git a/homeassistant/components/denonavr/strings.json b/homeassistant/components/denonavr/strings.json index 1c85efc9ff4..a4e07e33a6a 100644 --- a/homeassistant/components/denonavr/strings.json +++ b/homeassistant/components/denonavr/strings.json @@ -46,5 +46,31 @@ } } } + }, + "services": { + "get_command": { + "name": "Get command", + "description": "Send sa generic HTTP get command.", + "fields": { + "command": { + "name": "Command", + "description": "Endpoint of the command, including associated parameters." + } + } + }, + "set_dynamic_eq": { + "name": "Set dynamic equalizer", + "description": "Enables or disables DynamicEQ.", + "fields": { + "dynamic_eq": { + "name": "Dynamic equalizer", + "description": "True/false for enable/disable." + } + } + }, + "update_audyssey": { + "name": "Update Audyssey", + "description": "Updates Audyssey settings." + } } } diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index af04da27406..de9f06a0e88 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTime, ) -from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -26,8 +26,11 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from .const import ( CONF_ROUND_DIGITS, @@ -210,14 +213,12 @@ class DerivativeSensor(RestoreSensor, SensorEntity): _LOGGER.warning("Could not restore last state: %s", err) @callback - def calc_derivative(event: Event) -> None: + def calc_derivative(event: EventType[EventStateChangedData]) -> None: """Handle the sensor state changes.""" - old_state: State | None - new_state: State | None if ( - (old_state := event.data.get("old_state")) is None + (old_state := event.data["old_state"]) is None or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) - or (new_state := event.data.get("new_state")) is None + or (new_state := event.data["new_state"]) is None or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE) ): return diff --git a/homeassistant/components/derivative/strings.json b/homeassistant/components/derivative/strings.json index 7a4ee9d4fc3..ef36d46d8b9 100644 --- a/homeassistant/components/derivative/strings.json +++ b/homeassistant/components/derivative/strings.json @@ -6,7 +6,7 @@ "title": "Add Derivative sensor", "description": "Create a sensor that estimates the derivative of a sensor.", "data": { - "name": "Name", + "name": "[%key:common::config_flow::data::name%]", "round": "Precision", "source": "Input sensor", "time_window": "Time window", diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index af2fd61081c..d7641c34316 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -349,9 +349,10 @@ def async_validate_entity_schema( config = schema(config) registry = er.async_get(hass) - config[CONF_ENTITY_ID] = er.async_resolve_entity_id( - registry, config[CONF_ENTITY_ID] - ) + if CONF_ENTITY_ID in config: + config[CONF_ENTITY_ID] = er.async_resolve_entity_id( + registry, config[CONF_ENTITY_ID] + ) return config diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 038ded07e8a..83c599bc65d 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -75,7 +75,7 @@ async def async_validate_device_automation_config( # config entry is loaded registry = dr.async_get(hass) if not (device := registry.async_get(validated_config[CONF_DEVICE_ID])): - # The device referenced by the device trigger does not exist + # The device referenced by the device automation does not exist raise InvalidDeviceAutomationConfig( f"Unknown device '{validated_config[CONF_DEVICE_ID]}'" ) @@ -91,7 +91,7 @@ async def async_validate_device_automation_config( break if not device_config_entry: - # The config entry referenced by the device trigger does not exist + # The config entry referenced by the device automation does not exist raise InvalidDeviceAutomationConfig( f"Device '{validated_config[CONF_DEVICE_ID]}' has no config entry from " f"domain '{validated_config[CONF_DOMAIN]}'" diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 286929c5345..7d8d0791b4d 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -365,7 +365,7 @@ class ScannerEntity(BaseTrackerEntity): assert self.mac_address is not None return dr.async_get(self.hass).async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, self.mac_address)} + connections={(dr.CONNECTION_NETWORK_MAC, self.mac_address)} ) async def async_internal_added_to_hass(self) -> None: @@ -405,13 +405,13 @@ class ScannerEntity(BaseTrackerEntity): @property def state_attributes(self) -> dict[str, StateType]: """Return the device state attributes.""" - attr: dict[str, StateType] = {} - attr.update(super().state_attributes) - if self.ip_address: - attr[ATTR_IP] = self.ip_address - if self.mac_address is not None: - attr[ATTR_MAC] = self.mac_address - if self.hostname is not None: - attr[ATTR_HOST_NAME] = self.hostname + attr = super().state_attributes + + if ip_address := self.ip_address: + attr[ATTR_IP] = ip_address + if (mac_address := self.mac_address) is not None: + attr[ATTR_MAC] = mac_address + if (hostname := self.hostname) is not None: + attr[ATTR_HOST_NAME] = hostname return attr diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index ad68472d9b0..3a0b0afd7c9 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -2,11 +2,10 @@ from __future__ import annotations from datetime import timedelta +from enum import StrEnum import logging from typing import Final -from homeassistant.backports.enum import StrEnum - LOGGER: Final = logging.getLogger(__package__) DOMAIN: Final = "device_tracker" diff --git a/homeassistant/components/device_tracker/manifest.json b/homeassistant/components/device_tracker/manifest.json index 11c85ebf872..5fde0fc9fa1 100644 --- a/homeassistant/components/device_tracker/manifest.json +++ b/homeassistant/components/device_tracker/manifest.json @@ -1,7 +1,6 @@ { "domain": "device_tracker", "name": "Device Tracker", - "after_dependencies": [], "codeowners": ["@home-assistant/core"], "dependencies": ["zone"], "documentation": "https://www.home-assistant.io/integrations/device_tracker", diff --git a/homeassistant/components/device_tracker/services.yaml b/homeassistant/components/device_tracker/services.yaml index c6c2d212e2d..08ccbcf0b5a 100644 --- a/homeassistant/components/device_tracker/services.yaml +++ b/homeassistant/components/device_tracker/services.yaml @@ -1,50 +1,34 @@ # Describes the format for available device tracker services see: - name: See - description: Control tracked device. fields: mac: - name: MAC address - description: MAC address of device example: "FF:FF:FF:FF:FF:FF" selector: text: dev_id: - name: Device ID - description: Id of device (find id in known_devices.yaml). example: "phonedave" selector: text: host_name: - name: Host name - description: Hostname of device example: "Dave" selector: text: location_name: - name: Location name - description: Name of location where device is located (not_home is away). example: "home" selector: text: gps: - name: GPS coordinates - description: GPS coordinates where device is located (latitude, longitude). example: "[51.509802, -0.086692]" selector: object: gps_accuracy: - name: GPS accuracy - description: Accuracy of GPS coordinates. selector: number: min: 1 max: 100 unit_of_measurement: "%" battery: - name: Battery level - description: Battery level of device. selector: number: min: 0 diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index 54e4f922053..44c43219b82 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -42,10 +42,40 @@ } } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "see": { + "name": "See", + "description": "Records a seen tracked device.", + "fields": { + "mac": { + "name": "MAC address", + "description": "MAC address of the device." + }, + "dev_id": { + "name": "Device ID", + "description": "ID of the device (find the ID in `known_devices.yaml`)." + }, + "host_name": { + "name": "Hostname", + "description": "Hostname of the device." + }, + "location_name": { + "name": "Location", + "description": "Name of the location where the device is located. The options are: `home`, `not_home`, or the name of the zone." + }, + "gps": { + "name": "GPS coordinates", + "description": "GPS coordinates where the device is located, specified by latitude and longitude (for example: [51.513845, -0.100539])." + }, + "gps_accuracy": { + "name": "GPS accuracy", + "description": "Accuracy of the GPS coordinates." + }, + "battery": { + "name": "Battery level", + "description": "Battery level of the device." + } + } } } } diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json index 84f05b88384..eeae9aa2e2f 100644 --- a/homeassistant/components/devolo_home_control/strings.json +++ b/homeassistant/components/devolo_home_control/strings.json @@ -18,9 +18,9 @@ }, "zeroconf_confirm": { "data": { - "username": "Email / devolo ID", + "username": "[%key:component::devolo_home_control::config::step::user::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "mydevolo_url": "mydevolo URL" + "mydevolo_url": "[%key:component::devolo_home_control::config::step::user::data::mydevolo_url%]" } } } diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 4a9f6c2b163..b3cfd1b65f2 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -51,10 +51,11 @@ from homeassistant.helpers.device_registry import ( ) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import ( + EventStateChangedData, async_track_state_added_domain, async_track_time_interval, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.loader import DHCPMatcher, async_get_dhcp from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.network import is_invalid, is_link_local, is_loopback @@ -196,7 +197,7 @@ class WatcherBase(ABC): dev_reg: DeviceRegistry = async_get(self.hass) if device := dev_reg.async_get_device( - identifiers=set(), connections={(CONNECTION_NETWORK_MAC, uppercase_mac)} + connections={(CONNECTION_NETWORK_MAC, uppercase_mac)} ): for entry_id in device.config_entries: if entry := self.hass.config_entries.async_get_entry(entry_id): @@ -317,14 +318,16 @@ class DeviceTrackerWatcher(WatcherBase): self._async_process_device_state(state) @callback - def _async_process_device_event(self, event: Event) -> None: + def _async_process_device_event( + self, event: EventType[EventStateChangedData] + ) -> None: """Process a device tracker state change event.""" self._async_process_device_state(event.data["new_state"]) @callback - def _async_process_device_state(self, state: State) -> None: + def _async_process_device_state(self, state: State | None) -> None: """Process a device tracker state.""" - if state.state != STATE_HOME: + if state is None or state.state != STATE_HOME: return attributes = state.attributes diff --git a/homeassistant/components/diagnostics/const.py b/homeassistant/components/diagnostics/const.py index 0d07abde2bd..20f97be1eb1 100644 --- a/homeassistant/components/diagnostics/const.py +++ b/homeassistant/components/diagnostics/const.py @@ -1,5 +1,5 @@ """Constants for the Diagnostics integration.""" -from homeassistant.backports.enum import StrEnum +from enum import StrEnum DOMAIN = "diagnostics" diff --git a/homeassistant/components/discord/strings.json b/homeassistant/components/discord/strings.json index 07c8fa8bdb5..1cd67d3b021 100644 --- a/homeassistant/components/discord/strings.json +++ b/homeassistant/components/discord/strings.json @@ -8,7 +8,7 @@ } }, "reauth_confirm": { - "description": "Refer to the documentation on getting your Discord bot key.\n\n{url}", + "description": "[%key:component::discord::config::step::user::description%]", "data": { "api_token": "[%key:common::config_flow::data::api_token%]" } diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 54f6fca83d4..fe1045203d8 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.httpx_client import get_async_client -from .const import APP_NAME, DOMAIN +from .const import DOMAIN from .coordinator import DiscovergyUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -38,7 +38,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_client=pydiscovergy.Discovergy( email=entry.data[CONF_EMAIL], password=entry.data[CONF_PASSWORD], - app_name=APP_NAME, httpx_client=get_async_client(hass), authentication=BasicAuth(), ), @@ -49,7 +48,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: # try to get meters from api to check if credentials are still valid and for later use # if no exception is raised everything is fine to go - discovergy_data.meters = await discovergy_data.api_client.get_meters() + discovergy_data.meters = await discovergy_data.api_client.meters() except discovergyError.InvalidLogin as err: raise ConfigEntryAuthFailed("Invalid email or password") from err except Exception as err: # pylint: disable=broad-except @@ -69,7 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - discovergy_data.coordinators[meter.get_meter_id()] = coordinator + discovergy_data.coordinators[meter.meter_id] = coordinator hass.data[DOMAIN][entry.entry_id] = discovergy_data await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index d6b81ed8837..3434b1dd84c 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.httpx_client import get_async_client -from .const import APP_NAME, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -82,10 +82,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await pydiscovergy.Discovergy( email=user_input[CONF_EMAIL], password=user_input[CONF_PASSWORD], - app_name=APP_NAME, httpx_client=get_async_client(self.hass), authentication=BasicAuth(), - ).get_meters() + ).meters() except discovergyError.HTTPError: errors["base"] = "cannot_connect" except discovergyError.InvalidLogin: diff --git a/homeassistant/components/discovergy/const.py b/homeassistant/components/discovergy/const.py index 866e9f11def..f410eb94bcf 100644 --- a/homeassistant/components/discovergy/const.py +++ b/homeassistant/components/discovergy/const.py @@ -3,4 +3,3 @@ from __future__ import annotations DOMAIN = "discovergy" MANUFACTURER = "Discovergy" -APP_NAME = "homeassistant" diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index e3b6e91e03f..6ee5a4c3e84 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -47,9 +47,7 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): async def _async_update_data(self) -> Reading: """Get last reading for meter.""" try: - return await self.discovergy_client.get_last_reading( - self.meter.get_meter_id() - ) + return await self.discovergy_client.meter_last_reading(self.meter.meter_id) except AccessTokenExpired as err: raise ConfigEntryAuthFailed( f"Auth expired while fetching last reading for meter {self.meter.get_meter_id()}" diff --git a/homeassistant/components/discovergy/diagnostics.py b/homeassistant/components/discovergy/diagnostics.py index 02d5585c1dc..a7c79bf3b13 100644 --- a/homeassistant/components/discovergy/diagnostics.py +++ b/homeassistant/components/discovergy/diagnostics.py @@ -19,9 +19,9 @@ TO_REDACT_METER = { "serial_number", "full_serial_number", "location", - "fullSerialNumber", - "printedFullSerialNumber", - "administrationNumber", + "full_serial_number", + "printed_full_serial_number", + "administration_number", } @@ -39,8 +39,8 @@ async def async_get_config_entry_diagnostics( flattened_meter.append(async_redact_data(meter.__dict__, TO_REDACT_METER)) # get last reading for meter and make a dict of it - coordinator = data.coordinators[meter.get_meter_id()] - last_readings[meter.get_meter_id()] = coordinator.data.__dict__ + coordinator = data.coordinators[meter.meter_id] + last_readings[meter.meter_id] = coordinator.data.__dict__ return { "entry": async_redact_data(entry.as_dict(), TO_REDACT_CONFIG_ENTRY), diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index c929386e8e8..23d7f1ad5bf 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/discovergy", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["pydiscovergy==1.2.1"] + "requirements": ["pydiscovergy==2.0.1"] } diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 35955a6b189..79fc6af1b9a 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -11,16 +11,13 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_NAME, UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, UnitOfVolume, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -104,6 +101,7 @@ ELECTRICITY_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + alternative_keys=["voltage1"], ), DiscovergySensorEntityDescription( key="phase2Voltage", @@ -113,6 +111,7 @@ ELECTRICITY_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + alternative_keys=["voltage2"], ), DiscovergySensorEntityDescription( key="phase3Voltage", @@ -122,6 +121,7 @@ ELECTRICITY_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, + alternative_keys=["voltage3"], ), # energy sensors DiscovergySensorEntityDescription( @@ -154,8 +154,6 @@ async def async_setup_entry( entities: list[DiscovergySensor] = [] for meter in meters: - meter_id = meter.get_meter_id() - sensors = None if meter.measurement_type == "ELECTRICITY": sensors = ELECTRICITY_SENSORS @@ -167,7 +165,7 @@ async def async_setup_entry( # check if this meter has this data, then add this sensor for key in {description.key, *description.alternative_keys}: coordinator: DiscovergyUpdateCoordinator = data.coordinators[ - meter_id + meter.meter_id ] if key in coordinator.data.values: entities.append( @@ -198,12 +196,12 @@ class DiscovergySensor(CoordinatorEntity[DiscovergyUpdateCoordinator], SensorEnt self.entity_description = description self._attr_unique_id = f"{meter.full_serial_number}-{data_key}" - self._attr_device_info = { - ATTR_IDENTIFIERS: {(DOMAIN, meter.get_meter_id())}, - ATTR_NAME: f"{meter.measurement_type.capitalize()} {meter.location.street} {meter.location.street_number}", - ATTR_MODEL: f"{meter.type} {meter.full_serial_number}", - ATTR_MANUFACTURER: MANUFACTURER, - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, meter.meter_id)}, + name=f"{meter.measurement_type.capitalize()} {meter.location.street} {meter.location.street_number}", + model=f"{meter.type} {meter.full_serial_number}", + manufacturer=MANUFACTURER, + ) @property def native_value(self) -> StateType: diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py deleted file mode 100644 index 79653e1c9bc..00000000000 --- a/homeassistant/components/discovery/__init__.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Starts a service to scan in intervals for new devices.""" -from __future__ import annotations - -from datetime import datetime, timedelta -import json -import logging -from typing import NamedTuple - -from netdisco.discovery import NetworkDiscovery -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.components import zeroconf -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import Event, HassJob, HomeAssistant, callback -from homeassistant.helpers import discovery_flow -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import async_discover, async_load_platform -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_get_zeroconf -import homeassistant.util.dt as dt_util - -DOMAIN = "discovery" - -SCAN_INTERVAL = timedelta(seconds=300) -SERVICE_APPLE_TV = "apple_tv" -SERVICE_DAIKIN = "daikin" -SERVICE_DLNA_DMR = "dlna_dmr" -SERVICE_ENIGMA2 = "enigma2" -SERVICE_HASS_IOS_APP = "hass_ios" -SERVICE_HASSIO = "hassio" -SERVICE_HEOS = "heos" -SERVICE_KONNECTED = "konnected" -SERVICE_MOBILE_APP = "hass_mobile_app" -SERVICE_NETGEAR = "netgear_router" -SERVICE_OCTOPRINT = "octoprint" -SERVICE_SABNZBD = "sabnzbd" -SERVICE_SAMSUNG_PRINTER = "samsung_printer" -SERVICE_TELLDUSLIVE = "tellstick" -SERVICE_YEELIGHT = "yeelight" -SERVICE_WEMO = "belkin_wemo" -SERVICE_XIAOMI_GW = "xiaomi_gw" - -# These have custom protocols -CONFIG_ENTRY_HANDLERS = { - SERVICE_TELLDUSLIVE: "tellduslive", - "logitech_mediaserver": "squeezebox", -} - - -class ServiceDetails(NamedTuple): - """Store service details.""" - - component: str - platform: str | None - - -# These have no config flows -SERVICE_HANDLERS = { - SERVICE_ENIGMA2: ServiceDetails("media_player", "enigma2"), - "yamaha": ServiceDetails("media_player", "yamaha"), - "bluesound": ServiceDetails("media_player", "bluesound"), -} - -OPTIONAL_SERVICE_HANDLERS: dict[str, tuple[str, str | None]] = {} - -MIGRATED_SERVICE_HANDLERS = [ - SERVICE_APPLE_TV, - "axis", - "bose_soundtouch", - "deconz", - SERVICE_DAIKIN, - "denonavr", - SERVICE_DLNA_DMR, - "esphome", - "google_cast", - SERVICE_HASS_IOS_APP, - SERVICE_HASSIO, - SERVICE_HEOS, - "harmony", - "homekit", - "ikea_tradfri", - "kodi", - SERVICE_KONNECTED, - SERVICE_MOBILE_APP, - SERVICE_NETGEAR, - SERVICE_OCTOPRINT, - "openhome", - "philips_hue", - SERVICE_SAMSUNG_PRINTER, - "sonos", - "songpal", - SERVICE_WEMO, - SERVICE_XIAOMI_GW, - "volumio", - SERVICE_YEELIGHT, - SERVICE_SABNZBD, - "nanoleaf_aurora", - "lg_smart_device", -] - -DEFAULT_ENABLED = ( - list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS) + MIGRATED_SERVICE_HANDLERS -) -DEFAULT_DISABLED = list(OPTIONAL_SERVICE_HANDLERS) + MIGRATED_SERVICE_HANDLERS - -CONF_IGNORE = "ignore" -CONF_ENABLE = "enable" - -CONFIG_SCHEMA = vol.Schema( - { - vol.Optional(DOMAIN): vol.Schema( - { - vol.Optional(CONF_IGNORE, default=[]): vol.All( - cv.ensure_list, [vol.In(DEFAULT_ENABLED)] - ), - vol.Optional(CONF_ENABLE, default=[]): vol.All( - cv.ensure_list, [vol.In(DEFAULT_DISABLED + DEFAULT_ENABLED)] - ), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Start a discovery service.""" - - logger = logging.getLogger(__name__) - netdisco = NetworkDiscovery() - already_discovered = set() - - if DOMAIN in config: - # Platforms ignore by config - ignored_platforms = config[DOMAIN][CONF_IGNORE] - - # Optional platforms enabled by config - enabled_platforms = config[DOMAIN][CONF_ENABLE] - else: - ignored_platforms = [] - enabled_platforms = [] - - for platform in enabled_platforms: - if platform in DEFAULT_ENABLED: - logger.warning( - ( - "Please remove %s from your discovery.enable configuration " - "as it is now enabled by default" - ), - platform, - ) - - zeroconf_instance = await zeroconf.async_get_instance(hass) - # Do not scan for types that have already been converted - # as it will generate excess network traffic for questions - # the zeroconf instance already knows the answers - zeroconf_types = list(await async_get_zeroconf(hass)) - - async def new_service_found(service, info): - """Handle a new service if one is found.""" - if service in MIGRATED_SERVICE_HANDLERS: - return - - if service in ignored_platforms: - logger.info("Ignoring service: %s %s", service, info) - return - - discovery_hash = json.dumps([service, info], sort_keys=True) - if discovery_hash in already_discovered: - logger.debug("Already discovered service %s %s.", service, info) - return - - already_discovered.add(discovery_hash) - - if service in CONFIG_ENTRY_HANDLERS: - discovery_flow.async_create_flow( - hass, - CONFIG_ENTRY_HANDLERS[service], - context={"source": config_entries.SOURCE_DISCOVERY}, - data=info, - ) - return - - service_details = SERVICE_HANDLERS.get(service) - - if not service_details and service in enabled_platforms: - service_details = OPTIONAL_SERVICE_HANDLERS[service] - - # We do not know how to handle this service. - if not service_details: - logger.debug("Unknown service discovered: %s %s", service, info) - return - - logger.info("Found new service: %s %s", service, info) - - if service_details.platform is None: - await async_discover(hass, service, info, service_details.component, config) - else: - await async_load_platform( - hass, service_details.component, service_details.platform, info, config - ) - - async def scan_devices(now: datetime) -> None: - """Scan for devices.""" - try: - results = await hass.async_add_executor_job( - _discover, netdisco, zeroconf_instance, zeroconf_types - ) - - for result in results: - hass.async_create_task(new_service_found(*result)) - except OSError: - logger.error("Network is unreachable") - - async_track_point_in_utc_time( - hass, scan_devices_job, dt_util.utcnow() + SCAN_INTERVAL - ) - - @callback - def schedule_first(event: Event) -> None: - """Schedule the first discovery when Home Assistant starts up.""" - async_track_point_in_utc_time(hass, scan_devices_job, dt_util.utcnow()) - - scan_devices_job = HassJob(scan_devices, cancel_on_shutdown=True) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, schedule_first) - - return True - - -def _discover(netdisco, zeroconf_instance, zeroconf_types): - """Discover devices.""" - results = [] - try: - netdisco.scan( - zeroconf_instance=zeroconf_instance, suppress_mdns_types=zeroconf_types - ) - - for disc in netdisco.discover(): - for service in netdisco.get_info(disc): - results.append((disc, service)) - - finally: - netdisco.stop() - - return results diff --git a/homeassistant/components/discovery/manifest.json b/homeassistant/components/discovery/manifest.json deleted file mode 100644 index d6d3443f562..00000000000 --- a/homeassistant/components/discovery/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "domain": "discovery", - "name": "Discovery", - "after_dependencies": ["zeroconf"], - "codeowners": ["@home-assistant/core"], - "documentation": "https://www.home-assistant.io/integrations/discovery", - "integration_type": "system", - "loggers": ["netdisco"], - "quality_scale": "internal", - "requirements": ["netdisco==3.0.0"] -} diff --git a/homeassistant/components/dlink/strings.json b/homeassistant/components/dlink/strings.json index 9ac7453093c..ee7abb3e979 100644 --- a/homeassistant/components/dlink/strings.json +++ b/homeassistant/components/dlink/strings.json @@ -24,11 +24,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The D-Link Smart Plug YAML configuration is being removed", - "description": "Configuring D-Link Smart Plug using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the D-Link Power Plug YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py index d06372bb28b..0814945bc07 100644 --- a/homeassistant/components/dlink/switch.py +++ b/homeassistant/components/dlink/switch.py @@ -17,7 +17,6 @@ SCAN_INTERVAL = timedelta(minutes=2) SWITCH_TYPE = SwitchEntityDescription( key="switch", - name="Switch", ) @@ -34,6 +33,8 @@ async def async_setup_entry( class SmartPlugSwitch(DLinkEntity, SwitchEntity): """Representation of a D-Link Smart Plug switch.""" + _attr_name = None + @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the device.""" diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index bcd402e6a63..1ad29c72c26 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -126,7 +126,8 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a flow initialized by SSDP discovery.""" - LOGGER.debug("async_step_ssdp: discovery_info %s", pformat(discovery_info)) + if LOGGER.isEnabledFor(logging.DEBUG): + LOGGER.debug("async_step_ssdp: discovery_info %s", pformat(discovery_info)) await self._async_set_info_from_discovery(discovery_info) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 322cd1e4d2b..350ea692338 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.33.2", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.34.1", "getmac==0.8.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index eddb2633bea..50877756d52 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -37,7 +37,6 @@ from .const import ( CONF_CALLBACK_URL_OVERRIDE, CONF_LISTEN_PORT, CONF_POLL_AVAILABILITY, - DOMAIN, LOGGER as _LOGGER, MEDIA_METADATA_DIDL, MEDIA_TYPE_MAP, @@ -381,7 +380,6 @@ class DlnaDmrEntity(MediaPlayerEntity): device_entry = dev_reg.async_get_or_create( config_entry_id=self.registry_entry.config_entry_id, connections=connections, - identifiers={(DOMAIN, self.unique_id)}, default_manufacturer=self._device.manufacturer, default_model=self._device.model_name, default_name=self._device.name, diff --git a/homeassistant/components/dlna_dmr/strings.json b/homeassistant/components/dlna_dmr/strings.json index d646f20f7a1..48f347a0908 100644 --- a/homeassistant/components/dlna_dmr/strings.json +++ b/homeassistant/components/dlna_dmr/strings.json @@ -34,7 +34,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "not_dmr": "Device is not a supported Digital Media Renderer" + "not_dmr": "[%key:component::dlna_dmr::config::abort::not_dmr%]" } }, "options": { diff --git a/homeassistant/components/dlna_dms/config_flow.py b/homeassistant/components/dlna_dms/config_flow.py index 8cb34be927f..e147055df05 100644 --- a/homeassistant/components/dlna_dms/config_flow.py +++ b/homeassistant/components/dlna_dms/config_flow.py @@ -67,7 +67,8 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a flow initialized by SSDP discovery.""" - LOGGER.debug("async_step_ssdp: discovery_info %s", pformat(discovery_info)) + if LOGGER.isEnabledFor(logging.DEBUG): + LOGGER.debug("async_step_ssdp: discovery_info %s", pformat(discovery_info)) await self._async_parse_discovery(discovery_info) diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 8fc55830c63..6352d98da3c 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine from dataclasses import dataclass +from enum import StrEnum import functools from typing import Any, TypeVar, cast @@ -15,7 +16,6 @@ from async_upnp_client.exceptions import UpnpActionError, UpnpConnectionError, U from async_upnp_client.profiles.dlna import ContentDirectoryErrorCode, DmsDevice from didl_lite import didl_lite -from homeassistant.backports.enum import StrEnum from homeassistant.backports.functools import cached_property from homeassistant.components import ssdp from homeassistant.components.media_player import BrowseError, MediaClass diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 227a343a7a4..9aabc3cea5e 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.33.2"], + "requirements": ["async-upnp-client==0.34.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/dnsip/strings.json b/homeassistant/components/dnsip/strings.json index 713cc84efd4..d402e27287c 100644 --- a/homeassistant/components/dnsip/strings.json +++ b/homeassistant/components/dnsip/strings.json @@ -17,8 +17,8 @@ "step": { "init": { "data": { - "resolver": "Resolver for IPV4 lookup", - "resolver_ipv6": "Resolver for IPV6 lookup" + "resolver": "[%key:component::dnsip::config::step::user::data::resolver%]", + "resolver_ipv6": "[%key:component::dnsip::config::step::user::data::resolver_ipv6%]" } } }, diff --git a/homeassistant/components/dominos/services.yaml b/homeassistant/components/dominos/services.yaml index 6a354bc3a63..f2261072ddd 100644 --- a/homeassistant/components/dominos/services.yaml +++ b/homeassistant/components/dominos/services.yaml @@ -1,10 +1,6 @@ order: - name: Order - description: Places a set of orders with Dominos Pizza. fields: order_entity_id: - name: Order Entity - description: The ID (as specified in the configuration) of an order to place. If provided as an array, all of the identified orders will be placed. example: dominos.medium_pan selector: text: diff --git a/homeassistant/components/dominos/strings.json b/homeassistant/components/dominos/strings.json new file mode 100644 index 00000000000..0ceabd7abe8 --- /dev/null +++ b/homeassistant/components/dominos/strings.json @@ -0,0 +1,14 @@ +{ + "services": { + "order": { + "name": "Order", + "description": "Places a set of orders with Dominos Pizza.", + "fields": { + "order_entity_id": { + "name": "Order entity", + "description": "The ID (as specified in the configuration) of an order to place. If provided as an array, all of the identified orders will be placed." + } + } + } + } +} diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 52c89f3f34b..bc7c7d97430 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], - "requirements": ["pydoods==1.0.2", "Pillow==9.5.0"] + "requirements": ["pydoods==1.0.2", "Pillow==10.0.0"] } diff --git a/homeassistant/components/downloader/services.yaml b/homeassistant/components/downloader/services.yaml index cecb3804227..54d06db5627 100644 --- a/homeassistant/components/downloader/services.yaml +++ b/homeassistant/components/downloader/services.yaml @@ -1,29 +1,19 @@ download_file: - name: Download file - description: Download a file to the download location. fields: url: - name: URL - description: The URL of the file to download. required: true example: "http://example.org/myfile" selector: text: subdir: - name: Subdirectory - description: Download into subdirectory. example: "download_dir" selector: text: filename: - name: Filename - description: Determine the filename. example: "my_file_name" selector: text: overwrite: - name: Overwrite - description: Whether to overwrite the file or not. default: false selector: boolean: diff --git a/homeassistant/components/downloader/strings.json b/homeassistant/components/downloader/strings.json new file mode 100644 index 00000000000..c81b9f0ea39 --- /dev/null +++ b/homeassistant/components/downloader/strings.json @@ -0,0 +1,26 @@ +{ + "services": { + "download_file": { + "name": "Download file", + "description": "Downloads a file to the download location.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "The URL of the file to download." + }, + "subdir": { + "name": "Subdirectory", + "description": "Download into subdirectory." + }, + "filename": { + "name": "Filename", + "description": "Determine the filename." + }, + "overwrite": { + "name": "Overwrite", + "description": "Whether to overwrite the file or not." + } + } + } + } +} diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index e6d1d035e3b..12ad3350e44 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -35,7 +35,7 @@ from homeassistant.const import ( from homeassistant.core import CoreState, Event, HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import EventType, StateType +from homeassistant.helpers.typing import StateType from homeassistant.util import Throttle from .const import ( @@ -457,7 +457,7 @@ async def async_setup_entry( if transport: # Register listener to close transport on HA shutdown @callback - def close_transport(_event: EventType) -> None: + def close_transport(_event: Event) -> None: """Close the transport on HA shutdown.""" if not transport: # noqa: B023 return diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 5724ad643fe..7dc44e47a98 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -18,15 +18,15 @@ "setup_serial": { "data": { "port": "Select device", - "dsmr_version": "Select DSMR version" + "dsmr_version": "[%key:component::dsmr::config::step::setup_network::data::dsmr_version%]" }, - "title": "Device" + "title": "[%key:common::config_flow::data::device%]" }, "setup_serial_manual_path": { "data": { "port": "[%key:common::config_flow::data::usb_path%]" }, - "title": "Path" + "title": "[%key:common::config_flow::data::path%]" } }, "error": { @@ -37,7 +37,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "cannot_communicate": "Failed to communicate" + "cannot_communicate": "[%key:component::dsmr::config::error::cannot_communicate%]" } }, "entity": { diff --git a/homeassistant/components/duckdns/services.yaml b/homeassistant/components/duckdns/services.yaml index 6c8b5af8199..485afa44a03 100644 --- a/homeassistant/components/duckdns/services.yaml +++ b/homeassistant/components/duckdns/services.yaml @@ -1,10 +1,6 @@ set_txt: - name: Set TXT - description: Set the TXT record of your DuckDNS subdomain. fields: txt: - name: TXT - description: Payload for the TXT record. required: true example: "This domain name is reserved for use in documentation" selector: diff --git a/homeassistant/components/duckdns/strings.json b/homeassistant/components/duckdns/strings.json new file mode 100644 index 00000000000..d560b760e47 --- /dev/null +++ b/homeassistant/components/duckdns/strings.json @@ -0,0 +1,14 @@ +{ + "services": { + "set_txt": { + "name": "Set TXT", + "description": "Sets the TXT record of your DuckDNS subdomain.", + "fields": { + "txt": { + "name": "TXT", + "description": "Payload for the TXT record." + } + } + } + } +} diff --git a/homeassistant/components/dunehd/media_player.py b/homeassistant/components/dunehd/media_player.py index a184b91c05e..367eb6cb296 100644 --- a/homeassistant/components/dunehd/media_player.py +++ b/homeassistant/components/dunehd/media_player.py @@ -35,7 +35,7 @@ async def async_setup_entry( """Add Dune HD entities from a config_entry.""" unique_id = entry.entry_id - player: str = hass.data[DOMAIN][entry.entry_id] + player: DuneHDPlayer = hass.data[DOMAIN][entry.entry_id] async_add_entities([DuneHDPlayerEntity(player, DEFAULT_NAME, unique_id)], True) @@ -43,6 +43,9 @@ async def async_setup_entry( class DuneHDPlayerEntity(MediaPlayerEntity): """Implementation of the Dune HD player.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, player: DuneHDPlayer, name: str, unique_id: str) -> None: """Initialize entity to control Dune HD.""" self._player = player @@ -70,11 +73,6 @@ class DuneHDPlayerEntity(MediaPlayerEntity): state = MediaPlayerState.ON return state - @property - def name(self) -> str: - """Return the name of the device.""" - return self._name - @property def available(self) -> bool: """Return True if entity is available.""" @@ -91,7 +89,7 @@ class DuneHDPlayerEntity(MediaPlayerEntity): return DeviceInfo( identifiers={(DOMAIN, self._unique_id)}, manufacturer=ATTR_MANUFACTURER, - name=DEFAULT_NAME, + name=self._name, ) @property diff --git a/homeassistant/components/duotecno/__init__.py b/homeassistant/components/duotecno/__init__.py new file mode 100644 index 00000000000..98003c3e8c4 --- /dev/null +++ b/homeassistant/components/duotecno/__init__.py @@ -0,0 +1,37 @@ +"""The duotecno integration.""" +from __future__ import annotations + +from duotecno.controller import PyDuotecno +from duotecno.exceptions import InvalidPassword, LoadFailure + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.COVER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up duotecno from a config entry.""" + + controller = PyDuotecno() + try: + await controller.connect( + entry.data[CONF_HOST], entry.data[CONF_PORT], entry.data[CONF_PASSWORD] + ) + except (OSError, InvalidPassword, LoadFailure) as err: + raise ConfigEntryNotReady from err + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/duotecno/config_flow.py b/homeassistant/components/duotecno/config_flow.py new file mode 100644 index 00000000000..37087d4ea1a --- /dev/null +++ b/homeassistant/components/duotecno/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for duotecno integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from duotecno.controller import PyDuotecno +from duotecno.exceptions import InvalidPassword +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_PORT): int, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for duotecno.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + controller = PyDuotecno() + await controller.connect( + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input[CONF_PASSWORD], + True, + ) + except ConnectionError: + errors["base"] = "cannot_connect" + except InvalidPassword: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=f"{user_input[CONF_HOST]}", data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/duotecno/const.py b/homeassistant/components/duotecno/const.py new file mode 100644 index 00000000000..114867b8d95 --- /dev/null +++ b/homeassistant/components/duotecno/const.py @@ -0,0 +1,3 @@ +"""Constants for the duotecno integration.""" + +DOMAIN = "duotecno" diff --git a/homeassistant/components/duotecno/cover.py b/homeassistant/components/duotecno/cover.py new file mode 100644 index 00000000000..0fd212df085 --- /dev/null +++ b/homeassistant/components/duotecno/cover.py @@ -0,0 +1,85 @@ +"""Support for Velbus covers.""" +from __future__ import annotations + +from typing import Any + +from duotecno.unit import DuoswitchUnit + +from homeassistant.components.cover import ( + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import DuotecnoEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the duoswitch endities.""" + cntrl = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + DuotecnoCover(channel) for channel in cntrl.get_units("DuoswitchUnit") + ) + + +class DuotecnoCover(DuotecnoEntity, CoverEntity): + """Representation a Velbus cover.""" + + _unit: DuoswitchUnit + + def __init__(self, unit: DuoswitchUnit) -> None: + """Initialize the cover.""" + super().__init__(unit) + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed.""" + return self._unit.is_closed() + + @property + def is_opening(self) -> bool: + """Return if the cover is opening.""" + return self._unit.is_opening() + + @property + def is_closing(self) -> bool: + """Return if the cover is closing.""" + return self._unit.is_closing() + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + try: + await self._unit.open() + except OSError as err: + raise HomeAssistantError( + "Transmit for the open_cover packet failed" + ) from err + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + try: + await self._unit.close() + except OSError as err: + raise HomeAssistantError( + "Transmit for the close_cover packet failed" + ) from err + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + try: + await self._unit.stop() + except OSError as err: + raise HomeAssistantError( + "Transmit for the stop_cover packet failed" + ) from err diff --git a/homeassistant/components/duotecno/entity.py b/homeassistant/components/duotecno/entity.py new file mode 100644 index 00000000000..f1c72aa55c4 --- /dev/null +++ b/homeassistant/components/duotecno/entity.py @@ -0,0 +1,36 @@ +"""Support for Velbus devices.""" +from __future__ import annotations + +from duotecno.unit import BaseUnit + +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import DOMAIN + + +class DuotecnoEntity(Entity): + """Representation of a Duotecno entity.""" + + _attr_should_poll: bool = False + _unit: BaseUnit + + def __init__(self, unit) -> None: + """Initialize a Duotecno entity.""" + self._unit = unit + self._attr_name = unit.get_name() + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, str(unit.get_node_address())), + }, + manufacturer="Duotecno", + name=unit.get_node_name(), + ) + self._attr_unique_id = f"{unit.get_node_address()}-{unit.get_number()}" + + async def async_added_to_hass(self) -> None: + """When added to hass.""" + self._unit.on_status_update(self._on_update) + + async def _on_update(self) -> None: + """When a unit has an update.""" + self.async_write_ha_state() diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json new file mode 100644 index 00000000000..ae82574146e --- /dev/null +++ b/homeassistant/components/duotecno/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "duotecno", + "name": "Duotecno", + "codeowners": ["@cereal2nd"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/duotecno", + "iot_class": "local_push", + "requirements": ["pyduotecno==2023.8.0"] +} diff --git a/homeassistant/components/duotecno/strings.json b/homeassistant/components/duotecno/strings.json new file mode 100644 index 00000000000..379291eb626 --- /dev/null +++ b/homeassistant/components/duotecno/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/duotecno/switch.py b/homeassistant/components/duotecno/switch.py new file mode 100644 index 00000000000..a9921de85d3 --- /dev/null +++ b/homeassistant/components/duotecno/switch.py @@ -0,0 +1,50 @@ +"""Support for Duotecno switches.""" +from typing import Any + +from duotecno.unit import SwitchUnit + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import DuotecnoEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Velbus switch based on config_entry.""" + cntrl = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + DuotecnoSwitch(channel) for channel in cntrl.get_units("SwitchUnit") + ) + + +class DuotecnoSwitch(DuotecnoEntity, SwitchEntity): + """Representation of a switch.""" + + _unit: SwitchUnit + + @property + def is_on(self) -> bool: + """Return true if the switch is on.""" + return self._unit.is_on() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the switch to turn on.""" + try: + await self._unit.turn_on() + except OSError as err: + raise HomeAssistantError("Transmit for the turn_on packet failed") from err + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the switch to turn off.""" + try: + await self._unit.turn_off() + except OSError as err: + raise HomeAssistantError("Transmit for the turn_off packet failed") from err diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index f44d736b426..62bb4af7930 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -22,7 +22,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -89,12 +89,17 @@ async def async_setup_platform( # Show issue as long as the YAML configuration exists. async_create_issue( hass, - DOMAIN, - "deprecated_yaml", + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", breaks_in_ha_version="2023.12.0", is_fixable=False, + issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Deutscher Wetterdienst (DWD) Weather Warnings", + }, ) hass.async_create_task( diff --git a/homeassistant/components/dwd_weather_warnings/strings.json b/homeassistant/components/dwd_weather_warnings/strings.json index c5c954a9f8e..60e53f90dbd 100644 --- a/homeassistant/components/dwd_weather_warnings/strings.json +++ b/homeassistant/components/dwd_weather_warnings/strings.json @@ -15,11 +15,5 @@ "already_configured": "Warncell ID / name is already configured.", "invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Deutscher Wetterdienst (DWD) Weather Warnings YAML configuration is being removed", - "description": "Configuring Deutscher Wetterdienst (DWD) Weather Warnings using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Deutscher Wetterdienst (DWD) Weather Warnings YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index 946d4ac653d..8438307c698 100644 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -31,12 +32,17 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Raise an issue that this is deprecated and has been imported async_create_issue( self.hass, - DOMAIN, - "deprecated_yaml", + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", is_fixable=False, is_persistent=False, + issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Dynalite", + }, ) host = import_info[CONF_HOST] diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py index 3ebf04ab219..85c672e0f64 100644 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -41,17 +41,15 @@ def async_setup_entry_base( class DynaliteBase(RestoreEntity, ABC): """Base class for the Dynalite entities.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, device: Any, bridge: DynaliteBridge) -> None: """Initialize the base class.""" self._device = device self._bridge = bridge self._unsub_dispatchers: list[Callable[[], None]] = [] - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._device.name - @property def unique_id(self) -> str: """Return the unique ID of the entity.""" @@ -68,7 +66,7 @@ class DynaliteBase(RestoreEntity, ABC): return DeviceInfo( identifiers={(DOMAIN, self._device.unique_id)}, manufacturer="Dynalite", - name=self.name, + name=self._device.name, ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/dynalite/panel.py b/homeassistant/components/dynalite/panel.py index e7a0890033c..b7020367f74 100644 --- a/homeassistant/components/dynalite/panel.py +++ b/homeassistant/components/dynalite/panel.py @@ -108,9 +108,8 @@ async def async_register_dynalite_frontend(hass: HomeAssistant): await panel_custom.async_register_panel( hass=hass, frontend_url_path=DOMAIN, + config_panel_domain=DOMAIN, webcomponent_name="dynalite-panel", - sidebar_title=DOMAIN.capitalize(), - sidebar_icon="mdi:power", module_url=f"{URL_BASE}/entrypoint-{build_id}.js", embed_iframe=True, require_admin=True, diff --git a/homeassistant/components/dynalite/services.yaml b/homeassistant/components/dynalite/services.yaml index d34335ca1d3..97c5d9c2486 100644 --- a/homeassistant/components/dynalite/services.yaml +++ b/homeassistant/components/dynalite/services.yaml @@ -1,21 +1,16 @@ request_area_preset: - name: Request area preset - description: "Requests Dynalite to report the preset for an area." fields: host: - description: "Host gateway IP to send to or all configured gateways if not specified." example: "192.168.0.101" selector: text: area: - description: "Area to request the preset reported" required: true selector: number: min: 1 max: 9999 channel: - description: "Channel to request the preset to be reported from." default: 1 selector: number: @@ -23,26 +18,18 @@ request_area_preset: max: 9999 request_channel_level: - name: Request channel level - description: "Requests Dynalite to report the level of a specific channel." fields: host: - name: Host - description: "Host gateway IP to send to or all configured gateways if not specified." example: "192.168.0.101" selector: text: area: - name: Area - description: "Area for the requested channel" required: true selector: number: min: 1 max: 9999 channel: - name: Channel - description: "Channel to request the level for." required: true selector: number: diff --git a/homeassistant/components/dynalite/strings.json b/homeassistant/components/dynalite/strings.json index 1d78108f909..468cdebf0b1 100644 --- a/homeassistant/components/dynalite/strings.json +++ b/homeassistant/components/dynalite/strings.json @@ -15,10 +15,42 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, - "issues": { - "deprecated_yaml": { - "title": "The Dynalite YAML configuration is being removed", - "description": "Configuring Dynalite using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Dynalite YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + "services": { + "request_area_preset": { + "name": "Request area preset", + "description": "Requests Dynalite to report the preset for an area.", + "fields": { + "host": { + "name": "[%key:common::config_flow::data::host%]", + "description": "Host gateway IP to send to or all configured gateways if not specified." + }, + "area": { + "name": "Area", + "description": "Area to request the preset reported." + }, + "channel": { + "name": "Channel", + "description": "Channel to request the preset to be reported from." + } + } + }, + "request_channel_level": { + "name": "Request channel level", + "description": "Requests Dynalite to report the level of a specific channel.", + "fields": { + "host": { + "name": "[%key:common::config_flow::data::host%]", + "description": "[%key:component::dynalite::services::request_area_preset::fields::host::description%]" + }, + "area": { + "name": "[%key:component::dynalite::services::request_area_preset::fields::area::name%]", + "description": "Area for the requested channel." + }, + "channel": { + "name": "Channel", + "description": "Channel to request the level for." + } + } } } } diff --git a/homeassistant/components/ebusd/services.yaml b/homeassistant/components/ebusd/services.yaml index dc356bec226..6615e947f28 100644 --- a/homeassistant/components/ebusd/services.yaml +++ b/homeassistant/components/ebusd/services.yaml @@ -1,10 +1,6 @@ write: - name: Write - description: Call ebusd write command. fields: call: - name: Call - description: Property name and value to set required: true example: '{"name": "Hc1MaxFlowTempDesired", "value": 21}' selector: diff --git a/homeassistant/components/ebusd/strings.json b/homeassistant/components/ebusd/strings.json new file mode 100644 index 00000000000..4097be02393 --- /dev/null +++ b/homeassistant/components/ebusd/strings.json @@ -0,0 +1,14 @@ +{ + "services": { + "write": { + "name": "Write", + "description": "Calls the ebusd write command.", + "fields": { + "call": { + "name": "Call", + "description": "Property name and value to set." + } + } + } + } +} diff --git a/homeassistant/components/ecobee/services.yaml b/homeassistant/components/ecobee/services.yaml index aba57989119..a184f422725 100644 --- a/homeassistant/components/ecobee/services.yaml +++ b/homeassistant/components/ecobee/services.yaml @@ -1,28 +1,17 @@ create_vacation: - name: Create vacation - description: >- - Create a vacation on the selected thermostat. Note: start/end date and time must all be specified - together for these parameters to have an effect. If start/end date and time are not specified, the - vacation will start immediately and last 14 days (unless deleted earlier). fields: entity_id: - name: Entity - description: ecobee thermostat on which to create the vacation. required: true selector: entity: integration: ecobee domain: climate vacation_name: - name: Vacation name - description: Name of the vacation to create; must be unique on the thermostat. required: true example: "Skiing" selector: text: cool_temp: - name: Cool temperature - description: Cooling temperature during the vacation. required: true selector: number: @@ -31,8 +20,6 @@ create_vacation: step: 0.5 unit_of_measurement: "°" heat_temp: - name: Heat temperature - description: Heating temperature during the vacation. required: true selector: number: @@ -41,36 +28,22 @@ create_vacation: step: 0.5 unit_of_measurement: "°" start_date: - name: Start date - description: >- - Date the vacation starts in the YYYY-MM-DD format (optional, immediately if not provided along with - start_time, end_date, and end_time). example: "2019-03-15" selector: text: start_time: - name: start time - description: Time the vacation starts, in the local time of the thermostat, in the 24-hour format "HH:MM:SS" example: "20:00:00" selector: time: end_date: - name: End date - description: >- - Date the vacation ends in the YYYY-MM-DD format (optional, 14 days from now if not provided along with - start_date, start_time, and end_time). example: "2019-03-20" selector: text: end_time: - name: End time - description: Time the vacation ends, in the local time of the thermostat, in the 24-hour format "HH:MM:SS" example: "20:00:00" selector: time: fan_mode: - name: Fan mode - description: Fan mode of the thermostat during the vacation. default: "auto" selector: select: @@ -78,8 +51,6 @@ create_vacation: - "on" - "auto" fan_min_on_time: - name: Fan minimum on time - description: Minimum number of minutes to run the fan each hour (0 to 60) during the vacation. default: 0 selector: number: @@ -88,13 +59,8 @@ create_vacation: unit_of_measurement: minutes delete_vacation: - name: Delete vacation - description: >- - Delete a vacation on the selected thermostat. fields: entity_id: - name: Entity - description: ecobee thermostat on which to delete the vacation. required: true example: "climate.kitchen" selector: @@ -102,45 +68,31 @@ delete_vacation: integration: ecobee domain: climate vacation_name: - name: Vacation name - description: Name of the vacation to delete. required: true example: "Skiing" selector: text: resume_program: - name: Resume program - description: Resume the programmed schedule. fields: entity_id: - name: Entity - description: Name(s) of entities to change. selector: entity: integration: ecobee domain: climate resume_all: - name: Resume all - description: Resume all events and return to the scheduled program. default: false selector: boolean: set_fan_min_on_time: - name: Set fan minimum on time - description: Set the minimum fan on time. fields: entity_id: - name: Entity - description: Name(s) of entities to change. selector: entity: integration: ecobee domain: climate fan_min_on_time: - name: Fan minimum on time - description: New value of fan min on time. required: true selector: number: @@ -149,50 +101,36 @@ set_fan_min_on_time: unit_of_measurement: minutes set_dst_mode: - name: Set Daylight savings time mode - description: Enable/disable automatic daylight savings time. target: entity: integration: ecobee domain: climate fields: dst_enabled: - name: Daylight savings time enabled - description: Enable automatic daylight savings time. required: true selector: boolean: set_mic_mode: - name: Set mic mode - description: Enable/disable Alexa mic (only for Ecobee 4). target: entity: integration: ecobee domain: climate fields: mic_enabled: - name: Mic enabled - description: Enable Alexa mic. required: true selector: boolean: set_occupancy_modes: - name: Set occupancy modes - description: Enable/disable Smart Home/Away and Follow Me modes. target: entity: integration: ecobee domain: climate fields: auto_away: - name: Auto away - description: Enable Smart Home/Away mode. selector: boolean: follow_me: - name: Follow me - description: Enable Follow Me mode. selector: boolean: diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index 647ea55e311..fc43fc3000e 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -28,5 +28,129 @@ "name": "Ventilator min time away" } } + }, + "services": { + "create_vacation": { + "name": "Create vacation", + "description": "Creates a vacation on the selected thermostat. Note: start/end date and time must all be specified together for these parameters to have an effect. If start/end date and time are not specified, the vacation will start immediately and last 14 days (unless deleted earlier).", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Ecobee thermostat on which to create the vacation." + }, + "vacation_name": { + "name": "Vacation name", + "description": "Name of the vacation to create; must be unique on the thermostat." + }, + "cool_temp": { + "name": "Cool temperature", + "description": "Cooling temperature during the vacation." + }, + "heat_temp": { + "name": "Heat temperature", + "description": "Heating temperature during the vacation." + }, + "start_date": { + "name": "Start date", + "description": "Date the vacation starts in the YYYY-MM-DD format (optional, immediately if not provided along with start_time, end_date, and end_time)." + }, + "start_time": { + "name": "Start time", + "description": "Time the vacation starts, in the local time of the thermostat, in the 24-hour format \"HH:MM:SS\"." + }, + "end_date": { + "name": "End date", + "description": "Date the vacation ends in the YYYY-MM-DD format (optional, 14 days from now if not provided along with start_date, start_time, and end_time)." + }, + "end_time": { + "name": "End time", + "description": "Time the vacation ends, in the local time of the thermostat, in the 24-hour format \"HH:MM:SS\"." + }, + "fan_mode": { + "name": "Fan mode", + "description": "Fan mode of the thermostat during the vacation." + }, + "fan_min_on_time": { + "name": "Fan minimum on time", + "description": "Minimum number of minutes to run the fan each hour (0 to 60) during the vacation." + } + } + }, + "delete_vacation": { + "name": "Delete vacation", + "description": "Deletes a vacation on the selected thermostat.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Ecobee thermostat on which to delete the vacation." + }, + "vacation_name": { + "name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]", + "description": "Name of the vacation to delete." + } + } + }, + "resume_program": { + "name": "Resume program", + "description": "Resumes the programmed schedule.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name(s) of entities to change." + }, + "resume_all": { + "name": "Resume all", + "description": "Resume all events and return to the scheduled program." + } + } + }, + "set_fan_min_on_time": { + "name": "Set fan minimum on time", + "description": "Sets the minimum fan on time.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "[%key:component::ecobee::services::resume_program::fields::entity_id::description%]" + }, + "fan_min_on_time": { + "name": "[%key:component::ecobee::services::create_vacation::fields::fan_min_on_time::name%]", + "description": "New value of fan min on time." + } + } + }, + "set_dst_mode": { + "name": "Set Daylight savings time mode", + "description": "Enables/disables automatic daylight savings time.", + "fields": { + "dst_enabled": { + "name": "Daylight savings time enabled", + "description": "Enable automatic daylight savings time." + } + } + }, + "set_mic_mode": { + "name": "Set mic mode", + "description": "Enables/disables Alexa mic (only for Ecobee 4).", + "fields": { + "mic_enabled": { + "name": "Mic enabled", + "description": "Enable Alexa mic." + } + } + }, + "set_occupancy_modes": { + "name": "Set occupancy modes", + "description": "Enables/disables Smart Home/Away and Follow Me modes.", + "fields": { + "auto_away": { + "name": "Auto away", + "description": "Enable Smart Home/Away mode." + }, + "follow_me": { + "name": "Follow me", + "description": "Enable Follow Me mode." + } + } + } } } diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index cd87b175cf9..9cb8a8c38d8 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -46,54 +46,60 @@ ECOVACS_API_DEVICEID = "".join( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Ecovacs component.""" _LOGGER.debug("Creating new Ecovacs component") - hass.data[ECOVACS_DEVICES] = [] - - ecovacs_api = EcoVacsAPI( - ECOVACS_API_DEVICEID, - config[DOMAIN].get(CONF_USERNAME), - EcoVacsAPI.md5(config[DOMAIN].get(CONF_PASSWORD)), - config[DOMAIN].get(CONF_COUNTRY), - config[DOMAIN].get(CONF_CONTINENT), - ) - - devices = ecovacs_api.devices() - _LOGGER.debug("Ecobot devices: %s", devices) - - for device in devices: - _LOGGER.info( - "Discovered Ecovacs device on account: %s with nickname %s", - device.get("did"), - device.get("nick"), + def get_devices() -> list[VacBot]: + ecovacs_api = EcoVacsAPI( + ECOVACS_API_DEVICEID, + config[DOMAIN].get(CONF_USERNAME), + EcoVacsAPI.md5(config[DOMAIN].get(CONF_PASSWORD)), + config[DOMAIN].get(CONF_COUNTRY), + config[DOMAIN].get(CONF_CONTINENT), ) - vacbot = VacBot( - ecovacs_api.uid, - ecovacs_api.REALM, - ecovacs_api.resource, - ecovacs_api.user_access_token, - device, - config[DOMAIN].get(CONF_CONTINENT).lower(), - monitor=True, - ) - hass.data[ECOVACS_DEVICES].append(vacbot) + ecovacs_devices = ecovacs_api.devices() + _LOGGER.debug("Ecobot devices: %s", ecovacs_devices) - def stop(event: object) -> None: + devices: list[VacBot] = [] + for device in ecovacs_devices: + _LOGGER.info( + "Discovered Ecovacs device on account: %s with nickname %s", + device.get("did"), + device.get("nick"), + ) + vacbot = VacBot( + ecovacs_api.uid, + ecovacs_api.REALM, + ecovacs_api.resource, + ecovacs_api.user_access_token, + device, + config[DOMAIN].get(CONF_CONTINENT).lower(), + monitor=True, + ) + + devices.append(vacbot) + return devices + + hass.data[ECOVACS_DEVICES] = await hass.async_add_executor_job(get_devices) + + async def async_stop(event: object) -> None: """Shut down open connections to Ecovacs XMPP server.""" - for device in hass.data[ECOVACS_DEVICES]: + devices: list[VacBot] = hass.data[ECOVACS_DEVICES] + for device in devices: _LOGGER.info( "Shutting down connection to Ecovacs device %s", device.vacuum.get("did"), ) - device.disconnect() + await hass.async_add_executor_job(device.disconnect) # Listen for HA stop to disconnect. - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) if hass.data[ECOVACS_DEVICES]: _LOGGER.debug("Starting vacuum components") - discovery.load_platform(hass, Platform.VACUUM, DOMAIN, {}, config) + hass.async_create_task( + discovery.async_load_platform(hass, Platform.VACUUM, DOMAIN, {}, config) + ) return True diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index f1bf7deb502..2ec9a1a3e4a 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -6,7 +6,15 @@ from typing import Any import sucks -from homeassistant.components.vacuum import VacuumEntity, VacuumEntityFeature +from homeassistant.components.vacuum import ( + STATE_CLEANING, + STATE_DOCKED, + STATE_ERROR, + STATE_IDLE, + STATE_RETURNING, + StateVacuumEntity, + VacuumEntityFeature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level @@ -20,21 +28,23 @@ ATTR_ERROR = "error" ATTR_COMPONENT_PREFIX = "component_" -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Ecovacs vacuums.""" vacuums = [] - for device in hass.data[ECOVACS_DEVICES]: + devices: list[sucks.VacBot] = hass.data[ECOVACS_DEVICES] + for device in devices: + await hass.async_add_executor_job(device.connect_and_wait_until_ready) vacuums.append(EcovacsVacuum(device)) _LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums) - add_entities(vacuums, True) + async_add_entities(vacuums) -class EcovacsVacuum(VacuumEntity): +class EcovacsVacuum(StateVacuumEntity): """Ecovacs Vacuums such as Deebot.""" _attr_fan_speed_list = [sucks.FAN_SPEED_NORMAL, sucks.FAN_SPEED_HIGH] @@ -44,10 +54,9 @@ class EcovacsVacuum(VacuumEntity): | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.CLEAN_SPOT | VacuumEntityFeature.STOP - | VacuumEntityFeature.TURN_OFF - | VacuumEntityFeature.TURN_ON + | VacuumEntityFeature.START | VacuumEntityFeature.LOCATE - | VacuumEntityFeature.STATUS + | VacuumEntityFeature.STATE | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.FAN_SPEED ) @@ -55,15 +64,11 @@ class EcovacsVacuum(VacuumEntity): def __init__(self, device: sucks.VacBot) -> None: """Initialize the Ecovacs Vacuum.""" self.device = device - self.device.connect_and_wait_until_ready() - if self.device.vacuum.get("nick") is not None: - self._attr_name = str(self.device.vacuum["nick"]) - else: - # In case there is no nickname defined, use the device id - self._attr_name = str(format(self.device.vacuum["did"])) + vacuum = self.device.vacuum - self._error = None - _LOGGER.debug("Vacuum initialized: %s", self.name) + self.error = None + self._attr_unique_id = vacuum["did"] + self._attr_name = vacuum.get("nick", vacuum["did"]) async def async_added_to_hass(self) -> None: """Set up the event listeners now that hass is ready.""" @@ -79,9 +84,9 @@ class EcovacsVacuum(VacuumEntity): to change, that will come through as a separate on_status event """ if error == "no_error": - self._error = None + self.error = None else: - self._error = error + self.error = error self.hass.bus.fire( "ecovacs_error", {"entity_id": self.entity_id, "error": error} @@ -89,36 +94,24 @@ class EcovacsVacuum(VacuumEntity): self.schedule_update_ha_state() @property - def unique_id(self) -> str: - """Return an unique ID.""" - return self.device.vacuum.get("did") + def state(self) -> str | None: + """Return the state of the vacuum cleaner.""" + if self.error is not None: + return STATE_ERROR - @property - def is_on(self) -> bool: - """Return true if vacuum is currently cleaning.""" - return self.device.is_cleaning + if self.device.is_cleaning: + return STATE_CLEANING - @property - def is_charging(self) -> bool: - """Return true if vacuum is currently charging.""" - return self.device.is_charging + if self.device.is_charging: + return STATE_DOCKED - @property - def status(self) -> str | None: - """Return the status of the vacuum cleaner.""" - return self.device.vacuum_status + if self.device.vacuum_status == sucks.CLEAN_MODE_STOP: + return STATE_IDLE - def return_to_base(self, **kwargs: Any) -> None: - """Set the vacuum cleaner to return to the dock.""" + if self.device.vacuum_status == sucks.CHARGE_MODE_RETURNING: + return STATE_RETURNING - self.device.run(sucks.Charge()) - - @property - def battery_icon(self) -> str: - """Return the battery icon for the vacuum cleaner.""" - return icon_for_battery_level( - battery_level=self.battery_level, charging=self.is_charging - ) + return None @property def battery_level(self) -> int | None: @@ -126,22 +119,42 @@ class EcovacsVacuum(VacuumEntity): if self.device.battery_status is not None: return self.device.battery_status * 100 - return super().battery_level + return None + + @property + def battery_icon(self) -> str: + """Return the battery icon for the vacuum cleaner.""" + return icon_for_battery_level( + battery_level=self.battery_level, charging=self.device.is_charging + ) @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" return self.device.fan_speed - def turn_on(self, **kwargs: Any) -> None: + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the device-specific state attributes of this vacuum.""" + data: dict[str, Any] = {} + data[ATTR_ERROR] = self.error + + for key, val in self.device.components.items(): + attr_name = ATTR_COMPONENT_PREFIX + key + data[attr_name] = int(val * 100) + + return data + + def return_to_base(self, **kwargs: Any) -> None: + """Set the vacuum cleaner to return to the dock.""" + + self.device.run(sucks.Charge()) + + def start(self, **kwargs: Any) -> None: """Turn the vacuum on and start cleaning.""" self.device.run(sucks.Clean()) - def turn_off(self, **kwargs: Any) -> None: - """Turn the vacuum off stopping the cleaning and returning home.""" - self.return_to_base() - def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" @@ -159,7 +172,7 @@ class EcovacsVacuum(VacuumEntity): def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set fan speed.""" - if self.is_on: + if self.state == STATE_CLEANING: self.device.run(sucks.Clean(mode=self.device.clean_status, speed=fan_speed)) def send_command( @@ -170,15 +183,3 @@ class EcovacsVacuum(VacuumEntity): ) -> None: """Send a command to a vacuum cleaner.""" self.device.run(sucks.VacBotCommand(command, params)) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the device-specific state attributes of this vacuum.""" - data: dict[str, Any] = {} - data[ATTR_ERROR] = self._error - - for key, val in self.device.components.items(): - attr_name = ATTR_COMPONENT_PREFIX + key - data[attr_name] = int(val * 100) - - return data diff --git a/homeassistant/components/ecowitt/entity.py b/homeassistant/components/ecowitt/entity.py index ca5e14b6d7b..76bd89af3d5 100644 --- a/homeassistant/components/ecowitt/entity.py +++ b/homeassistant/components/ecowitt/entity.py @@ -25,7 +25,7 @@ class EcowittEntity(Entity): identifiers={ (DOMAIN, sensor.station.key), }, - name=sensor.station.station, + name=sensor.station.model, model=sensor.station.model, sw_version=sensor.station.version, ) diff --git a/homeassistant/components/eight_sleep/services.yaml b/homeassistant/components/eight_sleep/services.yaml index 39b960a6f7c..b191187bb0a 100644 --- a/homeassistant/components/eight_sleep/services.yaml +++ b/homeassistant/components/eight_sleep/services.yaml @@ -1,14 +1,10 @@ heat_set: - name: Heat set - description: Set heating/cooling level for eight sleep. target: entity: integration: eight_sleep domain: sensor fields: duration: - name: Duration - description: Duration to heat/cool at the target level in seconds. required: true selector: number: @@ -16,8 +12,6 @@ heat_set: max: 28800 unit_of_measurement: seconds target: - name: Target - description: Target cooling/heating level from -100 to 100. required: true selector: number: diff --git a/homeassistant/components/eight_sleep/strings.json b/homeassistant/components/eight_sleep/strings.json index 21accc53a06..b2fb73cc020 100644 --- a/homeassistant/components/eight_sleep/strings.json +++ b/homeassistant/components/eight_sleep/strings.json @@ -13,7 +13,23 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "Cannot connect to Eight Sleep cloud: {error}" + "cannot_connect": "[%key:component::eight_sleep::config::error::cannot_connect%]" + } + }, + "services": { + "heat_set": { + "name": "Heat set", + "description": "Sets heating/cooling level for eight sleep.", + "fields": { + "duration": { + "name": "Duration", + "description": "Duration to heat/cool at the target level in seconds." + }, + "target": { + "name": "Target", + "description": "Target cooling/heating level from -100 to 100." + } + } } } } diff --git a/homeassistant/components/electric_kiwi/__init__.py b/homeassistant/components/electric_kiwi/__init__.py new file mode 100644 index 00000000000..3ae6b1c70cf --- /dev/null +++ b/homeassistant/components/electric_kiwi/__init__.py @@ -0,0 +1,65 @@ +"""The Electric Kiwi integration.""" +from __future__ import annotations + +import aiohttp +from electrickiwi_api import ElectricKiwiApi +from electrickiwi_api.exceptions import ApiException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow + +from . import api +from .const import DOMAIN +from .coordinator import ElectricKiwiHOPDataCoordinator + +PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Electric Kiwi from a config entry.""" + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + + try: + await session.async_ensure_token_valid() + except aiohttp.ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed(err) from err + raise ConfigEntryNotReady from err + except aiohttp.ClientError as err: + raise ConfigEntryNotReady from err + + ek_api = ElectricKiwiApi( + api.AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) + ) + hop_coordinator = ElectricKiwiHOPDataCoordinator(hass, ek_api) + + try: + await ek_api.set_active_session() + await hop_coordinator.async_config_entry_first_refresh() + except ApiException as err: + raise ConfigEntryNotReady from err + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = hop_coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/electric_kiwi/api.py b/homeassistant/components/electric_kiwi/api.py new file mode 100644 index 00000000000..89109f01948 --- /dev/null +++ b/homeassistant/components/electric_kiwi/api.py @@ -0,0 +1,33 @@ +"""API for Electric Kiwi bound to Home Assistant OAuth.""" + +from __future__ import annotations + +from typing import cast + +from aiohttp import ClientSession +from electrickiwi_api import AbstractAuth + +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import API_BASE_URL + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Electric Kiwi authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Electric Kiwi auth.""" + # add host when ready for production "https://api.electrickiwi.co.nz" defaults to dev + super().__init__(websession, API_BASE_URL) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + + return cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/electric_kiwi/application_credentials.py b/homeassistant/components/electric_kiwi/application_credentials.py new file mode 100644 index 00000000000..4a3ef8aa1c5 --- /dev/null +++ b/homeassistant/components/electric_kiwi/application_credentials.py @@ -0,0 +1,38 @@ +"""application_credentials platform the Electric Kiwi integration.""" + +from homeassistant.components.application_credentials import ( + AuthorizationServer, + ClientCredential, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from .oauth2 import ElectricKiwiLocalOAuth2Implementation + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: + """Return auth implementation.""" + return ElectricKiwiLocalOAuth2Implementation( + hass, + auth_domain, + credential, + authorization_server=await async_get_authorization_server(hass), + ) + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "more_info_url": "https://www.home-assistant.io/integrations/electric_kiwi/" + } diff --git a/homeassistant/components/electric_kiwi/config_flow.py b/homeassistant/components/electric_kiwi/config_flow.py new file mode 100644 index 00000000000..c2c80aaa402 --- /dev/null +++ b/homeassistant/components/electric_kiwi/config_flow.py @@ -0,0 +1,59 @@ +"""Config flow for Electric Kiwi.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN, SCOPE_VALUES + + +class ElectricKiwiOauth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Electric Kiwi OAuth2 authentication.""" + + DOMAIN = DOMAIN + + def __init__(self) -> None: + """Set up instance.""" + super().__init__() + self._reauth_entry: ConfigEntry | None = None + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": SCOPE_VALUES} + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict) -> FlowResult: + """Create an entry for Electric Kiwi.""" + existing_entry = await self.async_set_unique_id(DOMAIN) + if existing_entry: + self.hass.config_entries.async_update_entry(existing_entry, data=data) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return await super().async_oauth_create_entry(data) diff --git a/homeassistant/components/electric_kiwi/const.py b/homeassistant/components/electric_kiwi/const.py new file mode 100644 index 00000000000..907b6247172 --- /dev/null +++ b/homeassistant/components/electric_kiwi/const.py @@ -0,0 +1,11 @@ +"""Constants for the Electric Kiwi integration.""" + +NAME = "Electric Kiwi" +DOMAIN = "electric_kiwi" +ATTRIBUTION = "Data provided by the Juice Hacker API" + +OAUTH2_AUTHORIZE = "https://welcome.electrickiwi.co.nz/oauth/authorize" +OAUTH2_TOKEN = "https://welcome.electrickiwi.co.nz/oauth/token" +API_BASE_URL = "https://api.electrickiwi.co.nz" + +SCOPE_VALUES = "read_connection_detail read_billing_frequency read_account_running_balance read_consumption_summary read_consumption_averages read_hop_intervals_config read_hop_connection save_hop_connection read_session" diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py new file mode 100644 index 00000000000..3e0ba997cd4 --- /dev/null +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -0,0 +1,81 @@ +"""Electric Kiwi coordinators.""" +from collections import OrderedDict +from datetime import timedelta +import logging + +import async_timeout +from electrickiwi_api import ElectricKiwiApi +from electrickiwi_api.exceptions import ApiException, AuthException +from electrickiwi_api.model import Hop, HopIntervals + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +HOP_SCAN_INTERVAL = timedelta(hours=2) + + +class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): + """ElectricKiwi Data object.""" + + def __init__(self, hass: HomeAssistant, ek_api: ElectricKiwiApi) -> None: + """Initialize ElectricKiwiAccountDataCoordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="Electric Kiwi HOP Data", + # Polling interval. Will only be polled if there are subscribers. + update_interval=HOP_SCAN_INTERVAL, + ) + self._ek_api = ek_api + self.hop_intervals: HopIntervals | None = None + + def get_hop_options(self) -> dict[str, int]: + """Get the hop interval options for selection.""" + if self.hop_intervals is not None: + return { + f"{v.start_time} - {v.end_time}": k + for k, v in self.hop_intervals.intervals.items() + } + return {} + + async def async_update_hop(self, hop_interval: int) -> Hop: + """Update selected hop and data.""" + try: + self.async_set_updated_data(await self._ek_api.post_hop(hop_interval)) + except AuthException as auth_err: + raise ConfigEntryAuthFailed from auth_err + except ApiException as api_err: + raise UpdateFailed( + f"Error communicating with EK API: {api_err}" + ) from api_err + + return self.data + + async def _async_update_data(self) -> Hop: + """Fetch data from API endpoint. + + filters the intervals to remove ones that are not active + """ + try: + async with async_timeout.timeout(60): + if self.hop_intervals is None: + hop_intervals: HopIntervals = await self._ek_api.get_hop_intervals() + hop_intervals.intervals = OrderedDict( + filter( + lambda pair: pair[1].active == 1, + hop_intervals.intervals.items(), + ) + ) + + self.hop_intervals = hop_intervals + return await self._ek_api.get_hop() + except AuthException as auth_err: + raise ConfigEntryAuthFailed from auth_err + except ApiException as api_err: + raise UpdateFailed( + f"Error communicating with EK API: {api_err}" + ) from api_err diff --git a/homeassistant/components/electric_kiwi/manifest.json b/homeassistant/components/electric_kiwi/manifest.json new file mode 100644 index 00000000000..8ddb4c1af7c --- /dev/null +++ b/homeassistant/components/electric_kiwi/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "electric_kiwi", + "name": "Electric Kiwi", + "codeowners": ["@mikey0000"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/electric_kiwi", + "integration_type": "hub", + "iot_class": "cloud_polling", + "requirements": ["electrickiwi-api==0.8.5"] +} diff --git a/homeassistant/components/electric_kiwi/oauth2.py b/homeassistant/components/electric_kiwi/oauth2.py new file mode 100644 index 00000000000..ce3e473159a --- /dev/null +++ b/homeassistant/components/electric_kiwi/oauth2.py @@ -0,0 +1,76 @@ +"""OAuth2 implementations for Toon.""" +from __future__ import annotations + +import base64 +from typing import Any, cast + +from homeassistant.components.application_credentials import ( + AuthImplementation, + AuthorizationServer, + ClientCredential, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import SCOPE_VALUES + + +class ElectricKiwiLocalOAuth2Implementation(AuthImplementation): + """Local OAuth2 implementation for Electric Kiwi.""" + + def __init__( + self, + hass: HomeAssistant, + domain: str, + client_credential: ClientCredential, + authorization_server: AuthorizationServer, + ) -> None: + """Set up Electric Kiwi oauth.""" + super().__init__( + hass=hass, + auth_domain=domain, + credential=client_credential, + authorization_server=authorization_server, + ) + + self._name = client_credential.name + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": SCOPE_VALUES} + + async def async_resolve_external_data(self, external_data: Any) -> dict: + """Initialize local Electric Kiwi auth implementation.""" + data = { + "grant_type": "authorization_code", + "code": external_data["code"], + "redirect_uri": external_data["state"]["redirect_uri"], + } + + return await self._token_request(data) + + async def _async_refresh_token(self, token: dict) -> dict: + """Refresh tokens.""" + data = { + "grant_type": "refresh_token", + "refresh_token": token["refresh_token"], + } + + new_token = await self._token_request(data) + return {**token, **new_token} + + async def _token_request(self, data: dict) -> dict: + """Make a token request.""" + session = async_get_clientsession(self.hass) + client_str = f"{self.client_id}:{self.client_secret}" + client_string_bytes = client_str.encode("ascii") + + base64_bytes = base64.b64encode(client_string_bytes) + base64_client = base64_bytes.decode("ascii") + headers = {"Authorization": f"Basic {base64_client}"} + + resp = await session.post(self.token_url, data=data, headers=headers) + resp.raise_for_status() + resp_json = cast(dict, await resp.json()) + return resp_json diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py new file mode 100644 index 00000000000..a657b768aa5 --- /dev/null +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -0,0 +1,114 @@ +"""Support for Electric Kiwi sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta +import logging + +from electrickiwi_api.model import Hop + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util + +from .const import ATTRIBUTION, DOMAIN +from .coordinator import ElectricKiwiHOPDataCoordinator + +_LOGGER = logging.getLogger(DOMAIN) + +ATTR_EK_HOP_START = "hop_sensor_start" +ATTR_EK_HOP_END = "hop_sensor_end" + + +@dataclass +class ElectricKiwiHOPRequiredKeysMixin: + """Mixin for required HOP keys.""" + + value_func: Callable[[Hop], datetime] + + +@dataclass +class ElectricKiwiHOPSensorEntityDescription( + SensorEntityDescription, + ElectricKiwiHOPRequiredKeysMixin, +): + """Describes Electric Kiwi HOP sensor entity.""" + + +def _check_and_move_time(hop: Hop, time: str) -> datetime: + """Return the time a day forward if HOP end_time is in the past.""" + date_time = datetime.combine( + datetime.today(), + datetime.strptime(time, "%I:%M %p").time(), + ).astimezone(dt_util.DEFAULT_TIME_ZONE) + + end_time = datetime.combine( + datetime.today(), + datetime.strptime(hop.end.end_time, "%I:%M %p").time(), + ).astimezone(dt_util.DEFAULT_TIME_ZONE) + + if end_time < datetime.now().astimezone(dt_util.DEFAULT_TIME_ZONE): + return date_time + timedelta(days=1) + return date_time + + +HOP_SENSOR_TYPE: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( + ElectricKiwiHOPSensorEntityDescription( + key=ATTR_EK_HOP_START, + translation_key="hopfreepowerstart", + device_class=SensorDeviceClass.TIMESTAMP, + value_func=lambda hop: _check_and_move_time(hop, hop.start.start_time), + ), + ElectricKiwiHOPSensorEntityDescription( + key=ATTR_EK_HOP_END, + translation_key="hopfreepowerend", + device_class=SensorDeviceClass.TIMESTAMP, + value_func=lambda hop: _check_and_move_time(hop, hop.end.end_time), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Electric Kiwi Sensor Setup.""" + hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id] + hop_entities = [ + ElectricKiwiHOPEntity(hop_coordinator, description) + for description in HOP_SENSOR_TYPE + ] + async_add_entities(hop_entities) + + +class ElectricKiwiHOPEntity( + CoordinatorEntity[ElectricKiwiHOPDataCoordinator], SensorEntity +): + """Entity object for Electric Kiwi sensor.""" + + entity_description: ElectricKiwiHOPSensorEntityDescription + _attr_has_entity_name = True + _attr_attribution = ATTRIBUTION + + def __init__( + self, + hop_coordinator: ElectricKiwiHOPDataCoordinator, + description: ElectricKiwiHOPSensorEntityDescription, + ) -> None: + """Entity object for Electric Kiwi sensor.""" + super().__init__(hop_coordinator) + + self._attr_unique_id = f"{self.coordinator._ek_api.customer_number}_{self.coordinator._ek_api.connection_id}_{description.key}" + self.entity_description = description + + @property + def native_value(self) -> datetime: + """Return the state of the sensor.""" + return self.entity_description.value_func(self.coordinator.data) diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json new file mode 100644 index 00000000000..19056180f17 --- /dev/null +++ b/homeassistant/components/electric_kiwi/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Electric Kiwi integration needs to re-authenticate your account" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "entity": { + "sensor": { + "hopfreepowerstart": { + "name": "Hour of free power start" + }, + "hopfreepowerend": { + "name": "Hour of free power end" + } + } + } +} diff --git a/homeassistant/components/elgato/services.yaml b/homeassistant/components/elgato/services.yaml index 05d341a7041..2037633ff71 100644 --- a/homeassistant/components/elgato/services.yaml +++ b/homeassistant/components/elgato/services.yaml @@ -1,8 +1,4 @@ identify: - name: Identify - description: >- - Identify an Elgato Light. Blinks the light, which can be useful - for, e.g., a visual notification. target: entity: integration: elgato diff --git a/homeassistant/components/elgato/strings.json b/homeassistant/components/elgato/strings.json index 8a2f20f209f..e6b16215793 100644 --- a/homeassistant/components/elgato/strings.json +++ b/homeassistant/components/elgato/strings.json @@ -45,5 +45,11 @@ "name": "Energy saving" } } + }, + "services": { + "identify": { + "name": "Identify", + "description": "Identifies an Elgato Light. Blinks the light, which can be useful for, e.g., a visual notification." + } } } diff --git a/homeassistant/components/elkm1/services.yaml b/homeassistant/components/elkm1/services.yaml index 1f130416363..1f3bb8ffebb 100644 --- a/homeassistant/components/elkm1/services.yaml +++ b/homeassistant/components/elkm1/services.yaml @@ -1,197 +1,143 @@ alarm_bypass: - name: Alarm bypass - description: Bypass all zones for the area. target: entity: integration: elkm1 domain: alarm_control_panel fields: code: - name: Code - description: An code to authorize the bypass of the alarm control panel. required: true example: 4242 selector: text: alarm_clear_bypass: - name: Alarm clear bypass - description: Remove bypass on all zones for the area. target: entity: integration: elkm1 domain: alarm_control_panel fields: code: - name: Code - description: An code to authorize the bypass clear of the alarm control panel. required: true example: 4242 selector: text: alarm_arm_home_instant: - name: Alarm are home instant - description: Arm the ElkM1 in home instant mode. target: entity: integration: elkm1 domain: alarm_control_panel fields: code: - name: Code - description: An code to arm the alarm control panel. required: true example: 1234 selector: text: alarm_arm_night_instant: - name: Alarm arm night instant - description: Arm the ElkM1 in night instant mode. target: entity: integration: elkm1 domain: alarm_control_panel fields: code: - name: Code - description: An code to arm the alarm control panel. required: true example: 1234 selector: text: alarm_arm_vacation: - name: Alarm arm vacation - description: Arm the ElkM1 in vacation mode. target: entity: integration: elkm1 domain: alarm_control_panel fields: code: - name: Code - description: An code to arm the alarm control panel. required: true example: 1234 selector: text: alarm_display_message: - name: Alarm display message - description: Display a message on all of the ElkM1 keypads for an area. target: entity: integration: elkm1 domain: alarm_control_panel fields: clear: - name: Clear - description: 0=clear message, 1=clear message with * key, 2=Display until timeout default: 2 selector: number: min: 0 max: 2 beep: - name: Beep - description: 0=no beep, 1=beep default: 0 selector: boolean: timeout: - name: Timeout - description: Time to display message, 0=forever, max 65535 default: 0 selector: number: min: 0 max: 65535 line1: - name: Line 1 - description: Up to 16 characters of text (truncated if too long). example: The answer to life. default: "" selector: text: line2: - name: Line 2 - description: Up to 16 characters of text (truncated if too long). example: the universe, and everything. default: "" selector: text: set_time: - name: Set time - description: Set the time for the panel. fields: prefix: - name: Prefix - description: Prefix for the panel. example: gatehouse selector: text: speak_phrase: - name: Speak phrase - description: Speak a phrase. See list of phrases in ElkM1 ASCII Protocol documentation. fields: number: - name: Phrase number - description: Phrase number to speak. required: true example: 42 selector: text: prefix: - name: Prefix - description: Prefix to identify panel when multiple panels configured. example: gatehouse default: "" selector: text: speak_word: - name: Speak word - description: Speak a word. See list of words in ElkM1 ASCII Protocol documentation. fields: number: - name: Word number - description: Word number to speak. required: true selector: number: min: 1 max: 473 prefix: - name: Prefix - description: Prefix to identify panel when multiple panels configured. example: gatehouse default: "" selector: text: sensor_counter_refresh: - name: Sensor counter refresh - description: Refresh the value of a counter from the panel. target: entity: integration: elkm1 domain: sensor sensor_counter_set: - name: Sensor counter set - description: Set the value of a counter on the panel. target: entity: integration: elkm1 domain: sensor fields: value: - name: Value - description: Value to set the counter to. required: true selector: number: @@ -199,24 +145,18 @@ sensor_counter_set: max: 65536 sensor_zone_bypass: - name: Sensor zone bypass - description: Bypass zone. target: entity: integration: elkm1 domain: sensor fields: code: - name: Code - description: An code to authorize the bypass of the zone. required: true example: 4242 selector: text: sensor_zone_trigger: - name: Sensor zone trigger - description: Trigger zone. target: entity: integration: elkm1 diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json index d1871c7536c..c854307dd92 100644 --- a/homeassistant/components/elkm1/strings.json +++ b/homeassistant/components/elkm1/strings.json @@ -6,7 +6,7 @@ "title": "Connect to Elk-M1 Control", "description": "Choose a discovered system or 'Manual Entry' if no devices have been discovered.", "data": { - "device": "Device" + "device": "[%key:common::config_flow::data::device%]" } }, "manual_connection": { @@ -45,5 +45,149 @@ "already_configured": "An ElkM1 with this prefix is already configured", "address_already_configured": "An ElkM1 with this address is already configured" } + }, + "services": { + "alarm_bypass": { + "name": "Alarm bypass", + "description": "Bypasses all zones for the area.", + "fields": { + "code": { + "name": "Code", + "description": "An code to authorize the bypass of the alarm control panel." + } + } + }, + "alarm_clear_bypass": { + "name": "Alarm clear bypass", + "description": "Removes bypass on all zones for the area.", + "fields": { + "code": { + "name": "Code", + "description": "An code to authorize the bypass clear of the alarm control panel." + } + } + }, + "alarm_arm_home_instant": { + "name": "Alarm are home instant", + "description": "Arms the ElkM1 in home instant mode.", + "fields": { + "code": { + "name": "Code", + "description": "An code to arm the alarm control panel." + } + } + }, + "alarm_arm_night_instant": { + "name": "Alarm arm night instant", + "description": "Arms the ElkM1 in night instant mode.", + "fields": { + "code": { + "name": "Code", + "description": "[%key:component::elkm1::services::alarm_arm_home_instant::fields::code::description%]" + } + } + }, + "alarm_arm_vacation": { + "name": "Alarm arm vacation", + "description": "Arm the ElkM1 in vacation mode.", + "fields": { + "code": { + "name": "Code", + "description": "[%key:component::elkm1::services::alarm_arm_home_instant::fields::code::description%]" + } + } + }, + "alarm_display_message": { + "name": "Alarm display message", + "description": "Displays a message on all of the ElkM1 keypads for an area.", + "fields": { + "clear": { + "name": "Clear", + "description": "0=clear message, 1=clear message with * key, 2=Display until timeout." + }, + "beep": { + "name": "Beep", + "description": "0=no beep, 1=beep." + }, + "timeout": { + "name": "Timeout", + "description": "Time to display message, 0=forever, max 65535." + }, + "line1": { + "name": "Line 1", + "description": "Up to 16 characters of text (truncated if too long)." + }, + "line2": { + "name": "Line 2", + "description": "[%key:component::elkm1::services::alarm_display_message::fields::line1::description%]" + } + } + }, + "set_time": { + "name": "Set time", + "description": "Sets the time for the panel.", + "fields": { + "prefix": { + "name": "Prefix", + "description": "Prefix for the panel." + } + } + }, + "speak_phrase": { + "name": "Speak phrase", + "description": "Speaks a phrase. See list of phrases in ElkM1 ASCII Protocol documentation.", + "fields": { + "number": { + "name": "Phrase number", + "description": "Phrase number to speak." + }, + "prefix": { + "name": "[%key:component::elkm1::services::set_time::fields::prefix::name%]", + "description": "Prefix to identify panel when multiple panels configured." + } + } + }, + "speak_word": { + "name": "Speak word", + "description": "Speaks a word. See list of words in ElkM1 ASCII Protocol documentation.", + "fields": { + "number": { + "name": "Word number", + "description": "Word number to speak." + }, + "prefix": { + "name": "[%key:component::elkm1::services::set_time::fields::prefix::name%]", + "description": "[%key:component::elkm1::services::speak_phrase::fields::prefix::description%]" + } + } + }, + "sensor_counter_refresh": { + "name": "Sensor counter refresh", + "description": "Refreshes the value of a counter from the panel." + }, + "sensor_counter_set": { + "name": "Sensor counter set", + "description": "Sets the value of a counter on the panel.", + "fields": { + "value": { + "name": "Value", + "description": "Value to set the counter to." + } + } + }, + "sensor_zone_bypass": { + "name": "Sensor zone bypass", + "description": "Bypasses zone.", + "fields": { + "code": { + "name": "Code", + "description": "An code to authorize the bypass of the zone." + } + } + }, + "sensor_zone_trigger": { + "name": "Sensor zone trigger", + "description": "Triggers zone." + } } } diff --git a/homeassistant/components/elmax/common.py b/homeassistant/components/elmax/common.py index 5334da23125..b0f51740b04 100644 --- a/homeassistant/components/elmax/common.py +++ b/homeassistant/components/elmax/common.py @@ -21,6 +21,7 @@ from elmax_api.model.panel import PanelEntry, PanelStatus from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -168,17 +169,17 @@ class ElmaxEntity(CoordinatorEntity[ElmaxCoordinator]): return self._device.name @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device specific attributes.""" - return { - "identifiers": {(DOMAIN, self._panel.hash)}, - "name": self._panel.get_name_by_user( + return DeviceInfo( + identifiers={(DOMAIN, self._panel.hash)}, + name=self._panel.get_name_by_user( self.coordinator.http_client.get_authenticated_username() ), - "manufacturer": "Elmax", - "model": self._panel_version, - "sw_version": self._panel_version, - } + manufacturer="Elmax", + model=self._panel_version, + sw_version=self._panel_version, + ) @property def available(self) -> bool: diff --git a/homeassistant/components/elmax/strings.json b/homeassistant/components/elmax/strings.json index e8cdbe23a5c..4bc705adfbe 100644 --- a/homeassistant/components/elmax/strings.json +++ b/homeassistant/components/elmax/strings.json @@ -13,7 +13,7 @@ "data": { "panel_name": "Panel Name", "panel_id": "Panel ID", - "panel_pin": "PIN Code" + "panel_pin": "[%key:common::config_flow::data::pin%]" } }, "reauth_confirm": { diff --git a/homeassistant/components/elv/manifest.json b/homeassistant/components/elv/manifest.json index 92213f39fce..9b71595e58f 100644 --- a/homeassistant/components/elv/manifest.json +++ b/homeassistant/components/elv/manifest.json @@ -2,7 +2,7 @@ "domain": "elv", "name": "ELV PCA", "codeowners": ["@majuss"], - "documentation": "https://www.home-assistant.io/integrations/pca", + "documentation": "https://www.home-assistant.io/integrations/elv", "iot_class": "local_polling", "loggers": ["pypca"], "requirements": ["pypca==0.0.7"] diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py index 1de6ec98520..104e05605cb 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -15,13 +15,14 @@ from homeassistant.components import ( script, ) from homeassistant.const import CONF_ENTITIES, CONF_TYPE -from homeassistant.core import Event, HomeAssistant, State, callback, split_entity_id +from homeassistant.core import HomeAssistant, State, callback, split_entity_id from homeassistant.helpers import storage from homeassistant.helpers.event import ( + EventStateChangedData, async_track_state_added_domain, async_track_state_removed_domain, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType SUPPORTED_DOMAINS = { climate.DOMAIN, @@ -222,7 +223,7 @@ class Config: return states @callback - def _clear_exposed_cache(self, event: Event) -> None: + def _clear_exposed_cache(self, event: EventType[EventStateChangedData]) -> None: """Clear the cache of exposed states.""" self.get_exposed_states.cache_clear() # pylint: disable=no-member diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index f779f5d8e94..f0a54ba0ea9 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -63,7 +63,11 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import State -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import EventType from homeassistant.util.json import json_loads from homeassistant.util.network import is_local @@ -110,6 +114,13 @@ UNAUTHORIZED_USER = [ {"error": {"address": "/", "description": "unauthorized user", "type": "1"}} ] +DIMMABLE_SUPPORT_FEATURES = ( + CoverEntityFeature.SET_POSITION + | FanEntityFeature.SET_SPEED + | MediaPlayerEntityFeature.VOLUME_SET + | ClimateEntityFeature.TARGET_TEMPERATURE +) + class HueUnauthorizedUser(HomeAssistantView): """Handle requests to find the emulated hue bridge.""" @@ -801,12 +812,9 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS], } ) - elif entity_features & ( - CoverEntityFeature.SET_POSITION - | FanEntityFeature.SET_SPEED - | MediaPlayerEntityFeature.VOLUME_SET - | ClimateEntityFeature.TARGET_TEMPERATURE - ) or light.brightness_supported(color_modes): + elif entity_features & DIMMABLE_SUPPORT_FEATURES or light.brightness_supported( + color_modes + ): # Dimmable light (Zigbee Device ID: 0x0100) # Supports groups, scenes, on/off and dimming retval["type"] = "Dimmable light" @@ -884,7 +892,7 @@ async def wait_for_state_change_or_timeout( ev = asyncio.Event() @core.callback - def _async_event_changed(event: core.Event) -> None: + def _async_event_changed(event: EventType[EventStateChangedData]) -> None: ev.set() unsub = async_track_state_change_event(hass, [entity_id], _async_event_changed) diff --git a/homeassistant/components/energy/strings.json b/homeassistant/components/energy/strings.json index 611d36882ee..9a72541bb50 100644 --- a/homeassistant/components/energy/strings.json +++ b/homeassistant/components/energy/strings.json @@ -39,11 +39,11 @@ }, "entity_unexpected_unit_gas_price": { "title": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::title%]", - "description": "[%key:component::energy::issues::entity_unexpected_unit_energy::description%]" + "description": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::description%]" }, "entity_unexpected_unit_water_price": { - "title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]", - "description": "[%key:component::energy::issues::entity_unexpected_unit_energy::description%]" + "title": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::title%]", + "description": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::description%]" }, "entity_unexpected_state_class": { "title": "Unexpected state class", diff --git a/homeassistant/components/enocean/strings.json b/homeassistant/components/enocean/strings.json index a2aff2a4207..97da526185f 100644 --- a/homeassistant/components/enocean/strings.json +++ b/homeassistant/components/enocean/strings.json @@ -10,7 +10,7 @@ "manual": { "title": "Enter the path to your ENOcean dongle", "data": { - "path": "USB dongle path" + "path": "[%key:component::enocean::config::step::detect::data::path%]" } } }, diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index 147eddacf81..1a4feb59376 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -16,7 +16,8 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import COORDINATOR, DOMAIN, NAME, PLATFORMS, SENSORS +from .const import COORDINATOR, DOMAIN, NAME, PLATFORMS +from .sensor import SENSORS SCAN_INTERVAL = timedelta(seconds=60) diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index 4a105e5a067..e7c0b7f2a5e 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -1,10 +1,5 @@ """The enphase_envoy component.""" -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import Platform, UnitOfEnergy, UnitOfPower +from homeassistant.const import Platform DOMAIN = "enphase_envoy" @@ -13,60 +8,3 @@ PLATFORMS = [Platform.SENSOR] COORDINATOR = "coordinator" NAME = "name" - -SENSORS = ( - SensorEntityDescription( - key="production", - name="Current Power Production", - native_unit_of_measurement=UnitOfPower.WATT, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.POWER, - ), - SensorEntityDescription( - key="daily_production", - name="Today's Energy Production", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, - device_class=SensorDeviceClass.ENERGY, - ), - SensorEntityDescription( - key="seven_days_production", - name="Last Seven Days Energy Production", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SensorEntityDescription( - key="lifetime_production", - name="Lifetime Energy Production", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, - device_class=SensorDeviceClass.ENERGY, - ), - SensorEntityDescription( - key="consumption", - name="Current Power Consumption", - native_unit_of_measurement=UnitOfPower.WATT, - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.POWER, - ), - SensorEntityDescription( - key="daily_consumption", - name="Today's Energy Consumption", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, - device_class=SensorDeviceClass.ENERGY, - ), - SensorEntityDescription( - key="seven_days_consumption", - name="Last Seven Days Energy Consumption", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - ), - SensorEntityDescription( - key="lifetime_consumption", - name="Lifetime Energy Consumption", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, - device_class=SensorDeviceClass.ENERGY, - ), -) diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 44ffbcdb497..f42c8d94ea2 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPower +from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -25,7 +25,7 @@ from homeassistant.helpers.update_coordinator import ( ) from homeassistant.util import dt as dt_util -from .const import COORDINATOR, DOMAIN, NAME, SENSORS +from .const import COORDINATOR, DOMAIN, NAME ICON = "mdi:flash" _LOGGER = logging.getLogger(__name__) @@ -75,6 +75,63 @@ INVERTER_SENSORS = ( ), ) +SENSORS = ( + SensorEntityDescription( + key="production", + name="Current Power Production", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + SensorEntityDescription( + key="daily_production", + name="Today's Energy Production", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + SensorEntityDescription( + key="seven_days_production", + name="Last Seven Days Energy Production", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SensorEntityDescription( + key="lifetime_production", + name="Lifetime Energy Production", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + SensorEntityDescription( + key="consumption", + name="Current Power Consumption", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + SensorEntityDescription( + key="daily_consumption", + name="Today's Energy Consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + SensorEntityDescription( + key="seven_days_consumption", + name="Last Seven Days Energy Consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + ), + SensorEntityDescription( + key="lifetime_consumption", + name="Lifetime Energy Consumption", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), +) + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/environment_canada/services.yaml b/homeassistant/components/environment_canada/services.yaml index 09f95f16a44..4293b313f5c 100644 --- a/homeassistant/components/environment_canada/services.yaml +++ b/homeassistant/components/environment_canada/services.yaml @@ -1,14 +1,10 @@ set_radar_type: - name: Set radar type - description: Set the type of radar image to retrieve. target: entity: integration: environment_canada domain: camera fields: radar_type: - name: Radar type - description: The type of radar image to display. required: true example: Snow selector: diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json index d30124ddf5a..eb9ec24dad0 100644 --- a/homeassistant/components/environment_canada/strings.json +++ b/homeassistant/components/environment_canada/strings.json @@ -117,5 +117,17 @@ "name": "Forecast" } } + }, + "services": { + "set_radar_type": { + "name": "Set radar type", + "description": "Sets the type of radar image to retrieve.", + "fields": { + "radar_type": { + "name": "Radar type", + "description": "The type of radar image to display." + } + } + } } } diff --git a/homeassistant/components/envisalink/services.yaml b/homeassistant/components/envisalink/services.yaml index b15a3b94e01..6751a3ecc56 100644 --- a/homeassistant/components/envisalink/services.yaml +++ b/homeassistant/components/envisalink/services.yaml @@ -1,43 +1,27 @@ # Describes the format for available Envisalink services. alarm_keypress: - name: Alarm keypress - description: Send custom keypresses to the alarm. fields: entity_id: - name: Entity - description: Name of the alarm control panel to trigger. required: true selector: entity: integration: envisalink domain: alarm_control_panel keypress: - name: Keypress - description: "String to send to the alarm panel (1-6 characters)." required: true example: "*71" selector: text: invoke_custom_function: - name: Invoke custom function - description: > - Allows users with DSC panels to trigger a PGM output (1-4). - Note that you need to specify the alarm panel's "code" parameter for this to work. fields: partition: - name: Partition - description: > - The alarm panel partition to trigger the PGM output on. - Typically this is just "1". required: true example: "1" selector: text: pgm: - name: PGM - description: The PGM number to trigger on the alarm panel. required: true selector: number: diff --git a/homeassistant/components/envisalink/strings.json b/homeassistant/components/envisalink/strings.json new file mode 100644 index 00000000000..a539c890169 --- /dev/null +++ b/homeassistant/components/envisalink/strings.json @@ -0,0 +1,32 @@ +{ + "services": { + "alarm_keypress": { + "name": "Alarm keypress", + "description": "Sends custom keypresses to the alarm.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name of the alarm control panel to trigger." + }, + "keypress": { + "name": "Keypress", + "description": "String to send to the alarm panel (1-6 characters)." + } + } + }, + "invoke_custom_function": { + "name": "Invoke custom function", + "description": "Allows users with DSC panels to trigger a PGM output (1-4). Note that you need to specify the alarm panel's \"code\" parameter for this to work.\n.", + "fields": { + "partition": { + "name": "Partition", + "description": "The alarm panel partition to trigger the PGM output on. Typically this is just \"1\".\n." + }, + "pgm": { + "name": "PGM", + "description": "The PGM number to trigger on the alarm panel." + } + } + } + } +} diff --git a/homeassistant/components/epson/services.yaml b/homeassistant/components/epson/services.yaml index 37add1bc202..94038aab408 100644 --- a/homeassistant/components/epson/services.yaml +++ b/homeassistant/components/epson/services.yaml @@ -1,14 +1,10 @@ select_cmode: - name: Select color mode - description: Select Color mode of Epson projector target: entity: integration: epson domain: media_player fields: cmode: - name: Color mode - description: Name of Cmode required: true example: "cinema" selector: diff --git a/homeassistant/components/epson/strings.json b/homeassistant/components/epson/strings.json index 9716153958b..4e3780322e9 100644 --- a/homeassistant/components/epson/strings.json +++ b/homeassistant/components/epson/strings.json @@ -15,5 +15,17 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "powered_off": "Is projector turned on? You need to turn on projector for initial configuration." } + }, + "services": { + "select_cmode": { + "name": "Select color mode", + "description": "Selects color mode of Epson projector.", + "fields": { + "cmode": { + "name": "Color mode", + "description": "Name of Cmode." + } + } + } } } diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 4d82881e173..8a976b25c7a 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/eq3btsmart", "iot_class": "local_polling", "loggers": ["bleak", "eq3bt"], - "requirements": ["construct==2.10.56", "python-eq3bt==0.2"] + "requirements": ["construct==2.10.68", "python-eq3bt==0.2"] } diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index ed55180bc0e..4a36535cc9b 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -1,151 +1,48 @@ """Support for esphome devices.""" from __future__ import annotations -import logging -from typing import Any, NamedTuple, TypeVar - from aioesphomeapi import ( APIClient, - APIConnectionError, - APIVersion, - DeviceInfo as EsphomeDeviceInfo, - HomeassistantServiceCall, - InvalidAuthAPIError, - InvalidEncryptionKeyAPIError, - ReconnectLogic, - RequiresEncryptionAPIError, - UserService, - UserServiceArgType, - VoiceAssistantEventType, ) -from awesomeversion import AwesomeVersion -import voluptuous as vol -from homeassistant.components import tag, zeroconf +from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_ID, CONF_HOST, - CONF_MODE, CONF_PASSWORD, CONF_PORT, - EVENT_HOMEASSISTANT_STOP, __version__ as ha_version, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback -from homeassistant.exceptions import TemplateError -from homeassistant.helpers import template +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) -from homeassistant.helpers.service import async_set_service_schema -from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType -from .bluetooth import async_connect_scanner from .const import ( - CONF_ALLOW_SERVICE_CALLS, - DEFAULT_ALLOW_SERVICE_CALLS, + CONF_NOISE_PSK, DOMAIN, ) -from .dashboard import async_get_dashboard, async_setup as async_setup_dashboard +from .dashboard import async_setup as async_setup_dashboard from .domain_data import DomainData # Import config flow so that it's added to the registry from .entry_data import RuntimeEntryData -from .voice_assistant import VoiceAssistantUDPServer - -CONF_DEVICE_NAME = "device_name" -CONF_NOISE_PSK = "noise_psk" -_LOGGER = logging.getLogger(__name__) -_R = TypeVar("_R") - -STABLE_BLE_VERSION_STR = "2023.6.0" -STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) -PROJECT_URLS = { - "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", -} -DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" +from .manager import ESPHomeManager, cleanup_instance CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -@callback -def _async_check_firmware_version( - hass: HomeAssistant, device_info: EsphomeDeviceInfo, api_version: APIVersion -) -> None: - """Create or delete an the ble_firmware_outdated issue.""" - # ESPHome device_info.mac_address is the unique_id - issue = f"ble_firmware_outdated-{device_info.mac_address}" - if ( - not device_info.bluetooth_proxy_feature_flags_compat(api_version) - # If the device has a project name its up to that project - # to tell them about the firmware version update so we don't notify here - or (device_info.project_name and device_info.project_name not in PROJECT_URLS) - or AwesomeVersion(device_info.esphome_version) >= STABLE_BLE_VERSION - ): - async_delete_issue(hass, DOMAIN, issue) - return - async_create_issue( - hass, - DOMAIN, - issue, - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url=PROJECT_URLS.get(device_info.project_name, DEFAULT_URL), - translation_key="ble_firmware_outdated", - translation_placeholders={ - "name": device_info.name, - "version": STABLE_BLE_VERSION_STR, - }, - ) - - -@callback -def _async_check_using_api_password( - hass: HomeAssistant, device_info: EsphomeDeviceInfo, has_password: bool -) -> None: - """Create or delete an the api_password_deprecated issue.""" - # ESPHome device_info.mac_address is the unique_id - issue = f"api_password_deprecated-{device_info.mac_address}" - if not has_password: - async_delete_issue(hass, DOMAIN, issue) - return - async_create_issue( - hass, - DOMAIN, - issue, - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url="https://esphome.io/components/api.html", - translation_key="api_password_deprecated", - translation_placeholders={ - "name": device_info.name, - }, - ) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the esphome component.""" await async_setup_dashboard(hass) return True -async def async_setup_entry( # noqa: C901 - hass: HomeAssistant, entry: ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the esphome component.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] password = entry.data[CONF_PASSWORD] noise_psk = entry.data.get(CONF_NOISE_PSK) - device_id: str = None # type: ignore[assignment] zeroconf_instance = await zeroconf.async_get_instance(hass) @@ -158,538 +55,27 @@ async def async_setup_entry( # noqa: C901 noise_psk=noise_psk, ) - services_issue = f"service_calls_not_enabled-{entry.unique_id}" - if entry.options.get(CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS): - async_delete_issue(hass, DOMAIN, services_issue) - domain_data = DomainData.get(hass) entry_data = RuntimeEntryData( client=cli, entry_id=entry.entry_id, + title=entry.title, store=domain_data.get_or_create_store(hass, entry), original_options=dict(entry.options), ) domain_data.set_entry_data(entry, entry_data) - async def on_stop(event: Event) -> None: - """Cleanup the socket client on HA stop.""" - await _cleanup_instance(hass, entry) - - # Use async_listen instead of async_listen_once so that we don't deregister - # the callback twice when shutting down Home Assistant. - # "Unable to remove unknown listener - # .onetime_listener>" - entry_data.cleanup_callbacks.append( - hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) + manager = ESPHomeManager( + hass, entry, host, password, cli, zeroconf_instance, domain_data, entry_data ) - - @callback - def async_on_service_call(service: HomeassistantServiceCall) -> None: - """Call service when user automation in ESPHome config is triggered.""" - device_info = entry_data.device_info - assert device_info is not None - domain, service_name = service.service.split(".", 1) - service_data = service.data - - if service.data_template: - try: - data_template = { - key: Template(value) for key, value in service.data_template.items() - } - template.attach(hass, data_template) - service_data.update( - template.render_complex(data_template, service.variables) - ) - except TemplateError as ex: - _LOGGER.error("Error rendering data template for %s: %s", host, ex) - return - - if service.is_event: - # ESPHome uses service call packet for both events and service calls - # Ensure the user can only send events of form 'esphome.xyz' - if domain != "esphome": - _LOGGER.error( - "Can only generate events under esphome domain! (%s)", host - ) - return - - # Call native tag scan - if service_name == "tag_scanned" and device_id is not None: - tag_id = service_data["tag_id"] - hass.async_create_task(tag.async_scan_tag(hass, tag_id, device_id)) - return - - hass.bus.async_fire( - service.service, - { - ATTR_DEVICE_ID: device_id, - **service_data, - }, - ) - elif entry.options.get(CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS): - hass.async_create_task( - hass.services.async_call( - domain, service_name, service_data, blocking=True - ) - ) - else: - async_create_issue( - hass, - DOMAIN, - services_issue, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="service_calls_not_allowed", - translation_placeholders={ - "name": device_info.friendly_name or device_info.name, - }, - ) - _LOGGER.error( - "%s: Service call %s.%s: with data %s rejected; " - "If you trust this device and want to allow access for it to make " - "Home Assistant service calls, you can enable this " - "functionality in the options flow", - device_info.friendly_name or device_info.name, - domain, - service_name, - service_data, - ) - - async def _send_home_assistant_state( - entity_id: str, attribute: str | None, state: State | None - ) -> None: - """Forward Home Assistant states to ESPHome.""" - if state is None or (attribute and attribute not in state.attributes): - return - - send_state = state.state - if attribute: - attr_val = state.attributes[attribute] - # ESPHome only handles "on"/"off" for boolean values - if isinstance(attr_val, bool): - send_state = "on" if attr_val else "off" - else: - send_state = attr_val - - await cli.send_home_assistant_state(entity_id, attribute, str(send_state)) - - @callback - def async_on_state_subscription( - entity_id: str, attribute: str | None = None - ) -> None: - """Subscribe and forward states for requested entities.""" - - async def send_home_assistant_state_event(event: Event) -> None: - """Forward Home Assistant states updates to ESPHome.""" - - # Only communicate changes to the state or attribute tracked - if event.data.get("new_state") is None or ( - event.data.get("old_state") is not None - and "new_state" in event.data - and ( - ( - not attribute - and event.data["old_state"].state - == event.data["new_state"].state - ) - or ( - attribute - and attribute in event.data["old_state"].attributes - and attribute in event.data["new_state"].attributes - and event.data["old_state"].attributes[attribute] - == event.data["new_state"].attributes[attribute] - ) - ) - ): - return - - await _send_home_assistant_state( - event.data["entity_id"], attribute, event.data.get("new_state") - ) - - unsub = async_track_state_change_event( - hass, [entity_id], send_home_assistant_state_event - ) - entry_data.disconnect_callbacks.append(unsub) - - # Send initial state - hass.async_create_task( - _send_home_assistant_state(entity_id, attribute, hass.states.get(entity_id)) - ) - - voice_assistant_udp_server: VoiceAssistantUDPServer | None = None - - def _handle_pipeline_event( - event_type: VoiceAssistantEventType, data: dict[str, str] | None - ) -> None: - cli.send_voice_assistant_event(event_type, data) - - def _handle_pipeline_finished() -> None: - nonlocal voice_assistant_udp_server - - entry_data.async_set_assist_pipeline_state(False) - - if voice_assistant_udp_server is not None: - voice_assistant_udp_server.close() - voice_assistant_udp_server = None - - async def _handle_pipeline_start(conversation_id: str, use_vad: bool) -> int | None: - """Start a voice assistant pipeline.""" - nonlocal voice_assistant_udp_server - - if voice_assistant_udp_server is not None: - return None - - voice_assistant_udp_server = VoiceAssistantUDPServer( - hass, entry_data, _handle_pipeline_event, _handle_pipeline_finished - ) - port = await voice_assistant_udp_server.start_server() - - hass.async_create_background_task( - voice_assistant_udp_server.run_pipeline( - device_id=device_id, - conversation_id=conversation_id or None, - use_vad=use_vad, - ), - "esphome.voice_assistant_udp_server.run_pipeline", - ) - entry_data.async_set_assist_pipeline_state(True) - - return port - - async def _handle_pipeline_stop() -> None: - """Stop a voice assistant pipeline.""" - nonlocal voice_assistant_udp_server - - if voice_assistant_udp_server is not None: - voice_assistant_udp_server.stop() - - async def on_connect() -> None: - """Subscribe to states and list entities on successful API login.""" - nonlocal device_id - try: - device_info = await cli.device_info() - - # Migrate config entry to new unique ID if necessary - # This was changed in 2023.1 - if entry.unique_id != format_mac(device_info.mac_address): - hass.config_entries.async_update_entry( - entry, unique_id=format_mac(device_info.mac_address) - ) - - # Make sure we have the correct device name stored - # so we can map the device to ESPHome Dashboard config - if entry.data.get(CONF_DEVICE_NAME) != device_info.name: - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_DEVICE_NAME: device_info.name} - ) - - entry_data.device_info = device_info - assert cli.api_version is not None - entry_data.api_version = cli.api_version - entry_data.available = True - entry_data.expected_disconnect = True - if entry_data.device_info.name: - reconnect_logic.name = entry_data.device_info.name - - if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): - entry_data.disconnect_callbacks.append( - await async_connect_scanner(hass, entry, cli, entry_data) - ) - - device_id = _async_setup_device_registry( - hass, entry, entry_data.device_info - ) - entry_data.async_update_device_state(hass) - - entity_infos, services = await cli.list_entities_services() - await entry_data.async_update_static_infos(hass, entry, entity_infos) - await _setup_services(hass, entry_data, services) - await cli.subscribe_states(entry_data.async_update_state) - await cli.subscribe_service_calls(async_on_service_call) - await cli.subscribe_home_assistant_states(async_on_state_subscription) - - if device_info.voice_assistant_version: - entry_data.disconnect_callbacks.append( - await cli.subscribe_voice_assistant( - _handle_pipeline_start, - _handle_pipeline_stop, - ) - ) - - hass.async_create_task(entry_data.async_save_to_store()) - except APIConnectionError as err: - _LOGGER.warning("Error getting initial data for %s: %s", host, err) - # Re-connection logic will trigger after this - await cli.disconnect() - else: - _async_check_firmware_version(hass, device_info, entry_data.api_version) - _async_check_using_api_password(hass, device_info, bool(password)) - - async def on_disconnect(expected_disconnect: bool) -> None: - """Run disconnect callbacks on API disconnect.""" - name = entry_data.device_info.name if entry_data.device_info else host - _LOGGER.debug( - "%s: %s disconnected (expected=%s), running disconnected callbacks", - name, - host, - expected_disconnect, - ) - for disconnect_cb in entry_data.disconnect_callbacks: - disconnect_cb() - entry_data.disconnect_callbacks = [] - entry_data.available = False - entry_data.expected_disconnect = expected_disconnect - # Mark state as stale so that we will always dispatch - # the next state update of that type when the device reconnects - entry_data.stale_state = { - (type(entity_state), key) - for state_dict in entry_data.state.values() - for key, entity_state in state_dict.items() - } - if not hass.is_stopping: - # Avoid marking every esphome entity as unavailable on shutdown - # since it generates a lot of state changed events and database - # writes when we already know we're shutting down and the state - # will be cleared anyway. - entry_data.async_update_device_state(hass) - - async def on_connect_error(err: Exception) -> None: - """Start reauth flow if appropriate connect error type.""" - if isinstance( - err, - ( - RequiresEncryptionAPIError, - InvalidEncryptionKeyAPIError, - InvalidAuthAPIError, - ), - ): - entry.async_start_reauth(hass) - - reconnect_logic = ReconnectLogic( - client=cli, - on_connect=on_connect, - on_disconnect=on_disconnect, - zeroconf_instance=zeroconf_instance, - name=host, - on_connect_error=on_connect_error, - ) - - infos, services = await entry_data.async_load_from_store() - await entry_data.async_update_static_infos(hass, entry, infos) - await _setup_services(hass, entry_data, services) - - if entry_data.device_info is not None and entry_data.device_info.name: - reconnect_logic.name = entry_data.device_info.name - if entry.unique_id is None: - hass.config_entries.async_update_entry( - entry, unique_id=format_mac(entry_data.device_info.mac_address) - ) - - await reconnect_logic.start() - entry_data.cleanup_callbacks.append(reconnect_logic.stop_callback) - - entry.async_on_unload(entry.add_update_listener(entry_data.async_update_listener)) + await manager.async_start() return True -@callback -def _async_setup_device_registry( - hass: HomeAssistant, entry: ConfigEntry, device_info: EsphomeDeviceInfo -) -> str: - """Set up device registry feature for a particular config entry.""" - sw_version = device_info.esphome_version - if device_info.compilation_time: - sw_version += f" ({device_info.compilation_time})" - - configuration_url = None - if device_info.webserver_port > 0: - configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" - elif dashboard := async_get_dashboard(hass): - configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}" - - manufacturer = "espressif" - if device_info.manufacturer: - manufacturer = device_info.manufacturer - model = device_info.model - hw_version = None - if device_info.project_name: - project_name = device_info.project_name.split(".") - manufacturer = project_name[0] - model = project_name[1] - hw_version = device_info.project_version - - device_registry = dr.async_get(hass) - device_entry = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - configuration_url=configuration_url, - connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}, - name=device_info.friendly_name or device_info.name, - manufacturer=manufacturer, - model=model, - sw_version=sw_version, - hw_version=hw_version, - ) - return device_entry.id - - -class ServiceMetadata(NamedTuple): - """Metadata for services.""" - - validator: Any - example: str - selector: dict[str, Any] - description: str | None = None - - -ARG_TYPE_METADATA = { - UserServiceArgType.BOOL: ServiceMetadata( - validator=cv.boolean, - example="False", - selector={"boolean": None}, - ), - UserServiceArgType.INT: ServiceMetadata( - validator=vol.Coerce(int), - example="42", - selector={"number": {CONF_MODE: "box"}}, - ), - UserServiceArgType.FLOAT: ServiceMetadata( - validator=vol.Coerce(float), - example="12.3", - selector={"number": {CONF_MODE: "box", "step": 1e-3}}, - ), - UserServiceArgType.STRING: ServiceMetadata( - validator=cv.string, - example="Example text", - selector={"text": None}, - ), - UserServiceArgType.BOOL_ARRAY: ServiceMetadata( - validator=[cv.boolean], - description="A list of boolean values.", - example="[True, False]", - selector={"object": {}}, - ), - UserServiceArgType.INT_ARRAY: ServiceMetadata( - validator=[vol.Coerce(int)], - description="A list of integer values.", - example="[42, 34]", - selector={"object": {}}, - ), - UserServiceArgType.FLOAT_ARRAY: ServiceMetadata( - validator=[vol.Coerce(float)], - description="A list of floating point numbers.", - example="[ 12.3, 34.5 ]", - selector={"object": {}}, - ), - UserServiceArgType.STRING_ARRAY: ServiceMetadata( - validator=[cv.string], - description="A list of strings.", - example="['Example text', 'Another example']", - selector={"object": {}}, - ), -} - - -async def _register_service( - hass: HomeAssistant, entry_data: RuntimeEntryData, service: UserService -) -> None: - if entry_data.device_info is None: - raise ValueError("Device Info needs to be fetched first") - service_name = f"{entry_data.device_info.name.replace('-', '_')}_{service.name}" - schema = {} - fields = {} - - for arg in service.args: - if arg.type not in ARG_TYPE_METADATA: - _LOGGER.error( - "Can't register service %s because %s is of unknown type %s", - service_name, - arg.name, - arg.type, - ) - return - metadata = ARG_TYPE_METADATA[arg.type] - schema[vol.Required(arg.name)] = metadata.validator - fields[arg.name] = { - "name": arg.name, - "required": True, - "description": metadata.description, - "example": metadata.example, - "selector": metadata.selector, - } - - async def execute_service(call: ServiceCall) -> None: - await entry_data.client.execute_service(service, call.data) - - hass.services.async_register( - DOMAIN, service_name, execute_service, vol.Schema(schema) - ) - - service_desc = { - "description": ( - f"Calls the service {service.name} of the node" - f" {entry_data.device_info.name}" - ), - "fields": fields, - } - - async_set_service_schema(hass, DOMAIN, service_name, service_desc) - - -async def _setup_services( - hass: HomeAssistant, entry_data: RuntimeEntryData, services: list[UserService] -) -> None: - if entry_data.device_info is None: - # Can happen if device has never connected or .storage cleared - return - old_services = entry_data.services.copy() - to_unregister = [] - to_register = [] - for service in services: - if service.key in old_services: - # Already exists - if (matching := old_services.pop(service.key)) != service: - # Need to re-register - to_unregister.append(matching) - to_register.append(service) - else: - # New service - to_register.append(service) - - for service in old_services.values(): - to_unregister.append(service) - - entry_data.services = {serv.key: serv for serv in services} - - for service in to_unregister: - service_name = f"{entry_data.device_info.name}_{service.name}" - hass.services.async_remove(DOMAIN, service_name) - - for service in to_register: - await _register_service(hass, entry_data, service) - - -async def _cleanup_instance( - hass: HomeAssistant, entry: ConfigEntry -) -> RuntimeEntryData: - """Cleanup the esphome client if it exists.""" - domain_data = DomainData.get(hass) - data = domain_data.pop_entry_data(entry) - data.available = False - for disconnect_cb in data.disconnect_callbacks: - disconnect_cb() - data.disconnect_callbacks = [] - for cleanup_callback in data.cleanup_callbacks: - cleanup_callback() - await data.async_cleanup() - await data.client.disconnect() - return data - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload an esphome config entry.""" - entry_data = await _cleanup_instance(hass, entry) + entry_data = await cleanup_instance(hass, entry) return await hass.config_entries.async_unload_platforms( entry, entry_data.loaded_platforms ) diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py index aea65f9358e..4acd335c1b8 100644 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ b/homeassistant/components/esphome/bluetooth/__init__.py @@ -1,7 +1,6 @@ """Bluetooth support for esphome.""" from __future__ import annotations -from collections.abc import Callable from functools import partial import logging @@ -16,36 +15,35 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback from ..entry_data import RuntimeEntryData -from .client import ESPHomeClient +from .cache import ESPHomeBluetoothCache +from .client import ( + ESPHomeClient, + ESPHomeClientData, +) +from .device import ESPHomeBluetoothDevice from .scanner import ESPHomeScanner _LOGGER = logging.getLogger(__name__) @hass_callback -def _async_can_connect_factory( - entry_data: RuntimeEntryData, source: str -) -> Callable[[], bool]: - """Create a can_connect function for a specific RuntimeEntryData instance.""" - - @hass_callback - def _async_can_connect() -> bool: - """Check if a given source can make another connection.""" - can_connect = bool(entry_data.available and entry_data.ble_connections_free) - _LOGGER.debug( - ( - "%s [%s]: Checking can connect, available=%s, ble_connections_free=%s" - " result=%s" - ), - entry_data.name, - source, - entry_data.available, - entry_data.ble_connections_free, - can_connect, - ) - return can_connect - - return _async_can_connect +def _async_can_connect( + entry_data: RuntimeEntryData, bluetooth_device: ESPHomeBluetoothDevice, source: str +) -> bool: + """Check if a given source can make another connection.""" + can_connect = bool(entry_data.available and bluetooth_device.ble_connections_free) + _LOGGER.debug( + ( + "%s [%s]: Checking can connect, available=%s, ble_connections_free=%s" + " result=%s" + ), + entry_data.name, + source, + entry_data.available, + bluetooth_device.ble_connections_free, + can_connect, + ) + return can_connect async def async_connect_scanner( @@ -53,16 +51,20 @@ async def async_connect_scanner( entry: ConfigEntry, cli: APIClient, entry_data: RuntimeEntryData, + cache: ESPHomeBluetoothCache, ) -> CALLBACK_TYPE: """Connect scanner.""" assert entry.unique_id is not None source = str(entry.unique_id) new_info_callback = async_get_advertisement_callback(hass) - assert entry_data.device_info is not None - feature_flags = entry_data.device_info.bluetooth_proxy_feature_flags_compat( + device_info = entry_data.device_info + assert device_info is not None + feature_flags = device_info.bluetooth_proxy_feature_flags_compat( entry_data.api_version ) connectable = bool(feature_flags & BluetoothProxyFeature.ACTIVE_CONNECTIONS) + bluetooth_device = ESPHomeBluetoothDevice(entry_data.name, device_info.mac_address) + entry_data.bluetooth_device = bluetooth_device _LOGGER.debug( "%s [%s]: Connecting scanner feature_flags=%s, connectable=%s", entry.title, @@ -70,22 +72,35 @@ async def async_connect_scanner( feature_flags, connectable, ) + client_data = ESPHomeClientData( + bluetooth_device=bluetooth_device, + cache=cache, + client=cli, + device_info=device_info, + api_version=entry_data.api_version, + title=entry.title, + scanner=None, + disconnect_callbacks=entry_data.disconnect_callbacks, + ) connector = HaBluetoothConnector( # MyPy doesn't like partials, but this is correct # https://github.com/python/mypy/issues/1484 - client=partial(ESPHomeClient, config_entry=entry), # type: ignore[arg-type] + client=partial(ESPHomeClient, client_data=client_data), # type: ignore[arg-type] source=source, - can_connect=_async_can_connect_factory(entry_data, source), + can_connect=hass_callback( + partial(_async_can_connect, entry_data, bluetooth_device, source) + ), ) scanner = ESPHomeScanner( hass, source, entry.title, new_info_callback, connector, connectable ) + client_data.scanner = scanner if connectable: # If its connectable be sure not to register the scanner # until we know the connection is fully setup since otherwise # there is a race condition where the connection can fail await cli.subscribe_bluetooth_connections_free( - entry_data.async_update_ble_connection_limits + bluetooth_device.async_update_ble_connection_limits ) unload_callbacks = [ async_register_scanner(hass, scanner, connectable), diff --git a/homeassistant/components/esphome/bluetooth/cache.py b/homeassistant/components/esphome/bluetooth/cache.py new file mode 100644 index 00000000000..3ec29121382 --- /dev/null +++ b/homeassistant/components/esphome/bluetooth/cache.py @@ -0,0 +1,50 @@ +"""Bluetooth cache for esphome.""" +from __future__ import annotations + +from collections.abc import MutableMapping +from dataclasses import dataclass, field + +from bleak.backends.service import BleakGATTServiceCollection +from lru import LRU # pylint: disable=no-name-in-module + +MAX_CACHED_SERVICES = 128 + + +@dataclass(slots=True) +class ESPHomeBluetoothCache: + """Shared cache between all ESPHome bluetooth devices.""" + + _gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field( + default_factory=lambda: LRU(MAX_CACHED_SERVICES) + ) + _gatt_mtu_cache: MutableMapping[int, int] = field( + default_factory=lambda: LRU(MAX_CACHED_SERVICES) + ) + + def get_gatt_services_cache( + self, address: int + ) -> BleakGATTServiceCollection | None: + """Get the BleakGATTServiceCollection for the given address.""" + return self._gatt_services_cache.get(address) + + def set_gatt_services_cache( + self, address: int, services: BleakGATTServiceCollection + ) -> None: + """Set the BleakGATTServiceCollection for the given address.""" + self._gatt_services_cache[address] = services + + def clear_gatt_services_cache(self, address: int) -> None: + """Clear the BleakGATTServiceCollection for the given address.""" + self._gatt_services_cache.pop(address, None) + + def get_gatt_mtu_cache(self, address: int) -> int | None: + """Get the mtu cache for the given address.""" + return self._gatt_mtu_cache.get(address) + + def set_gatt_mtu_cache(self, address: int, mtu: int) -> None: + """Set the mtu cache for the given address.""" + self._gatt_mtu_cache[address] = mtu + + def clear_gatt_mtu_cache(self, address: int) -> None: + """Clear the mtu cache for the given address.""" + self._gatt_mtu_cache.pop(address, None) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index d452ab8764a..748035bedac 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -4,6 +4,8 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine import contextlib +from dataclasses import dataclass, field +from functools import partial import logging from typing import Any, TypeVar, cast import uuid @@ -11,11 +13,15 @@ import uuid from aioesphomeapi import ( ESP_CONNECTION_ERROR_DESCRIPTION, ESPHOME_GATT_ERRORS, + APIClient, + APIVersion, BLEConnectionError, BluetoothProxyFeature, + DeviceInfo, ) from aioesphomeapi.connection import APIConnectionError, TimeoutAPIError from aioesphomeapi.core import BluetoothGATTAPIError +from async_interrupt import interrupt import async_timeout from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.client import BaseBleakClient, NotifyCallback @@ -23,13 +29,13 @@ from bleak.backends.device import BLEDevice from bleak.backends.service import BleakGATTServiceCollection from bleak.exc import BleakError -from homeassistant.components.bluetooth import async_scanner_by_source -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.core import CALLBACK_TYPE -from ..domain_data import DomainData +from .cache import ESPHomeBluetoothCache from .characteristic import BleakGATTCharacteristicESPHome from .descriptor import BleakGATTDescriptorESPHome +from .device import ESPHomeBluetoothDevice +from .scanner import ESPHomeScanner from .service import BleakGATTServiceESPHome DEFAULT_MTU = 23 @@ -62,29 +68,21 @@ def verify_connected(func: _WrapFuncType) -> _WrapFuncType: async def _async_wrap_bluetooth_connected_operation( self: ESPHomeClient, *args: Any, **kwargs: Any ) -> Any: - disconnected_event = ( - self._disconnected_event # pylint: disable=protected-access + # pylint: disable=protected-access + loop = self._loop + disconnected_futures = self._disconnected_futures + disconnected_future = loop.create_future() + disconnected_futures.add(disconnected_future) + ble_device = self._ble_device + disconnect_message = ( + f"{self._source_name }: {ble_device.name} - {ble_device.address}: " + "Disconnected during operation" ) - if not disconnected_event: - raise BleakError("Not connected") - action_task = asyncio.create_task(func(self, *args, **kwargs)) - disconnect_task = asyncio.create_task(disconnected_event.wait()) - await asyncio.wait( - (action_task, disconnect_task), - return_when=asyncio.FIRST_COMPLETED, - ) - if disconnect_task.done(): - action_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await action_task - - raise BleakError( - f"{self._source_name}: " # pylint: disable=protected-access - f"{self._ble_device.name} - " # pylint: disable=protected-access - f" {self._ble_device.address}: " # pylint: disable=protected-access - "Disconnected during operation" - ) - return action_task.result() + try: + async with interrupt(disconnected_future, BleakError, disconnect_message): + return await func(self, *args, **kwargs) + finally: + disconnected_futures.discard(disconnected_future) return cast(_WrapFuncType, _async_wrap_bluetooth_connected_operation) @@ -125,6 +123,20 @@ def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: return cast(_WrapFuncType, _async_wrap_bluetooth_operation) +@dataclass(slots=True) +class ESPHomeClientData: + """Define a class that stores client data for an esphome client.""" + + bluetooth_device: ESPHomeBluetoothDevice + cache: ESPHomeBluetoothCache + client: APIClient + device_info: DeviceInfo + api_version: APIVersion + title: str + scanner: ESPHomeScanner | None + disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list) + + class ESPHomeClient(BaseBleakClient): """ESPHome Bleak Client.""" @@ -132,35 +144,38 @@ class ESPHomeClient(BaseBleakClient): self, address_or_ble_device: BLEDevice | str, *args: Any, - config_entry: ConfigEntry, + client_data: ESPHomeClientData, **kwargs: Any, ) -> None: """Initialize the ESPHomeClient.""" + device_info = client_data.device_info + self._disconnect_callbacks = client_data.disconnect_callbacks assert isinstance(address_or_ble_device, BLEDevice) super().__init__(address_or_ble_device, *args, **kwargs) - self._hass: HomeAssistant = kwargs["hass"] + self._loop = asyncio.get_running_loop() self._ble_device = address_or_ble_device self._address_as_int = mac_to_int(self._ble_device.address) assert self._ble_device.details is not None self._source = self._ble_device.details["source"] - self.domain_data = DomainData.get(self._hass) - self.entry_data = self.domain_data.get_entry_data(config_entry) - self._client = self.entry_data.client + self._cache = client_data.cache + self._bluetooth_device = client_data.bluetooth_device + self._client = client_data.client self._is_connected = False self._mtu: int | None = None self._cancel_connection_state: CALLBACK_TYPE | None = None self._notify_cancels: dict[ int, tuple[Callable[[], Coroutine[Any, Any, None]], Callable[[], None]] ] = {} - self._disconnected_event: asyncio.Event | None = None - device_info = self.entry_data.device_info - assert device_info is not None - self._device_info = device_info + self._disconnected_futures: set[asyncio.Future[None]] = set() + self._device_info = client_data.device_info self._feature_flags = device_info.bluetooth_proxy_feature_flags_compat( - self.entry_data.api_version + client_data.api_version ) self._address_type = address_or_ble_device.details["address_type"] - self._source_name = f"{config_entry.title} [{self._source}]" + self._source_name = f"{client_data.title} [{self._source}]" + scanner = client_data.scanner + assert scanner is not None + self._scanner = scanner def __str__(self) -> str: """Return the string representation of the client.""" @@ -192,9 +207,10 @@ class ESPHomeClient(BaseBleakClient): for _, notify_abort in self._notify_cancels.values(): notify_abort() self._notify_cancels.clear() - if self._disconnected_event: - self._disconnected_event.set() - self._disconnected_event = None + for future in self._disconnected_futures: + if not future.done(): + future.set_result(None) + self._disconnected_futures.clear() self._unsubscribe_connection_state() def _async_ble_device_disconnected(self) -> None: @@ -211,14 +227,14 @@ class ESPHomeClient(BaseBleakClient): self._async_call_bleak_disconnected_callback() def _async_esp_disconnected(self) -> None: - """Handle the esp32 client disconnecting from hass.""" + """Handle the esp32 client disconnecting from us.""" _LOGGER.debug( "%s: %s - %s: ESP device disconnected", self._source_name, self._ble_device.name, self._ble_device.address, ) - self.entry_data.disconnect_callbacks.remove(self._async_esp_disconnected) + self._disconnect_callbacks.remove(self._async_esp_disconnected) self._async_ble_device_disconnected() def _async_call_bleak_disconnected_callback(self) -> None: @@ -227,6 +243,65 @@ class ESPHomeClient(BaseBleakClient): self._disconnected_callback() self._disconnected_callback = None + def _on_bluetooth_connection_state( + self, + connected_future: asyncio.Future[bool], + connected: bool, + mtu: int, + error: int, + ) -> None: + """Handle a connect or disconnect.""" + _LOGGER.debug( + "%s: %s - %s: Connection state changed to connected=%s mtu=%s error=%s", + self._source_name, + self._ble_device.name, + self._ble_device.address, + connected, + mtu, + error, + ) + if connected: + self._is_connected = True + if not self._mtu: + self._mtu = mtu + self._cache.set_gatt_mtu_cache(self._address_as_int, mtu) + else: + self._async_ble_device_disconnected() + + if connected_future.done(): + return + + if error: + try: + ble_connection_error = BLEConnectionError(error) + ble_connection_error_name = ble_connection_error.name + human_error = ESP_CONNECTION_ERROR_DESCRIPTION[ble_connection_error] + except (KeyError, ValueError): + ble_connection_error_name = str(error) + human_error = ESPHOME_GATT_ERRORS.get( + error, f"Unknown error code {error}" + ) + connected_future.set_exception( + BleakError( + f"Error {ble_connection_error_name} while connecting:" + f" {human_error}" + ) + ) + return + + if not connected: + connected_future.set_exception(BleakError("Disconnected")) + return + + _LOGGER.debug( + "%s: %s - %s: connected, registering for disconnected callbacks", + self._source_name, + self._ble_device.name, + self._ble_device.address, + ) + self._disconnect_callbacks.append(self._async_esp_disconnected) + connected_future.set_result(connected) + @api_error_as_bleak_error async def connect( self, dangerous_use_bleak_cache: bool = False, **kwargs: Any @@ -241,82 +316,24 @@ class ESPHomeClient(BaseBleakClient): Boolean representing connection status. """ await self._wait_for_free_connection_slot(CONNECT_FREE_SLOT_TIMEOUT) - domain_data = self.domain_data - entry_data = self.entry_data + cache = self._cache - self._mtu = domain_data.get_gatt_mtu_cache(self._address_as_int) + self._mtu = cache.get_gatt_mtu_cache(self._address_as_int) has_cache = bool( dangerous_use_bleak_cache and self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING - and domain_data.get_gatt_services_cache(self._address_as_int) + and cache.get_gatt_services_cache(self._address_as_int) and self._mtu ) - connected_future: asyncio.Future[bool] = asyncio.Future() - - def _on_bluetooth_connection_state( - connected: bool, mtu: int, error: int - ) -> None: - """Handle a connect or disconnect.""" - _LOGGER.debug( - "%s: %s - %s: Connection state changed to connected=%s mtu=%s error=%s", - self._source_name, - self._ble_device.name, - self._ble_device.address, - connected, - mtu, - error, - ) - if connected: - self._is_connected = True - if not self._mtu: - self._mtu = mtu - domain_data.set_gatt_mtu_cache(self._address_as_int, mtu) - else: - self._async_ble_device_disconnected() - - if connected_future.done(): - return - - if error: - try: - ble_connection_error = BLEConnectionError(error) - ble_connection_error_name = ble_connection_error.name - human_error = ESP_CONNECTION_ERROR_DESCRIPTION[ble_connection_error] - except (KeyError, ValueError): - ble_connection_error_name = str(error) - human_error = ESPHOME_GATT_ERRORS.get( - error, f"Unknown error code {error}" - ) - connected_future.set_exception( - BleakError( - f"Error {ble_connection_error_name} while connecting:" - f" {human_error}" - ) - ) - return - - if not connected: - connected_future.set_exception(BleakError("Disconnected")) - return - - _LOGGER.debug( - "%s: %s - %s: connected, registering for disconnected callbacks", - self._source_name, - self._ble_device.name, - self._ble_device.address, - ) - entry_data.disconnect_callbacks.append(self._async_esp_disconnected) - connected_future.set_result(connected) + connected_future: asyncio.Future[bool] = self._loop.create_future() timeout = kwargs.get("timeout", self._timeout) - if not (scanner := async_scanner_by_source(self._hass, self._source)): - raise BleakError("Scanner disappeared for {self._source_name}") - with scanner.connecting(): + with self._scanner.connecting(): try: self._cancel_connection_state = ( await self._client.bluetooth_device_connect( self._address_as_int, - _on_bluetooth_connection_state, + partial(self._on_bluetooth_connection_state, connected_future), timeout=timeout, has_cache=has_cache, feature_flags=self._feature_flags, @@ -332,7 +349,7 @@ class ESPHomeClient(BaseBleakClient): # exception. await connected_future raise - except Exception: + except Exception as ex: if connected_future.done(): with contextlib.suppress(BleakError): # If the connect call throws an exception, @@ -342,29 +359,33 @@ class ESPHomeClient(BaseBleakClient): # exception from the connect call as it # will be more descriptive. await connected_future - connected_future.cancel() + connected_future.cancel(f"Unhandled exception in connect call: {ex}") raise await connected_future try: - await self.get_services(dangerous_use_bleak_cache=dangerous_use_bleak_cache) + await self._get_services( + dangerous_use_bleak_cache=dangerous_use_bleak_cache + ) except asyncio.CancelledError: # On cancel we must still raise cancelled error # to avoid blocking the cancellation even if the # disconnect call fails. with contextlib.suppress(Exception): - await self.disconnect() + await self._disconnect() raise except Exception: - await self.disconnect() + await self._disconnect() raise - self._disconnected_event = asyncio.Event() return True @api_error_as_bleak_error async def disconnect(self) -> bool: """Disconnect from the peripheral device.""" + return await self._disconnect() + + async def _disconnect(self) -> bool: self._async_disconnected_cleanup() await self._client.bluetooth_device_disconnect(self._address_as_int) await self._wait_for_free_connection_slot(DISCONNECT_TIMEOUT) @@ -372,7 +393,8 @@ class ESPHomeClient(BaseBleakClient): async def _wait_for_free_connection_slot(self, timeout: float) -> None: """Wait for a free connection slot.""" - if self.entry_data.ble_connections_free: + bluetooth_device = self._bluetooth_device + if bluetooth_device.ble_connections_free: return _LOGGER.debug( "%s: %s - %s: Out of connection slots, waiting for a free one", @@ -381,7 +403,7 @@ class ESPHomeClient(BaseBleakClient): self._ble_device.address, ) async with async_timeout.timeout(timeout): - await self.entry_data.wait_for_ble_connections_free() + await bluetooth_device.wait_for_ble_connections_free() @property def is_connected(self) -> bool: @@ -437,15 +459,27 @@ class ESPHomeClient(BaseBleakClient): A :py:class:`bleak.backends.service.BleakGATTServiceCollection` with this device's services tree. """ + return await self._get_services( + dangerous_use_bleak_cache=dangerous_use_bleak_cache, **kwargs + ) + + @verify_connected + async def _get_services( + self, dangerous_use_bleak_cache: bool = False, **kwargs: Any + ) -> BleakGATTServiceCollection: + """Get all services registered for this GATT server. + + Must only be called from get_services or connected + """ address_as_int = self._address_as_int - domain_data = self.domain_data + cache = self._cache # If the connection version >= 3, we must use the cache # because the esp has already wiped the services list to # save memory. if ( self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING or dangerous_use_bleak_cache - ) and (cached_services := domain_data.get_gatt_services_cache(address_as_int)): + ) and (cached_services := cache.get_gatt_services_cache(address_as_int)): _LOGGER.debug( "%s: %s - %s: Cached services hit", self._source_name, @@ -504,7 +538,7 @@ class ESPHomeClient(BaseBleakClient): self._ble_device.name, self._ble_device.address, ) - domain_data.set_gatt_services_cache(address_as_int, services) + cache.set_gatt_services_cache(address_as_int, services) return services def _resolve_characteristic( @@ -521,11 +555,13 @@ class ESPHomeClient(BaseBleakClient): raise BleakError(f"Characteristic {char_specifier} was not found!") return characteristic + @verify_connected @api_error_as_bleak_error async def clear_cache(self) -> bool: """Clear the GATT cache.""" - self.domain_data.clear_gatt_services_cache(self._address_as_int) - self.domain_data.clear_gatt_mtu_cache(self._address_as_int) + cache = self._cache + cache.clear_gatt_services_cache(self._address_as_int) + cache.clear_gatt_mtu_cache(self._address_as_int) if not self._feature_flags & BluetoothProxyFeature.CACHE_CLEARING: _LOGGER.warning( "On device cache clear is not available with this ESPHome version; " @@ -708,6 +744,7 @@ class ESPHomeClient(BaseBleakClient): wait_for_response=False, ) + @verify_connected @api_error_as_bleak_error async def stop_notify( self, @@ -740,5 +777,5 @@ class ESPHomeClient(BaseBleakClient): self._ble_device.name, self._ble_device.address, ) - if not self._hass.loop.is_closed(): - self._hass.loop.call_soon_threadsafe(self._async_disconnected_cleanup) + if not self._loop.is_closed(): + self._loop.call_soon_threadsafe(self._async_disconnected_cleanup) diff --git a/homeassistant/components/esphome/bluetooth/device.py b/homeassistant/components/esphome/bluetooth/device.py new file mode 100644 index 00000000000..8d060151dbf --- /dev/null +++ b/homeassistant/components/esphome/bluetooth/device.py @@ -0,0 +1,54 @@ +"""Bluetooth device models for esphome.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +import logging + +from homeassistant.core import callback + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(slots=True) +class ESPHomeBluetoothDevice: + """Bluetooth data for a specific ESPHome device.""" + + name: str + mac_address: str + ble_connections_free: int = 0 + ble_connections_limit: int = 0 + _ble_connection_free_futures: list[asyncio.Future[int]] = field( + default_factory=list + ) + + @callback + def async_update_ble_connection_limits(self, free: int, limit: int) -> None: + """Update the BLE connection limits.""" + _LOGGER.debug( + "%s [%s]: BLE connection limits: used=%s free=%s limit=%s", + self.name, + self.mac_address, + limit - free, + free, + limit, + ) + self.ble_connections_free = free + self.ble_connections_limit = limit + if not free: + return + for fut in self._ble_connection_free_futures: + # If wait_for_ble_connections_free gets cancelled, it will + # leave a future in the list. We need to check if it's done + # before setting the result. + if not fut.done(): + fut.set_result(free) + self._ble_connection_free_futures.clear() + + async def wait_for_ble_connections_free(self) -> int: + """Wait until there are free BLE connections.""" + if self.ble_connections_free > 0: + return self.ble_connections_free + fut: asyncio.Future[int] = asyncio.Future() + self._ble_connection_free_futures.append(fut) + return await fut diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index 94a9b03b90c..f3fb8b867d8 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +from collections.abc import Callable, Coroutine +from functools import partial from typing import Any from aioesphomeapi import CameraInfo, CameraState @@ -40,48 +42,56 @@ class EsphomeCamera(Camera, EsphomeEntity[CameraInfo, CameraState]): """Initialize.""" Camera.__init__(self) EsphomeEntity.__init__(self, *args, **kwargs) - self._image_cond = asyncio.Condition() + self._loop = asyncio.get_running_loop() + self._image_futures: list[asyncio.Future[bool | None]] = [] + + @callback + def _set_futures(self, result: bool) -> None: + """Set futures to done.""" + for future in self._image_futures: + if not future.done(): + future.set_result(result) + self._image_futures.clear() + + @callback + def _on_device_update(self) -> None: + """Handle device going available or unavailable.""" + super()._on_device_update() + if not self.available: + self._set_futures(False) @callback def _on_state_update(self) -> None: """Notify listeners of new image when update arrives.""" super()._on_state_update() - self.hass.async_create_task(self._on_state_update_coro()) - - async def _on_state_update_coro(self) -> None: - async with self._image_cond: - self._image_cond.notify_all() + self._set_futures(True) async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return single camera image bytes.""" - if not self.available: - return None - await self._client.request_single_image() - async with self._image_cond: - await self._image_cond.wait() - if not self.available: - # Availability can change while waiting for 'self._image.cond' - return None # type: ignore[unreachable] - return self._state.data[:] + return await self._async_request_image(self._client.request_single_image) - async def _async_camera_stream_image(self) -> bytes | None: - """Return a single camera image in a stream.""" + async def _async_request_image( + self, request_method: Callable[[], Coroutine[Any, Any, None]] + ) -> bytes | None: + """Wait for an image to be available and return it.""" if not self.available: return None - await self._client.request_image_stream() - async with self._image_cond: - await self._image_cond.wait() - if not self.available: - # Availability can change while waiting for 'self._image.cond' - return None # type: ignore[unreachable] - return self._state.data[:] + image_future = self._loop.create_future() + self._image_futures.append(image_future) + await request_method() + if not await image_future: + return None + return self._state.data async def handle_async_mjpeg_stream( self, request: web.Request ) -> web.StreamResponse: """Serve an HTTP MJPEG stream from the camera.""" - return await camera.async_get_still_stream( - request, self._async_camera_stream_image, camera.DEFAULT_CONTENT_TYPE, 0.0 + stream_request = partial( + self._async_request_image, self._client.request_image_stream + ) + return await camera.async_get_still_stream( + request, stream_request, camera.DEFAULT_CONTENT_TYPE, 0.0 ) diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 34043da012e..a9b184cc936 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -140,6 +140,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti """A climate implementation for ESPHome.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_translation_key = "climate" @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 7f554901812..5011439c778 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -27,9 +27,10 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac -from . import CONF_DEVICE_NAME, CONF_NOISE_PSK from .const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_DEVICE_NAME, + CONF_NOISE_PSK, DEFAULT_ALLOW_SERVICE_CALLS, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DOMAIN, @@ -54,6 +55,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._host: str | None = None self._port: int | None = None self._password: str | None = None + self._noise_required: bool | None = None self._noise_psk: str | None = None self._device_info: DeviceInfo | None = None self._reauth_entry: ConfigEntry | None = None @@ -150,33 +152,45 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"name": self._name} async def _async_try_fetch_device_info(self) -> FlowResult: - error = await self.fetch_device_info() + """Try to fetch device info and return any errors.""" + response: str | None + if self._noise_required: + # If we already know we need encryption, don't try to fetch device info + # without encryption. + response = ERROR_REQUIRES_ENCRYPTION_KEY + else: + # After 2024.08, stop trying to fetch device info without encryption + # so we can avoid probe requests to check for password. At this point + # most devices should announce encryption support and password is + # deprecated and can be discovered by trying to connect only after they + # interact with the flow since it is expected to be a rare case. + response = await self.fetch_device_info() - if error == ERROR_REQUIRES_ENCRYPTION_KEY: + if response == ERROR_REQUIRES_ENCRYPTION_KEY: if not self._device_name and not self._noise_psk: # If device name is not set we can send a zero noise psk # to get the device name which will allow us to populate # the device name and hopefully get the encryption key # from the dashboard. self._noise_psk = ZERO_NOISE_PSK - error = await self.fetch_device_info() + response = await self.fetch_device_info() self._noise_psk = None if ( self._device_name and await self._retrieve_encryption_key_from_dashboard() ): - error = await self.fetch_device_info() + response = await self.fetch_device_info() # If the fetched key is invalid, unset it again. - if error == ERROR_INVALID_ENCRYPTION_KEY: + if response == ERROR_INVALID_ENCRYPTION_KEY: self._noise_psk = None - error = ERROR_REQUIRES_ENCRYPTION_KEY + response = ERROR_REQUIRES_ENCRYPTION_KEY - if error == ERROR_REQUIRES_ENCRYPTION_KEY: + if response == ERROR_REQUIRES_ENCRYPTION_KEY: return await self.async_step_encryption_key() - if error is not None: - return await self._async_step_user_base(error=error) + if response is not None: + return await self._async_step_user_base(error=response) return await self._async_authenticate_or_add() async def _async_authenticate_or_add(self) -> FlowResult: @@ -219,6 +233,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._device_name = device_name self._host = discovery_info.host self._port = discovery_info.port + self._noise_required = bool(discovery_info.properties.get("api_encryption")) # Check if already configured await self.async_set_unique_id(mac_address) diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index a53bb2db8ed..f0e3972f197 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -1,7 +1,19 @@ """ESPHome constants.""" +from awesomeversion import AwesomeVersion DOMAIN = "esphome" CONF_ALLOW_SERVICE_CALLS = "allow_service_calls" +CONF_DEVICE_NAME = "device_name" +CONF_NOISE_PSK = "noise_psk" + DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False + + +STABLE_BLE_VERSION_STR = "2023.6.0" +STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) +PROJECT_URLS = { + "esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/", +} +DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index 292d1921abf..a984d057c0c 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -30,12 +30,14 @@ async def async_get_config_entry_diagnostics( if (storage_data := await entry_data.store.async_load()) is not None: diag["storage_data"] = storage_data - if config_entry.unique_id and ( - scanner := async_scanner_by_source(hass, config_entry.unique_id) + if ( + config_entry.unique_id + and (scanner := async_scanner_by_source(hass, config_entry.unique_id)) + and (bluetooth_device := entry_data.bluetooth_device) ): diag["bluetooth"] = { - "connections_free": entry_data.ble_connections_free, - "connections_limit": entry_data.ble_connections_limit, + "connections_free": bluetooth_device.ble_connections_free, + "connections_limit": bluetooth_device.ble_connections_limit, "scanner": await scanner.async_diagnostics(), } diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index 2fc32129d1f..bf7c5d9c969 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -1,65 +1,29 @@ """Support for esphome domain data.""" from __future__ import annotations -from collections.abc import MutableMapping from dataclasses import dataclass, field -from typing import cast - -from bleak.backends.service import BleakGATTServiceCollection -from lru import LRU # pylint: disable=no-name-in-module -from typing_extensions import Self +from typing import Self, cast from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder +from .bluetooth.cache import ESPHomeBluetoothCache from .const import DOMAIN from .entry_data import ESPHomeStorage, RuntimeEntryData STORAGE_VERSION = 1 -MAX_CACHED_SERVICES = 128 -@dataclass +@dataclass(slots=True) class DomainData: """Define a class that stores global esphome data in hass.data[DOMAIN].""" _entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict) _stores: dict[str, ESPHomeStorage] = field(default_factory=dict) - _gatt_services_cache: MutableMapping[int, BleakGATTServiceCollection] = field( - default_factory=lambda: LRU(MAX_CACHED_SERVICES) + bluetooth_cache: ESPHomeBluetoothCache = field( + default_factory=ESPHomeBluetoothCache ) - _gatt_mtu_cache: MutableMapping[int, int] = field( - default_factory=lambda: LRU(MAX_CACHED_SERVICES) - ) - - def get_gatt_services_cache( - self, address: int - ) -> BleakGATTServiceCollection | None: - """Get the BleakGATTServiceCollection for the given address.""" - return self._gatt_services_cache.get(address) - - def set_gatt_services_cache( - self, address: int, services: BleakGATTServiceCollection - ) -> None: - """Set the BleakGATTServiceCollection for the given address.""" - self._gatt_services_cache[address] = services - - def clear_gatt_services_cache(self, address: int) -> None: - """Clear the BleakGATTServiceCollection for the given address.""" - self._gatt_services_cache.pop(address, None) - - def get_gatt_mtu_cache(self, address: int) -> int | None: - """Get the mtu cache for the given address.""" - return self._gatt_mtu_cache.get(address) - - def set_gatt_mtu_cache(self, address: int, mtu: int) -> None: - """Set the mtu cache for the given address.""" - self._gatt_mtu_cache[address] = mtu - - def clear_gatt_mtu_cache(self, address: int) -> None: - """Clear the mtu cache for the given address.""" - self._gatt_mtu_cache.pop(address, None) def get_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData: """Return the runtime entry data associated with this config entry. @@ -70,18 +34,13 @@ class DomainData: def set_entry_data(self, entry: ConfigEntry, entry_data: RuntimeEntryData) -> None: """Set the runtime entry data associated with this config entry.""" - if entry.entry_id in self._entry_datas: - raise ValueError("Entry data for this entry is already set") + assert entry.entry_id not in self._entry_datas, "Entry data already set!" self._entry_datas[entry.entry_id] = entry_data def pop_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData: """Pop the runtime entry data instance associated with this config entry.""" return self._entry_datas.pop(entry.entry_id) - def is_entry_loaded(self, entry: ConfigEntry) -> bool: - """Check whether the given entry is loaded.""" - return entry.entry_id in self._entry_datas - def get_or_create_store( self, hass: HomeAssistant, entry: ConfigEntry ) -> ESPHomeStorage: diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 15c136f17c3..c35b4dc9b13 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -23,6 +23,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import ( @@ -60,6 +61,7 @@ async def platform_async_setup_entry( entry_data: RuntimeEntryData = DomainData.get(hass).get_entry_data(entry) entry_data.info[info_type] = {} entry_data.state.setdefault(state_type, {}) + platform = entity_platform.async_get_current_platform() @callback def async_list_entities(infos: list[EntityInfo]) -> None: @@ -71,7 +73,7 @@ async def platform_async_setup_entry( for info in infos: if not current_infos.pop(info.key, None): # Create new entity - entity = entity_type(entry_data, info, state_type) + entity = entity_type(entry_data, platform.domain, info, state_type) add_entities.append(entity) new_infos[info.key] = info @@ -145,10 +147,12 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): def __init__( self, entry_data: RuntimeEntryData, + domain: str, entity_info: EntityInfo, state_type: type[_StateT], ) -> None: """Initialize.""" + self._entry_data = entry_data self._on_entry_data_changed() self._key = entity_info.key @@ -157,11 +161,29 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): assert entry_data.device_info is not None device_info = entry_data.device_info self._device_info = device_info - self._attr_has_entity_name = bool(device_info.friendly_name) self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} ) self._entry_id = entry_data.entry_id + # + # If `friendly_name` is set, we use the Friendly naming rules, if + # `friendly_name` is not set we make an exception to the naming rules for + # backwards compatibility and use the Legacy naming rules. + # + # Friendly naming + # - Friendly name is prepended to entity names + # - Device Name is prepended to entity ids + # - Entity id is constructed from device name and object id + # + # Legacy naming + # - Device name is not prepended to entity names + # - Device name is not prepended to entity ids + # - Entity id is constructed from entity name + # + if not device_info.friendly_name: + return + self._attr_has_entity_name = True + self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}" async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index a7c81543a94..b7870e9cca0 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -14,6 +14,7 @@ from aioesphomeapi import ( APIVersion, BinarySensorInfo, CameraInfo, + CameraState, ClimateInfo, CoverInfo, DeviceInfo, @@ -39,6 +40,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store +from .bluetooth.device import ESPHomeBluetoothDevice from .dashboard import async_get_dashboard INFO_TO_COMPONENT_TYPE: Final = {v: k for k, v in COMPONENT_TYPE_TO_INFO.items()} @@ -79,11 +81,12 @@ class ESPHomeStorage(Store[StoreData]): """ESPHome Storage.""" -@dataclass +@dataclass(slots=True) class RuntimeEntryData: """Store runtime data for esphome config entries.""" entry_id: str + title: str client: APIClient store: ESPHomeStorage state: dict[type[EntityState], dict[int, EntityState]] = field(default_factory=dict) @@ -96,6 +99,7 @@ class RuntimeEntryData: available: bool = False expected_disconnect: bool = False # Last disconnect was expected (e.g. deep sleep) device_info: DeviceInfo | None = None + bluetooth_device: ESPHomeBluetoothDevice | None = None api_version: APIVersion = field(default_factory=APIVersion) cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list) disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list) @@ -106,11 +110,6 @@ class RuntimeEntryData: platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock) _storage_contents: StoreData | None = None _pending_storage: Callable[[], StoreData] | None = None - ble_connections_free: int = 0 - ble_connections_limit: int = 0 - _ble_connection_free_futures: list[asyncio.Future[int]] = field( - default_factory=list - ) assist_pipeline_update_callbacks: list[Callable[[], None]] = field( default_factory=list ) @@ -129,14 +128,16 @@ class RuntimeEntryData: @property def name(self) -> str: """Return the name of the device.""" - return self.device_info.name if self.device_info else self.entry_id + device_info = self.device_info + return (device_info and device_info.name) or self.title @property def friendly_name(self) -> str: """Return the friendly name of the device.""" - if self.device_info and self.device_info.friendly_name: - return self.device_info.friendly_name - return self.name + device_info = self.device_info + return (device_info and device_info.friendly_name) or self.name.title().replace( + "_", " " + ) @property def signal_device_updated(self) -> str: @@ -195,37 +196,6 @@ class RuntimeEntryData: return _unsub - @callback - def async_update_ble_connection_limits(self, free: int, limit: int) -> None: - """Update the BLE connection limits.""" - _LOGGER.debug( - "%s [%s]: BLE connection limits: used=%s free=%s limit=%s", - self.name, - self.device_info.mac_address if self.device_info else "unknown", - limit - free, - free, - limit, - ) - self.ble_connections_free = free - self.ble_connections_limit = limit - if not free: - return - for fut in self._ble_connection_free_futures: - # If wait_for_ble_connections_free gets cancelled, it will - # leave a future in the list. We need to check if it's done - # before setting the result. - if not fut.done(): - fut.set_result(free) - self._ble_connection_free_futures.clear() - - async def wait_for_ble_connections_free(self) -> int: - """Wait until there are free BLE connections.""" - if self.ble_connections_free > 0: - return self.ble_connections_free - fut: asyncio.Future[int] = asyncio.Future() - self._ble_connection_free_futures.append(fut) - return await fut - @callback def async_set_assist_pipeline_state(self, state: bool) -> None: """Set the assist pipeline state.""" @@ -336,29 +306,33 @@ class RuntimeEntryData: current_state_by_type = self.state[state_type] current_state = current_state_by_type.get(key, _SENTINEL) subscription_key = (state_type, key) + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) if ( current_state == state and subscription_key not in stale_state + and state_type is not CameraState and not ( - type(state) is SensorState # pylint: disable=unidiomatic-typecheck + state_type is SensorState # pylint: disable=unidiomatic-typecheck and (platform_info := self.info.get(SensorInfo)) and (entity_info := platform_info.get(state.key)) and (cast(SensorInfo, entity_info)).force_update ) ): + if debug_enabled: + _LOGGER.debug( + "%s: ignoring duplicate update with key %s: %s", + self.name, + key, + state, + ) + return + if debug_enabled: _LOGGER.debug( - "%s: ignoring duplicate update with key %s: %s", + "%s: dispatching update with key %s: %s", self.name, key, state, ) - return - _LOGGER.debug( - "%s: dispatching update with key %s: %s", - self.name, - key, - state, - ) stale_state.discard(subscription_key) current_state_by_type[key] = state if subscription := self.state_subscriptions.get(subscription_key): @@ -399,8 +373,8 @@ class RuntimeEntryData: async def async_save_to_store(self) -> None: """Generate dynamic data to store and save it to the filesystem.""" - if self.device_info is None: - raise ValueError("device_info is not set yet") + if TYPE_CHECKING: + assert self.device_info is not None store_data: StoreData = { "device_info": self.device_info.to_dict(), "services": [], @@ -409,9 +383,10 @@ class RuntimeEntryData: for info_type, infos in self.info.items(): comp_type = INFO_TO_COMPONENT_TYPE[info_type] store_data[comp_type] = [info.to_dict() for info in infos.values()] # type: ignore[literal-required] - for service in self.services.values(): - store_data["services"].append(service.to_dict()) + store_data["services"] = [ + service.to_dict() for service in self.services.values() + ] if store_data == self._storage_contents: return diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index c6be200e2b2..27a259f4441 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -119,9 +119,6 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): @esphome_state_property def percentage(self) -> int | None: """Return the current speed percentage.""" - if not self._static_info.supports_speed: - return None - if not self._supports_speed_levels: return ordered_list_item_to_percentage( ORDERED_NAMED_FAN_SPEEDS, self._state.speed # type: ignore[misc] diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py new file mode 100644 index 00000000000..71dc02acf02 --- /dev/null +++ b/homeassistant/components/esphome/manager.py @@ -0,0 +1,711 @@ +"""Manager for esphome devices.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, NamedTuple + +from aioesphomeapi import ( + APIClient, + APIConnectionError, + APIVersion, + DeviceInfo as EsphomeDeviceInfo, + HomeassistantServiceCall, + InvalidAuthAPIError, + InvalidEncryptionKeyAPIError, + ReconnectLogic, + RequiresEncryptionAPIError, + UserService, + UserServiceArgType, + VoiceAssistantEventType, +) +from awesomeversion import AwesomeVersion +import voluptuous as vol + +from homeassistant.components import tag, zeroconf +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_MODE, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) +from homeassistant.helpers.service import async_set_service_schema +from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import EventType + +from .bluetooth import async_connect_scanner +from .const import ( + CONF_ALLOW_SERVICE_CALLS, + CONF_DEVICE_NAME, + DEFAULT_ALLOW_SERVICE_CALLS, + DEFAULT_URL, + DOMAIN, + PROJECT_URLS, + STABLE_BLE_VERSION, + STABLE_BLE_VERSION_STR, +) +from .dashboard import async_get_dashboard +from .domain_data import DomainData + +# Import config flow so that it's added to the registry +from .entry_data import RuntimeEntryData +from .voice_assistant import VoiceAssistantUDPServer + +_LOGGER = logging.getLogger(__name__) + + +@callback +def _async_check_firmware_version( + hass: HomeAssistant, device_info: EsphomeDeviceInfo, api_version: APIVersion +) -> None: + """Create or delete an the ble_firmware_outdated issue.""" + # ESPHome device_info.mac_address is the unique_id + issue = f"ble_firmware_outdated-{device_info.mac_address}" + if ( + not device_info.bluetooth_proxy_feature_flags_compat(api_version) + # If the device has a project name its up to that project + # to tell them about the firmware version update so we don't notify here + or (device_info.project_name and device_info.project_name not in PROJECT_URLS) + or AwesomeVersion(device_info.esphome_version) >= STABLE_BLE_VERSION + ): + async_delete_issue(hass, DOMAIN, issue) + return + async_create_issue( + hass, + DOMAIN, + issue, + is_fixable=False, + severity=IssueSeverity.WARNING, + learn_more_url=PROJECT_URLS.get(device_info.project_name, DEFAULT_URL), + translation_key="ble_firmware_outdated", + translation_placeholders={ + "name": device_info.name, + "version": STABLE_BLE_VERSION_STR, + }, + ) + + +@callback +def _async_check_using_api_password( + hass: HomeAssistant, device_info: EsphomeDeviceInfo, has_password: bool +) -> None: + """Create or delete an the api_password_deprecated issue.""" + # ESPHome device_info.mac_address is the unique_id + issue = f"api_password_deprecated-{device_info.mac_address}" + if not has_password: + async_delete_issue(hass, DOMAIN, issue) + return + async_create_issue( + hass, + DOMAIN, + issue, + is_fixable=False, + severity=IssueSeverity.WARNING, + learn_more_url="https://esphome.io/components/api.html", + translation_key="api_password_deprecated", + translation_placeholders={ + "name": device_info.name, + }, + ) + + +class ESPHomeManager: + """Class to manage an ESPHome connection.""" + + __slots__ = ( + "hass", + "host", + "password", + "entry", + "cli", + "device_id", + "domain_data", + "voice_assistant_udp_server", + "reconnect_logic", + "zeroconf_instance", + "entry_data", + ) + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + host: str, + password: str | None, + cli: APIClient, + zeroconf_instance: zeroconf.HaZeroconf, + domain_data: DomainData, + entry_data: RuntimeEntryData, + ) -> None: + """Initialize the esphome manager.""" + self.hass = hass + self.host = host + self.password = password + self.entry = entry + self.cli = cli + self.device_id: str | None = None + self.domain_data = domain_data + self.voice_assistant_udp_server: VoiceAssistantUDPServer | None = None + self.reconnect_logic: ReconnectLogic | None = None + self.zeroconf_instance = zeroconf_instance + self.entry_data = entry_data + + async def on_stop(self, event: Event) -> None: + """Cleanup the socket client on HA stop.""" + await cleanup_instance(self.hass, self.entry) + + @property + def services_issue(self) -> str: + """Return the services issue name for this entry.""" + return f"service_calls_not_enabled-{self.entry.unique_id}" + + @callback + def async_on_service_call(self, service: HomeassistantServiceCall) -> None: + """Call service when user automation in ESPHome config is triggered.""" + hass = self.hass + domain, service_name = service.service.split(".", 1) + service_data = service.data + + if service.data_template: + try: + data_template = { + key: Template(value) for key, value in service.data_template.items() + } + template.attach(hass, data_template) + service_data.update( + template.render_complex(data_template, service.variables) + ) + except TemplateError as ex: + _LOGGER.error("Error rendering data template for %s: %s", self.host, ex) + return + + if service.is_event: + device_id = self.device_id + # ESPHome uses service call packet for both events and service calls + # Ensure the user can only send events of form 'esphome.xyz' + if domain != "esphome": + _LOGGER.error( + "Can only generate events under esphome domain! (%s)", self.host + ) + return + + # Call native tag scan + if service_name == "tag_scanned" and device_id is not None: + tag_id = service_data["tag_id"] + hass.async_create_task(tag.async_scan_tag(hass, tag_id, device_id)) + return + + hass.bus.async_fire( + service.service, + { + ATTR_DEVICE_ID: device_id, + **service_data, + }, + ) + elif self.entry.options.get( + CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS + ): + hass.async_create_task( + hass.services.async_call( + domain, service_name, service_data, blocking=True + ) + ) + else: + device_info = self.entry_data.device_info + assert device_info is not None + async_create_issue( + hass, + DOMAIN, + self.services_issue, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="service_calls_not_allowed", + translation_placeholders={ + "name": device_info.friendly_name or device_info.name, + }, + ) + _LOGGER.error( + "%s: Service call %s.%s: with data %s rejected; " + "If you trust this device and want to allow access for it to make " + "Home Assistant service calls, you can enable this " + "functionality in the options flow", + device_info.friendly_name or device_info.name, + domain, + service_name, + service_data, + ) + + async def _send_home_assistant_state( + self, entity_id: str, attribute: str | None, state: State | None + ) -> None: + """Forward Home Assistant states to ESPHome.""" + if state is None or (attribute and attribute not in state.attributes): + return + + send_state = state.state + if attribute: + attr_val = state.attributes[attribute] + # ESPHome only handles "on"/"off" for boolean values + if isinstance(attr_val, bool): + send_state = "on" if attr_val else "off" + else: + send_state = attr_val + + await self.cli.send_home_assistant_state(entity_id, attribute, str(send_state)) + + @callback + def async_on_state_subscription( + self, entity_id: str, attribute: str | None = None + ) -> None: + """Subscribe and forward states for requested entities.""" + hass = self.hass + + async def send_home_assistant_state_event( + event: EventType[EventStateChangedData], + ) -> None: + """Forward Home Assistant states updates to ESPHome.""" + event_data = event.data + new_state = event_data["new_state"] + old_state = event_data["old_state"] + + if new_state is None or old_state is None: + return + + # Only communicate changes to the state or attribute tracked + if (not attribute and old_state.state == new_state.state) or ( + attribute + and old_state.attributes.get(attribute) + == new_state.attributes.get(attribute) + ): + return + + await self._send_home_assistant_state( + event.data["entity_id"], attribute, new_state + ) + + self.entry_data.disconnect_callbacks.append( + async_track_state_change_event( + hass, [entity_id], send_home_assistant_state_event + ) + ) + + # Send initial state + hass.async_create_task( + self._send_home_assistant_state( + entity_id, attribute, hass.states.get(entity_id) + ) + ) + + def _handle_pipeline_event( + self, event_type: VoiceAssistantEventType, data: dict[str, str] | None + ) -> None: + self.cli.send_voice_assistant_event(event_type, data) + + def _handle_pipeline_finished(self) -> None: + self.entry_data.async_set_assist_pipeline_state(False) + + if self.voice_assistant_udp_server is not None: + self.voice_assistant_udp_server.close() + self.voice_assistant_udp_server = None + + async def _handle_pipeline_start( + self, conversation_id: str, use_vad: bool + ) -> int | None: + """Start a voice assistant pipeline.""" + if self.voice_assistant_udp_server is not None: + return None + + hass = self.hass + voice_assistant_udp_server = VoiceAssistantUDPServer( + hass, + self.entry_data, + self._handle_pipeline_event, + self._handle_pipeline_finished, + ) + port = await voice_assistant_udp_server.start_server() + + assert self.device_id is not None, "Device ID must be set" + hass.async_create_background_task( + voice_assistant_udp_server.run_pipeline( + device_id=self.device_id, + conversation_id=conversation_id or None, + use_vad=use_vad, + ), + "esphome.voice_assistant_udp_server.run_pipeline", + ) + self.entry_data.async_set_assist_pipeline_state(True) + + return port + + async def _handle_pipeline_stop(self) -> None: + """Stop a voice assistant pipeline.""" + if self.voice_assistant_udp_server is not None: + self.voice_assistant_udp_server.stop() + + async def on_connect(self) -> None: + """Subscribe to states and list entities on successful API login.""" + entry = self.entry + entry_data = self.entry_data + reconnect_logic = self.reconnect_logic + hass = self.hass + cli = self.cli + try: + device_info = await cli.device_info() + + # Migrate config entry to new unique ID if necessary + # This was changed in 2023.1 + if entry.unique_id != format_mac(device_info.mac_address): + hass.config_entries.async_update_entry( + entry, unique_id=format_mac(device_info.mac_address) + ) + + # Make sure we have the correct device name stored + # so we can map the device to ESPHome Dashboard config + if entry.data.get(CONF_DEVICE_NAME) != device_info.name: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_DEVICE_NAME: device_info.name} + ) + + entry_data.device_info = device_info + assert cli.api_version is not None + entry_data.api_version = cli.api_version + entry_data.available = True + # Reset expected disconnect flag on successful reconnect + # as it will be flipped to False on unexpected disconnect. + # + # We use this to determine if a deep sleep device should + # be marked as unavailable or not. + entry_data.expected_disconnect = True + if entry_data.device_info.name: + assert reconnect_logic is not None, "Reconnect logic must be set" + reconnect_logic.name = entry_data.device_info.name + + if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): + entry_data.disconnect_callbacks.append( + await async_connect_scanner( + hass, entry, cli, entry_data, self.domain_data.bluetooth_cache + ) + ) + + self.device_id = _async_setup_device_registry(hass, entry, entry_data) + entry_data.async_update_device_state(hass) + + entity_infos, services = await cli.list_entities_services() + await entry_data.async_update_static_infos(hass, entry, entity_infos) + await _setup_services(hass, entry_data, services) + await cli.subscribe_states(entry_data.async_update_state) + await cli.subscribe_service_calls(self.async_on_service_call) + await cli.subscribe_home_assistant_states(self.async_on_state_subscription) + + if device_info.voice_assistant_version: + entry_data.disconnect_callbacks.append( + await cli.subscribe_voice_assistant( + self._handle_pipeline_start, + self._handle_pipeline_stop, + ) + ) + + hass.async_create_task(entry_data.async_save_to_store()) + except APIConnectionError as err: + _LOGGER.warning("Error getting initial data for %s: %s", self.host, err) + # Re-connection logic will trigger after this + await cli.disconnect() + else: + _async_check_firmware_version(hass, device_info, entry_data.api_version) + _async_check_using_api_password(hass, device_info, bool(self.password)) + + async def on_disconnect(self, expected_disconnect: bool) -> None: + """Run disconnect callbacks on API disconnect.""" + entry_data = self.entry_data + hass = self.hass + host = self.host + name = entry_data.device_info.name if entry_data.device_info else host + _LOGGER.debug( + "%s: %s disconnected (expected=%s), running disconnected callbacks", + name, + host, + expected_disconnect, + ) + for disconnect_cb in entry_data.disconnect_callbacks: + disconnect_cb() + entry_data.disconnect_callbacks = [] + entry_data.available = False + entry_data.expected_disconnect = expected_disconnect + # Mark state as stale so that we will always dispatch + # the next state update of that type when the device reconnects + entry_data.stale_state = { + (type(entity_state), key) + for state_dict in entry_data.state.values() + for key, entity_state in state_dict.items() + } + if not hass.is_stopping: + # Avoid marking every esphome entity as unavailable on shutdown + # since it generates a lot of state changed events and database + # writes when we already know we're shutting down and the state + # will be cleared anyway. + entry_data.async_update_device_state(hass) + + async def on_connect_error(self, err: Exception) -> None: + """Start reauth flow if appropriate connect error type.""" + if isinstance( + err, + ( + RequiresEncryptionAPIError, + InvalidEncryptionKeyAPIError, + InvalidAuthAPIError, + ), + ): + self.entry.async_start_reauth(self.hass) + + async def async_start(self) -> None: + """Start the esphome connection manager.""" + hass = self.hass + entry = self.entry + entry_data = self.entry_data + + if entry.options.get(CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS): + async_delete_issue(hass, DOMAIN, self.services_issue) + + # Use async_listen instead of async_listen_once so that we don't deregister + # the callback twice when shutting down Home Assistant. + # "Unable to remove unknown listener + # .onetime_listener>" + entry_data.cleanup_callbacks.append( + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self.on_stop) + ) + + reconnect_logic = ReconnectLogic( + client=self.cli, + on_connect=self.on_connect, + on_disconnect=self.on_disconnect, + zeroconf_instance=self.zeroconf_instance, + name=self.host, + on_connect_error=self.on_connect_error, + ) + self.reconnect_logic = reconnect_logic + + infos, services = await entry_data.async_load_from_store() + await entry_data.async_update_static_infos(hass, entry, infos) + await _setup_services(hass, entry_data, services) + + if entry_data.device_info is not None and entry_data.device_info.name: + reconnect_logic.name = entry_data.device_info.name + if entry.unique_id is None: + hass.config_entries.async_update_entry( + entry, unique_id=format_mac(entry_data.device_info.mac_address) + ) + + await reconnect_logic.start() + entry_data.cleanup_callbacks.append(reconnect_logic.stop_callback) + + entry.async_on_unload( + entry.add_update_listener(entry_data.async_update_listener) + ) + + +@callback +def _async_setup_device_registry( + hass: HomeAssistant, entry: ConfigEntry, entry_data: RuntimeEntryData +) -> str: + """Set up device registry feature for a particular config entry.""" + device_info = entry_data.device_info + if TYPE_CHECKING: + assert device_info is not None + sw_version = device_info.esphome_version + if device_info.compilation_time: + sw_version += f" ({device_info.compilation_time})" + + configuration_url = None + if device_info.webserver_port > 0: + configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}" + elif dashboard := async_get_dashboard(hass): + configuration_url = f"homeassistant://hassio/ingress/{dashboard.addon_slug}" + + manufacturer = "espressif" + if device_info.manufacturer: + manufacturer = device_info.manufacturer + model = device_info.model + hw_version = None + if device_info.project_name: + project_name = device_info.project_name.split(".") + manufacturer = project_name[0] + model = project_name[1] + hw_version = device_info.project_version + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + configuration_url=configuration_url, + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}, + name=entry_data.friendly_name, + manufacturer=manufacturer, + model=model, + sw_version=sw_version, + hw_version=hw_version, + ) + return device_entry.id + + +class ServiceMetadata(NamedTuple): + """Metadata for services.""" + + validator: Any + example: str + selector: dict[str, Any] + description: str | None = None + + +ARG_TYPE_METADATA = { + UserServiceArgType.BOOL: ServiceMetadata( + validator=cv.boolean, + example="False", + selector={"boolean": None}, + ), + UserServiceArgType.INT: ServiceMetadata( + validator=vol.Coerce(int), + example="42", + selector={"number": {CONF_MODE: "box"}}, + ), + UserServiceArgType.FLOAT: ServiceMetadata( + validator=vol.Coerce(float), + example="12.3", + selector={"number": {CONF_MODE: "box", "step": 1e-3}}, + ), + UserServiceArgType.STRING: ServiceMetadata( + validator=cv.string, + example="Example text", + selector={"text": None}, + ), + UserServiceArgType.BOOL_ARRAY: ServiceMetadata( + validator=[cv.boolean], + description="A list of boolean values.", + example="[True, False]", + selector={"object": {}}, + ), + UserServiceArgType.INT_ARRAY: ServiceMetadata( + validator=[vol.Coerce(int)], + description="A list of integer values.", + example="[42, 34]", + selector={"object": {}}, + ), + UserServiceArgType.FLOAT_ARRAY: ServiceMetadata( + validator=[vol.Coerce(float)], + description="A list of floating point numbers.", + example="[ 12.3, 34.5 ]", + selector={"object": {}}, + ), + UserServiceArgType.STRING_ARRAY: ServiceMetadata( + validator=[cv.string], + description="A list of strings.", + example="['Example text', 'Another example']", + selector={"object": {}}, + ), +} + + +async def _register_service( + hass: HomeAssistant, entry_data: RuntimeEntryData, service: UserService +) -> None: + if entry_data.device_info is None: + raise ValueError("Device Info needs to be fetched first") + service_name = f"{entry_data.device_info.name.replace('-', '_')}_{service.name}" + schema = {} + fields = {} + + for arg in service.args: + if arg.type not in ARG_TYPE_METADATA: + _LOGGER.error( + "Can't register service %s because %s is of unknown type %s", + service_name, + arg.name, + arg.type, + ) + return + metadata = ARG_TYPE_METADATA[arg.type] + schema[vol.Required(arg.name)] = metadata.validator + fields[arg.name] = { + "name": arg.name, + "required": True, + "description": metadata.description, + "example": metadata.example, + "selector": metadata.selector, + } + + async def execute_service(call: ServiceCall) -> None: + await entry_data.client.execute_service(service, call.data) + + hass.services.async_register( + DOMAIN, service_name, execute_service, vol.Schema(schema) + ) + + service_desc = { + "description": ( + f"Calls the service {service.name} of the node" + f" {entry_data.device_info.name}" + ), + "fields": fields, + } + + async_set_service_schema(hass, DOMAIN, service_name, service_desc) + + +async def _setup_services( + hass: HomeAssistant, entry_data: RuntimeEntryData, services: list[UserService] +) -> None: + if entry_data.device_info is None: + # Can happen if device has never connected or .storage cleared + return + old_services = entry_data.services.copy() + to_unregister = [] + to_register = [] + for service in services: + if service.key in old_services: + # Already exists + if (matching := old_services.pop(service.key)) != service: + # Need to re-register + to_unregister.append(matching) + to_register.append(service) + else: + # New service + to_register.append(service) + + for service in old_services.values(): + to_unregister.append(service) + + entry_data.services = {serv.key: serv for serv in services} + + for service in to_unregister: + service_name = f"{entry_data.device_info.name}_{service.name}" + hass.services.async_remove(DOMAIN, service_name) + + for service in to_register: + await _register_service(hass, entry_data, service) + + +async def cleanup_instance(hass: HomeAssistant, entry: ConfigEntry) -> RuntimeEntryData: + """Cleanup the esphome client if it exists.""" + domain_data = DomainData.get(hass) + data = domain_data.pop_entry_data(entry) + data.available = False + for disconnect_cb in data.disconnect_callbacks: + disconnect_cb() + data.disconnect_callbacks = [] + for cleanup_callback in data.cleanup_callbacks: + cleanup_callback() + await data.async_cleanup() + await data.client.disconnect() + return data diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 8f5e6b95c39..d35cf90c60f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,8 +15,9 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==15.1.1", - "bluetooth-data-tools==1.3.0", + "async_interrupt==1.1.1", + "aioesphomeapi==15.1.15", + "bluetooth-data-tools==1.6.1", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json index 2ec1fe1bc41..e38e8e1a2c4 100644 --- a/homeassistant/components/esphome/strings.json +++ b/homeassistant/components/esphome/strings.json @@ -35,7 +35,7 @@ }, "reauth_confirm": { "data": { - "noise_psk": "Encryption key" + "noise_psk": "[%key:component::esphome::config::step::encryption_key::data::noise_psk%]" }, "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your device configuration." }, @@ -76,6 +76,17 @@ "relaxed": "[%key:component::assist_pipeline::entity::select::vad_sensitivity::state::relaxed%]" } } + }, + "climate": { + "climate": { + "state_attributes": { + "fan_mode": { + "state": { + "quiet": "Quiet" + } + } + } + } } }, "issues": { diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index 6f51b9df744..2ac69c3a22d 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -14,6 +14,7 @@ from homeassistant.components.update import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo @@ -27,6 +28,9 @@ from .entry_data import RuntimeEntryData KEY_UPDATE_LOCK = "esphome_update_lock" +_LOGGER = logging.getLogger(__name__) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -109,14 +113,10 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): During deep sleep the ESP will not be connectable (by design) and thus, even when unavailable, we'll show it as available. """ - return ( - super().available - and ( - self._entry_data.available - or self._entry_data.expected_disconnect - or self._device_info.has_deep_sleep - ) - and self._device_info.name in self.coordinator.data + return super().available and ( + self._entry_data.available + or self._entry_data.expected_disconnect + or self._device_info.has_deep_sleep ) @property @@ -137,33 +137,26 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): """URL to the full release notes of the latest version available.""" return "https://esphome.io/changelog/" + @callback + def _async_static_info_updated(self, _: list[EntityInfo]) -> None: + """Handle static info update.""" + self.async_write_ha_state() + async def async_added_to_hass(self) -> None: """Handle entity added to Home Assistant.""" await super().async_added_to_hass() - - @callback - def _static_info_updated(infos: list[EntityInfo]) -> None: - """Handle static info update.""" - self.async_write_ha_state() - self.async_on_remove( async_dispatcher_connect( self.hass, self._entry_data.signal_static_info_updated, - _static_info_updated, + self._async_static_info_updated, ) ) - - @callback - def _on_device_update() -> None: - """Handle update of device state, like availability.""" - self.async_write_ha_state() - self.async_on_remove( async_dispatcher_connect( self.hass, self._entry_data.signal_device_updated, - _on_device_update, + self.async_write_ha_state, ) ) @@ -172,16 +165,20 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity): ) -> None: """Install an update.""" async with self.hass.data.setdefault(KEY_UPDATE_LOCK, asyncio.Lock()): - device = self.coordinator.data.get(self._device_info.name) + coordinator = self.coordinator + api = coordinator.api + device = coordinator.data.get(self._device_info.name) assert device is not None - if not await self.coordinator.api.compile(device["configuration"]): - logging.getLogger(__name__).error( - "Error compiling %s. Try again in ESPHome dashboard for error", - device["configuration"], - ) - if not await self.coordinator.api.upload(device["configuration"], "OTA"): - logging.getLogger(__name__).error( - "Error OTA updating %s. Try again in ESPHome dashboard for error", - device["configuration"], - ) - await self.coordinator.async_request_refresh() + try: + if not await api.compile(device["configuration"]): + raise HomeAssistantError( + f"Error compiling {device['configuration']}; " + "Try again in ESPHome dashboard for more information." + ) + if not await api.upload(device["configuration"], "OTA"): + raise HomeAssistantError( + f"Error updating {device['configuration']} via OTA; " + "Try again in ESPHome dashboard for more information." + ) + finally: + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py new file mode 100644 index 00000000000..f6ba2d79bfe --- /dev/null +++ b/homeassistant/components/event/__init__.py @@ -0,0 +1,206 @@ +"""Component for handling incoming events as a platform.""" +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta +from enum import StrEnum +import logging +from typing import Any, Self, final + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity +from homeassistant.helpers.typing import ConfigType +from homeassistant.util import dt as dt_util + +from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +_LOGGER = logging.getLogger(__name__) + + +class EventDeviceClass(StrEnum): + """Device class for events.""" + + DOORBELL = "doorbell" + BUTTON = "button" + MOTION = "motion" + + +__all__ = [ + "ATTR_EVENT_TYPE", + "ATTR_EVENT_TYPES", + "DOMAIN", + "PLATFORM_SCHEMA_BASE", + "PLATFORM_SCHEMA", + "EventDeviceClass", + "EventEntity", + "EventEntityDescription", +] + +# mypy: disallow-any-generics + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Event entities.""" + component = hass.data[DOMAIN] = EntityComponent[EventEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[EventEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[EventEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class EventEntityDescription(EntityDescription): + """A class that describes event entities.""" + + device_class: EventDeviceClass | None = None + event_types: list[str] | None = None + + +@dataclass +class EventExtraStoredData(ExtraStoredData): + """Object to hold extra stored data.""" + + last_event_type: str | None + last_event_attributes: dict[str, Any] | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the event data.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored event state from a dict.""" + try: + return cls( + restored["last_event_type"], + restored["last_event_attributes"], + ) + except KeyError: + return None + + +class EventEntity(RestoreEntity): + """Representation of an Event entity.""" + + entity_description: EventEntityDescription + _attr_device_class: EventDeviceClass | None + _attr_event_types: list[str] + _attr_state: None + + __last_event_triggered: datetime | None = None + __last_event_type: str | None = None + __last_event_attributes: dict[str, Any] | None = None + + @property + def device_class(self) -> EventDeviceClass | None: + """Return the class of this entity.""" + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None + + @property + def event_types(self) -> list[str]: + """Return a list of possible events.""" + if hasattr(self, "_attr_event_types"): + return self._attr_event_types + if ( + hasattr(self, "entity_description") + and self.entity_description.event_types is not None + ): + return self.entity_description.event_types + raise AttributeError() + + @final + def _trigger_event( + self, event_type: str, event_attributes: dict[str, Any] | None = None + ) -> None: + """Process a new event.""" + if event_type not in self.event_types: + raise ValueError(f"Invalid event type {event_type} for {self.entity_id}") + self.__last_event_triggered = dt_util.utcnow() + self.__last_event_type = event_type + self.__last_event_attributes = event_attributes + + def _default_to_device_class_name(self) -> bool: + """Return True if an unnamed entity should be named by its device class. + + For events this is True if the entity has a device class. + """ + return self.device_class is not None + + @property + @final + def capability_attributes(self) -> dict[str, list[str]]: + """Return capability attributes.""" + return { + ATTR_EVENT_TYPES: self.event_types, + } + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if (last_event := self.__last_event_triggered) is None: + return None + return last_event.isoformat(timespec="milliseconds") + + @final + @property + def state_attributes(self) -> dict[str, Any]: + """Return the state attributes.""" + attributes = {ATTR_EVENT_TYPE: self.__last_event_type} + if last_event_attributes := self.__last_event_attributes: + attributes |= last_event_attributes + return attributes + + @final + async def async_internal_added_to_hass(self) -> None: + """Call when the event entity is added to hass.""" + await super().async_internal_added_to_hass() + if ( + (state := await self.async_get_last_state()) + and state.state is not None + and (event_data := await self.async_get_last_event_data()) + ): + self.__last_event_triggered = dt_util.parse_datetime(state.state) + self.__last_event_type = event_data.last_event_type + self.__last_event_attributes = event_data.last_event_attributes + + @property + def extra_restore_state_data(self) -> EventExtraStoredData: + """Return event specific state data to be restored.""" + return EventExtraStoredData( + self.__last_event_type, + self.__last_event_attributes, + ) + + async def async_get_last_event_data(self) -> EventExtraStoredData | None: + """Restore event specific state date.""" + if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: + return None + return EventExtraStoredData.from_dict(restored_last_extra_data.as_dict()) diff --git a/homeassistant/components/event/const.py b/homeassistant/components/event/const.py new file mode 100644 index 00000000000..cd6a8b96f7a --- /dev/null +++ b/homeassistant/components/event/const.py @@ -0,0 +1,5 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "event" +ATTR_EVENT_TYPE = "event_type" +ATTR_EVENT_TYPES = "event_types" diff --git a/homeassistant/components/event/manifest.json b/homeassistant/components/event/manifest.json new file mode 100644 index 00000000000..2da0940012a --- /dev/null +++ b/homeassistant/components/event/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "event", + "name": "Event", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/event", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/event/recorder.py b/homeassistant/components/event/recorder.py new file mode 100644 index 00000000000..759fd80bcf0 --- /dev/null +++ b/homeassistant/components/event/recorder.py @@ -0,0 +1,12 @@ +"""Integration platform for recorder.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant, callback + +from . import ATTR_EVENT_TYPES + + +@callback +def exclude_attributes(hass: HomeAssistant) -> set[str]: + """Exclude static attributes from being recorded in the database.""" + return {ATTR_EVENT_TYPES} diff --git a/homeassistant/components/event/strings.json b/homeassistant/components/event/strings.json new file mode 100644 index 00000000000..02f4da8ca08 --- /dev/null +++ b/homeassistant/components/event/strings.json @@ -0,0 +1,25 @@ +{ + "title": "Event", + "entity_component": { + "_": { + "name": "[%key:component::button::title%]", + "state_attributes": { + "event_type": { + "name": "Event type" + }, + "event_types": { + "name": "Event types" + } + } + }, + "doorbell": { + "name": "Doorbell" + }, + "button": { + "name": "Button" + }, + "motion": { + "name": "Motion" + } + } +} diff --git a/homeassistant/components/evergy/__init__.py b/homeassistant/components/evergy/__init__.py new file mode 100644 index 00000000000..cf1018dccef --- /dev/null +++ b/homeassistant/components/evergy/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Evergy.""" diff --git a/homeassistant/components/evergy/manifest.json b/homeassistant/components/evergy/manifest.json new file mode 100644 index 00000000000..a54dfca196d --- /dev/null +++ b/homeassistant/components/evergy/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "evergy", + "name": "Evergy", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/evil_genius_labs/__init__.py b/homeassistant/components/evil_genius_labs/__init__.py index d7083715394..839d546588c 100644 --- a/homeassistant/components/evil_genius_labs/__init__.py +++ b/homeassistant/components/evil_genius_labs/__init__.py @@ -103,6 +103,8 @@ class EvilGeniusUpdateCoordinator(DataUpdateCoordinator[dict]): class EvilGeniusEntity(CoordinatorEntity[EvilGeniusUpdateCoordinator]): """Base entity for Evil Genius.""" + _attr_has_entity_name = True + @property def device_info(self) -> DeviceInfo: """Return device info.""" diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py index 41fbcfa9b48..a915619b1b8 100644 --- a/homeassistant/components/evil_genius_labs/light.py +++ b/homeassistant/components/evil_genius_labs/light.py @@ -32,6 +32,7 @@ async def async_setup_entry( class EvilGeniusLight(EvilGeniusEntity, LightEntity): """Evil Genius Labs light.""" + _attr_name = None _attr_supported_features = LightEntityFeature.EFFECT _attr_supported_color_modes = {ColorMode.RGB} _attr_color_mode = ColorMode.RGB @@ -47,11 +48,6 @@ class EvilGeniusLight(EvilGeniusEntity, LightEntity): ] self._attr_effect_list.insert(0, HA_NO_EFFECT) - @property - def name(self) -> str: - """Return name.""" - return cast(str, self.coordinator.data["name"]["value"]) - @property def is_on(self) -> bool: """Return if light is on.""" diff --git a/homeassistant/components/evohome/services.yaml b/homeassistant/components/evohome/services.yaml index 52428dd5e1e..a16395ad6c0 100644 --- a/homeassistant/components/evohome/services.yaml +++ b/homeassistant/components/evohome/services.yaml @@ -2,14 +2,8 @@ # Describes the format for available services set_system_mode: - name: Set system mode - description: >- - Set the system mode, either indefinitely, or for a specified period of time, after - which it will revert to Auto. Not all systems support all modes. fields: mode: - name: Mode - description: "Mode to set thermostat." example: Away selector: select: @@ -21,41 +15,19 @@ set_system_mode: - "DayOff" - "HeatingOff" period: - name: Period - description: >- - A period of time in days; used only with Away, DayOff, or Custom. The system - will revert to Auto at midnight (up to 99 days, today is day 1). example: '{"days": 28}' selector: object: duration: - name: Duration - description: The duration in hours; used only with AutoWithEco (up to 24 hours). example: '{"hours": 18}' selector: object: reset_system: - name: Reset system - description: >- - Set the system to Auto mode and reset all the zones to follow their schedules. - Not all Evohome systems support this feature (i.e. AutoWithReset mode). - refresh_system: - name: Refresh system - description: >- - Pull the latest data from the vendor's servers now, rather than waiting for the - next scheduled update. - set_zone_override: - name: Set zone override - description: >- - Override a zone's setpoint, either indefinitely, or for a specified period of - time, after which it will revert to following its schedule. fields: entity_id: - name: Entity - description: The entity_id of the Evohome zone. required: true example: climate.bathroom selector: @@ -63,8 +35,6 @@ set_zone_override: integration: evohome domain: climate setpoint: - name: Setpoint - description: The temperature to be used instead of the scheduled setpoint. required: true selector: number: @@ -72,21 +42,13 @@ set_zone_override: max: 35.0 step: 0.1 duration: - name: Duration - description: >- - The zone will revert to its schedule after this time. If 0 the change is until - the next scheduled setpoint. example: '{"minutes": 135}' selector: object: clear_zone_override: - name: Clear zone override - description: Set a zone to follow its schedule. fields: entity_id: - name: Entity - description: The entity_id of the zone. required: true selector: entity: diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json new file mode 100644 index 00000000000..aa38ee170a5 --- /dev/null +++ b/homeassistant/components/evohome/strings.json @@ -0,0 +1,58 @@ +{ + "services": { + "set_system_mode": { + "name": "Set system mode", + "description": "Sets the system mode, either indefinitely, or for a specified period of time, after which it will revert to Auto. Not all systems support all modes.", + "fields": { + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "Mode to set thermostat." + }, + "period": { + "name": "Period", + "description": "A period of time in days; used only with Away, DayOff, or Custom. The system will revert to Auto at midnight (up to 99 days, today is day 1)." + }, + "duration": { + "name": "Duration", + "description": "The duration in hours; used only with AutoWithEco (up to 24 hours)." + } + } + }, + "reset_system": { + "name": "Reset system", + "description": "Sets the system to Auto mode and reset all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. AutoWithReset mode)." + }, + "refresh_system": { + "name": "Refresh system", + "description": "Pulls the latest data from the vendor's servers now, rather than waiting for the next scheduled update." + }, + "set_zone_override": { + "name": "Set zone override", + "description": "Overrides a zone's setpoint, either indefinitely, or for a specified period of time, after which it will revert to following its schedule.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "The entity_id of the Evohome zone." + }, + "setpoint": { + "name": "Setpoint", + "description": "The temperature to be used instead of the scheduled setpoint." + }, + "duration": { + "name": "Duration", + "description": "The zone will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint." + } + } + }, + "clear_zone_override": { + "name": "Clear zone override", + "description": "Sets a zone to follow its schedule.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "The entity_id of the zone." + } + } + } + } +} diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 9386a407acb..c007de78130 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -33,10 +33,14 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS_BY_TYPE: dict[str, list] = { ATTR_TYPE_CAMERA: [], ATTR_TYPE_CLOUD: [ + Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CAMERA, + Platform.IMAGE, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/ezviz/alarm_control_panel.py b/homeassistant/components/ezviz/alarm_control_panel.py new file mode 100644 index 00000000000..32f9b38888f --- /dev/null +++ b/homeassistant/components/ezviz/alarm_control_panel.py @@ -0,0 +1,165 @@ +"""Support for Ezviz alarm.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from pyezviz import PyEzvizError +from pyezviz.constants import DefenseModeType + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityDescription, + AlarmControlPanelEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DATA_COORDINATOR, + DOMAIN, + MANUFACTURER, +) +from .coordinator import EzvizDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=60) +PARALLEL_UPDATES = 0 + + +@dataclass +class EzvizAlarmControlPanelEntityDescriptionMixin: + """Mixin values for EZVIZ Alarm control panel entities.""" + + ezviz_alarm_states: list + + +@dataclass +class EzvizAlarmControlPanelEntityDescription( + AlarmControlPanelEntityDescription, EzvizAlarmControlPanelEntityDescriptionMixin +): + """Describe an EZVIZ Alarm control panel entity.""" + + +ALARM_TYPE = EzvizAlarmControlPanelEntityDescription( + key="ezviz_alarm", + ezviz_alarm_states=[ + None, + STATE_ALARM_DISARMED, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + ], +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Ezviz alarm control panel.""" + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + device_info: DeviceInfo = { + "identifiers": {(DOMAIN, entry.unique_id)}, # type: ignore[arg-type] + "name": "EZVIZ Alarm", + "model": "EZVIZ Alarm", + "manufacturer": MANUFACTURER, + } + + async_add_entities( + [EzvizAlarm(coordinator, entry.entry_id, device_info, ALARM_TYPE)] + ) + + +class EzvizAlarm(AlarmControlPanelEntity): + """Representation of an Ezviz alarm control panel.""" + + entity_description: EzvizAlarmControlPanelEntityDescription + _attr_has_entity_name = True + _attr_name = None + _attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_HOME + ) + _attr_code_arm_required = False + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + entry_id: str, + device_info: DeviceInfo, + entity_description: EzvizAlarmControlPanelEntityDescription, + ) -> None: + """Initialize alarm control panel entity.""" + self._attr_unique_id = f"{entry_id}_{entity_description.key}" + self._attr_device_info = device_info + self.entity_description = entity_description + self.coordinator = coordinator + self._attr_state = None + + async def async_added_to_hass(self) -> None: + """Entity added to hass.""" + self.async_schedule_update_ha_state(True) + + def alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + try: + if self.coordinator.ezviz_client.api_set_defence_mode( + DefenseModeType.HOME_MODE.value + ): + self._attr_state = STATE_ALARM_DISARMED + + except PyEzvizError as err: + raise HomeAssistantError("Cannot disarm EZVIZ alarm") from err + + def alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + try: + if self.coordinator.ezviz_client.api_set_defence_mode( + DefenseModeType.AWAY_MODE.value + ): + self._attr_state = STATE_ALARM_ARMED_AWAY + + except PyEzvizError as err: + raise HomeAssistantError("Cannot arm EZVIZ alarm") from err + + def alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + try: + if self.coordinator.ezviz_client.api_set_defence_mode( + DefenseModeType.SLEEP_MODE.value + ): + self._attr_state = STATE_ALARM_ARMED_HOME + + except PyEzvizError as err: + raise HomeAssistantError("Cannot arm EZVIZ alarm") from err + + def update(self) -> None: + """Fetch data from EZVIZ.""" + ezviz_alarm_state_number = "0" + try: + ezviz_alarm_state_number = ( + self.coordinator.ezviz_client.get_group_defence_mode() + ) + _LOGGER.debug( + "Updating EZVIZ alarm with response %s", ezviz_alarm_state_number + ) + self._attr_state = self.entity_description.ezviz_alarm_states[ + int(ezviz_alarm_state_number) + ] + + except PyEzvizError as error: + raise HomeAssistantError( + f"Could not fetch EZVIZ alarm status: {error}" + ) from error diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py index 77e95fa221d..3ed61d8fc3d 100644 --- a/homeassistant/components/ezviz/binary_sensor.py +++ b/homeassistant/components/ezviz/binary_sensor.py @@ -22,9 +22,13 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { device_class=BinarySensorDeviceClass.MOTION, ), "alarm_schedules_enabled": BinarySensorEntityDescription( - key="alarm_schedules_enabled" + key="alarm_schedules_enabled", + translation_key="alarm_schedules_enabled", + ), + "encrypted": BinarySensorEntityDescription( + key="encrypted", + translation_key="encrypted", ), - "encrypted": BinarySensorEntityDescription(key="encrypted"), } @@ -50,6 +54,8 @@ async def async_setup_entry( class EzvizBinarySensor(EzvizEntity, BinarySensorEntity): """Representation of a EZVIZ sensor.""" + _attr_has_entity_name = True + def __init__( self, coordinator: EzvizDataUpdateCoordinator, @@ -59,7 +65,6 @@ class EzvizBinarySensor(EzvizEntity, BinarySensorEntity): """Initialize the sensor.""" super().__init__(coordinator, serial) self._sensor_name = binary_sensor - self._attr_name = f"{self._camera_name} {binary_sensor.title()}" self._attr_unique_id = f"{serial}_{self._camera_name}.{binary_sensor}" self.entity_description = BINARY_SENSOR_TYPES[binary_sensor] diff --git a/homeassistant/components/ezviz/button.py b/homeassistant/components/ezviz/button.py new file mode 100644 index 00000000000..1c04de956c6 --- /dev/null +++ b/homeassistant/components/ezviz/button.py @@ -0,0 +1,131 @@ +"""Support for EZVIZ button controls.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from pyezviz import EzvizClient +from pyezviz.constants import SupportExt +from pyezviz.exceptions import HTTPError, PyEzvizError + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizEntity + +PARALLEL_UPDATES = 1 + + +@dataclass +class EzvizButtonEntityDescriptionMixin: + """Mixin values for EZVIZ button entities.""" + + method: Callable[[EzvizClient, str, str], Any] + supported_ext: str + + +@dataclass +class EzvizButtonEntityDescription( + ButtonEntityDescription, EzvizButtonEntityDescriptionMixin +): + """Describe a EZVIZ Button.""" + + +BUTTON_ENTITIES = ( + EzvizButtonEntityDescription( + key="ptz_up", + translation_key="ptz_up", + icon="mdi:pan", + method=lambda pyezviz_client, serial, run: pyezviz_client.ptz_control( + "UP", serial, run + ), + supported_ext=str(SupportExt.SupportPtz.value), + ), + EzvizButtonEntityDescription( + key="ptz_down", + translation_key="ptz_down", + icon="mdi:pan", + method=lambda pyezviz_client, serial, run: pyezviz_client.ptz_control( + "DOWN", serial, run + ), + supported_ext=str(SupportExt.SupportPtz.value), + ), + EzvizButtonEntityDescription( + key="ptz_left", + translation_key="ptz_left", + icon="mdi:pan", + method=lambda pyezviz_client, serial, run: pyezviz_client.ptz_control( + "LEFT", serial, run + ), + supported_ext=str(SupportExt.SupportPtz.value), + ), + EzvizButtonEntityDescription( + key="ptz_right", + translation_key="ptz_right", + icon="mdi:pan", + method=lambda pyezviz_client, serial, run: pyezviz_client.ptz_control( + "RIGHT", serial, run + ), + supported_ext=str(SupportExt.SupportPtz.value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up EZVIZ button based on a config entry.""" + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + # Add button entities if supportExt value indicates PTZ capbility. + # Could be missing or "0" for unsupported. + # If present with value of "1" then add button entity. + + async_add_entities( + EzvizButtonEntity(coordinator, camera, entity_description) + for camera in coordinator.data + for capibility, value in coordinator.data[camera]["supportExt"].items() + for entity_description in BUTTON_ENTITIES + if capibility == entity_description.supported_ext + if value == "1" + ) + + +class EzvizButtonEntity(EzvizEntity, ButtonEntity): + """Representation of a EZVIZ button entity.""" + + entity_description: EzvizButtonEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + description: EzvizButtonEntityDescription, + ) -> None: + """Initialize the button.""" + super().__init__(coordinator, serial) + self._attr_unique_id = f"{serial}_{description.key}" + self.entity_description = description + + def press(self) -> None: + """Execute the button action.""" + try: + self.entity_description.method( + self.coordinator.ezviz_client, self._serial, "START" + ) + self.entity_description.method( + self.coordinator.ezviz_client, self._serial, "STOP" + ) + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Cannot perform PTZ action on {self.name}" + ) from err diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 60a332446ce..7f03aef1d97 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -263,6 +263,17 @@ class EzvizCamera(EzvizEntity, Camera): def perform_ptz(self, direction: str, speed: int) -> None: """Perform a PTZ action on the camera.""" + ir.async_create_issue( + self.hass, + DOMAIN, + "service_depreciation_ptz", + breaks_in_ha_version="2024.2.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_depreciation_ptz", + ) + try: self.coordinator.ezviz_client.ptz_control( str(direction).upper(), self._serial, "START", speed @@ -290,6 +301,16 @@ class EzvizCamera(EzvizEntity, Camera): def perform_alarm_sound(self, level: int) -> None: """Enable/Disable movement sound alarm.""" + ir.async_create_issue( + self.hass, + DOMAIN, + "service_deprecation_alarm_sound_level", + breaks_in_ha_version="2024.2.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation_alarm_sound_level", + ) try: self.coordinator.ezviz_client.alarm_sound(self._serial, level, 1) except HTTPError as err: @@ -313,7 +334,8 @@ class EzvizCamera(EzvizEntity, Camera): DOMAIN, "service_depreciation_detection_sensibility", breaks_in_ha_version="2023.12.0", - is_fixable=False, + is_fixable=True, + is_persistent=True, severity=ir.IssueSeverity.WARNING, translation_key="service_depreciation_detection_sensibility", ) diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index d052a4b8216..c28d84552d6 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -6,8 +6,6 @@ MANUFACTURER = "EZVIZ" # Configuration ATTR_SERIAL = "serial" CONF_FFMPEG_ARGUMENTS = "ffmpeg_arguments" -ATTR_HOME = "HOME_MODE" -ATTR_AWAY = "AWAY_MODE" ATTR_TYPE_CLOUD = "EZVIZ_CLOUD_ACCOUNT" ATTR_TYPE_CAMERA = "CAMERA_ACCOUNT" CONF_SESSION_ID = "session_id" diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py new file mode 100644 index 00000000000..9bc65f12355 --- /dev/null +++ b/homeassistant/components/ezviz/image.py @@ -0,0 +1,87 @@ +"""Support EZVIZ last motion image.""" +from __future__ import annotations + +import logging + +from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription +from homeassistant.config_entries import ( + ConfigEntry, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, +) +from homeassistant.util import dt as dt_util + +from .const import ( + DATA_COORDINATOR, + DOMAIN, +) +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizEntity + +_LOGGER = logging.getLogger(__name__) + +IMAGE_TYPE = ImageEntityDescription( + key="last_motion_image", + translation_key="last_motion_image", +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up EZVIZ image entities based on a config entry.""" + + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + EzvizLastMotion(hass, coordinator, camera) for camera in coordinator.data + ) + + +class EzvizLastMotion(EzvizEntity, ImageEntity): + """Return Last Motion Image from Ezviz Camera.""" + + _attr_has_entity_name = True + + def __init__( + self, hass: HomeAssistant, coordinator: EzvizDataUpdateCoordinator, serial: str + ) -> None: + """Initialize a image entity.""" + EzvizEntity.__init__(self, coordinator, serial) + ImageEntity.__init__(self, hass) + self._attr_unique_id = f"{serial}_{IMAGE_TYPE.key}" + self.entity_description = IMAGE_TYPE + self._attr_image_url = self.data["last_alarm_pic"] + self._attr_image_last_updated = dt_util.parse_datetime( + str(self.data["last_alarm_time"]) + ) + + async def _async_load_image_from_url(self, url: str) -> Image | None: + """Load an image by url.""" + if response := await self._fetch_url(url): + return Image( + content=response.content, + content_type="image/jpeg", # Actually returns binary/octet-stream + ) + return None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if ( + self.data["last_alarm_pic"] + and self.data["last_alarm_pic"] != self._attr_image_url + ): + _LOGGER.debug("Image url changed to %s", self.data["last_alarm_pic"]) + + self._attr_image_url = self.data["last_alarm_pic"] + self._cached_image = None + self._attr_image_last_updated = dt_util.parse_datetime( + str(self.data["last_alarm_time"]) + ) + + super()._handle_coordinator_update() diff --git a/homeassistant/components/ezviz/light.py b/homeassistant/components/ezviz/light.py index 38007962e4e..9702959649d 100644 --- a/homeassistant/components/ezviz/light.py +++ b/homeassistant/components/ezviz/light.py @@ -8,7 +8,7 @@ from pyezviz.exceptions import HTTPError, PyEzvizError from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( @@ -61,22 +61,14 @@ class EzvizLight(EzvizEntity, LightEntity): ) self._attr_unique_id = f"{serial}_Light" self._attr_name = "Light" - - @property - def brightness(self) -> int | None: - """Return the brightness of this light between 0..255.""" - return round( + self._attr_is_on = self.data["switches"][DeviceSwitchType.ALARM_LIGHT.value] + self._attr_brightness = round( percentage_to_ranged_value( BRIGHTNESS_RANGE, self.coordinator.data[self._serial]["alarm_light_luminance"], ) ) - @property - def is_on(self) -> bool: - """Return the state of the light.""" - return self.data["switches"][DeviceSwitchType.ALARM_LIGHT.value] - async def async_turn_on(self, **kwargs: Any) -> None: """Turn on light.""" try: @@ -85,41 +77,55 @@ class EzvizLight(EzvizEntity, LightEntity): BRIGHTNESS_RANGE, kwargs[ATTR_BRIGHTNESS] ) - update_ok = await self.hass.async_add_executor_job( + if await self.hass.async_add_executor_job( self.coordinator.ezviz_client.set_floodlight_brightness, self._serial, data, - ) - else: - update_ok = await self.hass.async_add_executor_job( - self.coordinator.ezviz_client.switch_status, - self._serial, - DeviceSwitchType.ALARM_LIGHT.value, - 1, - ) + ): + self._attr_brightness = kwargs[ATTR_BRIGHTNESS] + + if await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.switch_status, + self._serial, + DeviceSwitchType.ALARM_LIGHT.value, + 1, + ): + self._attr_is_on = True + self.async_write_ha_state() except (HTTPError, PyEzvizError) as err: raise HomeAssistantError( f"Failed to turn on light {self._attr_name}" ) from err - if update_ok: - await self.coordinator.async_request_refresh() - async def async_turn_off(self, **kwargs: Any) -> None: """Turn off light.""" try: - update_ok = await self.hass.async_add_executor_job( + if await self.hass.async_add_executor_job( self.coordinator.ezviz_client.switch_status, self._serial, DeviceSwitchType.ALARM_LIGHT.value, 0, - ) + ): + self._attr_is_on = False + self.async_write_ha_state() except (HTTPError, PyEzvizError) as err: raise HomeAssistantError( f"Failed to turn off light {self._attr_name}" ) from err - if update_ok: - await self.coordinator.async_request_refresh() + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_is_on = self.data["switches"].get(DeviceSwitchType.ALARM_LIGHT.value) + + if isinstance(self.data["alarm_light_luminance"], int): + self._attr_brightness = round( + percentage_to_ranged_value( + BRIGHTNESS_RANGE, + self.data["alarm_light_luminance"], + ) + ) + + super()._handle_coordinator_update() diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index 074685c69f9..74d496ef6c1 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -66,14 +66,11 @@ async def async_setup_entry( ] async_add_entities( - [ - EzvizSensor(coordinator, camera, value, entry.entry_id) - for camera in coordinator.data - for capibility, value in coordinator.data[camera]["supportExt"].items() - if capibility == NUMBER_TYPE.supported_ext - if value in NUMBER_TYPE.supported_ext_value - ], - update_before_add=True, + EzvizSensor(coordinator, camera, value, entry.entry_id) + for camera in coordinator.data + for capibility, value in coordinator.data[camera]["supportExt"].items() + if capibility == NUMBER_TYPE.supported_ext + if value in NUMBER_TYPE.supported_ext_value ) @@ -98,6 +95,10 @@ class EzvizSensor(EzvizBaseEntity, NumberEntity): self.config_entry_id = config_entry_id self.sensor_value: int | None = None + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + self.async_schedule_update_ha_state(True) + @property def native_value(self) -> float | None: """Return the state of the entity.""" diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py new file mode 100644 index 00000000000..ef1dd785392 --- /dev/null +++ b/homeassistant/components/ezviz/select.py @@ -0,0 +1,99 @@ +"""Support for EZVIZ select controls.""" +from __future__ import annotations + +from dataclasses import dataclass + +from pyezviz.constants import DeviceSwitchType, SoundMode +from pyezviz.exceptions import HTTPError, PyEzvizError + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizEntity + +PARALLEL_UPDATES = 1 + + +@dataclass +class EzvizSelectEntityDescriptionMixin: + """Mixin values for EZVIZ Select entities.""" + + supported_switch: int + + +@dataclass +class EzvizSelectEntityDescription( + SelectEntityDescription, EzvizSelectEntityDescriptionMixin +): + """Describe a EZVIZ Select entity.""" + + +SELECT_TYPE = EzvizSelectEntityDescription( + key="alarm_sound_mod", + translation_key="alarm_sound_mode", + icon="mdi:alarm", + entity_category=EntityCategory.CONFIG, + options=["soft", "intensive", "silent"], + supported_switch=DeviceSwitchType.ALARM_TONE.value, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up EZVIZ select entities based on a config entry.""" + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + EzvizSelect(coordinator, camera) + for camera in coordinator.data + for switch in coordinator.data[camera]["switches"] + if switch == SELECT_TYPE.supported_switch + ) + + +class EzvizSelect(EzvizEntity, SelectEntity): + """Representation of a EZVIZ select entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, serial) + self._attr_unique_id = f"{serial}_{SELECT_TYPE.key}" + self.entity_description = SELECT_TYPE + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + sound_mode_value = getattr( + SoundMode, self.data[self.entity_description.key] + ).value + if sound_mode_value in [0, 1, 2]: + return self.options[sound_mode_value] + + return None + + def select_option(self, option: str) -> None: + """Change the selected option.""" + sound_mode_value = self.options.index(option) + + try: + self.coordinator.ezviz_client.alarm_sound(self._serial, sound_mode_value, 1) + + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Cannot set Warning sound level for {self.entity_id}" + ) from err diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index 11412c1fc70..9b19148bdb7 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -18,25 +18,55 @@ from .entity import EzvizEntity PARALLEL_UPDATES = 1 SENSOR_TYPES: dict[str, SensorEntityDescription] = { - "sw_version": SensorEntityDescription(key="sw_version"), "battery_level": SensorEntityDescription( key="battery_level", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), - "alarm_sound_mod": SensorEntityDescription(key="alarm_sound_mod"), - "last_alarm_time": SensorEntityDescription(key="last_alarm_time"), - "Seconds_Last_Trigger": SensorEntityDescription( - key="Seconds_Last_Trigger", + "alarm_sound_mod": SensorEntityDescription( + key="alarm_sound_mod", + translation_key="alarm_sound_mod", entity_registry_enabled_default=False, ), - "last_alarm_pic": SensorEntityDescription(key="last_alarm_pic"), - "supported_channels": SensorEntityDescription(key="supported_channels"), - "local_ip": SensorEntityDescription(key="local_ip"), - "wan_ip": SensorEntityDescription(key="wan_ip"), - "PIR_Status": SensorEntityDescription(key="PIR_Status"), - "last_alarm_type_code": SensorEntityDescription(key="last_alarm_type_code"), - "last_alarm_type_name": SensorEntityDescription(key="last_alarm_type_name"), + "last_alarm_time": SensorEntityDescription( + key="last_alarm_time", + translation_key="last_alarm_time", + entity_registry_enabled_default=False, + ), + "Seconds_Last_Trigger": SensorEntityDescription( + key="Seconds_Last_Trigger", + translation_key="seconds_last_trigger", + entity_registry_enabled_default=False, + ), + "last_alarm_pic": SensorEntityDescription( + key="last_alarm_pic", + translation_key="last_alarm_pic", + entity_registry_enabled_default=False, + ), + "supported_channels": SensorEntityDescription( + key="supported_channels", + translation_key="supported_channels", + ), + "local_ip": SensorEntityDescription( + key="local_ip", + translation_key="local_ip", + ), + "wan_ip": SensorEntityDescription( + key="wan_ip", + translation_key="wan_ip", + ), + "PIR_Status": SensorEntityDescription( + key="PIR_Status", + translation_key="pir_status", + ), + "last_alarm_type_code": SensorEntityDescription( + key="last_alarm_type_code", + translation_key="last_alarm_type_code", + ), + "last_alarm_type_name": SensorEntityDescription( + key="last_alarm_type_name", + translation_key="last_alarm_type_name", + ), } @@ -62,7 +92,7 @@ async def async_setup_entry( class EzvizSensor(EzvizEntity, SensorEntity): """Representation of a EZVIZ sensor.""" - coordinator: EzvizDataUpdateCoordinator + _attr_has_entity_name = True def __init__( self, coordinator: EzvizDataUpdateCoordinator, serial: str, sensor: str @@ -70,7 +100,6 @@ class EzvizSensor(EzvizEntity, SensorEntity): """Initialize the sensor.""" super().__init__(coordinator, serial) self._sensor_name = sensor - self._attr_name = f"{self._camera_name} {sensor.title()}" self._attr_unique_id = f"{serial}_{self._camera_name}.{sensor}" self.entity_description = SENSOR_TYPES[sensor] diff --git a/homeassistant/components/ezviz/services.yaml b/homeassistant/components/ezviz/services.yaml index 9733d7418a3..7d1cda2fa63 100644 --- a/homeassistant/components/ezviz/services.yaml +++ b/homeassistant/components/ezviz/services.yaml @@ -1,14 +1,10 @@ alarm_sound: - name: Set warning sound level. - description: Set movement warning sound level. target: entity: integration: ezviz domain: camera fields: level: - name: Sound level - description: Sound level (2 is disabled, 1 intensive, 0 soft). required: true example: 0 default: 0 @@ -19,16 +15,12 @@ alarm_sound: step: 1 mode: box ptz: - name: PTZ - description: Moves the camera to the direction, with defined speed target: entity: integration: ezviz domain: camera fields: direction: - name: Direction - description: Direction to move camera (up, down, left, right). required: true example: "up" default: "up" @@ -40,8 +32,6 @@ ptz: - "left" - "right" speed: - name: Speed - description: Speed of movement (from 1 to 9). required: true example: 5 default: 5 @@ -52,17 +42,12 @@ ptz: step: 1 mode: box set_alarm_detection_sensibility: - name: Detection sensitivity - description: Sets the detection sensibility level. target: entity: integration: ezviz domain: camera fields: level: - name: Sensitivity Level - description: "Sensibility level (1-6) for type 0 (Normal camera) - or (1-100) for type 3 (PIR sensor camera)." required: true example: 3 default: 3 @@ -73,8 +58,6 @@ set_alarm_detection_sensibility: step: 1 mode: box type_value: - name: Detection type - description: "Type of detection. Options : 0 - Camera or 3 - PIR Sensor Camera" required: true example: "0" default: "0" @@ -84,15 +67,12 @@ set_alarm_detection_sensibility: - "0" - "3" sound_alarm: - name: Sound Alarm - description: Sounds the alarm on your camera. target: entity: integration: ezviz domain: camera fields: enable: - description: Enter 1 or 2 (1=disable, 2=enable). required: true example: 1 default: 1 @@ -103,8 +83,6 @@ sound_alarm: step: 1 mode: box wake_device: - name: Wake Camera - description: This can be used to wake the camera/device from hibernation. target: entity: integration: ezviz diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 5711aff2a4a..d60c4816d24 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -62,7 +62,161 @@ "issues": { "service_depreciation_detection_sensibility": { "title": "Ezviz Detection sensitivity service is being removed", - "description": "Ezviz Detection sensitivity service is deprecated and will be removed in Home Assistant 2023.12; Please adjust the automation or script that uses the service and select submit below to mark this issue as resolved." + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::ezviz::issues::service_depreciation_detection_sensibility::title%]", + "description": "The Ezviz Detection sensitivity service is deprecated and will be removed in Home Assistant 2023.12.\nTo set the sensitivity, you can instead use the `number.set_value` service targetting the Detection sensitivity entity.\n\nPlease remove this service from your automations and scripts and select **submit** to close this issue." + } + } + } + }, + "service_deprecation_alarm_sound_level": { + "title": "Ezviz Alarm sound level service is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::ezviz::issues::service_deprecation_alarm_sound_level::title%]", + "description": "Ezviz Alarm sound level service is deprecated and will be removed.\nTo set the Alarm sound level, you can instead use the `select.select_option` service targetting the Warning sound entity.\n\nPlease remove this service from your automations and scripts and select **submit** to close this issue." + } + } + } + }, + "service_depreciation_ptz": { + "title": "EZVIZ PTZ service is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::ezviz::issues::service_depreciation_ptz::title%]", + "description": "EZVIZ PTZ service is deprecated and will be removed.\nTo move the camera, you can instead use the `button.press` service targetting the PTZ* entities.\n\nPlease remove the use of this service from your automations and scripts and select **submit** to close this issue." + } + } + } + } + }, + "entity": { + "select": { + "alarm_sound_mode": { + "name": "Warning sound", + "state": { + "soft": "Soft", + "intensive": "Intensive", + "silent": "Silent" + } + } + }, + "image": { + "last_motion_image": { + "name": "Last motion image" + } + }, + "button": { + "ptz_up": { + "name": "PTZ up" + }, + "ptz_down": { + "name": "PTZ down" + }, + "ptz_left": { + "name": "PTZ left" + }, + "ptz_right": { + "name": "PTZ right" + } + }, + "binary_sensor": { + "alarm_schedules_enabled": { + "name": "Alarm schedules enabled" + }, + "encrypted": { + "name": "Encryption" + } + }, + "sensor": { + "alarm_sound_mod": { + "name": "Alarm sound level" + }, + "last_alarm_time": { + "name": "Last alarm time" + }, + "seconds_last_trigger": { + "name": "Seconds since last trigger" + }, + "last_alarm_pic": { + "name": "Last alarm picture URL" + }, + "supported_channels": { + "name": "Supported channels" + }, + "local_ip": { + "name": "Local IP" + }, + "wan_ip": { + "name": "WAN IP" + }, + "pir_status": { + "name": "PIR status" + }, + "last_alarm_type_code": { + "name": "Last alarm type code" + }, + "last_alarm_type_name": { + "name": "Last alarm type name" + } + } + }, + "services": { + "alarm_sound": { + "name": "Set warning sound level.", + "description": "Setx movement warning sound level.", + "fields": { + "level": { + "name": "Sound level", + "description": "Sound level (2 is disabled, 1 intensive, 0 soft)." + } + } + }, + "ptz": { + "name": "PTZ", + "description": "Moves the camera to the direction, with defined speed.", + "fields": { + "direction": { + "name": "Direction", + "description": "Direction to move camera (up, down, left, right)." + }, + "speed": { + "name": "Speed", + "description": "Speed of movement (from 1 to 9)." + } + } + }, + "set_alarm_detection_sensibility": { + "name": "Detection sensitivity", + "description": "Sets the detection sensibility level.", + "fields": { + "level": { + "name": "Sensitivity level", + "description": "Sensibility level (1-6) for type 0 (Normal camera) or (1-100) for type 3 (PIR sensor camera)." + }, + "type_value": { + "name": "Detection type", + "description": "Type of detection. Options : 0 - Camera or 3 - PIR Sensor Camera." + } + } + }, + "sound_alarm": { + "name": "Sound alarm", + "description": "Sounds the alarm on your camera.", + "fields": { + "enable": { + "name": "Alarm sound", + "description": "Enter 1 or 2 (1=disable, 2=enable)." + } + } + }, + "wake_device": { + "name": "Wake camera", + "description": "This can be used to wake the camera/device from hibernation." } } } diff --git a/homeassistant/components/facebox/services.yaml b/homeassistant/components/facebox/services.yaml index 3f968cf385a..0438338f55e 100644 --- a/homeassistant/components/facebox/services.yaml +++ b/homeassistant/components/facebox/services.yaml @@ -1,24 +1,16 @@ teach_face: - name: Teach face - description: Teach facebox a face using a file. fields: entity_id: - name: Entity - description: The facebox entity to teach. selector: entity: integration: facebox domain: image_processing name: - name: Name - description: The name of the face to teach. required: true example: "my_name" selector: text: file_path: - name: File path - description: The path to the image file. required: true example: "/images/my_image.jpg" selector: diff --git a/homeassistant/components/facebox/strings.json b/homeassistant/components/facebox/strings.json new file mode 100644 index 00000000000..1869673b643 --- /dev/null +++ b/homeassistant/components/facebox/strings.json @@ -0,0 +1,22 @@ +{ + "services": { + "teach_face": { + "name": "Teach face", + "description": "Teaches facebox a face using a file.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "The facebox entity to teach." + }, + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "The name of the face to teach." + }, + "file_path": { + "name": "File path", + "description": "The path to the image file." + } + } + } + } +} diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index 52d5aca070a..8bd329ac8fe 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -1,29 +1,25 @@ # Describes the format for available fan services set_preset_mode: - name: Set preset mode - description: Set preset mode for a fan device. target: entity: domain: fan + supported_features: + - fan.FanEntityFeature.PRESET_MODE fields: preset_mode: - name: Preset mode - description: New value of preset mode. required: true example: "auto" selector: text: set_percentage: - name: Set speed percentage - description: Set fan speed percentage. target: entity: domain: fan + supported_features: + - fan.FanEntityFeature.SET_SPEED fields: percentage: - name: Percentage - description: Percentage speed setting. required: true selector: number: @@ -32,85 +28,74 @@ set_percentage: unit_of_measurement: "%" turn_on: - name: Turn on - description: Turn fan on. target: entity: domain: fan fields: percentage: - name: Percentage - description: Percentage speed setting. + filter: + supported_features: + - fan.FanEntityFeature.SET_SPEED selector: number: min: 0 max: 100 unit_of_measurement: "%" preset_mode: - name: Preset mode - description: Preset mode setting. example: "auto" + filter: + supported_features: + - fan.FanEntityFeature.PRESET_MODE selector: text: turn_off: - name: Turn off - description: Turn fan off. target: entity: domain: fan oscillate: - name: Oscillate - description: Oscillate the fan. target: entity: domain: fan + supported_features: + - fan.FanEntityFeature.OSCILLATE fields: oscillating: - name: Oscillating - description: Flag to turn on/off oscillation. required: true selector: boolean: toggle: - name: Toggle - description: Toggle the fan on/off. target: entity: domain: fan set_direction: - name: Set direction - description: Set the fan rotation. target: entity: domain: fan + supported_features: + - fan.FanEntityFeature.DIRECTION fields: direction: - name: Direction - description: The direction to rotate. required: true selector: select: options: - - label: "Forward" - value: "forward" - - label: "Reverse" - value: "reverse" - + - "forward" + - "reverse" + translation_key: direction increase_speed: - name: Increase speed - description: Increase the speed of the fan by one speed or a percentage_step. target: entity: domain: fan + supported_features: + - fan.FanEntityFeature.SET_SPEED fields: percentage_step: advanced: true required: false - description: Increase speed by a percentage. selector: number: min: 0 @@ -118,16 +103,15 @@ increase_speed: unit_of_measurement: "%" decrease_speed: - name: Decrease speed - description: Decrease the speed of the fan by one speed or a percentage_step. target: entity: domain: fan + supported_features: + - fan.FanEntityFeature.SET_SPEED fields: percentage_step: advanced: true required: false - description: Decrease speed by a percentage. selector: number: min: 0 diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index b69068d3d64..674dcc2b92e 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -2,18 +2,18 @@ "title": "Fan", "device_automation": { "condition_type": { - "is_on": "{entity_name} is on", - "is_off": "{entity_name} is off" + "is_on": "[%key:common::device_automation::condition_type::is_on%]", + "is_off": "[%key:common::device_automation::condition_type::is_off%]" }, "trigger_type": { - "changed_states": "{entity_name} turned on or off", - "turned_on": "{entity_name} turned on", - "turned_off": "{entity_name} turned off" + "changed_states": "[%key:common::device_automation::trigger_type::changed_states%]", + "turned_on": "[%key:common::device_automation::trigger_type::turned_on%]", + "turned_off": "[%key:common::device_automation::trigger_type::turned_off%]" }, "action_type": { - "toggle": "Toggle {entity_name}", - "turn_on": "Turn on {entity_name}", - "turn_off": "Turn off {entity_name}" + "toggle": "[%key:common::device_automation::action_type::toggle%]", + "turn_on": "[%key:common::device_automation::action_type::turn_on%]", + "turn_off": "[%key:common::device_automation::action_type::turn_off%]" } }, "entity_component": { @@ -53,10 +53,96 @@ } } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "set_preset_mode": { + "name": "Set preset mode", + "description": "Sets preset mode.", + "fields": { + "preset_mode": { + "name": "Preset mode", + "description": "Preset mode." + } + } + }, + "set_percentage": { + "name": "Set speed", + "description": "Sets the fan speed.", + "fields": { + "percentage": { + "name": "Percentage", + "description": "Speed of the fan." + } + } + }, + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Turns fan on.", + "fields": { + "percentage": { + "name": "[%key:component::fan::services::set_percentage::fields::percentage::name%]", + "description": "[%key:component::fan::services::set_percentage::fields::percentage::description%]" + }, + "preset_mode": { + "name": "[%key:component::fan::services::set_preset_mode::fields::preset_mode::name%]", + "description": "[%key:component::fan::services::set_preset_mode::fields::preset_mode::description%]" + } + } + }, + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Turns fan off." + }, + "oscillate": { + "name": "Oscillate", + "description": "Controls oscillatation of the fan.", + "fields": { + "oscillating": { + "name": "Oscillating", + "description": "Turn on/off oscillation." + } + } + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles the fan on/off." + }, + "set_direction": { + "name": "Set direction", + "description": "Sets the fan rotation direction.", + "fields": { + "direction": { + "name": "Direction", + "description": "Direction to rotate." + } + } + }, + "increase_speed": { + "name": "Increase speed", + "description": "Increases the speed of the fan.", + "fields": { + "percentage_step": { + "name": "Increment", + "description": "Increases the speed by a percentage step." + } + } + }, + "decrease_speed": { + "name": "Decrease speed", + "description": "Decreases the speed of the fan.", + "fields": { + "percentage_step": { + "name": "Decrement", + "description": "Decreases the speed by a percentage step." + } + } + } + }, + "selector": { + "direction": { + "options": { + "forward": "Forward", + "reverse": "Reverse" + } } } } diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index b3d5f66ae8c..b20b0213835 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -3,7 +3,11 @@ from __future__ import annotations from typing import Any -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.const import UnitOfDataRate from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -31,6 +35,7 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): _attr_name = "Fast.com Download" _attr_device_class = SensorDeviceClass.DATA_RATE _attr_native_unit_of_measurement = UnitOfDataRate.MEGABITS_PER_SECOND + _attr_state_class = SensorStateClass.MEASUREMENT _attr_icon = "mdi:speedometer" _attr_should_poll = False diff --git a/homeassistant/components/fastdotcom/services.yaml b/homeassistant/components/fastdotcom/services.yaml index 75963557a03..002b28b4e4d 100644 --- a/homeassistant/components/fastdotcom/services.yaml +++ b/homeassistant/components/fastdotcom/services.yaml @@ -1,3 +1 @@ speedtest: - name: Speed test - description: Immediately execute a speed test with Fast.com diff --git a/homeassistant/components/fastdotcom/strings.json b/homeassistant/components/fastdotcom/strings.json new file mode 100644 index 00000000000..b1e03681c96 --- /dev/null +++ b/homeassistant/components/fastdotcom/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "speedtest": { + "name": "Speed test", + "description": "Immediately executs a speed test with Fast.com." + } + } +} diff --git a/homeassistant/components/ffmpeg/services.yaml b/homeassistant/components/ffmpeg/services.yaml index 1fdde46e55c..35c11ee678f 100644 --- a/homeassistant/components/ffmpeg/services.yaml +++ b/homeassistant/components/ffmpeg/services.yaml @@ -1,32 +1,20 @@ restart: - name: Restart - description: Send a restart command to a ffmpeg based sensor. fields: entity_id: - name: Entity - description: Name of entity that will restart. Platform dependent. selector: entity: integration: ffmpeg domain: binary_sensor start: - name: Start - description: Send a start command to a ffmpeg based sensor. fields: entity_id: - name: Entity - description: Name of entity that will start. Platform dependent. selector: entity: integration: ffmpeg domain: binary_sensor stop: - name: Stop - description: Send a stop command to a ffmpeg based sensor. fields: entity_id: - name: Entity - description: Name of entity that will stop. Platform dependent. selector: entity: integration: ffmpeg diff --git a/homeassistant/components/ffmpeg/strings.json b/homeassistant/components/ffmpeg/strings.json new file mode 100644 index 00000000000..66c1f19de5b --- /dev/null +++ b/homeassistant/components/ffmpeg/strings.json @@ -0,0 +1,34 @@ +{ + "services": { + "restart": { + "name": "[%key:common::action::restart%]", + "description": "Sends a restart command to a ffmpeg based sensor.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name of entity that will restart. Platform dependent." + } + } + }, + "start": { + "name": "[%key:common::action::start%]", + "description": "Sends a start command to a ffmpeg based sensor.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name of entity that will start. Platform dependent." + } + } + }, + "stop": { + "name": "[%key:common::action::stop%]", + "description": "Sends a stop command to a ffmpeg based sensor.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name of entity that will stop. Platform dependent." + } + } + } + } +} diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index a1470baa4d2..c240d04ec1a 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -34,13 +34,21 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.core import HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.start import async_at_started -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import ( + ConfigType, + DiscoveryInfoType, + EventType, + StateType, +) from homeassistant.util.decorator import Registry import homeassistant.util.dt as dt_util @@ -217,10 +225,12 @@ class SensorFilter(SensorEntity): self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_id} @callback - def _update_filter_sensor_state_event(self, event: Event) -> None: + def _update_filter_sensor_state_event( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle device state changes.""" _LOGGER.debug("Update filter on event: %s", event) - self._update_filter_sensor_state(event.data.get("new_state")) + self._update_filter_sensor_state(event.data["new_state"]) @callback def _update_filter_sensor_state( diff --git a/homeassistant/components/filter/services.yaml b/homeassistant/components/filter/services.yaml index 431c73616ce..c983a105c93 100644 --- a/homeassistant/components/filter/services.yaml +++ b/homeassistant/components/filter/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload all filter entities diff --git a/homeassistant/components/filter/strings.json b/homeassistant/components/filter/strings.json new file mode 100644 index 00000000000..461eed9aefa --- /dev/null +++ b/homeassistant/components/filter/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads filters from the YAML-configuration." + } + } +} diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index d746e63ca52..1578359356d 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -1,18 +1,11 @@ """Constants for the Fitbit platform.""" from __future__ import annotations -from dataclasses import dataclass from typing import Final -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntityDescription, - SensorStateClass, -) from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, - PERCENTAGE, UnitOfLength, UnitOfMass, UnitOfTime, @@ -49,245 +42,6 @@ DEFAULT_CONFIG: Final[dict[str, str]] = { DEFAULT_CLOCK_FORMAT: Final = "24H" -@dataclass -class FitbitSensorEntityDescription(SensorEntityDescription): - """Describes Fitbit sensor entity.""" - - unit_type: str | None = None - - -FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( - FitbitSensorEntityDescription( - key="activities/activityCalories", - name="Activity Calories", - native_unit_of_measurement="cal", - icon="mdi:fire", - ), - FitbitSensorEntityDescription( - key="activities/calories", - name="Calories", - native_unit_of_measurement="cal", - icon="mdi:fire", - ), - FitbitSensorEntityDescription( - key="activities/caloriesBMR", - name="Calories BMR", - native_unit_of_measurement="cal", - icon="mdi:fire", - ), - FitbitSensorEntityDescription( - key="activities/distance", - name="Distance", - unit_type="distance", - icon="mdi:map-marker", - device_class=SensorDeviceClass.DISTANCE, - ), - FitbitSensorEntityDescription( - key="activities/elevation", - name="Elevation", - unit_type="elevation", - icon="mdi:walk", - device_class=SensorDeviceClass.DISTANCE, - ), - FitbitSensorEntityDescription( - key="activities/floors", - name="Floors", - native_unit_of_measurement="floors", - icon="mdi:walk", - ), - FitbitSensorEntityDescription( - key="activities/heart", - name="Resting Heart Rate", - native_unit_of_measurement="bpm", - icon="mdi:heart-pulse", - ), - FitbitSensorEntityDescription( - key="activities/minutesFairlyActive", - name="Minutes Fairly Active", - native_unit_of_measurement=UnitOfTime.MINUTES, - icon="mdi:walk", - device_class=SensorDeviceClass.DURATION, - ), - FitbitSensorEntityDescription( - key="activities/minutesLightlyActive", - name="Minutes Lightly Active", - native_unit_of_measurement=UnitOfTime.MINUTES, - icon="mdi:walk", - device_class=SensorDeviceClass.DURATION, - ), - FitbitSensorEntityDescription( - key="activities/minutesSedentary", - name="Minutes Sedentary", - native_unit_of_measurement=UnitOfTime.MINUTES, - icon="mdi:seat-recline-normal", - device_class=SensorDeviceClass.DURATION, - ), - FitbitSensorEntityDescription( - key="activities/minutesVeryActive", - name="Minutes Very Active", - native_unit_of_measurement=UnitOfTime.MINUTES, - icon="mdi:run", - device_class=SensorDeviceClass.DURATION, - ), - FitbitSensorEntityDescription( - key="activities/steps", - name="Steps", - native_unit_of_measurement="steps", - icon="mdi:walk", - ), - FitbitSensorEntityDescription( - key="activities/tracker/activityCalories", - name="Tracker Activity Calories", - native_unit_of_measurement="cal", - icon="mdi:fire", - ), - FitbitSensorEntityDescription( - key="activities/tracker/calories", - name="Tracker Calories", - native_unit_of_measurement="cal", - icon="mdi:fire", - ), - FitbitSensorEntityDescription( - key="activities/tracker/distance", - name="Tracker Distance", - unit_type="distance", - icon="mdi:map-marker", - device_class=SensorDeviceClass.DISTANCE, - ), - FitbitSensorEntityDescription( - key="activities/tracker/elevation", - name="Tracker Elevation", - unit_type="elevation", - icon="mdi:walk", - device_class=SensorDeviceClass.DISTANCE, - ), - FitbitSensorEntityDescription( - key="activities/tracker/floors", - name="Tracker Floors", - native_unit_of_measurement="floors", - icon="mdi:walk", - ), - FitbitSensorEntityDescription( - key="activities/tracker/minutesFairlyActive", - name="Tracker Minutes Fairly Active", - native_unit_of_measurement=UnitOfTime.MINUTES, - icon="mdi:walk", - device_class=SensorDeviceClass.DURATION, - ), - FitbitSensorEntityDescription( - key="activities/tracker/minutesLightlyActive", - name="Tracker Minutes Lightly Active", - native_unit_of_measurement=UnitOfTime.MINUTES, - icon="mdi:walk", - device_class=SensorDeviceClass.DURATION, - ), - FitbitSensorEntityDescription( - key="activities/tracker/minutesSedentary", - name="Tracker Minutes Sedentary", - native_unit_of_measurement=UnitOfTime.MINUTES, - icon="mdi:seat-recline-normal", - device_class=SensorDeviceClass.DURATION, - ), - FitbitSensorEntityDescription( - key="activities/tracker/minutesVeryActive", - name="Tracker Minutes Very Active", - native_unit_of_measurement=UnitOfTime.MINUTES, - icon="mdi:run", - device_class=SensorDeviceClass.DURATION, - ), - FitbitSensorEntityDescription( - key="activities/tracker/steps", - name="Tracker Steps", - native_unit_of_measurement="steps", - icon="mdi:walk", - ), - FitbitSensorEntityDescription( - key="body/bmi", - name="BMI", - native_unit_of_measurement="BMI", - icon="mdi:human", - state_class=SensorStateClass.MEASUREMENT, - ), - FitbitSensorEntityDescription( - key="body/fat", - name="Body Fat", - native_unit_of_measurement=PERCENTAGE, - icon="mdi:human", - state_class=SensorStateClass.MEASUREMENT, - ), - FitbitSensorEntityDescription( - key="body/weight", - name="Weight", - unit_type="weight", - icon="mdi:human", - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.WEIGHT, - ), - FitbitSensorEntityDescription( - key="sleep/awakeningsCount", - name="Awakenings Count", - native_unit_of_measurement="times awaken", - icon="mdi:sleep", - ), - FitbitSensorEntityDescription( - key="sleep/efficiency", - name="Sleep Efficiency", - native_unit_of_measurement=PERCENTAGE, - icon="mdi:sleep", - state_class=SensorStateClass.MEASUREMENT, - ), - FitbitSensorEntityDescription( - key="sleep/minutesAfterWakeup", - name="Minutes After Wakeup", - native_unit_of_measurement=UnitOfTime.MINUTES, - icon="mdi:sleep", - device_class=SensorDeviceClass.DURATION, - ), - FitbitSensorEntityDescription( - key="sleep/minutesAsleep", - name="Sleep Minutes Asleep", - native_unit_of_measurement=UnitOfTime.MINUTES, - icon="mdi:sleep", - device_class=SensorDeviceClass.DURATION, - ), - FitbitSensorEntityDescription( - key="sleep/minutesAwake", - name="Sleep Minutes Awake", - native_unit_of_measurement=UnitOfTime.MINUTES, - icon="mdi:sleep", - device_class=SensorDeviceClass.DURATION, - ), - FitbitSensorEntityDescription( - key="sleep/minutesToFallAsleep", - name="Sleep Minutes to Fall Asleep", - native_unit_of_measurement=UnitOfTime.MINUTES, - icon="mdi:sleep", - device_class=SensorDeviceClass.DURATION, - ), - FitbitSensorEntityDescription( - key="sleep/startTime", - name="Sleep Start Time", - icon="mdi:clock", - ), - FitbitSensorEntityDescription( - key="sleep/timeInBed", - name="Sleep Time in Bed", - native_unit_of_measurement=UnitOfTime.MINUTES, - icon="mdi:hotel", - device_class=SensorDeviceClass.DURATION, - ), -) - -FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( - key="devices/battery", - name="Battery", - icon="mdi:battery", -) - -FITBIT_RESOURCES_KEYS: Final[list[str]] = [ - desc.key for desc in (*FITBIT_RESOURCES_LIST, FITBIT_RESOURCE_BATTERY) -] - FITBIT_MEASUREMENTS: Final[dict[str, dict[str, str]]] = { "en_US": { ATTR_DURATION: UnitOfTime.MILLISECONDS, diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 11946c42173..6c93fbe35c1 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -1,6 +1,7 @@ """Support for the Fitbit API.""" from __future__ import annotations +from dataclasses import dataclass import datetime import logging import os @@ -17,9 +18,18 @@ from homeassistant.components import configurator from homeassistant.components.http import HomeAssistantView from homeassistant.components.sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, + SensorDeviceClass, SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_UNIT_SYSTEM, + PERCENTAGE, + UnitOfTime, ) -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_UNIT_SYSTEM from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -45,10 +55,6 @@ from .const import ( FITBIT_CONFIG_FILE, FITBIT_DEFAULT_RESOURCES, FITBIT_MEASUREMENTS, - FITBIT_RESOURCE_BATTERY, - FITBIT_RESOURCES_KEYS, - FITBIT_RESOURCES_LIST, - FitbitSensorEntityDescription, ) _LOGGER: Final = logging.getLogger(__name__) @@ -57,6 +63,246 @@ _CONFIGURING: dict[str, str] = {} SCAN_INTERVAL: Final = datetime.timedelta(minutes=30) + +@dataclass +class FitbitSensorEntityDescription(SensorEntityDescription): + """Describes Fitbit sensor entity.""" + + unit_type: str | None = None + + +FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( + FitbitSensorEntityDescription( + key="activities/activityCalories", + name="Activity Calories", + native_unit_of_measurement="cal", + icon="mdi:fire", + ), + FitbitSensorEntityDescription( + key="activities/calories", + name="Calories", + native_unit_of_measurement="cal", + icon="mdi:fire", + ), + FitbitSensorEntityDescription( + key="activities/caloriesBMR", + name="Calories BMR", + native_unit_of_measurement="cal", + icon="mdi:fire", + ), + FitbitSensorEntityDescription( + key="activities/distance", + name="Distance", + unit_type="distance", + icon="mdi:map-marker", + device_class=SensorDeviceClass.DISTANCE, + ), + FitbitSensorEntityDescription( + key="activities/elevation", + name="Elevation", + unit_type="elevation", + icon="mdi:walk", + device_class=SensorDeviceClass.DISTANCE, + ), + FitbitSensorEntityDescription( + key="activities/floors", + name="Floors", + native_unit_of_measurement="floors", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/heart", + name="Resting Heart Rate", + native_unit_of_measurement="bpm", + icon="mdi:heart-pulse", + ), + FitbitSensorEntityDescription( + key="activities/minutesFairlyActive", + name="Minutes Fairly Active", + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:walk", + device_class=SensorDeviceClass.DURATION, + ), + FitbitSensorEntityDescription( + key="activities/minutesLightlyActive", + name="Minutes Lightly Active", + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:walk", + device_class=SensorDeviceClass.DURATION, + ), + FitbitSensorEntityDescription( + key="activities/minutesSedentary", + name="Minutes Sedentary", + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:seat-recline-normal", + device_class=SensorDeviceClass.DURATION, + ), + FitbitSensorEntityDescription( + key="activities/minutesVeryActive", + name="Minutes Very Active", + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:run", + device_class=SensorDeviceClass.DURATION, + ), + FitbitSensorEntityDescription( + key="activities/steps", + name="Steps", + native_unit_of_measurement="steps", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/activityCalories", + name="Tracker Activity Calories", + native_unit_of_measurement="cal", + icon="mdi:fire", + ), + FitbitSensorEntityDescription( + key="activities/tracker/calories", + name="Tracker Calories", + native_unit_of_measurement="cal", + icon="mdi:fire", + ), + FitbitSensorEntityDescription( + key="activities/tracker/distance", + name="Tracker Distance", + unit_type="distance", + icon="mdi:map-marker", + device_class=SensorDeviceClass.DISTANCE, + ), + FitbitSensorEntityDescription( + key="activities/tracker/elevation", + name="Tracker Elevation", + unit_type="elevation", + icon="mdi:walk", + device_class=SensorDeviceClass.DISTANCE, + ), + FitbitSensorEntityDescription( + key="activities/tracker/floors", + name="Tracker Floors", + native_unit_of_measurement="floors", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesFairlyActive", + name="Tracker Minutes Fairly Active", + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:walk", + device_class=SensorDeviceClass.DURATION, + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesLightlyActive", + name="Tracker Minutes Lightly Active", + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:walk", + device_class=SensorDeviceClass.DURATION, + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesSedentary", + name="Tracker Minutes Sedentary", + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:seat-recline-normal", + device_class=SensorDeviceClass.DURATION, + ), + FitbitSensorEntityDescription( + key="activities/tracker/minutesVeryActive", + name="Tracker Minutes Very Active", + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:run", + device_class=SensorDeviceClass.DURATION, + ), + FitbitSensorEntityDescription( + key="activities/tracker/steps", + name="Tracker Steps", + native_unit_of_measurement="steps", + icon="mdi:walk", + ), + FitbitSensorEntityDescription( + key="body/bmi", + name="BMI", + native_unit_of_measurement="BMI", + icon="mdi:human", + state_class=SensorStateClass.MEASUREMENT, + ), + FitbitSensorEntityDescription( + key="body/fat", + name="Body Fat", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:human", + state_class=SensorStateClass.MEASUREMENT, + ), + FitbitSensorEntityDescription( + key="body/weight", + name="Weight", + unit_type="weight", + icon="mdi:human", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.WEIGHT, + ), + FitbitSensorEntityDescription( + key="sleep/awakeningsCount", + name="Awakenings Count", + native_unit_of_measurement="times awaken", + icon="mdi:sleep", + ), + FitbitSensorEntityDescription( + key="sleep/efficiency", + name="Sleep Efficiency", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:sleep", + state_class=SensorStateClass.MEASUREMENT, + ), + FitbitSensorEntityDescription( + key="sleep/minutesAfterWakeup", + name="Minutes After Wakeup", + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:sleep", + device_class=SensorDeviceClass.DURATION, + ), + FitbitSensorEntityDescription( + key="sleep/minutesAsleep", + name="Sleep Minutes Asleep", + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:sleep", + device_class=SensorDeviceClass.DURATION, + ), + FitbitSensorEntityDescription( + key="sleep/minutesAwake", + name="Sleep Minutes Awake", + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:sleep", + device_class=SensorDeviceClass.DURATION, + ), + FitbitSensorEntityDescription( + key="sleep/minutesToFallAsleep", + name="Sleep Minutes to Fall Asleep", + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:sleep", + device_class=SensorDeviceClass.DURATION, + ), + FitbitSensorEntityDescription( + key="sleep/startTime", + name="Sleep Start Time", + icon="mdi:clock", + ), + FitbitSensorEntityDescription( + key="sleep/timeInBed", + name="Sleep Time in Bed", + native_unit_of_measurement=UnitOfTime.MINUTES, + icon="mdi:hotel", + device_class=SensorDeviceClass.DURATION, + ), +) + +FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( + key="devices/battery", + name="Battery", + icon="mdi:battery", +) + +FITBIT_RESOURCES_KEYS: Final[list[str]] = [ + desc.key for desc in (*FITBIT_RESOURCES_LIST, FITBIT_RESOURCE_BATTERY) +] + PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend( { vol.Optional( diff --git a/homeassistant/components/flick_electric/manifest.json b/homeassistant/components/flick_electric/manifest.json index a7db00b8f17..0b1f2677d6a 100644 --- a/homeassistant/components/flick_electric/manifest.json +++ b/homeassistant/components/flick_electric/manifest.json @@ -3,7 +3,7 @@ "name": "Flick Electric", "codeowners": ["@ZephireNZ"], "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/flick_electric/", + "documentation": "https://www.home-assistant.io/integrations/flick_electric", "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyflick"], diff --git a/homeassistant/components/flipr/binary_sensor.py b/homeassistant/components/flipr/binary_sensor.py index 76385167d38..0597145c2da 100644 --- a/homeassistant/components/flipr/binary_sensor.py +++ b/homeassistant/components/flipr/binary_sensor.py @@ -47,7 +47,7 @@ class FliprBinarySensor(FliprEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if the binary sensor is on in case of a Problem is detected.""" - return ( - self.coordinator.data[self.entity_description.key] == "TooLow" - or self.coordinator.data[self.entity_description.key] == "TooHigh" + return self.coordinator.data[self.entity_description.key] in ( + "TooLow", + "TooHigh", ) diff --git a/homeassistant/components/flo/services.yaml b/homeassistant/components/flo/services.yaml index a074ebafe99..ce4abacb64c 100644 --- a/homeassistant/components/flo/services.yaml +++ b/homeassistant/components/flo/services.yaml @@ -1,16 +1,12 @@ # Describes the format for available Flo services set_sleep_mode: - name: Set sleep mode - description: Set the location into sleep mode. target: entity: integration: flo domain: switch fields: sleep_minutes: - name: Sleep minutes - description: The time to sleep in minutes. default: true selector: select: @@ -19,8 +15,6 @@ set_sleep_mode: - "1440" - "4320" revert_to_mode: - name: Revert to mode - description: The mode to revert to after sleep_minutes has elapsed. default: true selector: select: @@ -28,22 +22,16 @@ set_sleep_mode: - "away" - "home" set_away_mode: - name: Set away mode - description: Set the location into away mode. target: entity: integration: flo domain: switch set_home_mode: - name: Set home mode - description: Set the location into home mode. target: entity: integration: flo domain: switch run_health_test: - name: Run health test - description: Have the Flo device run a health test. target: entity: integration: flo diff --git a/homeassistant/components/flo/strings.json b/homeassistant/components/flo/strings.json index fadfc304fce..627f562be7e 100644 --- a/homeassistant/components/flo/strings.json +++ b/homeassistant/components/flo/strings.json @@ -49,5 +49,33 @@ "name": "Shutoff valve" } } + }, + "services": { + "set_sleep_mode": { + "name": "Set sleep mode", + "description": "Sets the location into sleep mode.", + "fields": { + "sleep_minutes": { + "name": "Sleep minutes", + "description": "The time to sleep in minutes." + }, + "revert_to_mode": { + "name": "Revert to mode", + "description": "The mode to revert to after sleep_minutes has elapsed." + } + } + }, + "set_away_mode": { + "name": "Set away mode", + "description": "Sets the location into away mode." + }, + "set_home_mode": { + "name": "Set home mode", + "description": "Sets the location into home mode." + }, + "run_health_test": { + "name": "Run health test", + "description": "Have the Flo device run a health test." + } } } diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py index 453e259bf46..c912c3419d7 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -25,6 +25,7 @@ from .const import ( KEY_DEVICE_TYPE, NOTIFICATION_HIGH_FLOW, NOTIFICATION_LEAK_DETECTED, + NOTIFICATION_LOW_BATTERY, ) from .coordinator import ( FlumeDeviceConnectionUpdateCoordinator, @@ -67,6 +68,12 @@ FLUME_BINARY_NOTIFICATION_SENSORS: tuple[FlumeBinarySensorEntityDescription, ... event_rule=NOTIFICATION_HIGH_FLOW, icon="mdi:waves", ), + FlumeBinarySensorEntityDescription( + key="low_battery", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.BATTERY, + event_rule=NOTIFICATION_LOW_BATTERY, + ), ) diff --git a/homeassistant/components/flume/const.py b/homeassistant/components/flume/const.py index 1889cca8fa5..9e932cce4dd 100644 --- a/homeassistant/components/flume/const.py +++ b/homeassistant/components/flume/const.py @@ -47,3 +47,4 @@ NOTIFICATION_BRIDGE_DISCONNECT = "Bridge Disconnection" BRIDGE_NOTIFICATION_KEY = "connected" BRIDGE_NOTIFICATION_RULE = "Bridge Disconnection" NOTIFICATION_LEAK_DETECTED = "Flume Smart Leak Alert" +NOTIFICATION_LOW_BATTERY = "Low Battery" diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json index 17a2b0b53be..953d9791f2f 100644 --- a/homeassistant/components/flume/manifest.json +++ b/homeassistant/components/flume/manifest.json @@ -8,7 +8,7 @@ "hostname": "flume-gw-*" } ], - "documentation": "https://www.home-assistant.io/integrations/flume/", + "documentation": "https://www.home-assistant.io/integrations/flume", "iot_class": "cloud_polling", "loggers": ["pyflume"], "requirements": ["PyFlume==0.6.5"] diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index 13f7ba36bcd..689f984722d 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -42,6 +42,9 @@ { "hostname": "zengge_[0-9a-f][0-9a-f]_*" }, + { + "hostname": "zengge" + }, { "macaddress": "C82E47*", "hostname": "sta*" @@ -51,5 +54,5 @@ "iot_class": "local_push", "loggers": ["flux_led"], "quality_scale": "platinum", - "requirements": ["flux-led==0.28.37"] + "requirements": ["flux-led==1.0.1"] } diff --git a/homeassistant/components/flux_led/services.yaml b/homeassistant/components/flux_led/services.yaml index b17d81f9174..73f479825da 100644 --- a/homeassistant/components/flux_led/services.yaml +++ b/homeassistant/components/flux_led/services.yaml @@ -1,12 +1,10 @@ set_custom_effect: - description: Set a custom light effect. target: entity: integration: flux_led domain: light fields: colors: - description: List of colors for the custom effect (RGB). (Max 16 Colors) example: | - [255,0,0] - [0,255,0] @@ -15,7 +13,6 @@ set_custom_effect: selector: object: speed_pct: - description: Effect speed for the custom effect (0-100). example: 80 default: 50 required: false @@ -26,7 +23,6 @@ set_custom_effect: max: 100 unit_of_measurement: "%" transition: - description: Effect transition. example: "jump" default: "gradual" required: false @@ -37,14 +33,12 @@ set_custom_effect: - "jump" - "strobe" set_zones: - description: Set strip zones for Addressable v3 controllers (0xA3). target: entity: integration: flux_led domain: light fields: colors: - description: List of colors for each zone (RGB). The length of each zone is the number of pixels per segment divided by the number of colors. (Max 2048 Colors) example: | - [255,0,0] - [0,255,0] @@ -54,7 +48,6 @@ set_zones: selector: object: speed_pct: - description: Effect speed for the custom effect (0-100) example: 80 default: 50 required: false @@ -65,7 +58,6 @@ set_zones: max: 100 unit_of_measurement: "%" effect: - description: Effect example: "running_water" default: "static" required: false @@ -78,14 +70,12 @@ set_zones: - "jump" - "breathing" set_music_mode: - description: Configure music mode on Controller RGB with MIC (0x08), Addressable v2 (0xA2), and Addressable v3 (0xA3) devices that have a built-in microphone. target: entity: integration: flux_led domain: light fields: sensitivity: - description: Microphone sensitivity (0-100) example: 80 default: 100 required: false @@ -96,7 +86,6 @@ set_music_mode: max: 100 unit_of_measurement: "%" brightness: - description: Light brightness (0-100) example: 80 default: 100 required: false @@ -107,13 +96,11 @@ set_music_mode: max: 100 unit_of_measurement: "%" light_screen: - description: Light screen mode for 2 dimensional pixels (Addressable models only) default: false required: false selector: boolean: effect: - description: Effect (1-16 on Addressable models, 0-3 on RGB with MIC models) example: 1 default: 1 required: false @@ -123,13 +110,11 @@ set_music_mode: step: 1 max: 16 foreground_color: - description: The foreground RGB color example: "[255, 100, 100]" required: false selector: object: background_color: - description: The background RGB color (Addressable models only) example: "[255, 100, 100]" required: false selector: diff --git a/homeassistant/components/flux_led/strings.json b/homeassistant/components/flux_led/strings.json index 51edd207e95..d1d812cb210 100644 --- a/homeassistant/components/flux_led/strings.json +++ b/homeassistant/components/flux_led/strings.json @@ -89,5 +89,73 @@ "name": "Music" } } + }, + "services": { + "set_custom_effect": { + "name": "Set custom effect", + "description": "Sets a custom light effect.", + "fields": { + "colors": { + "name": "Colors", + "description": "List of colors for the custom effect (RGB). (Max 16 Colors)." + }, + "speed_pct": { + "name": "Speed", + "description": "Effect speed for the custom effect (0-100)." + }, + "transition": { + "name": "Transition", + "description": "Effect transition." + } + } + }, + "set_zones": { + "name": "Set zones", + "description": "Sets strip zones for Addressable v3 controllers (0xA3).", + "fields": { + "colors": { + "name": "[%key:component::flux_led::services::set_custom_effect::fields::colors::name%]", + "description": "List of colors for each zone (RGB). The length of each zone is the number of pixels per segment divided by the number of colors. (Max 2048 Colors)." + }, + "speed_pct": { + "name": "Speed", + "description": "[%key:component::flux_led::services::set_custom_effect::fields::speed_pct::description%]" + }, + "effect": { + "name": "Effect", + "description": "Effect." + } + } + }, + "set_music_mode": { + "name": "Set music mode", + "description": "Configures music mode on Controller RGB with MIC (0x08), Addressable v2 (0xA2), and Addressable v3 (0xA3) devices that have a built-in microphone.", + "fields": { + "sensitivity": { + "name": "Sensitivity", + "description": "Microphone sensitivity (0-100)." + }, + "brightness": { + "name": "Brightness", + "description": "Light brightness (0-100)." + }, + "light_screen": { + "name": "Light screen", + "description": "Light screen mode for 2 dimensional pixels (Addressable models only)." + }, + "effect": { + "name": "Effect", + "description": "Effect (1-16 on Addressable models, 0-3 on RGB with MIC models)." + }, + "foreground_color": { + "name": "Foreground color", + "description": "The foreground RGB color." + }, + "background_color": { + "name": "Background color", + "description": "The background RGB color (Addressable models only)." + } + } + } } } diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 2858bff098e..1b511f03eda 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -38,7 +38,7 @@ class ForecastSolarSensorEntityDescription(SensorEntityDescription): SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( ForecastSolarSensorEntityDescription( key="energy_production_today", - name="Estimated energy production - today", + translation_key="energy_production_today", state=lambda estimate: estimate.energy_production_today, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -47,7 +47,7 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( ), ForecastSolarSensorEntityDescription( key="energy_production_today_remaining", - name="Estimated energy production - remaining today", + translation_key="energy_production_today_remaining", state=lambda estimate: estimate.energy_production_today_remaining, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -56,7 +56,7 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( ), ForecastSolarSensorEntityDescription( key="energy_production_tomorrow", - name="Estimated energy production - tomorrow", + translation_key="energy_production_tomorrow", state=lambda estimate: estimate.energy_production_tomorrow, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -65,17 +65,17 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( ), ForecastSolarSensorEntityDescription( key="power_highest_peak_time_today", - name="Highest power peak time - today", + translation_key="power_highest_peak_time_today", device_class=SensorDeviceClass.TIMESTAMP, ), ForecastSolarSensorEntityDescription( key="power_highest_peak_time_tomorrow", - name="Highest power peak time - tomorrow", + translation_key="power_highest_peak_time_tomorrow", device_class=SensorDeviceClass.TIMESTAMP, ), ForecastSolarSensorEntityDescription( key="power_production_now", - name="Estimated power production - now", + translation_key="power_production_now", device_class=SensorDeviceClass.POWER, state=lambda estimate: estimate.power_production_now, state_class=SensorStateClass.MEASUREMENT, @@ -83,37 +83,37 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( ), ForecastSolarSensorEntityDescription( key="power_production_next_hour", + translation_key="power_production_next_hour", state=lambda estimate: estimate.power_production_at_time( estimate.now() + timedelta(hours=1) ), - name="Estimated power production - next hour", device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfPower.WATT, ), ForecastSolarSensorEntityDescription( key="power_production_next_12hours", + translation_key="power_production_next_12hours", state=lambda estimate: estimate.power_production_at_time( estimate.now() + timedelta(hours=12) ), - name="Estimated power production - next 12 hours", device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfPower.WATT, ), ForecastSolarSensorEntityDescription( key="power_production_next_24hours", + translation_key="power_production_next_24hours", state=lambda estimate: estimate.power_production_at_time( estimate.now() + timedelta(hours=24) ), - name="Estimated power production - next 24 hours", device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfPower.WATT, ), ForecastSolarSensorEntityDescription( key="energy_current_hour", - name="Estimated energy production - this hour", + translation_key="energy_current_hour", state=lambda estimate: estimate.energy_current_hour, device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -122,8 +122,8 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( ), ForecastSolarSensorEntityDescription( key="energy_next_hour", + translation_key="energy_next_hour", state=lambda estimate: estimate.sum_energy_production(1), - name="Estimated energy production - next hour", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, diff --git a/homeassistant/components/forecast_solar/strings.json b/homeassistant/components/forecast_solar/strings.json index a7bc0190f5f..43e6fca4ada 100644 --- a/homeassistant/components/forecast_solar/strings.json +++ b/homeassistant/components/forecast_solar/strings.json @@ -8,7 +8,7 @@ "declination": "Declination (0 = Horizontal, 90 = Vertical)", "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]", - "modules power": "Total Watt peak power of your solar modules", + "modules_power": "Total Watt peak power of your solar modules", "name": "[%key:common::config_flow::data::name%]" } } @@ -23,13 +23,50 @@ "description": "These values allow tweaking the Forecast.Solar result. Please refer to the documentation if a field is unclear.", "data": { "api_key": "Forecast.Solar API Key (optional)", - "azimuth": "Azimuth (360 degrees, 0 = North, 90 = East, 180 = South, 270 = West)", + "azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]", "damping": "Damping factor: adjusts the results in the morning and evening", "inverter_size": "Inverter size (Watt)", - "declination": "Declination (0 = Horizontal, 90 = Vertical)", - "modules power": "Total Watt peak power of your solar modules" + "declination": "[%key:component::forecast_solar::config::step::user::data::declination%]", + "modules power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]" } } } + }, + "entity": { + "sensor": { + "energy_production_today": { + "name": "Estimated energy production - today" + }, + "energy_production_today_remaining": { + "name": "Estimated energy production - remaining today" + }, + "energy_production_tomorrow": { + "name": "Estimated energy production - tomorrow" + }, + "power_highest_peak_time_today": { + "name": "Highest power peak time - today" + }, + "power_highest_peak_time_tomorrow": { + "name": "Highest power peak time - tomorrow" + }, + "power_production_now": { + "name": "Estimated power production - now" + }, + "power_production_next_hour": { + "name": "Estimated power production - next hour" + }, + "power_production_next_12hours": { + "name": "Estimated power production - next 12 hours" + }, + "power_production_next_24hours": { + "name": "Estimated power production - next 24 hours" + }, + "energy_current_hour": { + "name": "Estimated energy production - this hour" + }, + "energy_next_hour": { + "name": "Estimated energy production - next hour" + } + } } } diff --git a/homeassistant/components/forked_daapd/const.py b/homeassistant/components/forked_daapd/const.py index 69438dc17f1..686a9dbbde9 100644 --- a/homeassistant/components/forked_daapd/const.py +++ b/homeassistant/components/forked_daapd/const.py @@ -82,6 +82,8 @@ SUPPORTED_FEATURES = ( | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.MEDIA_ANNOUNCE + | MediaPlayerEntityFeature.MEDIA_ENQUEUE ) SUPPORTED_FEATURES_ZONE = ( MediaPlayerEntityFeature.VOLUME_SET diff --git a/homeassistant/components/fortios/manifest.json b/homeassistant/components/fortios/manifest.json index a161d48398f..93e55071178 100644 --- a/homeassistant/components/fortios/manifest.json +++ b/homeassistant/components/fortios/manifest.json @@ -2,7 +2,7 @@ "domain": "fortios", "name": "FortiOS", "codeowners": ["@kimfrellsen"], - "documentation": "https://www.home-assistant.io/integrations/fortios/", + "documentation": "https://www.home-assistant.io/integrations/fortios", "iot_class": "local_polling", "loggers": ["fortiosapi", "paramiko"], "requirements": ["fortiosapi==1.0.5"] diff --git a/homeassistant/components/foscam/services.yaml b/homeassistant/components/foscam/services.yaml index a7e5394802b..ad46ec130d0 100644 --- a/homeassistant/components/foscam/services.yaml +++ b/homeassistant/components/foscam/services.yaml @@ -1,13 +1,10 @@ ptz: - name: PTZ - description: Pan/Tilt service for Foscam camera. target: entity: integration: foscam domain: camera fields: movement: - description: "Direction of the movement." required: true selector: select: @@ -21,7 +18,6 @@ ptz: - "top_right" - "up" travel_time: - description: "Travel time in seconds." default: 0.125 selector: number: @@ -31,15 +27,12 @@ ptz: unit_of_measurement: seconds ptz_preset: - name: PTZ preset - description: PTZ Preset service for Foscam camera. target: entity: integration: foscam domain: camera fields: preset_name: - description: "The name of the preset to move to. Presets can be created from within the official Foscam apps." required: true example: "TopMost" selector: diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 14aa88b7952..35964ee4546 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -21,5 +21,31 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "services": { + "ptz": { + "name": "PTZ", + "description": "Pan/Tilt service for Foscam camera.", + "fields": { + "movement": { + "name": "Movement", + "description": "Direction of the movement." + }, + "travel_time": { + "name": "Travel time", + "description": "Travel time in seconds." + } + } + }, + "ptz_preset": { + "name": "PTZ preset", + "description": "PTZ Preset service for Foscam camera.", + "fields": { + "preset_name": { + "name": "Preset name", + "description": "The name of the preset to move to. Presets can be created from within the official Foscam apps." + } + } + } } } diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py new file mode 100644 index 00000000000..aabd07366b4 --- /dev/null +++ b/homeassistant/components/freebox/binary_sensor.py @@ -0,0 +1,100 @@ +"""Support for Freebox devices (Freebox v6 and Freebox mini 4K).""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + EntityCategory, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .router import FreeboxRouter + +_LOGGER = logging.getLogger(__name__) + +RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="raid_degraded", + name="degraded", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the binary sensors.""" + router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id] + + _LOGGER.debug("%s - %s - %s raid(s)", router.name, router.mac, len(router.raids)) + + binary_entities = [ + FreeboxRaidDegradedSensor(router, raid, description) + for raid in router.raids.values() + for description in RAID_SENSORS + ] + + if binary_entities: + async_add_entities(binary_entities, True) + + +class FreeboxRaidDegradedSensor(BinarySensorEntity): + """Representation of a Freebox raid sensor.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + router: FreeboxRouter, + raid: dict[str, Any], + description: BinarySensorEntityDescription, + ) -> None: + """Initialize a Freebox raid degraded sensor.""" + self.entity_description = description + self._router = router + self._attr_device_info = router.device_info + self._raid = raid + self._attr_name = f"Raid array {raid['id']} {description.name}" + self._attr_unique_id = ( + f"{router.mac} {description.key} {raid['name']} {raid['id']}" + ) + + @callback + def async_update_state(self) -> None: + """Update the Freebox Raid sensor.""" + self._raid = self._router.raids[self._raid["id"]] + + @property + def is_on(self) -> bool: + """Return true if degraded.""" + return self._raid["degraded"] + + @callback + def async_on_demand_update(self): + """Update state.""" + self.async_update_state() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register state update callback.""" + self.async_update_state() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._router.signal_sensor_update, + self.async_on_demand_update, + ) + ) diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 767cb94de48..5a7c7863b4e 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -20,6 +20,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, + Platform.BINARY_SENSOR, Platform.SWITCH, Platform.CAMERA, ] diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 5622da48e67..4a9c22847ae 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -72,6 +72,7 @@ class FreeboxRouter: self.devices: dict[str, dict[str, Any]] = {} self.disks: dict[int, dict[str, Any]] = {} + self.raids: dict[int, dict[str, Any]] = {} self.sensors_temperature: dict[str, int] = {} self.sensors_connection: dict[str, float] = {} self.call_list: list[dict[str, Any]] = [] @@ -145,6 +146,8 @@ class FreeboxRouter: await self._update_disks_sensors() + await self._update_raids_sensors() + async_dispatcher_send(self.hass, self.signal_sensor_update) async def _update_disks_sensors(self) -> None: @@ -155,6 +158,14 @@ class FreeboxRouter: for fbx_disk in fbx_disks: self.disks[fbx_disk["id"]] = fbx_disk + async def _update_raids_sensors(self) -> None: + """Update Freebox raids.""" + # None at first request + fbx_raids: list[dict[str, Any]] = await self._api.storage.get_raids() or [] + + for fbx_raid in fbx_raids: + self.raids[fbx_raid["id"]] = fbx_raid + async def update_home_devices(self) -> None: """Update Home devices (alarm, light, sensor, switch, remote ...).""" if not self.home_granted: diff --git a/homeassistant/components/freebox/services.yaml b/homeassistant/components/freebox/services.yaml index 7b2a4059434..8ba6f278bfa 100644 --- a/homeassistant/components/freebox/services.yaml +++ b/homeassistant/components/freebox/services.yaml @@ -1,5 +1,3 @@ # Freebox service entries description. reboot: - name: Reboot - description: Reboots the Freebox. diff --git a/homeassistant/components/freebox/strings.json b/homeassistant/components/freebox/strings.json index 53a5fd59de3..5c4143b4562 100644 --- a/homeassistant/components/freebox/strings.json +++ b/homeassistant/components/freebox/strings.json @@ -20,5 +20,11 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "services": { + "reboot": { + "name": "Reboot", + "description": "Reboots the Freebox." + } } } diff --git a/homeassistant/components/freedompro/switch.py b/homeassistant/components/freedompro/switch.py index 97f0a968cff..7313be1920c 100644 --- a/homeassistant/components/freedompro/switch.py +++ b/homeassistant/components/freedompro/switch.py @@ -26,7 +26,7 @@ async def async_setup_entry( async_add_entities( Device(hass, api_key, device, coordinator) for device in coordinator.data - if device["type"] == "switch" or device["type"] == "outlet" + if device["type"] in ("switch", "outlet") ) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 26b336208fe..cdea8ebee54 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -6,6 +6,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta from functools import partial import logging +import re from types import MappingProxyType from typing import Any, TypedDict, cast @@ -129,13 +130,34 @@ class Interface(TypedDict): type: str -class HostInfo(TypedDict): - """FRITZ!Box host info class.""" - - mac: str - name: str - ip: str - status: bool +HostAttributes = TypedDict( + "HostAttributes", + { + "Index": int, + "IPAddress": str, + "MACAddress": str, + "Active": bool, + "HostName": str, + "InterfaceType": str, + "X_AVM-DE_Port": int, + "X_AVM-DE_Speed": int, + "X_AVM-DE_UpdateAvailable": bool, + "X_AVM-DE_UpdateSuccessful": str, + "X_AVM-DE_InfoURL": str | None, + "X_AVM-DE_MACAddressList": str | None, + "X_AVM-DE_Model": str | None, + "X_AVM-DE_URL": str | None, + "X_AVM-DE_Guest": bool, + "X_AVM-DE_RequestClient": str, + "X_AVM-DE_VPN": bool, + "X_AVM-DE_WANAccess": str, + "X_AVM-DE_Disallow": bool, + "X_AVM-DE_IsMeshable": str, + "X_AVM-DE_Priority": str, + "X_AVM-DE_FriendlyName": str, + "X_AVM-DE_FriendlyNameIsWriteable": str, + }, +) class UpdateCoordinatorDataType(TypedDict): @@ -238,7 +260,12 @@ class FritzBoxTools( self._unique_id = info.serial_number self._model = info.model_name - self._current_firmware = info.software_version + if ( + version_normalized := re.search(r"^\d+\.[0]?(.*)", info.software_version) + ) is not None: + self._current_firmware = version_normalized.group(1) + else: + self._current_firmware = info.software_version ( self._update_available, @@ -353,11 +380,11 @@ class FritzBoxTools( """Event specific per FRITZ!Box entry to signal updates in devices.""" return f"{DOMAIN}-device-update-{self._unique_id}" - async def _async_update_hosts_info(self) -> list[HostInfo]: + async def _async_update_hosts_info(self) -> list[HostAttributes]: """Retrieve latest hosts information from the FRITZ!Box.""" try: return await self.hass.async_add_executor_job( - self.fritz_hosts.get_hosts_info + self.fritz_hosts.get_hosts_attributes ) except Exception as ex: # pylint: disable=[broad-except] if not self.hass.is_stopping: @@ -392,29 +419,6 @@ class FritzBoxTools( return {int(item["DeflectionId"]): item for item in items} return {} - async def _async_get_wan_access(self, ip_address: str) -> bool | None: - """Get WAN access rule for given IP address.""" - try: - wan_access = await self.hass.async_add_executor_job( - partial( - self.connection.call_action, - "X_AVM-DE_HostFilter:1", - "GetWANAccessByIP", - NewIPv4Address=ip_address, - ) - ) - return not wan_access.get("NewDisallow") - except FRITZ_EXCEPTIONS as ex: - _LOGGER.debug( - ( - "could not get WAN access rule for client device with IP '%s'," - " error: %s" - ), - ip_address, - ex, - ) - return None - def manage_device_info( self, dev_info: Device, dev_mac: str, consider_home: bool ) -> bool: @@ -462,17 +466,17 @@ class FritzBoxTools( new_device = False hosts = {} for host in await self._async_update_hosts_info(): - if not host.get("mac"): + if not host.get("MACAddress"): continue - hosts[host["mac"]] = Device( - name=host["name"], - connected=host["status"], + hosts[host["MACAddress"]] = Device( + name=host["HostName"], + connected=host["Active"], connected_to="", connection_type="", - ip_address=host["ip"], + ip_address=host["IPAddress"], ssid=None, - wan_access=None, + wan_access="granted" in host["X_AVM-DE_WANAccess"], ) if not self.fritz_status.device_has_mesh_support or ( @@ -484,8 +488,6 @@ class FritzBoxTools( ) self.mesh_role = MeshRoles.NONE for mac, info in hosts.items(): - if info.ip_address: - info.wan_access = await self._async_get_wan_access(info.ip_address) if self.manage_device_info(info, mac, consider_home): new_device = True await self.async_send_signal_device_update(new_device) @@ -535,11 +537,6 @@ class FritzBoxTools( dev_info: Device = hosts[dev_mac] - if dev_info.ip_address: - dev_info.wan_access = await self._async_get_wan_access( - dev_info.ip_address - ) - for link in interf["node_links"]: intf = mesh_intf.get(link["node_interface_1_uid"]) if intf is not None: @@ -583,7 +580,7 @@ class FritzBoxTools( ) -> None: """Trigger device trackers cleanup.""" device_hosts_list = await self.hass.async_add_executor_job( - self.fritz_hosts.get_hosts_info + self.fritz_hosts.get_hosts_attributes ) entity_reg: er.EntityRegistry = er.async_get(self.hass) @@ -600,8 +597,8 @@ class FritzBoxTools( device_hosts_macs = set() device_hosts_names = set() for device in device_hosts_list: - device_hosts_macs.add(device["mac"]) - device_hosts_names.add(device["name"]) + device_hosts_macs.add(device["MACAddress"]) + device_hosts_names.add(device["HostName"]) for entry in ha_entity_reg_list: if entry.original_name is None: diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 1ce21081f9c..16015ec5837 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -1,5 +1,6 @@ """Constants for the FRITZ!Box Tools integration.""" +from enum import StrEnum from typing import Literal from fritzconnection.core.exceptions import ( @@ -13,7 +14,6 @@ from fritzconnection.core.exceptions import ( FritzServiceError, ) -from homeassistant.backports.enum import StrEnum from homeassistant.const import Platform diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 54419d5ae3f..8d52115d49b 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/fritz", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "requirements": ["fritzconnection[qr]==1.12.0", "xmltodict==0.13.0"], + "requirements": ["fritzconnection[qr]==1.12.2", "xmltodict==0.13.0"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/components/fritz/services.yaml b/homeassistant/components/fritz/services.yaml index 3c7ed643841..b9828280aa2 100644 --- a/homeassistant/components/fritz/services.yaml +++ b/homeassistant/components/fritz/services.yaml @@ -1,9 +1,6 @@ reconnect: - description: Reconnects your FRITZ!Box internet connection fields: device_id: - name: Fritz!Box Device - description: Select the Fritz!Box to reconnect required: true selector: device: @@ -11,11 +8,8 @@ reconnect: entity: device_class: connectivity reboot: - description: Reboots your FRITZ!Box fields: device_id: - name: Fritz!Box Device - description: Select the Fritz!Box to reboot required: true selector: device: @@ -24,11 +18,8 @@ reboot: device_class: connectivity cleanup: - description: Remove FRITZ!Box stale device_tracker entities fields: device_id: - name: Fritz!Box Device - description: Select the Fritz!Box to check required: true selector: device: @@ -36,12 +27,8 @@ cleanup: entity: device_class: connectivity set_guest_wifi_password: - name: Set guest wifi password - description: Set a new password for the guest wifi. The password must be between 8 and 63 characters long. If no additional parameter is set, the password will be auto-generated with a length of 12 characters. fields: device_id: - name: Fritz!Box Device - description: Select the Fritz!Box to check required: true selector: device: @@ -49,14 +36,10 @@ set_guest_wifi_password: entity: device_class: connectivity password: - name: Password - description: New password for the guest wifi required: false selector: text: length: - name: Password length - description: Length of the new password. The password will be auto-generated, if no password is set. required: false selector: number: diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index fcaa56424f1..7cbb10a236b 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -19,7 +19,7 @@ } }, "user": { - "title": "Set up FRITZ!Box Tools", + "title": "[%key:component::fritz::config::step::confirm::title%]", "description": "Set up FRITZ!Box Tools to control your FRITZ!Box.\nMinimum needed: username, password.", "data": { "host": "[%key:common::config_flow::data::host%]", @@ -55,33 +55,123 @@ }, "entity": { "binary_sensor": { - "is_connected": { "name": "Connection" }, - "is_linked": { "name": "Link" } + "is_connected": { + "name": "Connection" + }, + "is_linked": { + "name": "Link" + } }, "button": { - "cleanup": { "name": "Cleanup" }, - "firmware_update": { "name": "Firmware update" }, - "reconnect": { "name": "Reconnect" } + "cleanup": { + "name": "Cleanup" + }, + "firmware_update": { + "name": "Firmware update" + }, + "reconnect": { + "name": "Reconnect" + } }, "sensor": { - "connection_uptime": { "name": "Connection uptime" }, - "device_uptime": { "name": "Last restart" }, - "external_ip": { "name": "External IP" }, - "external_ipv6": { "name": "External IPv6" }, - "gb_received": { "name": "GB received" }, - "gb_sent": { "name": "GB sent" }, - "kb_s_received": { "name": "Download throughput" }, - "kb_s_sent": { "name": "Upload throughput" }, + "connection_uptime": { + "name": "Connection uptime" + }, + "device_uptime": { + "name": "Last restart" + }, + "external_ip": { + "name": "External IP" + }, + "external_ipv6": { + "name": "External IPv6" + }, + "gb_received": { + "name": "GB received" + }, + "gb_sent": { + "name": "GB sent" + }, + "kb_s_received": { + "name": "Download throughput" + }, + "kb_s_sent": { + "name": "Upload throughput" + }, "link_attenuation_received": { "name": "Link download power attenuation" }, - "link_attenuation_sent": { "name": "Link upload power attenuation" }, - "link_kb_s_received": { "name": "Link download throughput" }, - "link_kb_s_sent": { "name": "Link upload throughput" }, - "link_noise_margin_received": { "name": "Link download noise margin" }, - "link_noise_margin_sent": { "name": "Link upload noise margin" }, - "max_kb_s_received": { "name": "Max connection download throughput" }, - "max_kb_s_sent": { "name": "Max connection upload throughput" } + "link_attenuation_sent": { + "name": "Link upload power attenuation" + }, + "link_kb_s_received": { + "name": "Link download throughput" + }, + "link_kb_s_sent": { + "name": "Link upload throughput" + }, + "link_noise_margin_received": { + "name": "Link download noise margin" + }, + "link_noise_margin_sent": { + "name": "Link upload noise margin" + }, + "max_kb_s_received": { + "name": "Max connection download throughput" + }, + "max_kb_s_sent": { + "name": "Max connection upload throughput" + } + } + }, + "services": { + "reconnect": { + "name": "[%key:component::fritz::entity::button::reconnect::name%]", + "description": "Reconnects your FRITZ!Box internet connection.", + "fields": { + "device_id": { + "name": "Fritz!Box Device", + "description": "Select the Fritz!Box to reconnect." + } + } + }, + "reboot": { + "name": "Reboot", + "description": "Reboots your FRITZ!Box.", + "fields": { + "device_id": { + "name": "[%key:component::fritz::services::reconnect::fields::device_id::name%]", + "description": "Select the Fritz!Box to reboot." + } + } + }, + "cleanup": { + "name": "Remove stale device tracker entities", + "description": "Remove FRITZ!Box stale device_tracker entities.", + "fields": { + "device_id": { + "name": "[%key:component::fritz::services::reconnect::fields::device_id::name%]", + "description": "Select the Fritz!Box to check." + } + } + }, + "set_guest_wifi_password": { + "name": "Set guest Wi-Fi password", + "description": "Sets a new password for the guest Wi-Fi. The password must be between 8 and 63 characters long. If no additional parameter is set, the password will be auto-generated with a length of 12 characters.", + "fields": { + "device_id": { + "name": "[%key:component::fritz::services::reconnect::fields::device_id::name%]", + "description": "Select the Fritz!Box to configure." + }, + "password": { + "name": "[%key:common::config_flow::data::password%]", + "description": "New password for the guest Wi-Fi." + }, + "length": { + "name": "Password length", + "description": "Length of the new password. The password will be auto-generated, if no password is set." + } + } } } } diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 5b8c4048530..1352d9cb42e 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -518,7 +518,6 @@ class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity): default_manufacturer="AVM", default_model="FRITZ!Box Tracked device", default_name=device.hostname, - identifiers={(DOMAIN, self._mac)}, via_device=( DOMAIN, avm_wrapper.unique_id, diff --git a/homeassistant/components/fritzbox_callmonitor/config_flow.py b/homeassistant/components/fritzbox_callmonitor/config_flow.py index f7ce25c2ebe..5065aa65b4d 100644 --- a/homeassistant/components/fritzbox_callmonitor/config_flow.py +++ b/homeassistant/components/fritzbox_callmonitor/config_flow.py @@ -1,6 +1,7 @@ """Config flow for fritzbox_callmonitor.""" from __future__ import annotations +from enum import StrEnum from typing import Any, cast from fritzconnection import FritzConnection @@ -9,7 +10,6 @@ from requests.exceptions import ConnectionError as RequestsConnectionError import voluptuous as vol from homeassistant import config_entries -from homeassistant.backports.enum import StrEnum from homeassistant.const import ( CONF_HOST, CONF_NAME, diff --git a/homeassistant/components/fritzbox_callmonitor/const.py b/homeassistant/components/fritzbox_callmonitor/const.py index 4f224660ae9..75050374e52 100644 --- a/homeassistant/components/fritzbox_callmonitor/const.py +++ b/homeassistant/components/fritzbox_callmonitor/const.py @@ -1,7 +1,7 @@ """Constants for the AVM Fritz!Box call monitor integration.""" +from enum import StrEnum from typing import Final -from homeassistant.backports.enum import StrEnum from homeassistant.const import Platform diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json index d445c12e4da..c3c305ab07e 100644 --- a/homeassistant/components/fritzbox_callmonitor/manifest.json +++ b/homeassistant/components/fritzbox_callmonitor/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "requirements": ["fritzconnection[qr]==1.12.0"] + "requirements": ["fritzconnection[qr]==1.12.2"] } diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index ed2be40f30f..adf6bd3a35a 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping from datetime import datetime, timedelta +from enum import StrEnum import logging import queue from threading import Event as ThreadingEvent, Thread @@ -11,7 +12,6 @@ from typing import Any, cast from fritzconnection.core.fritzmonitor import FritzMonitor -from homeassistant.backports.enum import StrEnum from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index c4d764f4c71..6202b945d97 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -3,20 +3,29 @@ from __future__ import annotations import asyncio from collections.abc import Callable +from datetime import datetime, timedelta import logging from typing import Final, TypeVar from pyfronius import Fronius, FroniusError -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.event import async_track_time_interval -from .const import DOMAIN, SOLAR_NET_ID_SYSTEM, FroniusDeviceInfo +from .const import ( + DOMAIN, + SOLAR_NET_DISCOVERY_NEW, + SOLAR_NET_ID_SYSTEM, + SOLAR_NET_RESCAN_TIMER, + FroniusDeviceInfo, +) from .coordinator import ( FroniusCoordinatorBase, FroniusInverterUpdateCoordinator, @@ -95,17 +104,7 @@ class FroniusSolarNet: # _create_solar_net_device uses data from self.logger_coordinator when available self.system_device_info = await self._create_solar_net_device() - _inverter_infos = await self._get_inverter_infos() - for inverter_info in _inverter_infos: - coordinator = FroniusInverterUpdateCoordinator( - hass=self.hass, - solar_net=self, - logger=_LOGGER, - name=f"{DOMAIN}_inverter_{inverter_info.solar_net_id}_{self.host}", - inverter_info=inverter_info, - ) - await coordinator.async_config_entry_first_refresh() - self.inverter_coordinators.append(coordinator) + await self._init_devices_inverter() self.meter_coordinator = await self._init_optional_coordinator( FroniusMeterUpdateCoordinator( @@ -143,6 +142,15 @@ class FroniusSolarNet: ) ) + # Setup periodic re-scan + self.cleanup_callbacks.append( + async_track_time_interval( + self.hass, + self._init_devices_inverter, + timedelta(minutes=SOLAR_NET_RESCAN_TIMER), + ) + ) + async def _create_solar_net_device(self) -> DeviceInfo: """Create a device for the Fronius SolarNet system.""" solar_net_device: DeviceInfo = DeviceInfo( @@ -168,14 +176,55 @@ class FroniusSolarNet: ) return solar_net_device + async def _init_devices_inverter(self, _now: datetime | None = None) -> None: + """Get available inverters and set up coordinators for new found devices.""" + _inverter_infos = await self._get_inverter_infos() + + _LOGGER.debug("Processing inverters for: %s", _inverter_infos) + for _inverter_info in _inverter_infos: + _inverter_name = ( + f"{DOMAIN}_inverter_{_inverter_info.solar_net_id}_{self.host}" + ) + + # Add found inverter only not already existing + if _inverter_info.solar_net_id in [ + inv.inverter_info.solar_net_id for inv in self.inverter_coordinators + ]: + continue + + _coordinator = FroniusInverterUpdateCoordinator( + hass=self.hass, + solar_net=self, + logger=_LOGGER, + name=_inverter_name, + inverter_info=_inverter_info, + ) + await _coordinator.async_config_entry_first_refresh() + self.inverter_coordinators.append(_coordinator) + + # Only for re-scans. Initial setup adds entities through sensor.async_setup_entry + if self.config_entry.state == ConfigEntryState.LOADED: + dispatcher_send(self.hass, SOLAR_NET_DISCOVERY_NEW, _coordinator) + + _LOGGER.debug( + "New inverter added (UID: %s)", + _inverter_info.unique_id, + ) + async def _get_inverter_infos(self) -> list[FroniusDeviceInfo]: """Get information about the inverters in the SolarNet system.""" + inverter_infos: list[FroniusDeviceInfo] = [] + try: _inverter_info = await self.fronius.inverter_info() except FroniusError as err: + if self.config_entry.state == ConfigEntryState.LOADED: + # During a re-scan we will attempt again as per schedule. + _LOGGER.debug("Re-scan failed for %s", self.host) + return inverter_infos + raise ConfigEntryNotReady from err - inverter_infos: list[FroniusDeviceInfo] = [] for inverter in _inverter_info["inverters"]: solar_net_id = inverter["device_id"]["value"] unique_id = inverter["unique_id"]["value"] @@ -195,6 +244,12 @@ class FroniusSolarNet: unique_id=unique_id, ) ) + _LOGGER.debug( + "Inverter found at %s (Device ID: %s, UID: %s)", + self.host, + solar_net_id, + unique_id, + ) return inverter_infos @staticmethod diff --git a/homeassistant/components/fronius/const.py b/homeassistant/components/fronius/const.py index de3e0cc9563..b65864ee089 100644 --- a/homeassistant/components/fronius/const.py +++ b/homeassistant/components/fronius/const.py @@ -6,8 +6,10 @@ from homeassistant.helpers.entity import DeviceInfo DOMAIN: Final = "fronius" SolarNetId = str +SOLAR_NET_DISCOVERY_NEW: Final = "fronius_discovery_new" SOLAR_NET_ID_POWER_FLOW: SolarNetId = "power_flow" SOLAR_NET_ID_SYSTEM: SolarNetId = "system" +SOLAR_NET_RESCAN_TIMER: Final = 60 class FroniusConfigEntryData(TypedDict): diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 4e706db032f..ff949af0cba 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -24,12 +24,13 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import DOMAIN, SOLAR_NET_DISCOVERY_NEW if TYPE_CHECKING: from . import FroniusSolarNet @@ -53,6 +54,7 @@ async def async_setup_entry( ) -> None: """Set up Fronius sensor entities based on a config entry.""" solar_net: FroniusSolarNet = hass.data[DOMAIN][config_entry.entry_id] + for inverter_coordinator in solar_net.inverter_coordinators: inverter_coordinator.add_entities_for_seen_keys( async_add_entities, InverterSensor @@ -78,6 +80,19 @@ async def async_setup_entry( async_add_entities, StorageSensor ) + @callback + def async_add_new_entities(coordinator: FroniusInverterUpdateCoordinator) -> None: + """Add newly found inverter entities.""" + coordinator.add_entities_for_seen_keys(async_add_entities, InverterSensor) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + SOLAR_NET_DISCOVERY_NEW, + async_add_new_entities, + ) + ) + @dataclass class FroniusSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8c04e591968..59315e9f576 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -222,6 +222,9 @@ class Panel: # If the panel should only be visible to admins require_admin = False + # If the panel is a configuration panel for a integration + config_panel_domain: str | None = None + def __init__( self, component_name: str, @@ -230,6 +233,7 @@ class Panel: frontend_url_path: str | None, config: dict[str, Any] | None, require_admin: bool, + config_panel_domain: str | None, ) -> None: """Initialize a built-in panel.""" self.component_name = component_name @@ -238,6 +242,7 @@ class Panel: self.frontend_url_path = frontend_url_path or component_name self.config = config self.require_admin = require_admin + self.config_panel_domain = config_panel_domain @callback def to_response(self) -> PanelRespons: @@ -249,6 +254,7 @@ class Panel: "config": self.config, "url_path": self.frontend_url_path, "require_admin": self.require_admin, + "config_panel_domain": self.config_panel_domain, } @@ -264,6 +270,7 @@ def async_register_built_in_panel( require_admin: bool = False, *, update: bool = False, + config_panel_domain: str | None = None, ) -> None: """Register a built-in panel.""" panel = Panel( @@ -273,6 +280,7 @@ def async_register_built_in_panel( frontend_url_path, config, require_admin, + config_panel_domain, ) panels = hass.data.setdefault(DATA_PANELS, {}) @@ -720,3 +728,4 @@ class PanelRespons(TypedDict): config: dict[str, Any] | None url_path: str | None require_admin: bool + config_panel_domain: str | None diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 07c5585833d..84d1d4f5e27 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20230705.1"] + "requirements": ["home-assistant-frontend==20230802.0"] } diff --git a/homeassistant/components/frontend/services.yaml b/homeassistant/components/frontend/services.yaml index 2a562ab348a..8e6820fb5bb 100644 --- a/homeassistant/components/frontend/services.yaml +++ b/homeassistant/components/frontend/services.yaml @@ -1,28 +1,19 @@ # Describes the format for available frontend services set_theme: - name: Set theme - description: Set a theme unless the client selected per-device theme. fields: name: - name: Theme - description: Name of a predefined theme required: true example: "default" selector: theme: + include_default: true mode: - name: Mode - description: The mode the theme is for. default: "light" selector: select: options: - - label: "Dark" - value: "dark" - - label: "Light" - value: "light" - + - "dark" + - "light" + translation_key: mode reload_themes: - name: Reload themes - description: Reload themes from YAML configuration. diff --git a/homeassistant/components/frontend/strings.json b/homeassistant/components/frontend/strings.json new file mode 100644 index 00000000000..b5fdeb612c4 --- /dev/null +++ b/homeassistant/components/frontend/strings.json @@ -0,0 +1,30 @@ +{ + "services": { + "set_theme": { + "name": "Set the default theme", + "description": "Sets the default theme Home Assistant uses. Can be overridden by a user.", + "fields": { + "name": { + "name": "Theme", + "description": "Name of a theme." + }, + "mode": { + "name": "Mode", + "description": "Theme mode." + } + } + }, + "reload_themes": { + "name": "Reload themes", + "description": "Reloads themes from the YAML-configuration." + } + }, + "selector": { + "mode": { + "options": { + "dark": "Dark", + "light": "Light" + } + } + } +} diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 04b689ae917..62df3a12c2b 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -46,6 +46,8 @@ class AFSAPIDevice(MediaPlayerEntity): """Representation of a Frontier Silicon device on the network.""" _attr_media_content_type: str = MediaType.CHANNEL + _attr_has_entity_name = True + _attr_name = None _attr_supported_features = ( MediaPlayerEntityFeature.PAUSE @@ -73,7 +75,6 @@ class AFSAPIDevice(MediaPlayerEntity): identifiers={(DOMAIN, afsapi.webfsapi_endpoint)}, name=name, ) - self._attr_name = name self._max_volume: int | None = None diff --git a/homeassistant/components/fully_kiosk/manifest.json b/homeassistant/components/fully_kiosk/manifest.json index f313a117c44..dcd36671fce 100644 --- a/homeassistant/components/fully_kiosk/manifest.json +++ b/homeassistant/components/fully_kiosk/manifest.json @@ -8,7 +8,7 @@ "registered_devices": true } ], - "documentation": "https://www.home-assistant.io/integrations/fullykiosk", + "documentation": "https://www.home-assistant.io/integrations/fully_kiosk", "iot_class": "local_polling", "mqtt": ["fully/deviceInfo/+"], "requirements": ["python-fullykiosk==0.0.12"] diff --git a/homeassistant/components/fully_kiosk/services.yaml b/homeassistant/components/fully_kiosk/services.yaml index 1f75e4a0347..7784996da9b 100644 --- a/homeassistant/components/fully_kiosk/services.yaml +++ b/homeassistant/components/fully_kiosk/services.yaml @@ -1,50 +1,36 @@ load_url: - name: Load URL - description: Load a URL on Fully Kiosk Browser target: device: integration: fully_kiosk fields: url: - name: URL - description: URL to load. example: "https://home-assistant.io" required: true selector: text: set_config: - name: Set Configuration - description: Set a configuration parameter on Fully Kiosk Browser. target: device: integration: fully_kiosk fields: key: - name: Key - description: Configuration parameter to set. example: "motionSensitivity" required: true selector: text: value: - name: Value - description: Value for the configuration parameter. example: "90" required: true selector: text: start_application: - name: Start Application - description: Start an application on the device running Fully Kiosk Browser. target: device: integration: fully_kiosk fields: application: - name: Application - description: Package name of the application to start. example: "de.ozerov.fully" required: true selector: diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json index c10b6162859..d61e8a7b7a8 100644 --- a/homeassistant/components/fully_kiosk/strings.json +++ b/homeassistant/components/fully_kiosk/strings.json @@ -105,5 +105,41 @@ "name": "Screen" } } + }, + "services": { + "load_url": { + "name": "Load URL", + "description": "Loads a URL on Fully Kiosk Browser.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "URL to load." + } + } + }, + "set_config": { + "name": "Set Configuration", + "description": "Sets a configuration parameter on Fully Kiosk Browser.", + "fields": { + "key": { + "name": "Key", + "description": "Configuration parameter to set." + }, + "value": { + "name": "Value", + "description": "Value for the configuration parameter." + } + } + }, + "start_application": { + "name": "Start Application", + "description": "Starts an application on the device running Fully Kiosk Browser.", + "fields": { + "application": { + "name": "Application", + "description": "Package name of the application to start." + } + } + } } } diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py new file mode 100644 index 00000000000..2390f5af561 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -0,0 +1,92 @@ +"""The Gardena Bluetooth integration.""" +from __future__ import annotations + +import asyncio +import logging + +from bleak.backends.device import BLEDevice +from gardena_bluetooth.client import CachedConnection, Client +from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation +from gardena_bluetooth.exceptions import CommunicationFailure + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.entity import DeviceInfo +import homeassistant.util.dt as dt_util + +from .const import DOMAIN +from .coordinator import Coordinator, DeviceUnavailable + +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, +] +LOGGER = logging.getLogger(__name__) +TIMEOUT = 20.0 +DISCONNECT_DELAY = 5 + + +def get_connection(hass: HomeAssistant, address: str) -> CachedConnection: + """Set up a cached client that keeps connection after last use.""" + + def _device_lookup() -> BLEDevice: + device = bluetooth.async_ble_device_from_address( + hass, address, connectable=True + ) + if not device: + raise DeviceUnavailable("Unable to find device") + return device + + return CachedConnection(DISCONNECT_DELAY, _device_lookup) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Gardena Bluetooth from a config entry.""" + + address = entry.data[CONF_ADDRESS] + client = Client(get_connection(hass, address)) + try: + sw_version = await client.read_char(DeviceInformation.firmware_version, None) + manufacturer = await client.read_char(DeviceInformation.manufacturer_name, None) + model = await client.read_char(DeviceInformation.model_number, None) + name = await client.read_char( + DeviceConfiguration.custom_device_name, entry.title + ) + uuids = await client.get_all_characteristics_uuid() + await client.update_timestamp(dt_util.now()) + except (asyncio.TimeoutError, CommunicationFailure, DeviceUnavailable) as exception: + await client.disconnect() + raise ConfigEntryNotReady( + f"Unable to connect to device {address} due to {exception}" + ) from exception + + device = DeviceInfo( + identifiers={(DOMAIN, address)}, + name=name, + sw_version=sw_version, + manufacturer=manufacturer, + model=model, + ) + + coordinator = Coordinator(hass, LOGGER, client, uuids, device, address) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await coordinator.async_refresh() + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + coordinator: Coordinator = hass.data[DOMAIN].pop(entry.entry_id) + await coordinator.async_shutdown() + + return unload_ok diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py new file mode 100644 index 00000000000..0285f7bdf82 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -0,0 +1,64 @@ +"""Support for binary_sensor entities.""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from gardena_bluetooth.const import Valve +from gardena_bluetooth.parse import CharacteristicBool + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import Coordinator, GardenaBluetoothDescriptorEntity + + +@dataclass +class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescription): + """Description of entity.""" + + char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool("")) + + +DESCRIPTIONS = ( + GardenaBluetoothBinarySensorEntityDescription( + key=Valve.connected_state.uuid, + translation_key="valve_connected_state", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + char=Valve.connected_state, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up binary sensor based on a config entry.""" + coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] + entities = [ + GardenaBluetoothBinarySensor(coordinator, description) + for description in DESCRIPTIONS + if description.key in coordinator.characteristics + ] + async_add_entities(entities) + + +class GardenaBluetoothBinarySensor( + GardenaBluetoothDescriptorEntity, BinarySensorEntity +): + """Representation of a binary sensor.""" + + entity_description: GardenaBluetoothBinarySensorEntityDescription + + def _handle_coordinator_update(self) -> None: + char = self.entity_description.char + self._attr_is_on = self.coordinator.get_cached(char) + super()._handle_coordinator_update() diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py new file mode 100644 index 00000000000..b984d3420ae --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -0,0 +1,60 @@ +"""Support for button entities.""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from gardena_bluetooth.const import Reset +from gardena_bluetooth.parse import CharacteristicBool + +from homeassistant.components.button import ( + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import Coordinator, GardenaBluetoothDescriptorEntity + + +@dataclass +class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription): + """Description of entity.""" + + char: CharacteristicBool = field(default_factory=lambda: CharacteristicBool("")) + + +DESCRIPTIONS = ( + GardenaBluetoothButtonEntityDescription( + key=Reset.factory_reset.uuid, + translation_key="factory_reset", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + char=Reset.factory_reset, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up button based on a config entry.""" + coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] + entities = [ + GardenaBluetoothButton(coordinator, description) + for description in DESCRIPTIONS + if description.key in coordinator.characteristics + ] + async_add_entities(entities) + + +class GardenaBluetoothButton(GardenaBluetoothDescriptorEntity, ButtonEntity): + """Representation of a button.""" + + entity_description: GardenaBluetoothButtonEntityDescription + + async def async_press(self) -> None: + """Trigger button action.""" + await self.coordinator.write(self.entity_description.char, True) diff --git a/homeassistant/components/gardena_bluetooth/config_flow.py b/homeassistant/components/gardena_bluetooth/config_flow.py new file mode 100644 index 00000000000..3e981675057 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/config_flow.py @@ -0,0 +1,138 @@ +"""Config flow for Gardena Bluetooth integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from gardena_bluetooth.client import Client +from gardena_bluetooth.const import DeviceInformation, ScanService +from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure +from gardena_bluetooth.parse import ManufacturerData, ProductGroup +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.bluetooth import ( + BluetoothServiceInfo, + async_discovered_service_info, +) +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import AbortFlow, FlowResult + +from . import get_connection +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def _is_supported(discovery_info: BluetoothServiceInfo): + """Check if device is supported.""" + if ScanService not in discovery_info.service_uuids: + return False + + if not (data := discovery_info.manufacturer_data.get(ManufacturerData.company)): + _LOGGER.debug("Missing manufacturer data: %s", discovery_info) + return False + + manufacturer_data = ManufacturerData.decode(data) + if manufacturer_data.group != ProductGroup.WATER_CONTROL: + _LOGGER.debug("Unsupported device: %s", manufacturer_data) + return False + + return True + + +def _get_name(discovery_info: BluetoothServiceInfo): + if discovery_info.name and discovery_info.name != discovery_info.address: + return discovery_info.name + return "Gardena Device" + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Gardena Bluetooth.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.devices: dict[str, str] = {} + self.address: str | None + + async def async_read_data(self): + """Try to connect to device and extract information.""" + client = Client(get_connection(self.hass, self.address)) + try: + model = await client.read_char(DeviceInformation.model_number) + _LOGGER.debug("Found device with model: %s", model) + except (CharacteristicNotFound, CommunicationFailure) as exception: + raise AbortFlow( + "cannot_connect", description_placeholders={"error": str(exception)} + ) from exception + finally: + await client.disconnect() + + return {CONF_ADDRESS: self.address} + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfo + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + _LOGGER.debug("Discovered device: %s", discovery_info) + if not _is_supported(discovery_info): + return self.async_abort(reason="no_devices_found") + + self.address = discovery_info.address + self.devices = {discovery_info.address: _get_name(discovery_info)} + await self.async_set_unique_id(self.address) + self._abort_if_unique_id_configured() + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + assert self.address + title = self.devices[self.address] + + if user_input is not None: + data = await self.async_read_data() + return self.async_create_entry(title=title, data=data) + + self.context["title_placeholders"] = { + "name": title, + } + + self._set_confirm_only() + return self.async_show_form( + step_id="confirm", + description_placeholders=self.context["title_placeholders"], + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is not None: + self.address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(self.address, raise_on_progress=False) + self._abort_if_unique_id_configured() + return await self.async_step_confirm() + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass): + address = discovery_info.address + if address in current_addresses or not _is_supported(discovery_info): + continue + + self.devices[address] = _get_name(discovery_info) + + if not self.devices: + return self.async_abort(reason="no_devices_found") + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In(self.devices), + }, + ), + ) diff --git a/homeassistant/components/gardena_bluetooth/const.py b/homeassistant/components/gardena_bluetooth/const.py new file mode 100644 index 00000000000..7de4c15b5fa --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/const.py @@ -0,0 +1,3 @@ +"""Constants for the Gardena Bluetooth integration.""" + +DOMAIN = "gardena_bluetooth" diff --git a/homeassistant/components/gardena_bluetooth/coordinator.py b/homeassistant/components/gardena_bluetooth/coordinator.py new file mode 100644 index 00000000000..9f5dc3223b5 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/coordinator.py @@ -0,0 +1,133 @@ +"""Provides the DataUpdateCoordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from gardena_bluetooth.client import Client +from gardena_bluetooth.exceptions import ( + CharacteristicNoAccess, + GardenaBluetoothException, +) +from gardena_bluetooth.parse import Characteristic, CharacteristicType + +from homeassistant.components import bluetooth +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +SCAN_INTERVAL = timedelta(seconds=60) +LOGGER = logging.getLogger(__name__) + + +class DeviceUnavailable(HomeAssistantError): + """Raised if device can't be found.""" + + +class Coordinator(DataUpdateCoordinator[dict[str, bytes]]): + """Class to manage fetching data.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + client: Client, + characteristics: set[str], + device_info: DeviceInfo, + address: str, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass=hass, + logger=logger, + name="Gardena Bluetooth Data Update Coordinator", + update_interval=SCAN_INTERVAL, + ) + self.address = address + self.data = {} + self.client = client + self.characteristics = characteristics + self.device_info = device_info + + async def async_shutdown(self) -> None: + """Shutdown coordinator and any connection.""" + await super().async_shutdown() + await self.client.disconnect() + + async def _async_update_data(self) -> dict[str, bytes]: + """Poll the device.""" + uuids: set[str] = { + uuid for context in self.async_contexts() for uuid in context + } + if not uuids: + return {} + + data: dict[str, bytes] = {} + for uuid in uuids: + try: + data[uuid] = await self.client.read_char_raw(uuid) + except CharacteristicNoAccess as exception: + LOGGER.debug("Unable to get data for %s due to %s", uuid, exception) + except (GardenaBluetoothException, DeviceUnavailable) as exception: + raise UpdateFailed( + f"Unable to update data for {uuid} due to {exception}" + ) from exception + return data + + def get_cached( + self, char: Characteristic[CharacteristicType] + ) -> CharacteristicType | None: + """Read cached characteristic.""" + if data := self.data.get(char.uuid): + return char.decode(data) + return None + + async def write( + self, char: Characteristic[CharacteristicType], value: CharacteristicType + ) -> None: + """Write characteristic to device.""" + try: + await self.client.write_char(char, value) + except (GardenaBluetoothException, DeviceUnavailable) as exception: + raise HomeAssistantError( + f"Unable to write characteristic {char} dur to {exception}" + ) from exception + + self.data[char.uuid] = char.encode(value) + await self.async_refresh() + + +class GardenaBluetoothEntity(CoordinatorEntity[Coordinator]): + """Coordinator entity for Gardena Bluetooth.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: Coordinator, context: Any = None) -> None: + """Initialize coordinator entity.""" + super().__init__(coordinator, context) + self._attr_device_info = coordinator.device_info + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and bluetooth.async_address_present( + self.hass, self.coordinator.address, True + ) + + +class GardenaBluetoothDescriptorEntity(GardenaBluetoothEntity): + """Coordinator entity for entities with entity description.""" + + def __init__( + self, coordinator: Coordinator, description: EntityDescription + ) -> None: + """Initialize description entity.""" + super().__init__(coordinator, {description.key}) + self._attr_unique_id = f"{coordinator.address}-{description.key}" + self.entity_description = description diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json new file mode 100644 index 00000000000..0226460d4d8 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "gardena_bluetooth", + "name": "Gardena Bluetooth", + "bluetooth": [ + { + "manufacturer_id": 1062, + "service_uuid": "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", + "connectable": true + } + ], + "codeowners": ["@elupus"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", + "iot_class": "local_polling", + "requirements": ["gardena_bluetooth==1.0.2"] +} diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py new file mode 100644 index 00000000000..c425d17621d --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -0,0 +1,142 @@ +"""Support for number entities.""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from gardena_bluetooth.const import DeviceConfiguration, Valve +from gardena_bluetooth.parse import ( + CharacteristicInt, + CharacteristicLong, + CharacteristicUInt16, +) + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import ( + Coordinator, + GardenaBluetoothDescriptorEntity, + GardenaBluetoothEntity, +) + + +@dataclass +class GardenaBluetoothNumberEntityDescription(NumberEntityDescription): + """Description of entity.""" + + char: CharacteristicInt | CharacteristicUInt16 | CharacteristicLong = field( + default_factory=lambda: CharacteristicInt("") + ) + + +DESCRIPTIONS = ( + GardenaBluetoothNumberEntityDescription( + key=Valve.manual_watering_time.uuid, + translation_key="manual_watering_time", + native_unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberMode.BOX, + native_min_value=0.0, + native_max_value=24 * 60 * 60, + native_step=60, + entity_category=EntityCategory.CONFIG, + char=Valve.manual_watering_time, + ), + GardenaBluetoothNumberEntityDescription( + key=Valve.remaining_open_time.uuid, + translation_key="remaining_open_time", + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=0.0, + native_max_value=24 * 60 * 60, + native_step=60.0, + entity_category=EntityCategory.DIAGNOSTIC, + char=Valve.remaining_open_time, + ), + GardenaBluetoothNumberEntityDescription( + key=DeviceConfiguration.rain_pause.uuid, + translation_key="rain_pause", + native_unit_of_measurement=UnitOfTime.DAYS, + mode=NumberMode.BOX, + native_min_value=0.0, + native_max_value=127.0, + native_step=1.0, + entity_category=EntityCategory.CONFIG, + char=DeviceConfiguration.rain_pause, + ), + GardenaBluetoothNumberEntityDescription( + key=DeviceConfiguration.season_pause.uuid, + translation_key="season_pause", + native_unit_of_measurement=UnitOfTime.DAYS, + mode=NumberMode.BOX, + native_min_value=0.0, + native_max_value=365.0, + native_step=1.0, + entity_category=EntityCategory.CONFIG, + char=DeviceConfiguration.season_pause, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up entity based on a config entry.""" + coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] + entities: list[NumberEntity] = [ + GardenaBluetoothNumber(coordinator, description) + for description in DESCRIPTIONS + if description.key in coordinator.characteristics + ] + if Valve.remaining_open_time.uuid in coordinator.characteristics: + entities.append(GardenaBluetoothRemainingOpenSetNumber(coordinator)) + async_add_entities(entities) + + +class GardenaBluetoothNumber(GardenaBluetoothDescriptorEntity, NumberEntity): + """Representation of a number.""" + + entity_description: GardenaBluetoothNumberEntityDescription + + def _handle_coordinator_update(self) -> None: + data = self.coordinator.get_cached(self.entity_description.char) + if data is None: + self._attr_native_value = None + else: + self._attr_native_value = float(data) + super()._handle_coordinator_update() + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self.coordinator.write(self.entity_description.char, int(value)) + self.async_write_ha_state() + + +class GardenaBluetoothRemainingOpenSetNumber(GardenaBluetoothEntity, NumberEntity): + """Representation of a entity with remaining time.""" + + _attr_translation_key = "remaining_open_set" + _attr_native_unit_of_measurement = "min" + _attr_mode = NumberMode.BOX + _attr_native_min_value = 0.0 + _attr_native_max_value = 24 * 60 + _attr_native_step = 1.0 + + def __init__( + self, + coordinator: Coordinator, + ) -> None: + """Initialize the remaining time entity.""" + super().__init__(coordinator, {Valve.remaining_open_time.uuid}) + self._attr_unique_id = f"{coordinator.address}-remaining_open_set" + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self.coordinator.write(Valve.remaining_open_time, int(value * 60)) + self.async_write_ha_state() diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py new file mode 100644 index 00000000000..eaa44d9d4fb --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -0,0 +1,119 @@ +"""Support for switch entities.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone + +from gardena_bluetooth.const import Battery, Valve +from gardena_bluetooth.parse import Characteristic + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.util.dt as dt_util + +from .const import DOMAIN +from .coordinator import ( + Coordinator, + GardenaBluetoothDescriptorEntity, + GardenaBluetoothEntity, +) + + +@dataclass +class GardenaBluetoothSensorEntityDescription(SensorEntityDescription): + """Description of entity.""" + + char: Characteristic = field(default_factory=lambda: Characteristic("")) + + +DESCRIPTIONS = ( + GardenaBluetoothSensorEntityDescription( + key=Valve.activation_reason.uuid, + translation_key="activation_reason", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + char=Valve.activation_reason, + ), + GardenaBluetoothSensorEntityDescription( + key=Battery.battery_level.uuid, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + char=Battery.battery_level, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Gardena Bluetooth sensor based on a config entry.""" + coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] + entities: list[GardenaBluetoothEntity] = [ + GardenaBluetoothSensor(coordinator, description) + for description in DESCRIPTIONS + if description.key in coordinator.characteristics + ] + if Valve.remaining_open_time.uuid in coordinator.characteristics: + entities.append(GardenaBluetoothRemainSensor(coordinator)) + async_add_entities(entities) + + +class GardenaBluetoothSensor(GardenaBluetoothDescriptorEntity, SensorEntity): + """Representation of a sensor.""" + + entity_description: GardenaBluetoothSensorEntityDescription + + def _handle_coordinator_update(self) -> None: + value = self.coordinator.get_cached(self.entity_description.char) + if isinstance(value, datetime): + value = value.replace( + tzinfo=dt_util.get_time_zone(self.hass.config.time_zone) + ) + self._attr_native_value = value + super()._handle_coordinator_update() + + +class GardenaBluetoothRemainSensor(GardenaBluetoothEntity, SensorEntity): + """Representation of a sensor.""" + + _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_native_value: datetime | None = None + _attr_translation_key = "remaining_open_timestamp" + + def __init__( + self, + coordinator: Coordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, {Valve.remaining_open_time.uuid}) + self._attr_unique_id = f"{coordinator.address}-remaining_open_timestamp" + + def _handle_coordinator_update(self) -> None: + value = self.coordinator.get_cached(Valve.remaining_open_time) + if not value: + self._attr_native_value = None + super()._handle_coordinator_update() + return + + time = datetime.now(timezone.utc) + timedelta(seconds=value) + if not self._attr_native_value: + self._attr_native_value = time + super()._handle_coordinator_update() + return + + error = time - self._attr_native_value + if abs(error.total_seconds()) > 10: + self._attr_native_value = time + super()._handle_coordinator_update() + return diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json new file mode 100644 index 00000000000..1d9a281fdbc --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -0,0 +1,63 @@ +{ + "config": { + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:component::bluetooth::config::step::user::data::address%]" + } + }, + "confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "error": { + "cannot_connect": "Failed to connect: {error}" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "binary_sensor": { + "valve_connected_state": { + "name": "Valve connection" + } + }, + "button": { + "factory_reset": { + "name": "Factory reset" + } + }, + "number": { + "remaining_open_time": { + "name": "Remaining open time" + }, + "remaining_open_set": { + "name": "Open for" + }, + "manual_watering_time": { + "name": "Manual watering time" + }, + "rain_pause": { + "name": "Rain pause" + }, + "season_pause": { + "name": "Season pause" + } + }, + "sensor": { + "activation_reason": { + "name": "Activation reason" + }, + "remaining_open_timestamp": { + "name": "Valve closing" + } + }, + "switch": { + "state": { + "name": "[%key:common::state::open%]" + } + } + } +} diff --git a/homeassistant/components/gardena_bluetooth/switch.py b/homeassistant/components/gardena_bluetooth/switch.py new file mode 100644 index 00000000000..bc83e3ed5a9 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/switch.py @@ -0,0 +1,71 @@ +"""Support for switch entities.""" +from __future__ import annotations + +from typing import Any + +from gardena_bluetooth.const import Valve + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import Coordinator, GardenaBluetoothEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up switch based on a config entry.""" + coordinator: Coordinator = hass.data[DOMAIN][entry.entry_id] + entities = [] + if GardenaBluetoothValveSwitch.characteristics.issubset( + coordinator.characteristics + ): + entities.append(GardenaBluetoothValveSwitch(coordinator)) + + async_add_entities(entities) + + +class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity): + """Representation of a valve switch.""" + + characteristics = { + Valve.state.uuid, + Valve.manual_watering_time.uuid, + Valve.remaining_open_time.uuid, + } + + def __init__( + self, + coordinator: Coordinator, + ) -> None: + """Initialize the switch.""" + super().__init__( + coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid} + ) + self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}" + self._attr_translation_key = "state" + self._attr_is_on = None + + def _handle_coordinator_update(self) -> None: + self._attr_is_on = self.coordinator.get_cached(Valve.state) + super()._handle_coordinator_update() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + if not (data := self.coordinator.data.get(Valve.manual_watering_time.uuid)): + raise HomeAssistantError("Unable to get manual activation time.") + + value = Valve.manual_watering_time.decode(data) + await self.coordinator.write(Valve.remaining_open_time, value) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self.coordinator.write(Valve.remaining_open_time, 0) + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 234795e9014..c171c95e659 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -80,12 +80,6 @@ async def async_setup_platform( ) -> None: """Set up a generic IP Camera.""" - _LOGGER.warning( - "Loading generic IP camera via configuration.yaml is deprecated, " - "it will be automatically imported. Once you have confirmed correct " - "operation, please remove 'generic' (IP camera) section(s) from " - "configuration.yaml" - ) image = config.get(CONF_STILL_IMAGE_URL) stream = config.get(CONF_STREAM_SOURCE) config_new = { diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 34fc5713271..ec94d4c227c 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -40,11 +40,12 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.data_entry_flow import FlowResult, UnknownFlow from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util import slugify from .camera import GenericCamera, generate_auth @@ -380,6 +381,28 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: """Handle config import from yaml.""" + + _LOGGER.warning( + "Loading generic IP camera via configuration.yaml is deprecated, " + "it will be automatically imported. Once you have confirmed correct " + "operation, please remove 'generic' (IP camera) section(s) from " + "configuration.yaml" + ) + + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Generic IP Camera", + }, + ) # abort if we've already got this one. if self.check_for_existing(import_config): return self.async_abort(reason="already_exists") diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 134ce00ef70..a89ee370920 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/generic", "iot_class": "local_push", - "requirements": ["ha-av==10.1.0", "Pillow==9.5.0"] + "requirements": ["ha-av==10.1.1", "Pillow==10.0.0"] } diff --git a/homeassistant/components/generic/services.yaml b/homeassistant/components/generic/services.yaml index a05a9e3415d..c983a105c93 100644 --- a/homeassistant/components/generic/services.yaml +++ b/homeassistant/components/generic/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload all generic entities. diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index 5fddd2d78fe..a1519fa0f48 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -1,4 +1,5 @@ { + "title": "Generic Camera", "config": { "error": { "unknown": "[%key:common::config_flow::error::unknown%]", @@ -82,5 +83,11 @@ "stream_io_error": "[%key:component::generic::config::error::stream_io_error%]", "stream_not_permitted": "[%key:component::generic::config::error::stream_not_permitted%]" } + }, + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads generic cameras from the YAML-configuration." + } } } diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 01945f9e242..959b0a8e8df 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -9,6 +9,7 @@ from homeassistant.components.humidifier import ( MODE_AWAY, MODE_NORMAL, PLATFORM_SCHEMA, + HumidifierAction, HumidifierDeviceClass, HumidifierEntity, HumidifierEntityFeature, @@ -158,6 +159,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self._is_away = False if not self._device_class: self._device_class = HumidifierDeviceClass.HUMIDIFIER + self._attr_action = HumidifierAction.IDLE async def async_added_to_hass(self): """Run when entity about to be added.""" @@ -361,6 +363,15 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): """Handle humidifier switch state changes.""" if new_state is None: return + + if new_state.state == STATE_ON: + if self._device_class == HumidifierDeviceClass.DEHUMIDIFIER: + self._attr_action = HumidifierAction.DRYING + else: + self._attr_action = HumidifierAction.HUMIDIFYING + else: + self._attr_action = HumidifierAction.IDLE + self.async_schedule_update_ha_state() async def _async_update_humidity(self, humidity): diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index e3eed8866c8..d3d80747127 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -37,18 +37,25 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import DOMAIN as HA_DOMAIN, CoreState, HomeAssistant, callback +from homeassistant.core import ( + DOMAIN as HA_DOMAIN, + CoreState, + HomeAssistant, + State, + callback, +) from homeassistant.exceptions import ConditionError from homeassistant.helpers import condition import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( + EventStateChangedData, async_track_state_change_event, async_track_time_interval, ) from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from . import DOMAIN, PLATFORMS @@ -395,9 +402,11 @@ class GenericThermostat(ClimateEntity, RestoreEntity): # Get default temp from super class return super().max_temp - async def _async_sensor_changed(self, event): + async def _async_sensor_changed( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle temperature changes.""" - new_state = event.data.get("new_state") + new_state = event.data["new_state"] if new_state is None or new_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): return @@ -418,10 +427,10 @@ class GenericThermostat(ClimateEntity, RestoreEntity): await self._async_heater_turn_off() @callback - def _async_switch_changed(self, event): + def _async_switch_changed(self, event: EventType[EventStateChangedData]) -> None: """Handle heater switch state changes.""" - new_state = event.data.get("new_state") - old_state = event.data.get("old_state") + new_state = event.data["new_state"] + old_state = event.data["old_state"] if new_state is None: return if old_state is None: @@ -429,7 +438,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self.async_write_ha_state() @callback - def _async_update_temp(self, state): + def _async_update_temp(self, state: State) -> None: """Update thermostat with latest state from sensor.""" try: cur_temp = float(state.state) diff --git a/homeassistant/components/generic_thermostat/services.yaml b/homeassistant/components/generic_thermostat/services.yaml index ef6745bd36f..c983a105c93 100644 --- a/homeassistant/components/generic_thermostat/services.yaml +++ b/homeassistant/components/generic_thermostat/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload all generic_thermostat entities. diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json new file mode 100644 index 00000000000..8834892b7ab --- /dev/null +++ b/homeassistant/components/generic_thermostat/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads generic thermostats from the YAML-configuration." + } + } +} diff --git a/homeassistant/components/geniushub/services.yaml b/homeassistant/components/geniushub/services.yaml index 7d4cd14b19e..48b45a0811f 100644 --- a/homeassistant/components/geniushub/services.yaml +++ b/homeassistant/components/geniushub/services.yaml @@ -2,21 +2,14 @@ # Describes the format for available services set_zone_mode: - name: Set zone mode - description: >- - Set the zone to an operating mode. fields: entity_id: - name: Entity - description: The zone's entity_id. required: true selector: entity: integration: geniushub domain: climate mode: - name: Mode - description: "One of: off, timer or footprint." required: true selector: select: @@ -26,21 +19,14 @@ set_zone_mode: - "footprint" set_zone_override: - name: Set zone override - description: >- - Override the zone's set point for a given duration. fields: entity_id: - name: Entity - description: The zone's entity_id. required: true selector: entity: integration: geniushub domain: climate temperature: - name: Temperature - description: The target temperature. required: true selector: number: @@ -49,26 +35,17 @@ set_zone_override: step: 0.1 unit_of_measurement: "°" duration: - name: Duration - description: >- - The duration of the override. Optional, default 1 hour, maximum 24 hours. example: '{"minutes": 135}' selector: object: set_switch_override: - name: Set switch override - description: >- - Override switch for a given duration. target: entity: integration: geniushub domain: switch fields: duration: - name: Duration - description: >- - The duration of the override. Optional, default 1 hour, maximum 24 hours. example: '{"minutes": 135}' selector: object: diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json new file mode 100644 index 00000000000..ac057f5c639 --- /dev/null +++ b/homeassistant/components/geniushub/strings.json @@ -0,0 +1,46 @@ +{ + "services": { + "set_zone_mode": { + "name": "Set zone mode", + "description": "Set the zone to an operating mode.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "The zone's entity_id." + }, + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "One of: off, timer or footprint." + } + } + }, + "set_zone_override": { + "name": "Set zone override", + "description": "Overrides the zone's set point for a given duration.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "[%key:component::geniushub::services::set_zone_mode::fields::entity_id::description%]" + }, + "temperature": { + "name": "Temperature", + "description": "The target temperature." + }, + "duration": { + "name": "Duration", + "description": "The duration of the override. Optional, default 1 hour, maximum 24 hours." + } + } + }, + "set_switch_override": { + "name": "Set switch override", + "description": "Overrides switch for a given duration.", + "fields": { + "duration": { + "name": "Duration", + "description": "[%key:component::geniushub::services::set_zone_override::fields::duration::description%]" + } + } + } + } +} diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index b922d98f25e..c0192a0037d 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_URL, UnitOfLength, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -81,12 +81,17 @@ async def async_setup_platform( """Set up the GeoJSON Events platform.""" async_create_issue( hass, - DOMAIN, - "deprecated_yaml", + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", breaks_in_ha_version="2023.12.0", is_fixable=False, + issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "GeoJSON feed", + }, ) hass.async_create_task( hass.config_entries.flow.async_init( diff --git a/homeassistant/components/geo_json_events/strings.json b/homeassistant/components/geo_json_events/strings.json index e50369d6e74..1a2409b1cd2 100644 --- a/homeassistant/components/geo_json_events/strings.json +++ b/homeassistant/components/geo_json_events/strings.json @@ -12,11 +12,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The GeoJSON feed YAML configuration is being removed", - "description": "Configuring a GeoJSON feed using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the GeoJSON feed YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py index 24632e78454..5527f5ec9f1 100644 --- a/homeassistant/components/geo_location/trigger.py +++ b/homeassistant/components/geo_location/trigger.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.const import CONF_EVENT, CONF_PLATFORM, CONF_SOURCE, CONF_ZONE from homeassistant.core import ( CALLBACK_TYPE, - Event, HassJob, HomeAssistant, State, @@ -17,9 +16,13 @@ from homeassistant.core import ( ) from homeassistant.helpers import condition, config_validation as cv from homeassistant.helpers.config_validation import entity_domain -from homeassistant.helpers.event import TrackStates, async_track_state_change_filtered +from homeassistant.helpers.event import ( + EventStateChangedData, + TrackStates, + async_track_state_change_filtered, +) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType from . import DOMAIN @@ -60,11 +63,11 @@ async def async_attach_trigger( job = HassJob(action) @callback - def state_change_listener(event: Event) -> None: + def state_change_listener(event: EventType[EventStateChangedData]) -> None: """Handle specific state changes.""" # Skip if the event's source does not match the trigger's source. - from_state = event.data.get("old_state") - to_state = event.data.get("new_state") + from_state = event.data["old_state"] + to_state = event.data["new_state"] if not source_match(from_state, source) and not source_match(to_state, source): return @@ -96,7 +99,7 @@ async def async_attach_trigger( **trigger_data, "platform": "geo_location", "source": source, - "entity_id": event.data.get("entity_id"), + "entity_id": event.data["entity_id"], "from_state": from_state, "to_state": to_state, "zone": zone_state, diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index 892116121a0..66cbbcbd67e 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -50,6 +50,9 @@ async def async_setup_entry( class GeofencyEntity(TrackerEntity, RestoreEntity): """Represent a tracked device.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, device, gps=None, location_name=None, attributes=None): """Set up Geofency entity.""" self._attributes = attributes or {} @@ -79,11 +82,6 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): """Return a location name for the current location of the device.""" return self._location_name - @property - def name(self): - """Return the name of the device.""" - return self._name - @property def unique_id(self): """Return the unique ID.""" @@ -92,7 +90,10 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): @property def device_info(self) -> DeviceInfo: """Return the device info.""" - return DeviceInfo(identifiers={(GF_DOMAIN, self._unique_id)}, name=self._name) + return DeviceInfo( + identifiers={(GF_DOMAIN, self._unique_id)}, + name=self._name, + ) @property def source_type(self) -> SourceType: @@ -125,7 +126,7 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): @callback def _async_receive_data(self, device, gps, location_name, attributes): """Mark the device as seen.""" - if device != self.name: + if device != self._name: return self._attributes.update(attributes) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 213fabc911b..2b56a9f6cbb 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # We used to use int in device_entry identifiers, convert this to str. device_registry = dr.async_get(hass) old_ids = (DOMAIN, station_id) - device_entry = device_registry.async_get_device({old_ids}) # type: ignore[arg-type] + device_entry = device_registry.async_get_device(identifiers={old_ids}) # type: ignore[arg-type] if device_entry and entry.entry_id in device_entry.config_entries: new_ids = (DOMAIN, str(station_id)) device_registry.async_update_device(device_entry.id, new_identifiers={new_ids}) diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 61ec45a98f9..9001824d678 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -72,7 +72,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Wh stored", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="volts", diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index a3a5b7246b6..f37e120db68 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -51,9 +51,7 @@ class DeviceAuth(AuthImplementation): async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve a Google API Credentials object to Home Assistant token.""" creds: Credentials = external_data[DEVICE_AUTH_CREDS] - delta = ( - creds.token_expiry.replace(tzinfo=datetime.timezone.utc) - dt_util.utcnow() - ) + delta = creds.token_expiry.replace(tzinfo=datetime.UTC) - dt_util.utcnow() _LOGGER.debug( "Token expires at %s (in %s)", creds.token_expiry, delta.total_seconds() ) @@ -116,7 +114,7 @@ class DeviceFlow: # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime # object without tzinfo. For the comparison below to work, it needs one. user_code_expiry = self._device_flow_info.user_code_expiry.replace( - tzinfo=datetime.timezone.utc + tzinfo=datetime.UTC ) expiration_time = min(user_code_expiry, max_timeout) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index f4177e8c300..d5329598655 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@allenporter"], "config_flow": true, "dependencies": ["application_credentials"], - "documentation": "https://www.home-assistant.io/integrations/calendar.google/", + "documentation": "https://www.home-assistant.io/integrations/calendar.google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], "requirements": ["gcal-sync==4.1.4", "oauth2client==4.1.3"] diff --git a/homeassistant/components/google/services.yaml b/homeassistant/components/google/services.yaml index e7eeef75947..f715679dff8 100644 --- a/homeassistant/components/google/services.yaml +++ b/homeassistant/components/google/services.yaml @@ -1,111 +1,75 @@ add_event: - name: Add event - description: Add a new calendar event. fields: calendar_id: - name: Calendar ID - description: The id of the calendar you want. required: true example: "Your email" selector: text: summary: - name: Summary - description: Acts as the title of the event. required: true example: "Bowling" selector: text: description: - name: Description - description: The description of the event. Optional. example: "Birthday bowling" selector: text: start_date_time: - name: Start time - description: The date and time the event should start. example: "2019-03-22 20:00:00" selector: text: end_date_time: - name: End time - description: The date and time the event should end. example: "2019-03-22 22:00:00" selector: text: start_date: - name: Start date - description: The date the whole day event should start. example: "2019-03-10" selector: text: end_date: - name: End date - description: The date the whole day event should end. example: "2019-03-11" selector: text: in: - name: In - description: Days or weeks that you want to create the event in. example: '"days": 2 or "weeks": 2' selector: object: create_event: - name: Create event - description: Add a new calendar event. target: entity: integration: google domain: calendar fields: summary: - name: Summary - description: Acts as the title of the event. required: true example: "Bowling" selector: text: description: - name: Description - description: The description of the event. Optional. example: "Birthday bowling" selector: text: start_date_time: - name: Start time - description: The date and time the event should start. example: "2022-03-22 20:00:00" selector: text: end_date_time: - name: End time - description: The date and time the event should end. example: "2022-03-22 22:00:00" selector: text: start_date: - name: Start date - description: The date the whole day event should start. example: "2022-03-10" selector: text: end_date: - name: End date - description: The date the whole day event should end. example: "2022-03-11" selector: text: in: - name: In - description: Days or weeks that you want to create the event in. example: '"days": 2 or "weeks": 2' selector: object: location: - name: Location - description: The location of the event. Optional. example: "Conference Room - F123, Bldg. 002" selector: text: diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 5c9b6424473..b3594f31510 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -42,5 +42,83 @@ }, "application_credentials": { "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your Calendar:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **TV and Limited Input devices** for the Application Type.\n\n" + }, + "services": { + "add_event": { + "name": "Add event", + "description": "Adds a new calendar event.", + "fields": { + "calendar_id": { + "name": "Calendar ID", + "description": "The id of the calendar you want." + }, + "summary": { + "name": "Summary", + "description": "Acts as the title of the event." + }, + "description": { + "name": "Description", + "description": "The description of the event. Optional." + }, + "start_date_time": { + "name": "Start time", + "description": "The date and time the event should start." + }, + "end_date_time": { + "name": "End time", + "description": "The date and time the event should end." + }, + "start_date": { + "name": "Start date", + "description": "The date the whole day event should start." + }, + "end_date": { + "name": "End date", + "description": "The date the whole day event should end." + }, + "in": { + "name": "In", + "description": "Days or weeks that you want to create the event in." + } + } + }, + "create_event": { + "name": "Creates event", + "description": "Add a new calendar event.", + "fields": { + "summary": { + "name": "Summary", + "description": "[%key:component::google::services::add_event::fields::summary::description%]" + }, + "description": { + "name": "Description", + "description": "[%key:component::google::services::add_event::fields::description::description%]" + }, + "start_date_time": { + "name": "Start time", + "description": "The date and time the event should start." + }, + "end_date_time": { + "name": "End time", + "description": "The date and time the event should end." + }, + "start_date": { + "name": "Start date", + "description": "[%key:component::google::services::add_event::fields::start_date::description%]" + }, + "end_date": { + "name": "End date", + "description": "[%key:component::google::services::add_event::fields::end_date::description%]" + }, + "in": { + "name": "In", + "description": "Days or weeks that you want to create the event in." + }, + "location": { + "name": "[%key:common::config_flow::data::location%]", + "description": "The location of the event. Optional." + } + } + } } } diff --git a/homeassistant/components/google_assistant/button.py b/homeassistant/components/google_assistant/button.py index 415531214c5..47681308b53 100644 --- a/homeassistant/components/google_assistant/button.py +++ b/homeassistant/components/google_assistant/button.py @@ -34,14 +34,19 @@ async def async_setup_entry( class SyncButton(ButtonEntity): """Representation of a synchronization button.""" + _attr_has_entity_name = True + _attr_translation_key = "sync_devices" + def __init__(self, project_id: str, google_config: GoogleConfig) -> None: """Initialize button.""" super().__init__() self._google_config = google_config self._attr_entity_category = EntityCategory.DIAGNOSTIC self._attr_unique_id = f"{project_id}_sync" - self._attr_name = "Synchronize Devices" - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, project_id)}) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, project_id)}, + name="Google Assistant", + ) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/google_assistant/services.yaml b/homeassistant/components/google_assistant/services.yaml index fe5ef51c2ce..321eae3b2e9 100644 --- a/homeassistant/components/google_assistant/services.yaml +++ b/homeassistant/components/google_assistant/services.yaml @@ -1,9 +1,5 @@ request_sync: - name: Request sync - description: Send a request_sync command to Google. fields: agent_user_id: - name: Agent user ID - description: "Only needed for automations. Specific Home Assistant user id (not username, ID in configuration > users > under username) to sync with Google Assistant. Do not need when you call this service through Home Assistant front end or API. Used in automation script or other place where context.user_id is missing." selector: text: diff --git a/homeassistant/components/google_assistant/strings.json b/homeassistant/components/google_assistant/strings.json new file mode 100644 index 00000000000..8ef77f8d8c3 --- /dev/null +++ b/homeassistant/components/google_assistant/strings.json @@ -0,0 +1,21 @@ +{ + "entity": { + "button": { + "sync_devices": { + "name": "Synchronize devices" + } + } + }, + "services": { + "request_sync": { + "name": "Request sync", + "description": "Sends a request_sync command to Google.", + "fields": { + "agent_user_id": { + "name": "Agent user ID", + "description": "Only needed for automations. Specific Home Assistant user id (not username, ID in configuration > users > under username) to sync with Google Assistant. Do not need when you call this service through Home Assistant front end or API. Used in automation script or other place where context.user_id is missing." + } + } + } + } +} diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py index db2a8d9512e..4a294489c97 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -128,14 +128,6 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent): self.session: OAuth2Session | None = None self.language: str | None = None - @property - def attribution(self): - """Return the attribution.""" - return { - "name": "Powered by Google Assistant SDK", - "url": "https://www.home-assistant.io/integrations/google_assistant_sdk/", - } - @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" diff --git a/homeassistant/components/google_assistant_sdk/manifest.json b/homeassistant/components/google_assistant_sdk/manifest.json index 984bbdfe7c1..d52b7c18c41 100644 --- a/homeassistant/components/google_assistant_sdk/manifest.json +++ b/homeassistant/components/google_assistant_sdk/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@tronikos"], "config_flow": true, "dependencies": ["application_credentials", "http"], - "documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk/", + "documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk", "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", diff --git a/homeassistant/components/google_assistant_sdk/services.yaml b/homeassistant/components/google_assistant_sdk/services.yaml index fc2a3ad264f..f8853ec93ea 100644 --- a/homeassistant/components/google_assistant_sdk/services.yaml +++ b/homeassistant/components/google_assistant_sdk/services.yaml @@ -1,16 +1,10 @@ send_text_command: - name: Send text command - description: Send a command as a text query to Google Assistant. fields: command: - name: Command - description: Command(s) to send to Google Assistant. example: turn off kitchen TV selector: text: media_player: - name: Media Player Entity - description: Name(s) of media player entities to play response on example: media_player.living_room_speaker selector: entity: diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 66a2b975b5e..e9e2b7d4c09 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -38,5 +38,21 @@ }, "application_credentials": { "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Assistant SDK. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + }, + "services": { + "send_text_command": { + "name": "Send text command", + "description": "Sends a command as a text query to Google Assistant.", + "fields": { + "command": { + "name": "Command", + "description": "Command(s) to send to Google Assistant." + }, + "media_player": { + "name": "Media player entity", + "description": "Name(s) of media player entities to play response on." + } + } + } } } diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 3d0fac63420..1154c7132d2 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -69,14 +69,6 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): self.entry = entry self.history: dict[str, list[dict]] = {} - @property - def attribution(self): - """Return the attribution.""" - return { - "name": "Powered by Google Generative AI", - "url": "https://developers.generativeai.google/", - } - @property def supported_languages(self) -> list[str] | Literal["*"]: """Return a list of supported languages.""" diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 2df5398222c..2b1b41a2c28 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -18,7 +18,7 @@ "init": { "data": { "prompt": "Prompt Template", - "model": "Model", + "model": "[%key:common::generic::model%]", "temperature": "Temperature", "top_p": "Top P", "top_k": "Top K" diff --git a/homeassistant/components/google_mail/manifest.json b/homeassistant/components/google_mail/manifest.json index 1375ae45392..dfc5e279dc5 100644 --- a/homeassistant/components/google_mail/manifest.json +++ b/homeassistant/components/google_mail/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@tkdrob"], "config_flow": true, "dependencies": ["application_credentials"], - "documentation": "https://www.home-assistant.io/integrations/google_mail/", + "documentation": "https://www.home-assistant.io/integrations/google_mail", "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["google-api-python-client==2.71.0"] diff --git a/homeassistant/components/google_mail/services.yaml b/homeassistant/components/google_mail/services.yaml index 76ef40fa3aa..9ce1c41f27a 100644 --- a/homeassistant/components/google_mail/services.yaml +++ b/homeassistant/components/google_mail/services.yaml @@ -1,6 +1,4 @@ set_vacation: - name: Set Vacation - description: Set vacation responder settings for Google Mail. target: device: integration: google_mail @@ -8,46 +6,30 @@ set_vacation: integration: google_mail fields: enabled: - name: Enabled required: true default: true - description: Turn this off to end vacation responses. selector: boolean: title: - name: Title - description: The subject for the email selector: text: message: - name: Message - description: Body of the email required: true selector: text: plain_text: - name: Plain text default: true - description: Choose to send message in plain text or HTML. selector: boolean: restrict_contacts: - name: Restrict to Contacts - description: Restrict automatic reply to contacts. selector: boolean: restrict_domain: - name: Restrict to Domain - description: Restrict automatic reply to domain. This only affects GSuite accounts. selector: boolean: start: - name: start - description: First day of the vacation selector: date: end: - name: end - description: Last day of the vacation selector: date: diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index 2f76806dfd3..2bd70750ff9 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -37,5 +37,45 @@ "name": "Vacation end date" } } + }, + "services": { + "set_vacation": { + "name": "Set vacation", + "description": "Sets vacation responder settings for Google Mail.", + "fields": { + "enabled": { + "name": "[%key:common::state::enabled%]", + "description": "Turn this off to end vacation responses." + }, + "title": { + "name": "Title", + "description": "The subject for the email." + }, + "message": { + "name": "Message", + "description": "Body of the email." + }, + "plain_text": { + "name": "Plain text", + "description": "Choose to send message in plain text or HTML." + }, + "restrict_contacts": { + "name": "Restrict to contacts", + "description": "Restrict automatic reply to contacts." + }, + "restrict_domain": { + "name": "Restrict to domain", + "description": "Restrict automatic reply to domain. This only affects GSuite accounts." + }, + "start": { + "name": "[%key:common::action::start%]", + "description": "First day of the vacation." + }, + "end": { + "name": "End", + "description": "Last day of the vacation." + } + } + } } } diff --git a/homeassistant/components/google_sheets/manifest.json b/homeassistant/components/google_sheets/manifest.json index 5b2e5da8902..6fae364df3b 100644 --- a/homeassistant/components/google_sheets/manifest.json +++ b/homeassistant/components/google_sheets/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@tkdrob"], "config_flow": true, "dependencies": ["application_credentials"], - "documentation": "https://www.home-assistant.io/integrations/google_sheets/", + "documentation": "https://www.home-assistant.io/integrations/google_sheets", "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["gspread==5.5.0"] diff --git a/homeassistant/components/google_sheets/services.yaml b/homeassistant/components/google_sheets/services.yaml index 7524ba50fb5..169352d1bac 100644 --- a/homeassistant/components/google_sheets/services.yaml +++ b/homeassistant/components/google_sheets/services.yaml @@ -1,23 +1,15 @@ append_sheet: - name: Append to Sheet - description: Append data to a worksheet in Google Sheets. fields: config_entry: - name: Sheet - description: The sheet to add data to required: true selector: config_entry: integration: google_sheets worksheet: - name: Worksheet - description: Name of the worksheet. Defaults to the first one in the document. example: "Sheet1" selector: text: data: - name: Data - description: Data to be appended to the worksheet. This puts the values on a new row underneath the matching column (key). Any new key is placed on the top of a new column. required: true example: '{"hello": world, "cool": True, "count": 5}' selector: diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index 602301758f8..b2cba19031e 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -31,5 +31,25 @@ }, "application_credentials": { "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Sheets. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + }, + "services": { + "append_sheet": { + "name": "Append to sheet", + "description": "Appends data to a worksheet in Google Sheets.", + "fields": { + "config_entry": { + "name": "Sheet", + "description": "The sheet to add data to." + }, + "worksheet": { + "name": "Worksheet", + "description": "Name of the worksheet. Defaults to the first one in the document." + }, + "data": { + "name": "Data", + "description": "Data to be appended to the worksheet. This puts the values on a new row underneath the matching column (key). Any new key is placed on the top of a new column." + } + } + } } } diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index 317f2619bef..4cce9290a68 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -66,8 +66,11 @@ async def async_setup_entry( class GPSLoggerEntity(TrackerEntity, RestoreEntity): """Represent a tracked device.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, device, location, battery, accuracy, attributes): - """Set up Geofency entity.""" + """Set up GPSLogger entity.""" self._accuracy = accuracy self._attributes = attributes self._name = device @@ -101,11 +104,6 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity): """Return the gps accuracy of the device.""" return self._accuracy - @property - def name(self): - """Return the name of the device.""" - return self._name - @property def unique_id(self): """Return the unique ID.""" @@ -114,7 +112,10 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity): @property def device_info(self) -> DeviceInfo: """Return the device info.""" - return DeviceInfo(identifiers={(GPL_DOMAIN, self._unique_id)}, name=self._name) + return DeviceInfo( + identifiers={(GPL_DOMAIN, self._unique_id)}, + name=self._name, + ) @property def source_type(self) -> SourceType: @@ -165,7 +166,7 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity): @callback def _async_receive_data(self, device, location, battery, accuracy, attributes): """Mark the device as seen.""" - if device != self.name: + if device != self._name: return self._location = location diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 9480fa3ce17..33df9822ac2 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -10,7 +10,6 @@ from typing import Any, Protocol, cast import voluptuous as vol -from homeassistant import core as ha from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -29,7 +28,6 @@ from homeassistant.const import ( ) from homeassistant.core import ( CALLBACK_TYPE, - Event, HomeAssistant, ServiceCall, State, @@ -39,13 +37,16 @@ from homeassistant.core import ( from homeassistant.helpers import config_validation as cv, entity_registry as er, start from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) from homeassistant.helpers.integration_platform import ( async_process_integration_platform_for_component, async_process_integration_platforms, ) from homeassistant.helpers.reload import async_reload_integration_platforms -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.loader import bind_hass from .const import CONF_HIDE_MEMBERS @@ -82,6 +83,8 @@ PLATFORMS = [ REG_KEY = f"{DOMAIN}_registry" +ENTITY_PREFIX = f"{DOMAIN}." + _LOGGER = logging.getLogger(__name__) current_domain: ContextVar[str] = ContextVar("current_domain") @@ -180,28 +183,19 @@ def expand_entity_ids(hass: HomeAssistant, entity_ids: Iterable[Any]) -> list[st continue entity_id = entity_id.lower() - - try: - # If entity_id points at a group, expand it - domain, _ = ha.split_entity_id(entity_id) - - if domain == DOMAIN: - child_entities = get_entity_ids(hass, entity_id) - if entity_id in child_entities: - child_entities = list(child_entities) - child_entities.remove(entity_id) - found_ids.extend( - ent_id - for ent_id in expand_entity_ids(hass, child_entities) - if ent_id not in found_ids - ) - - elif entity_id not in found_ids: - found_ids.append(entity_id) - - except AttributeError: - # Raised by split_entity_id if entity_id is not a string - pass + # If entity_id points at a group, expand it + if entity_id.startswith(ENTITY_PREFIX): + child_entities = get_entity_ids(hass, entity_id) + if entity_id in child_entities: + child_entities = list(child_entities) + child_entities.remove(entity_id) + found_ids.extend( + ent_id + for ent_id in expand_entity_ids(hass, child_entities) + if ent_id not in found_ids + ) + elif entity_id not in found_ids: + found_ids.append(entity_id) return found_ids @@ -745,7 +739,9 @@ class Group(Entity): """Handle removal from Home Assistant.""" self._async_stop() - async def _async_state_changed_listener(self, event: Event) -> None: + async def _async_state_changed_listener( + self, event: EventType[EventStateChangedData] + ) -> None: """Respond to a member state changing. This method must be run in the event loop. @@ -756,7 +752,7 @@ class Group(Entity): self.async_set_context(event.context) - if (new_state := event.data.get("new_state")) is None: + if (new_state := event.data["new_state"]) is None: # The state was removed from the state machine self._reset_tracked_state() diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index 112b111bdca..7415ee8c60d 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -21,11 +21,14 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from . import GroupEntity @@ -114,7 +117,9 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity): """Register callbacks.""" @callback - def async_state_changed_listener(event: Event) -> None: + def async_state_changed_listener( + event: EventType[EventStateChangedData], + ) -> None: """Handle child updates.""" self.async_set_context(event.context) self.async_defer_or_update_ha_state() diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 38928302eb1..784ac9a94af 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -38,11 +38,14 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from . import GroupEntity from .util import attribute_equal, reduce_attribute @@ -126,10 +129,13 @@ class CoverGroup(GroupEntity, CoverEntity): self._attr_unique_id = unique_id @callback - def _update_supported_features_event(self, event: Event) -> None: + def _update_supported_features_event( + self, event: EventType[EventStateChangedData] + ) -> None: self.async_set_context(event.context) - if (entity := event.data.get("entity_id")) is not None: - self.async_update_supported_features(entity, event.data.get("new_state")) + self.async_update_supported_features( + event.data["entity_id"], event.data["new_state"] + ) @callback def async_update_supported_features( diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 0c4c59d2454..1fcb859f926 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -35,11 +35,14 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from . import GroupEntity from .util import ( @@ -142,10 +145,13 @@ class FanGroup(GroupEntity, FanEntity): return self._oscillating @callback - def _update_supported_features_event(self, event: Event) -> None: + def _update_supported_features_event( + self, event: EventType[EventStateChangedData] + ) -> None: self.async_set_context(event.context) - if (entity := event.data.get("entity_id")) is not None: - self.async_update_supported_features(entity, event.data.get("new_state")) + self.async_update_supported_features( + event.data["entity_id"], event.data["new_state"] + ) @callback def async_update_supported_features( diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 33d240a9a4d..e0f7974631b 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -44,11 +44,14 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from . import GroupEntity from .util import find_state_attributes, mean_tuple, reduce_attribute @@ -154,7 +157,9 @@ class LightGroup(GroupEntity, LightEntity): """Register callbacks.""" @callback - def async_state_changed_listener(event: Event) -> None: + def async_state_changed_listener( + event: EventType[EventStateChangedData], + ) -> None: """Handle child updates.""" self.async_set_context(event.context) self.async_defer_or_update_ha_state() diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index 07d08c7851d..233d1155c53 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -28,11 +28,14 @@ from homeassistant.const import ( STATE_UNKNOWN, STATE_UNLOCKING, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from . import GroupEntity @@ -115,7 +118,9 @@ class LockGroup(GroupEntity, LockEntity): """Register callbacks.""" @callback - def async_state_changed_listener(event: Event) -> None: + def async_state_changed_listener( + event: EventType[EventStateChangedData], + ) -> None: """Handle child updates.""" self.async_set_context(event.context) self.async_defer_or_update_ha_state() diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index 15be22ddfbf..f0d076ec130 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -47,10 +47,15 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType +KEY_ANNOUNCE = "announce" KEY_CLEAR_PLAYLIST = "clear_playlist" +KEY_ENQUEUE = "enqueue" KEY_ON_OFF = "on_off" KEY_PAUSE_PLAY_STOP = "play" KEY_PLAY_MEDIA = "play_media" @@ -115,7 +120,9 @@ class MediaPlayerGroup(MediaPlayerEntity): self._entities = entities self._features: dict[str, set[str]] = { + KEY_ANNOUNCE: set(), KEY_CLEAR_PLAYLIST: set(), + KEY_ENQUEUE: set(), KEY_ON_OFF: set(), KEY_PAUSE_PLAY_STOP: set(), KEY_PLAY_MEDIA: set(), @@ -126,11 +133,11 @@ class MediaPlayerGroup(MediaPlayerEntity): } @callback - def async_on_state_change(self, event: EventType) -> None: + def async_on_state_change(self, event: EventType[EventStateChangedData]) -> None: """Update supported features and state when a new state is received.""" self.async_set_context(event.context) self.async_update_supported_features( - event.data.get("entity_id"), event.data.get("new_state") # type: ignore[arg-type] + event.data["entity_id"], event.data["new_state"] ) self.async_update_state() @@ -192,6 +199,14 @@ class MediaPlayerGroup(MediaPlayerEntity): self._features[KEY_VOLUME].add(entity_id) else: self._features[KEY_VOLUME].discard(entity_id) + if new_features & MediaPlayerEntityFeature.MEDIA_ANNOUNCE: + self._features[KEY_ANNOUNCE].add(entity_id) + else: + self._features[KEY_ANNOUNCE].discard(entity_id) + if new_features & MediaPlayerEntityFeature.MEDIA_ENQUEUE: + self._features[KEY_ENQUEUE].add(entity_id) + else: + self._features[KEY_ENQUEUE].discard(entity_id) async def async_added_to_hass(self) -> None: """Register listeners.""" @@ -434,6 +449,10 @@ class MediaPlayerGroup(MediaPlayerEntity): | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_STEP ) + if self._features[KEY_ANNOUNCE]: + supported_features |= MediaPlayerEntityFeature.MEDIA_ANNOUNCE + if self._features[KEY_ENQUEUE]: + supported_features |= MediaPlayerEntityFeature.MEDIA_ENQUEUE self._attr_supported_features = supported_features self.async_write_ha_state() diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 4c6e8dccc1e..d62447d9947 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -33,11 +33,19 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ( + ConfigType, + DiscoveryInfoType, + EventType, + StateType, +) from . import GroupEntity from .const import CONF_IGNORE_NON_NUMERIC @@ -299,7 +307,9 @@ class SensorGroup(GroupEntity, SensorEntity): """Register callbacks.""" @callback - def async_state_changed_listener(event: Event) -> None: + def async_state_changed_listener( + event: EventType[EventStateChangedData], + ) -> None: """Handle child updates.""" self.async_set_context(event.context) self.async_defer_or_update_ha_state() diff --git a/homeassistant/components/group/services.yaml b/homeassistant/components/group/services.yaml index fdb1a1af014..e5ac921cc77 100644 --- a/homeassistant/components/group/services.yaml +++ b/homeassistant/components/group/services.yaml @@ -1,62 +1,39 @@ # Describes the format for available group services reload: - name: Reload - description: Reload group configuration, entities, and notify services. - set: - name: Set - description: Create/Update a user group. fields: object_id: - name: Object ID - description: Group id and part of entity id. required: true example: "test_group" selector: text: name: - name: Name - description: Name of group example: "My test group" selector: text: icon: - name: Icon - description: Name of icon for the group. example: "mdi:camera" selector: icon: entities: - name: Entities - description: List of all members in the group. Not compatible with 'delta'. example: domain.entity_id1, domain.entity_id2 selector: object: add_entities: - name: Add Entities - description: List of members that will change on group listening. example: domain.entity_id1, domain.entity_id2 selector: object: remove_entities: - name: Remove Entities - description: List of members that will be removed from group listening. example: domain.entity_id1, domain.entity_id2 selector: object: all: - name: All - description: Enable this option if the group should only turn on when all entities are on. selector: boolean: remove: - name: Remove - description: Remove a user group. fields: object_id: - name: Object ID - description: Group id and part of entity id. required: true example: "test_group" selector: diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 192823cef65..1c656b46b9e 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -23,7 +23,7 @@ "all": "All entities", "entities": "Members", "hide_members": "Hide members", - "name": "Name" + "name": "[%key:common::config_flow::data::name%]" } }, "cover": { @@ -70,9 +70,9 @@ "title": "[%key:component::group::config::step::user::title%]", "data": { "ignore_non_numeric": "Ignore non-numeric", - "entities": "Members", - "hide_members": "Hide members", - "name": "Name", + "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", + "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", + "name": "[%key:common::config_flow::data::name%]", "type": "Type", "round_digits": "Round value to number of decimals", "device_class": "Device class", @@ -172,7 +172,7 @@ }, "state_attributes": { "entity_id": { - "name": "Members" + "name": "[%key:component::group::config::step::binary_sensor::data::entities%]" } } } @@ -190,5 +190,55 @@ "product": "Product" } } + }, + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads group configuration, entities, and notify services from YAML-configuration." + }, + "set": { + "name": "Set", + "description": "Creates/Updates a user group.", + "fields": { + "object_id": { + "name": "Object ID", + "description": "Object ID of this group. This object ID is used as part of the entity ID. Entity ID format: [domain].[object_id]." + }, + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "Name of the group." + }, + "icon": { + "name": "Icon", + "description": "Name of the icon for the group." + }, + "entities": { + "name": "Entities", + "description": "List of all members in the group. Cannot be used in combination with `Add entities` or `Remove entities`." + }, + "add_entities": { + "name": "Add entities", + "description": "List of members to be added to the group. Cannot be used in combination with `Entities` or `Remove entities`." + }, + "remove_entities": { + "name": "Remove entities", + "description": "List of members to be removed from a group. Cannot be used in combination with `Entities` or `Add entities`." + }, + "all": { + "name": "All", + "description": "Enable this option if the group should only be used when all entities are in state `on`." + } + } + }, + "remove": { + "name": "Remove", + "description": "Removes a group.", + "fields": { + "object_id": { + "name": "[%key:component::group::services::set::fields::object_id::name%]", + "description": "[%key:component::group::services::set::fields::object_id::description%]" + } + } + } } } diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index 4b6b959ba17..f62c805ba1d 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -19,11 +19,14 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from . import GroupEntity @@ -113,7 +116,9 @@ class SwitchGroup(GroupEntity, SwitchEntity): """Register callbacks.""" @callback - def async_state_changed_listener(event: Event) -> None: + def async_state_changed_listener( + event: EventType[EventStateChangedData], + ) -> None: """Handle child updates.""" self.async_set_context(event.context) self.async_defer_or_update_ha_state() diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index 7cdf12ab6bd..a21c811af47 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -3,7 +3,7 @@ "name": "Growatt", "codeowners": ["@muppet3000"], "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/growatt_server/", + "documentation": "https://www.home-assistant.io/integrations/growatt_server", "iot_class": "cloud_polling", "loggers": ["growattServer"], "requirements": ["growattServer==1.3.0"] diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index d2c196dbfdd..f507387e628 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -188,10 +188,10 @@ "name": "Grid discharged today" }, "storage_load_consumption_today": { - "name": "Load consumption today" + "name": "[%key:component::growatt_server::entity::sensor::mix_load_consumption_today::name%]" }, "storage_load_consumption_lifetime": { - "name": "Lifetime load consumption" + "name": "[%key:component::growatt_server::entity::sensor::mix_load_consumption_lifetime::name%]" }, "storage_grid_charged_today": { "name": "Grid charged today" @@ -215,7 +215,7 @@ "name": "Charge today" }, "storage_import_from_grid": { - "name": "Import from grid" + "name": "[%key:component::growatt_server::entity::sensor::mix_import_from_grid::name%]" }, "storage_import_from_grid_today": { "name": "Import from grid today" @@ -224,7 +224,7 @@ "name": "Import from grid total" }, "storage_load_consumption": { - "name": "Load consumption" + "name": "[%key:component::growatt_server::entity::sensor::mix_load_consumption::name%]" }, "storage_grid_voltage": { "name": "AC input voltage" @@ -263,7 +263,7 @@ "name": "Energy today" }, "tlx_energy_total": { - "name": "Lifetime energy output" + "name": "[%key:component::growatt_server::entity::sensor::inverter_energy_total::name%]" }, "tlx_energy_total_input_1": { "name": "Lifetime total energy input 1" @@ -272,13 +272,13 @@ "name": "Energy Today Input 1" }, "tlx_voltage_input_1": { - "name": "Input 1 voltage" + "name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_1::name%]" }, "tlx_amperage_input_1": { - "name": "Input 1 Amperage" + "name": "[%key:component::growatt_server::entity::sensor::inverter_amperage_input_1::name%]" }, "tlx_wattage_input_1": { - "name": "Input 1 Wattage" + "name": "[%key:component::growatt_server::entity::sensor::inverter_wattage_input_1::name%]" }, "tlx_energy_total_input_2": { "name": "Lifetime total energy input 2" @@ -287,13 +287,13 @@ "name": "Energy Today Input 2" }, "tlx_voltage_input_2": { - "name": "Input 2 voltage" + "name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_2::name%]" }, "tlx_amperage_input_2": { - "name": "Input 2 Amperage" + "name": "[%key:component::growatt_server::entity::sensor::inverter_amperage_input_2::name%]" }, "tlx_wattage_input_2": { - "name": "Input 2 Wattage" + "name": "[%key:component::growatt_server::entity::sensor::inverter_wattage_input_2::name%]" }, "tlx_energy_total_input_3": { "name": "Lifetime total energy input 3" @@ -302,13 +302,13 @@ "name": "Energy Today Input 3" }, "tlx_voltage_input_3": { - "name": "Input 3 voltage" + "name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_3::name%]" }, "tlx_amperage_input_3": { - "name": "Input 3 Amperage" + "name": "[%key:component::growatt_server::entity::sensor::inverter_amperage_input_3::name%]" }, "tlx_wattage_input_3": { - "name": "Input 3 Wattage" + "name": "[%key:component::growatt_server::entity::sensor::inverter_wattage_input_3::name%]" }, "tlx_energy_total_input_4": { "name": "Lifetime total energy input 4" @@ -329,16 +329,16 @@ "name": "Lifetime total solar energy" }, "tlx_internal_wattage": { - "name": "Internal wattage" + "name": "[%key:component::growatt_server::entity::sensor::inverter_internal_wattage::name%]" }, "tlx_reactive_voltage": { - "name": "Reactive voltage" + "name": "[%key:component::growatt_server::entity::sensor::inverter_reactive_voltage::name%]" }, "tlx_frequency": { - "name": "AC frequency" + "name": "[%key:component::growatt_server::entity::sensor::inverter_frequency::name%]" }, "tlx_current_wattage": { - "name": "Output power" + "name": "[%key:component::growatt_server::entity::sensor::inverter_current_wattage::name%]" }, "tlx_temperature_1": { "name": "Temperature 1" @@ -392,13 +392,13 @@ "name": "Lifetime total battery 2 charged" }, "tlx_export_to_grid_today": { - "name": "Export to grid today" + "name": "[%key:component::growatt_server::entity::sensor::mix_export_to_grid_today::name%]" }, "tlx_export_to_grid_total": { "name": "Lifetime total export to grid" }, "tlx_load_consumption_today": { - "name": "Load consumption today" + "name": "[%key:component::growatt_server::entity::sensor::mix_load_consumption_today::name%]" }, "mix_load_consumption_total": { "name": "Lifetime total load consumption" @@ -419,7 +419,7 @@ "name": "Output Power" }, "total_energy_output": { - "name": "Lifetime energy output" + "name": "[%key:component::growatt_server::entity::sensor::inverter_energy_total::name%]" }, "total_maximum_output": { "name": "Maximum power" diff --git a/homeassistant/components/gtfs/manifest.json b/homeassistant/components/gtfs/manifest.json index e7f7e617df9..73a5998ea92 100644 --- a/homeassistant/components/gtfs/manifest.json +++ b/homeassistant/components/gtfs/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/gtfs", "iot_class": "local_polling", "loggers": ["pygtfs"], - "requirements": ["pygtfs==0.1.7"] + "requirements": ["pygtfs==0.1.9"] } diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index f587ef2e54c..ec8bd818d38 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -25,7 +25,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import DeviceInfo, EntityDescription -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -107,45 +106,6 @@ def async_get_entry_id_for_service_call(hass: HomeAssistant, call: ServiceCall) raise ValueError(f"No config entry for device ID: {device_id}") -@callback -def async_log_deprecated_service_call( - hass: HomeAssistant, - call: ServiceCall, - alternate_service: str, - alternate_target: str, - breaks_in_ha_version: str, -) -> None: - """Log a warning about a deprecated service call.""" - deprecated_service = f"{call.domain}.{call.service}" - - async_create_issue( - hass, - DOMAIN, - f"deprecated_service_{deprecated_service}", - breaks_in_ha_version=breaks_in_ha_version, - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_service", - translation_placeholders={ - "alternate_service": alternate_service, - "alternate_target": alternate_target, - "deprecated_service": deprecated_service, - }, - ) - - LOGGER.warning( - ( - 'The "%s" service is deprecated and will be removed in %s; use the "%s" ' - 'service and pass it a target entity ID of "%s"' - ), - deprecated_service, - breaks_in_ha_version, - alternate_service, - alternate_target, - ) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Elexa Guardian from a config entry.""" client = Client(entry.data[CONF_IP_ADDRESS], port=entry.data[CONF_PORT]) diff --git a/homeassistant/components/guardian/services.yaml b/homeassistant/components/guardian/services.yaml index 7415ac626a9..c8f2414a87b 100644 --- a/homeassistant/components/guardian/services.yaml +++ b/homeassistant/components/guardian/services.yaml @@ -1,68 +1,46 @@ # Describes the format for available Elexa Guardians services pair_sensor: - name: Pair Sensor - description: Add a new paired sensor to the valve controller. fields: device_id: - name: Valve Controller - description: The valve controller to add the sensor to required: true selector: device: integration: guardian uid: - name: UID - description: The UID of the paired sensor required: true example: 5410EC688BCF selector: text: unpair_sensor: - name: Unpair Sensor - description: Remove a paired sensor from the valve controller. fields: device_id: - name: Valve Controller - description: The valve controller to remove the sensor from required: true selector: device: integration: guardian uid: - name: UID - description: The UID of the paired sensor required: true example: 5410EC688BCF selector: text: upgrade_firmware: - name: Upgrade firmware - description: Upgrade the device firmware. fields: device_id: - name: Valve Controller - description: The valve controller whose firmware should be upgraded required: true selector: device: integration: guardian url: - name: URL - description: The URL of the server hosting the firmware file. example: https://repo.guardiancloud.services/gvc/fw selector: text: port: - name: Port - description: The port on which the firmware file is served. example: 443 selector: number: min: 1 max: 65535 filename: - name: Filename - description: The firmware filename. example: latest.bin selector: text: diff --git a/homeassistant/components/guardian/strings.json b/homeassistant/components/guardian/strings.json index dc3e6f4c17d..59630e87932 100644 --- a/homeassistant/components/guardian/strings.json +++ b/homeassistant/components/guardian/strings.json @@ -18,19 +18,6 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, - "issues": { - "deprecated_service": { - "title": "The {deprecated_service} service will be removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The {deprecated_service} service will be removed", - "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`." - } - } - } - } - }, "entity": { "binary_sensor": { "leak": { @@ -58,5 +45,57 @@ "name": "Valve controller" } } + }, + "services": { + "pair_sensor": { + "name": "Pair sensor", + "description": "Adds a new paired sensor to the valve controller.", + "fields": { + "device_id": { + "name": "[%key:component::guardian::entity::switch::valve_controller::name%]", + "description": "The valve controller to add the sensor to." + }, + "uid": { + "name": "UID", + "description": "The UID of the paired sensor." + } + } + }, + "unpair_sensor": { + "name": "Unpair sensor", + "description": "Removes a paired sensor from the valve controller.", + "fields": { + "device_id": { + "name": "[%key:component::guardian::entity::switch::valve_controller::name%]", + "description": "The valve controller to remove the sensor from." + }, + "uid": { + "name": "[%key:component::guardian::services::pair_sensor::fields::uid::name%]", + "description": "[%key:component::guardian::services::pair_sensor::fields::uid::description%]" + } + } + }, + "upgrade_firmware": { + "name": "Upgrade firmware", + "description": "Upgrades the device firmware.", + "fields": { + "device_id": { + "name": "[%key:component::guardian::entity::switch::valve_controller::name%]", + "description": "The valve controller whose firmware should be upgraded." + }, + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "The URL of the server hosting the firmware file." + }, + "port": { + "name": "[%key:common::config_flow::data::port%]", + "description": "The port on which the firmware file is served." + }, + "filename": { + "name": "Filename", + "description": "The firmware filename." + } + } + } } } diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml index e60e2238088..a7ef39eb529 100644 --- a/homeassistant/components/habitica/services.yaml +++ b/homeassistant/components/habitica/services.yaml @@ -1,25 +1,17 @@ # Describes the format for Habitica service api_call: - name: API name - description: Call Habitica API fields: name: - name: Name - description: Habitica's username to call for required: true example: "xxxNotAValidNickxxx" selector: text: path: - name: Path - description: "Items from API URL in form of an array with method attached at the end. Consult https://habitica.com/apidoc/. Example uses https://habitica.com/apidoc/#api-Task-CreateUserTasks" required: true example: '["tasks", "user", "post"]' selector: object: args: - name: Args - description: Any additional JSON or URL parameter arguments. See apidoc mentioned for path. Example uses same API endpoint example: '{"text": "Use API from Home Assistant", "type": "todo"}' selector: object: diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 3fe73d84667..8dacb0e6321 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -18,5 +18,25 @@ "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks. Note that api_id and api_key must be gotten from https://habitica.com/user/settings/api" } } + }, + "services": { + "api_call": { + "name": "API name", + "description": "Calls Habitica API.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "Habitica's username to call for." + }, + "path": { + "name": "[%key:common::config_flow::data::path%]", + "description": "Items from API URL in form of an array with method attached at the end. Consult https://habitica.com/apidoc/. Example uses https://habitica.com/apidoc/#api-Task-CreateUserTasks." + }, + "args": { + "name": "Args", + "description": "Any additional JSON or URL parameter arguments. See apidoc mentioned for path. Example uses same API endpoint." + } + } + } } } diff --git a/homeassistant/components/harmony/services.yaml b/homeassistant/components/harmony/services.yaml index fd53912397a..be2a3178a8b 100644 --- a/homeassistant/components/harmony/services.yaml +++ b/homeassistant/components/harmony/services.yaml @@ -1,22 +1,16 @@ sync: - name: Sync - description: Syncs the remote's configuration. target: entity: integration: harmony domain: remote change_channel: - name: Change channel - description: Sends change channel command to the Harmony HUB target: entity: integration: harmony domain: remote fields: channel: - name: Channel - description: Channel number to change to required: true selector: number: diff --git a/homeassistant/components/harmony/strings.json b/homeassistant/components/harmony/strings.json index 62de202372b..9ae22090d7f 100644 --- a/homeassistant/components/harmony/strings.json +++ b/homeassistant/components/harmony/strings.json @@ -10,7 +10,7 @@ } }, "link": { - "title": "Set up Logitech Harmony Hub", + "title": "[%key:component::harmony::config::step::user::title%]", "description": "Do you want to set up {name} ({host})?" } }, @@ -41,5 +41,21 @@ } } } + }, + "services": { + "sync": { + "name": "Sync", + "description": "Syncs the remote's configuration." + }, + "change_channel": { + "name": "Change channel", + "description": "Sends change channel command to the Harmony HUB.", + "fields": { + "channel": { + "name": "Channel", + "description": "Channel number to change to." + } + } + } } } diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 8c7f86700e7..9227b7da617 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -758,7 +758,7 @@ def async_remove_addons_from_dev_reg( ) -> None: """Remove addons from the device registry.""" for addon_slug in addons: - if dev := dev_reg.async_get_device({(DOMAIN, addon_slug)}): + if dev := dev_reg.async_get_device(identifiers={(DOMAIN, addon_slug)}): dev_reg.async_remove_device(dev.id) @@ -855,7 +855,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): async_remove_addons_from_dev_reg(self.dev_reg, stale_addons) if not self.is_hass_os and ( - dev := self.dev_reg.async_get_device({(DOMAIN, "OS")}) + dev := self.dev_reg.async_get_device(identifiers={(DOMAIN, "OS")}) ): # Remove the OS device if it exists and the installation is not hassos self.dev_reg.async_remove_device(dev.id) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 2bc314f169a..0735f2645cc 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -1,5 +1,5 @@ """Hass.io const variables.""" -from homeassistant.backports.enum import StrEnum +from enum import StrEnum DOMAIN = "hassio" diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 2480353c2d3..0e18a009323 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -85,6 +85,13 @@ NO_STORE = re.compile( # pylint: enable=implicit-str-concat # fmt: on +RESPONSE_HEADERS_FILTER = { + TRANSFER_ENCODING, + CONTENT_LENGTH, + CONTENT_TYPE, + CONTENT_ENCODING, +} + class HassIOView(HomeAssistantView): """Hass.io view to handle base part.""" @@ -170,8 +177,10 @@ class HassIOView(HomeAssistantView): ) response.content_type = client.content_type + if should_compress(response.content_type): + response.enable_compression() await response.prepare(request) - async for data in client.content.iter_chunked(4096): + async for data in client.content.iter_chunked(8192): await response.write(data) return response @@ -190,21 +199,13 @@ class HassIOView(HomeAssistantView): def _response_header(response: aiohttp.ClientResponse, path: str) -> dict[str, str]: """Create response header.""" - headers = {} - - for name, value in response.headers.items(): - if name in ( - TRANSFER_ENCODING, - CONTENT_LENGTH, - CONTENT_TYPE, - CONTENT_ENCODING, - ): - continue - headers[name] = value - + headers = { + name: value + for name, value in response.headers.items() + if name not in RESPONSE_HEADERS_FILTER + } if NO_STORE.match(path): headers[CACHE_CONTROL] = "no-store, max-age=0" - return headers @@ -213,3 +214,10 @@ def _get_timeout(path: str) -> ClientTimeout: if NO_TIMEOUT.match(path): return ClientTimeout(connect=10, total=None) return ClientTimeout(connect=10, total=300) + + +def should_compress(content_type: str) -> bool: + """Return if we should compress a response.""" + if content_type.startswith("image/"): + return "svg" in content_type + return not content_type.startswith(("video/", "audio/", "font/")) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index fc92e9309a0..4a612de7f87 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -17,11 +17,33 @@ from yarl import URL from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import UNDEFINED from .const import X_HASS_SOURCE, X_INGRESS_PATH +from .http import should_compress _LOGGER = logging.getLogger(__name__) +INIT_HEADERS_FILTER = { + hdrs.CONTENT_LENGTH, + hdrs.CONTENT_ENCODING, + hdrs.TRANSFER_ENCODING, + hdrs.ACCEPT_ENCODING, # Avoid local compression, as we will compress at the border + hdrs.SEC_WEBSOCKET_EXTENSIONS, + hdrs.SEC_WEBSOCKET_PROTOCOL, + hdrs.SEC_WEBSOCKET_VERSION, + hdrs.SEC_WEBSOCKET_KEY, +} +RESPONSE_HEADERS_FILTER = { + hdrs.TRANSFER_ENCODING, + hdrs.CONTENT_LENGTH, + hdrs.CONTENT_TYPE, + hdrs.CONTENT_ENCODING, +} + +MIN_COMPRESSED_SIZE = 128 +MAX_SIMPLE_RESPONSE_SIZE = 4194000 + @callback def async_setup_ingress_view(hass: HomeAssistant, host: str): @@ -145,28 +167,38 @@ class HassIOIngress(HomeAssistantView): skip_auto_headers={hdrs.CONTENT_TYPE}, ) as result: headers = _response_header(result) - + content_length_int = 0 + content_length = result.headers.get(hdrs.CONTENT_LENGTH, UNDEFINED) # Simple request - if ( - hdrs.CONTENT_LENGTH in result.headers - and int(result.headers.get(hdrs.CONTENT_LENGTH, 0)) < 4194000 - ) or result.status in (204, 304): + if result.status in (204, 304) or ( + content_length is not UNDEFINED + and (content_length_int := int(content_length or 0)) + <= MAX_SIMPLE_RESPONSE_SIZE + ): # Return Response body = await result.read() - return web.Response( + simple_response = web.Response( headers=headers, status=result.status, content_type=result.content_type, body=body, ) + if content_length_int > MIN_COMPRESSED_SIZE and should_compress( + simple_response.content_type + ): + simple_response.enable_compression() + await simple_response.prepare(request) + return simple_response # Stream response response = web.StreamResponse(status=result.status, headers=headers) response.content_type = result.content_type try: + if should_compress(response.content_type): + response.enable_compression() await response.prepare(request) - async for data in result.content.iter_chunked(4096): + async for data in result.content.iter_chunked(8192): await response.write(data) except ( @@ -179,24 +211,20 @@ class HassIOIngress(HomeAssistantView): return response +@lru_cache(maxsize=32) +def _forwarded_for_header(forward_for: str | None, peer_name: str) -> str: + """Create X-Forwarded-For header.""" + connected_ip = ip_address(peer_name) + return f"{forward_for}, {connected_ip!s}" if forward_for else f"{connected_ip!s}" + + def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, str]: """Create initial header.""" - headers = {} - - # filter flags - for name, value in request.headers.items(): - if name in ( - hdrs.CONTENT_LENGTH, - hdrs.CONTENT_ENCODING, - hdrs.TRANSFER_ENCODING, - hdrs.SEC_WEBSOCKET_EXTENSIONS, - hdrs.SEC_WEBSOCKET_PROTOCOL, - hdrs.SEC_WEBSOCKET_VERSION, - hdrs.SEC_WEBSOCKET_KEY, - ): - continue - headers[name] = value - + headers = { + name: value + for name, value in request.headers.items() + if name not in INIT_HEADERS_FILTER + } # Ingress information headers[X_HASS_SOURCE] = "core.ingress" headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}" @@ -208,12 +236,7 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st _LOGGER.error("Can't set forward_for header, missing peername") raise HTTPBadRequest() - connected_ip = ip_address(peername[0]) - if forward_for: - forward_for = f"{forward_for}, {connected_ip!s}" - else: - forward_for = f"{connected_ip!s}" - headers[hdrs.X_FORWARDED_FOR] = forward_for + headers[hdrs.X_FORWARDED_FOR] = _forwarded_for_header(forward_for, peername[0]) # Set X-Forwarded-Host if not (forward_host := request.headers.get(hdrs.X_FORWARDED_HOST)): @@ -223,7 +246,7 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st # Set X-Forwarded-Proto forward_proto = request.headers.get(hdrs.X_FORWARDED_PROTO) if not forward_proto: - forward_proto = request.url.scheme + forward_proto = request.scheme headers[hdrs.X_FORWARDED_PROTO] = forward_proto return headers @@ -231,31 +254,20 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: """Create response header.""" - headers = {} - - for name, value in response.headers.items(): - if name in ( - hdrs.TRANSFER_ENCODING, - hdrs.CONTENT_LENGTH, - hdrs.CONTENT_TYPE, - hdrs.CONTENT_ENCODING, - ): - continue - headers[name] = value - - return headers + return { + name: value + for name, value in response.headers.items() + if name not in RESPONSE_HEADERS_FILTER + } def _is_websocket(request: web.Request) -> bool: """Return True if request is a websocket.""" headers = request.headers - - if ( + return bool( "upgrade" in headers.get(hdrs.CONNECTION, "").lower() and headers.get(hdrs.UPGRADE, "").lower() == "websocket" - ): - return True - return False + ) async def _websocket_forward(ws_from, ws_to): diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 0bbd89aab86..8bd47faef08 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -4,9 +4,7 @@ from __future__ import annotations import asyncio from dataclasses import dataclass, field import logging -from typing import Any, TypedDict - -from typing_extensions import NotRequired +from typing import Any, NotRequired, TypedDict from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 60b54735493..33eb1e88ed3 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -1,193 +1,123 @@ addon_start: - name: Start add-on - description: Start add-on. fields: addon: - name: Add-on required: true - description: The add-on slug. example: core_ssh selector: addon: addon_restart: - name: Restart add-on. - description: Restart add-on. fields: addon: - name: Add-on required: true - description: The add-on slug. example: core_ssh selector: addon: addon_stdin: - name: Write data to add-on stdin. - description: Write data to add-on stdin. fields: addon: - name: Add-on required: true - description: The add-on slug. example: core_ssh selector: addon: addon_stop: - name: Stop add-on. - description: Stop add-on. fields: addon: - name: Add-on required: true - description: The add-on slug. example: core_ssh selector: addon: addon_update: - name: Update add-on. - description: Update add-on. This service should be used with caution since add-on updates can contain breaking changes. It is highly recommended that you review release notes/change logs before updating an add-on. fields: addon: - name: Add-on required: true - description: The add-on slug. example: core_ssh selector: addon: host_reboot: - name: Reboot the host system. - description: Reboot the host system. - host_shutdown: - name: Poweroff the host system. - description: Poweroff the host system. - backup_full: - name: Create a full backup. - description: Create a full backup. fields: name: - name: Name - description: Optional (default = current date and time). example: "Backup 1" selector: text: password: - name: Password - description: Optional password. example: "password" selector: text: compressed: - name: Compressed - description: Use compressed archives default: true selector: boolean: location: - name: Location - description: Name of a backup network storage to put backup (or /backup) example: my_backup_mount selector: backup_location: backup_partial: - name: Create a partial backup. - description: Create a partial backup. fields: homeassistant: - name: Home Assistant settings - description: Backup Home Assistant settings selector: boolean: addons: - name: Add-ons - description: Optional list of add-on slugs. example: ["core_ssh", "core_samba", "core_mosquitto"] selector: object: folders: - name: Folders - description: Optional list of directories. example: ["homeassistant", "share"] selector: object: name: - name: Name - description: Optional (default = current date and time). example: "Partial backup 1" selector: text: password: - name: Password - description: Optional password. example: "password" selector: text: compressed: - name: Compressed - description: Use compressed archives default: true selector: boolean: location: - name: Location - description: Name of a backup network storage to put backup (or /backup) example: my_backup_mount selector: backup_location: restore_full: - name: Restore from full backup. - description: Restore from full backup. fields: slug: - name: Slug required: true - description: Slug of backup to restore from. selector: text: password: - name: Password - description: Optional password. example: "password" selector: text: restore_partial: - name: Restore from partial backup. - description: Restore from partial backup. fields: slug: - name: Slug required: true - description: Slug of backup to restore from. selector: text: homeassistant: - name: Home Assistant settings - description: Restore Home Assistant selector: boolean: folders: - name: Folders - description: Optional list of directories. example: ["homeassistant", "share"] selector: object: addons: - name: Add-ons - description: Optional list of add-on slugs. example: ["core_ssh", "core_samba", "core_mosquitto"] selector: object: password: - name: Password - description: Optional password. example: "password" selector: text: diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index f9c212f946c..c45d455631b 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -24,7 +24,7 @@ "fix_menu": { "description": "Could not connect to `{reference}`. Check host logs for errors from the mount service for more details.\n\nUse reload to try to connect again. If you need to update `{reference}`, go to [storage]({storage_url}).", "menu_options": { - "mount_execute_reload": "Reload", + "mount_execute_reload": "[%key:common::action::reload%]", "mount_execute_remove": "Remove" } } @@ -184,18 +184,194 @@ }, "entity": { "binary_sensor": { - "state": { "name": "Running" } + "state": { + "name": "Running" + } }, "sensor": { - "agent_version": { "name": "OS Agent version" }, - "apparmor_version": { "name": "Apparmor version" }, - "cpu_percent": { "name": "CPU percent" }, - "disk_free": { "name": "Disk free" }, - "disk_total": { "name": "Disk total" }, - "disk_used": { "name": "Disk used" }, - "memory_percent": { "name": "Memory percent" }, - "version": { "name": "Version" }, - "version_latest": { "name": "Newest version" } + "agent_version": { + "name": "OS Agent version" + }, + "apparmor_version": { + "name": "Apparmor version" + }, + "cpu_percent": { + "name": "CPU percent" + }, + "disk_free": { + "name": "Disk free" + }, + "disk_total": { + "name": "Disk total" + }, + "disk_used": { + "name": "Disk used" + }, + "memory_percent": { + "name": "Memory percent" + }, + "version": { + "name": "Version" + }, + "version_latest": { + "name": "Newest version" + } + } + }, + "services": { + "addon_start": { + "name": "Start add-on", + "description": "Starts an add-on.", + "fields": { + "addon": { + "name": "Add-on", + "description": "The add-on slug." + } + } + }, + "addon_restart": { + "name": "Restart add-on.", + "description": "Restarts an add-on.", + "fields": { + "addon": { + "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", + "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]" + } + } + }, + "addon_stdin": { + "name": "Write data to add-on stdin.", + "description": "Writes data to add-on stdin.", + "fields": { + "addon": { + "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", + "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]" + } + } + }, + "addon_stop": { + "name": "Stop add-on.", + "description": "Stops an add-on.", + "fields": { + "addon": { + "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", + "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]" + } + } + }, + "addon_update": { + "name": "Update add-on.", + "description": "Updates an add-on. This service should be used with caution since add-on updates can contain breaking changes. It is highly recommended that you review release notes/change logs before updating an add-on.", + "fields": { + "addon": { + "name": "[%key:component::hassio::services::addon_start::fields::addon::name%]", + "description": "[%key:component::hassio::services::addon_start::fields::addon::description%]" + } + } + }, + "host_reboot": { + "name": "Reboot the host system.", + "description": "Reboots the host system." + }, + "host_shutdown": { + "name": "Power off the host system.", + "description": "Powers off the host system." + }, + "backup_full": { + "name": "Create a full backup.", + "description": "Creates a full backup.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "Optional (default = current date and time)." + }, + "password": { + "name": "[%key:common::config_flow::data::password%]", + "description": "Password to protect the backup with." + }, + "compressed": { + "name": "Compressed", + "description": "Compresses the backup files." + }, + "location": { + "name": "[%key:common::config_flow::data::location%]", + "description": "Name of a backup network storage to host backups." + } + } + }, + "backup_partial": { + "name": "Create a partial backup.", + "description": "Creates a partial backup.", + "fields": { + "homeassistant": { + "name": "Home Assistant settings", + "description": "Includes Home Assistant settings in the backup." + }, + "addons": { + "name": "Add-ons", + "description": "List of add-ons to include in the backup. Use the name slug of the add-on." + }, + "folders": { + "name": "Folders", + "description": "List of directories to include in the backup." + }, + "name": { + "name": "[%key:component::hassio::services::backup_full::fields::name::name%]", + "description": "[%key:component::hassio::services::backup_full::fields::name::description%]" + }, + "password": { + "name": "[%key:component::hassio::services::backup_full::fields::password::name%]", + "description": "[%key:component::hassio::services::backup_full::fields::password::description%]" + }, + "compressed": { + "name": "[%key:component::hassio::services::backup_full::fields::compressed::name%]", + "description": "[%key:component::hassio::services::backup_full::fields::compressed::description%]" + }, + "location": { + "name": "[%key:component::hassio::services::backup_full::fields::location::name%]", + "description": "[%key:component::hassio::services::backup_full::fields::location::description%]" + } + } + }, + "restore_full": { + "name": "Restore from full backup.", + "description": "Restores from full backup.", + "fields": { + "slug": { + "name": "Slug", + "description": "Slug of backup to restore from." + }, + "password": { + "name": "[%key:component::hassio::services::backup_full::fields::password::name%]", + "description": "Optional password." + } + } + }, + "restore_partial": { + "name": "Restore from partial backup.", + "description": "Restores from a partial backup.", + "fields": { + "slug": { + "name": "[%key:component::hassio::services::restore_full::fields::slug::name%]", + "description": "[%key:component::hassio::services::restore_full::fields::slug::description%]" + }, + "homeassistant": { + "name": "[%key:component::hassio::services::backup_partial::fields::homeassistant::name%]", + "description": "Restores Home Assistant." + }, + "folders": { + "name": "[%key:component::hassio::services::backup_partial::fields::folders::name%]", + "description": "[%key:component::hassio::services::backup_partial::fields::folders::description%]" + }, + "addons": { + "name": "[%key:component::hassio::services::backup_partial::fields::addons::name%]", + "description": "[%key:component::hassio::services::backup_partial::fields::addons::description%]" + }, + "password": { + "name": "[%key:component::hassio::services::backup_full::fields::password::name%]", + "description": "[%key:component::hassio::services::restore_full::fields::password::description%]" + } + } } } } diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 117de2116a4..77c2a28190b 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging import socket -from telnetlib import Telnet +from telnetlib import Telnet # pylint: disable=deprecated-module import voluptuous as vol diff --git a/homeassistant/components/hdmi_cec/services.yaml b/homeassistant/components/hdmi_cec/services.yaml index 7ad8b36473f..e4102c44208 100644 --- a/homeassistant/components/hdmi_cec/services.yaml +++ b/homeassistant/components/hdmi_cec/services.yaml @@ -1,74 +1,43 @@ power_on: - name: Power on - description: Power on all devices which supports it. select_device: - name: Select device - description: Select HDMI device. fields: device: - name: Device - description: Address of device to select. Can be entity_id, physical address or alias from configuration. required: true example: '"switch.hdmi_1" or "1.1.0.0" or "01:10"' selector: text: send_command: - name: Send command - description: Sends CEC command into HDMI CEC capable adapter. fields: att: - name: Att - description: Optional parameters. example: [0, 2] selector: object: cmd: - name: Command - description: 'Command itself. Could be decimal number or string with hexadeximal notation: "0x10".' example: 144 or "0x90" selector: text: dst: - name: Destination - description: 'Destination for command. Could be decimal number or string with hexadeximal notation: "0x10".' example: 5 or "0x5" selector: text: raw: - name: Raw - description: >- - Raw CEC command in format "00:00:00:00" where first two digits - are source and destination, second byte is command and optional other bytes - are command parameters. If raw command specified, other params are ignored. example: '"10:36"' selector: text: src: - name: Source - description: 'Source of command. Could be decimal number or string with hexadeximal notation: "0x10".' example: 12 or "0xc" selector: text: standby: - name: Standby - description: Standby all devices which supports it. update: - name: Update - description: Update devices state from network. volume: - name: Volume - description: Increase or decrease volume of system. fields: down: - name: Down - description: Decreases volume x levels. selector: number: min: 1 max: 100 mute: - name: Mute - description: Mutes audio system. selector: select: options: @@ -76,8 +45,6 @@ volume: - "on" - "toggle" up: - name: Up - description: Increases volume x levels. selector: number: min: 1 diff --git a/homeassistant/components/hdmi_cec/strings.json b/homeassistant/components/hdmi_cec/strings.json new file mode 100644 index 00000000000..22715907a99 --- /dev/null +++ b/homeassistant/components/hdmi_cec/strings.json @@ -0,0 +1,70 @@ +{ + "services": { + "power_on": { + "name": "Power on", + "description": "Power on all devices which supports it." + }, + "select_device": { + "name": "Select device", + "description": "Select HDMI device.", + "fields": { + "device": { + "name": "[%key:common::config_flow::data::device%]", + "description": "Address of device to select. Can be entity_id, physical address or alias from configuration." + } + } + }, + "send_command": { + "name": "Send command", + "description": "Sends CEC command into HDMI CEC capable adapter.", + "fields": { + "att": { + "name": "Att", + "description": "Optional parameters." + }, + "cmd": { + "name": "Command", + "description": "Command itself. Could be decimal number or string with hexadeximal notation: \"0x10\"." + }, + "dst": { + "name": "Destination", + "description": "Destination for command. Could be decimal number or string with hexadeximal notation: \"0x10\"." + }, + "raw": { + "name": "Raw", + "description": "Raw CEC command in format \"00:00:00:00\" where first two digits are source and destination, second byte is command and optional other bytes are command parameters. If raw command specified, other params are ignored." + }, + "src": { + "name": "Source", + "description": "Source of command. Could be decimal number or string with hexadeximal notation: \"0x10\"." + } + } + }, + "standby": { + "name": "[%key:common::state::standby%]", + "description": "Standby all devices which supports it." + }, + "update": { + "name": "Update", + "description": "Updates devices state from network." + }, + "volume": { + "name": "Volume", + "description": "Increases or decreases volume of system.", + "fields": { + "down": { + "name": "Down", + "description": "Decreases volume x levels." + }, + "mute": { + "name": "Mute", + "description": "Mutes audio system." + }, + "up": { + "name": "Up", + "description": "Increases volume x levels." + } + } + } + } +} diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 7aff107d8c5..c50b70245e3 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -220,7 +220,9 @@ class ControllerManager: # mapped_ids contains the mapped IDs (new:old) for new_id, old_id in mapped_ids.items(): # update device registry - entry = self._device_registry.async_get_device({(DOMAIN, old_id)}) + entry = self._device_registry.async_get_device( + identifiers={(DOMAIN, old_id)} + ) new_identifiers = {(DOMAIN, new_id)} if entry: self._device_registry.async_update_device( diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 9ad33caf073..c111a23bf06 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -52,6 +52,7 @@ BASE_SUPPORTED_FEATURES = ( | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.BROWSE_MEDIA + | MediaPlayerEntityFeature.MEDIA_ENQUEUE ) PLAY_STATE_TO_STATE = { @@ -113,6 +114,8 @@ class HeosMediaPlayer(MediaPlayerEntity): _attr_media_content_type = MediaType.MUSIC _attr_should_poll = False + _attr_has_entity_name = True + _attr_name = None def __init__(self, player): """Initialize.""" @@ -391,11 +394,6 @@ class HeosMediaPlayer(MediaPlayerEntity): """Title of current playing media.""" return self._player.now_playing_media.song - @property - def name(self) -> str: - """Return the name of the device.""" - return self._player.name - @property def shuffle(self) -> bool: """Boolean if shuffle is enabled.""" diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml index 320ed297873..8dc222d65ba 100644 --- a/homeassistant/components/heos/services.yaml +++ b/homeassistant/components/heos/services.yaml @@ -1,22 +1,14 @@ sign_in: - name: Sign in - description: Sign the controller in to a HEOS account. fields: username: - name: Username - description: The username or email of the HEOS account. required: true example: "example@example.com" selector: text: password: - name: Password - description: The password of the HEOS account. required: true example: "password" selector: text: sign_out: - name: Sign out - description: Sign the controller out of the HEOS account. diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index 09ada292afd..7bd362cf3d7 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -15,5 +15,25 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "services": { + "sign_in": { + "name": "Sign in", + "description": "Signs the controller in to a HEOS account.", + "fields": { + "username": { + "name": "[%key:common::config_flow::data::username%]", + "description": "The username or email of the HEOS account." + }, + "password": { + "name": "[%key:common::config_flow::data::password%]", + "description": "The password of the HEOS account." + } + } + }, + "sign_out": { + "name": "Sign out", + "description": "Signs the controller out of the HEOS account." + } } } diff --git a/homeassistant/components/here_travel_time/strings.json b/homeassistant/components/here_travel_time/strings.json index 2c031dc0a02..124aa070595 100644 --- a/homeassistant/components/here_travel_time/strings.json +++ b/homeassistant/components/here_travel_time/strings.json @@ -16,13 +16,13 @@ } }, "origin_coordinates": { - "title": "Choose Origin", + "title": "[%key:component::here_travel_time::config::step::origin_menu::title%]", "data": { "origin": "Origin as GPS coordinates" } }, "origin_entity_id": { - "title": "Choose Origin", + "title": "[%key:component::here_travel_time::config::step::origin_menu::title%]", "data": { "origin_entity_id": "Origin using an entity" } @@ -30,18 +30,18 @@ "destination_menu": { "title": "Choose Destination", "menu_options": { - "destination_coordinates": "Using a map location", - "destination_entity": "Using an entity" + "destination_coordinates": "[%key:component::here_travel_time::config::step::origin_menu::menu_options::origin_coordinates%]", + "destination_entity": "[%key:component::here_travel_time::config::step::origin_menu::menu_options::origin_entity%]" } }, "destination_coordinates": { - "title": "Choose Destination", + "title": "[%key:component::here_travel_time::config::step::destination_menu::title%]", "data": { "destination": "Destination as GPS coordinates" } }, "destination_entity_id": { - "title": "Choose Destination", + "title": "[%key:component::here_travel_time::config::step::destination_menu::title%]", "data": { "destination_entity_id": "Destination using an entity" } diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 93a5d272965..24ec07b6a87 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -30,10 +30,12 @@ from homeassistant.core import ( valid_entity_id, ) from homeassistant.helpers.event import ( + EventStateChangedData, async_track_point_in_utc_time, async_track_state_change_event, ) from homeassistant.helpers.json import JSON_DUMP +from homeassistant.helpers.typing import EventType import homeassistant.util.dt as dt_util from .const import EVENT_COALESCE_TIME, MAX_PENDING_HISTORY_STATES @@ -373,14 +375,12 @@ def _async_subscribe_events( assert is_callback(target), "target must be a callback" @callback - def _forward_state_events_filtered(event: Event) -> None: + def _forward_state_events_filtered(event: EventType[EventStateChangedData]) -> None: """Filter state events and forward them.""" - if (new_state := event.data.get("new_state")) is None or ( - old_state := event.data.get("old_state") + if (new_state := event.data["new_state"]) is None or ( + old_state := event.data["old_state"] ) is None: return - assert isinstance(new_state, State) - assert isinstance(old_state, State) if ( (significant_changes_only or minimal_response) and new_state.state == old_state.state diff --git a/homeassistant/components/history_stats/coordinator.py b/homeassistant/components/history_stats/coordinator.py index 7d44da9f5f6..6d4d6e55fa9 100644 --- a/homeassistant/components/history_stats/coordinator.py +++ b/homeassistant/components/history_stats/coordinator.py @@ -5,10 +5,14 @@ from datetime import timedelta import logging from typing import Any -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) from homeassistant.helpers.start import async_at_start +from homeassistant.helpers.typing import EventType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .data import HistoryStats, HistoryStatsState @@ -82,7 +86,9 @@ class HistoryStatsUpdateCoordinator(DataUpdateCoordinator[HistoryStatsState]): self.hass, [self._history_stats.entity_id], self._async_update_from_event ) - async def _async_update_from_event(self, event: Event) -> None: + async def _async_update_from_event( + self, event: EventType[EventStateChangedData] + ) -> None: """Process an update from an event.""" self.async_set_updated_data(await self._history_stats.async_update(event)) diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index af27766f514..69e56ba0333 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -5,8 +5,10 @@ from dataclasses import dataclass import datetime from homeassistant.components.recorder import get_instance, history -from homeassistant.core import Event, HomeAssistant, State +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import EventType import homeassistant.util.dt as dt_util from .helpers import async_calculate_period, floored_timestamp @@ -55,7 +57,9 @@ class HistoryStats: self._start = start self._end = end - async def async_update(self, event: Event | None) -> HistoryStatsState: + async def async_update( + self, event: EventType[EventStateChangedData] | None + ) -> HistoryStatsState: """Update the stats at a given time.""" # Get previous values of start and end previous_period_start, previous_period_end = self._period @@ -104,8 +108,7 @@ class HistoryStats: ) ): new_data = False - if event and event.data["new_state"] is not None: - new_state: State = event.data["new_state"] + if event and (new_state := event.data["new_state"]) is not None: if ( current_period_start_timestamp <= floored_timestamp(new_state.last_changed) diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 5b1242423c7..baa39468bc1 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -163,6 +163,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase): self._process_update() if self._type == CONF_TYPE_TIME: self._attr_device_class = SensorDeviceClass.DURATION + self._attr_suggested_display_precision = 2 @callback def _process_update(self) -> None: @@ -173,7 +174,10 @@ class HistoryStatsSensor(HistoryStatsSensorBase): return if self._type == CONF_TYPE_TIME: - self._attr_native_value = round(state.seconds_matched / 3600, 2) + value = state.seconds_matched / 3600 + if self._attr_unique_id is None: + value = round(value, 2) + self._attr_native_value = value elif self._type == CONF_TYPE_RATIO: self._attr_native_value = pretty_ratio(state.seconds_matched, state.period) elif self._type == CONF_TYPE_COUNT: diff --git a/homeassistant/components/history_stats/services.yaml b/homeassistant/components/history_stats/services.yaml index f254295ea20..c983a105c93 100644 --- a/homeassistant/components/history_stats/services.yaml +++ b/homeassistant/components/history_stats/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload all history_stats entities. diff --git a/homeassistant/components/history_stats/strings.json b/homeassistant/components/history_stats/strings.json new file mode 100644 index 00000000000..ea1c94b6ec3 --- /dev/null +++ b/homeassistant/components/history_stats/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads history stats sensors from the YAML-configuration." + } + } +} diff --git a/homeassistant/components/hive/services.yaml b/homeassistant/components/hive/services.yaml index d0de9645c6a..96066246230 100644 --- a/homeassistant/components/hive/services.yaml +++ b/homeassistant/components/hive/services.yaml @@ -1,21 +1,15 @@ boost_heating: - name: Boost Heating (To be deprecated) - description: To be deprecated please use boost_heating_on. target: entity: integration: hive domain: climate fields: time_period: - name: Time Period - description: Set the time period for the boost. required: true example: 01:30:00 selector: time: temperature: - name: Temperature - description: Set the target temperature for the boost period. default: 25.0 selector: number: @@ -24,23 +18,17 @@ boost_heating: step: 0.5 unit_of_measurement: ° boost_heating_on: - name: Boost Heating On - description: Set the boost mode ON defining the period of time and the desired target temperature for the boost. target: entity: integration: hive domain: climate fields: time_period: - name: Time Period - description: Set the time period for the boost. required: true example: 01:30:00 selector: time: temperature: - name: Temperature - description: Set the target temperature for the boost period. default: 25.0 selector: number: @@ -49,39 +37,27 @@ boost_heating_on: step: 0.5 unit_of_measurement: ° boost_heating_off: - name: Boost Heating Off - description: Set the boost mode OFF. fields: entity_id: - name: Entity ID - description: Select entity_id to turn boost off. required: true selector: entity: integration: hive domain: climate boost_hot_water: - name: Boost Hotwater - description: Set the boost mode ON or OFF defining the period of time for the boost. fields: entity_id: - name: Entity ID - description: Select entity_id to boost. required: true selector: entity: integration: hive domain: water_heater time_period: - name: Time Period - description: Set the time period for the boost. required: true example: 01:30:00 selector: time: on_off: - name: Mode - description: Set the boost function on or off. required: true selector: select: diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index 3435517aec7..e2a3e9dc7e1 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -25,7 +25,7 @@ "title": "Hive Configuration." }, "reauth": { - "title": "Hive Login", + "title": "[%key:component::hive::config::step::user::title%]", "description": "Re-enter your Hive login information.", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -56,5 +56,63 @@ } } } + }, + "services": { + "boost_heating": { + "name": "Boost heating (to be deprecated)", + "description": "To be deprecated please use boost_heating_on.", + "fields": { + "time_period": { + "name": "Time period", + "description": "Set the time period for the boost." + }, + "temperature": { + "name": "Temperature", + "description": "Set the target temperature for the boost period." + } + } + }, + "boost_heating_on": { + "name": "Boost heating on", + "description": "Sets the boost mode ON defining the period of time and the desired target temperature for the boost.", + "fields": { + "time_period": { + "name": "Time Period", + "description": "Set the time period for the boost." + }, + "temperature": { + "name": "Temperature", + "description": "[%key:component::hive::services::boost_heating::fields::temperature::description%]" + } + } + }, + "boost_heating_off": { + "name": "Boost heating off", + "description": "Sets the boost mode OFF.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Select entity_id to turn boost off." + } + } + }, + "boost_hot_water": { + "name": "Boost hotwater", + "description": "Sets the boost mode ON or OFF defining the period of time for the boost.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Select entity_id to boost." + }, + "time_period": { + "name": "Time period", + "description": "Set the time period for the boost." + }, + "on_off": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "Set the boost function on or off." + } + } + } } } diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 06a646dd481..0738b58595a 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -1,168 +1,112 @@ start_program: - name: Start program - description: Selects a program and starts it. fields: device_id: - name: Device ID - description: Id of the device. required: true selector: device: integration: home_connect program: - name: Program - description: Program to select example: "Dishcare.Dishwasher.Program.Auto2" required: true selector: text: key: - name: Option key - description: Key of the option. example: "BSH.Common.Option.StartInRelative" selector: text: value: - name: Option value - description: Value of the option. example: 1800 selector: object: unit: - name: Option unit - description: Unit for the option. example: "seconds" selector: text: select_program: - name: Select program - description: Selects a program without starting it. fields: device_id: - name: Device ID - description: Id of the device. required: true selector: device: integration: home_connect program: - name: Program - description: Program to select example: "Dishcare.Dishwasher.Program.Auto2" required: true selector: text: key: - name: Option key - description: Key of the option. example: "BSH.Common.Option.StartInRelative" selector: text: value: - name: Option value - description: Value of the option. example: 1800 selector: object: unit: - name: Option unit - description: Unit for the option. example: "seconds" selector: text: pause_program: - name: Pause program - description: Pauses the current running program. fields: device_id: - name: Device ID - description: Id of the device. required: true selector: device: integration: home_connect resume_program: - name: Resume program - description: Resumes a paused program. fields: device_id: - name: Device ID - description: Id of the device. required: true selector: device: integration: home_connect set_option_active: - name: Set active program option - description: Sets an option for the active program. fields: device_id: - name: Device ID - description: Id of the device. required: true selector: device: integration: home_connect key: - name: Key - description: Key of the option. example: "LaundryCare.Dryer.Option.DryingTarget" required: true selector: text: value: - name: Value - description: Value of the option. example: "LaundryCare.Dryer.EnumType.DryingTarget.IronDry" required: true selector: object: set_option_selected: - name: Set selected program option - description: Sets an option for the selected program. fields: device_id: - name: Device ID - description: Id of the device. required: true selector: device: integration: home_connect key: - name: Key - description: Key of the option. example: "LaundryCare.Dryer.Option.DryingTarget" required: true selector: text: value: - name: Value - description: Value of the option. example: "LaundryCare.Dryer.EnumType.DryingTarget.IronDry" required: true selector: object: change_setting: - name: Change setting - description: Changes a setting. fields: device_id: - name: Device ID - description: Id of the device. required: true selector: device: integration: home_connect key: - name: Key - description: Key of the setting. example: "BSH.Common.Setting.ChildLock" required: true selector: text: value: - name: Value - description: Value of the setting. example: "true" required: true selector: diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 79455783edf..091f0c18232 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -12,5 +12,133 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } + }, + "services": { + "start_program": { + "name": "Start program", + "description": "Selects a program and starts it.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "Id of the device." + }, + "program": { + "name": "Program", + "description": "Program to select." + }, + "key": { + "name": "Option key", + "description": "Key of the option." + }, + "value": { + "name": "Option value", + "description": "Value of the option." + }, + "unit": { + "name": "Option unit", + "description": "Unit for the option." + } + } + }, + "select_program": { + "name": "Select program", + "description": "Selects a program without starting it.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + }, + "program": { + "name": "[%key:component::home_connect::services::start_program::fields::program::name%]", + "description": "[%key:component::home_connect::services::start_program::fields::program::description%]" + }, + "key": { + "name": "[%key:component::home_connect::services::start_program::fields::key::name%]", + "description": "[%key:component::home_connect::services::start_program::fields::key::description%]" + }, + "value": { + "name": "[%key:component::home_connect::services::start_program::fields::value::name%]", + "description": "[%key:component::home_connect::services::start_program::fields::value::description%]" + }, + "unit": { + "name": "[%key:component::home_connect::services::start_program::fields::unit::name%]", + "description": "[%key:component::home_connect::services::start_program::fields::unit::description%]" + } + } + }, + "pause_program": { + "name": "Pause program", + "description": "Pauses the current running program.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + } + } + }, + "resume_program": { + "name": "Resume program", + "description": "Resumes a paused program.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + } + } + }, + "set_option_active": { + "name": "Set active program option", + "description": "Sets an option for the active program.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + }, + "key": { + "name": "Key", + "description": "[%key:component::home_connect::services::start_program::fields::key::description%]" + }, + "value": { + "name": "Value", + "description": "[%key:component::home_connect::services::start_program::fields::value::description%]" + } + } + }, + "set_option_selected": { + "name": "Set selected program option", + "description": "Sets an option for the selected program.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + }, + "key": { + "name": "Key", + "description": "[%key:component::home_connect::services::start_program::fields::key::description%]" + }, + "value": { + "name": "Value", + "description": "[%key:component::home_connect::services::start_program::fields::value::description%]" + } + } + }, + "change_setting": { + "name": "Change setting", + "description": "Changes a setting.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + }, + "key": { + "name": "Key", + "description": "Key of the setting." + }, + "value": { + "name": "Value", + "description": "Value of the setting." + } + } + } } } diff --git a/homeassistant/components/home_plus_control/__init__.py b/homeassistant/components/home_plus_control/__init__.py index d58086e59ec..007f8895bf0 100644 --- a/homeassistant/components/home_plus_control/__init__.py +++ b/homeassistant/components/home_plus_control/__init__.py @@ -128,7 +128,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entity_uids_to_remove = uids - set(module_data) for uid in entity_uids_to_remove: uids.remove(uid) - device = device_registry.async_get_device({(DOMAIN, uid)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, uid)}) device_registry.async_remove_device(device.id) # Send out signal for new entity addition to Home Assistant diff --git a/homeassistant/components/home_plus_control/switch.py b/homeassistant/components/home_plus_control/switch.py index 6e92fac3b72..99766ebfec9 100644 --- a/homeassistant/components/home_plus_control/switch.py +++ b/homeassistant/components/home_plus_control/switch.py @@ -66,17 +66,15 @@ class HomeControlSwitchEntity(CoordinatorEntity, SwitchEntity): consumption methods and state attributes. """ + _attr_has_entity_name = True + _attr_name = None + def __init__(self, coordinator, idx): """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator) self.idx = idx self.module = self.coordinator.data[self.idx] - @property - def name(self): - """Name of the device.""" - return self.module.name - @property def unique_id(self): """ID (unique) of the device.""" @@ -92,7 +90,7 @@ class HomeControlSwitchEntity(CoordinatorEntity, SwitchEntity): }, manufacturer="Legrand", model=HW_TYPE.get(self.module.hw_type), - name=self.name, + name=self.module.name, sw_version=self.module.fw, ) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 2fe27769c3f..899fee357fd 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -1,88 +1,47 @@ check_config: - name: Check configuration - description: - Check the Home Assistant configuration files for errors. Errors will be - displayed in the Home Assistant log. - reload_core_config: - name: Reload core configuration - description: Reload the core configuration. - restart: - name: Restart - description: Restart the Home Assistant service. - set_location: - name: Set location - description: Update the Home Assistant location. fields: latitude: - name: Latitude - description: Latitude of your location. required: true example: 32.87336 selector: text: longitude: - name: Longitude - description: Longitude of your location. required: true example: 117.22743 selector: text: stop: - name: Stop - description: Stop the Home Assistant service. - toggle: - name: Generic toggle - description: Generic service to toggle devices on/off under any domain target: entity: {} turn_on: - name: Generic turn on - description: Generic service to turn devices on under any domain. target: entity: {} turn_off: - name: Generic turn off - description: Generic service to turn devices off under any domain. target: entity: {} update_entity: - name: Update entity - description: Force one or more entities to update its data target: entity: {} reload_custom_templates: - name: Reload custom Jinja2 templates - description: >- - Reload Jinja2 templates found in the custom_templates folder in your config. - New values will be applied on the next render of the template. - reload_config_entry: - name: Reload config entry - description: Reload a config entry that matches a target. target: entity: {} device: {} fields: entry_id: advanced: true - name: Config entry id - description: A configuration entry id required: false example: 8955375327824e14ba89e4b29cc3ec9a selector: text: save_persistent_states: - name: Save Persistent States - description: - Save the persistent states (for entities derived from RestoreEntity) immediately. - Maintain the normal periodic saving interval. diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 0a41f9c7a99..5404ee4af64 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -4,6 +4,10 @@ "title": "The country has not been configured", "description": "No country has been configured, please update the configuration by clicking on the \"learn more\" button below." }, + "deprecated_yaml": { + "title": "The {integration_title} YAML configuration is being removed", + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + }, "historic_currency": { "title": "The configured currency is no longer in use", "description": "The currency {currency} is no longer in use, please reconfigure the currency configuration." @@ -19,6 +23,21 @@ "platform_only": { "title": "The {domain} integration does not support YAML configuration under its own key", "description": "The {domain} integration does not support configuration under its own key, it must be configured under its supported platforms.\n\nTo resolve this:\n\n1. Remove `{domain}:` from your YAML configuration file.\n\n2. Restart Home Assistant." + }, + "no_platform_setup": { + "title": "Unused YAML configuration for the {platform} integration", + "description": "It's not possible to configure {platform} {domain} by adding `{platform_key}` to the {domain} configuration. Please check the documentation for more information on how to set up this integration.\n\nTo resolve this:\n1. Remove `{platform_key}` occurences from the `{domain}:` configuration in your YAML configuration file.\n2. Restart Home Assistant.\n\nExample that should be removed:\n{yaml_example}\n" + }, + "storage_corruption": { + "title": "Storage corruption detected for `{storage_key}`", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::homeassistant::issues::storage_corruption::title%]", + "description": "The `{storage_key}` storage could not be parsed and has been renamed to `{corrupt_path}` to allow Home Assistant to continue.\n\nA default `{storage_key}` may have been created automatically.\n\nIf you made manual edits to the storage file, fix any syntax errors in `{corrupt_path}`, restore the file to the original path `{original_path}`, and restart Home Assistant. Otherwise, restore the system from a backup.\n\nClick SUBMIT below to confirm you have repaired the file or restored from a backup.\n\nThe exact error was: {error}" + } + } + } } }, "system_health": { @@ -37,5 +56,71 @@ "version": "Version", "virtualenv": "Virtual Environment" } + }, + "services": { + "check_config": { + "name": "Check configuration", + "description": "Checks the Home Assistant YAML-configuration files for errors. Errors will be shown in the Home Assistant logs." + }, + "reload_core_config": { + "name": "Reload core configuration", + "description": "Reloads the core configuration from the YAML-configuration." + }, + "restart": { + "name": "[%key:common::action::restart%]", + "description": "Restarts Home Assistant." + }, + "set_location": { + "name": "Set location", + "description": "Updates the Home Assistant location.", + "fields": { + "latitude": { + "name": "[%key:common::config_flow::data::latitude%]", + "description": "Latitude of your location." + }, + "longitude": { + "name": "[%key:common::config_flow::data::longitude%]", + "description": "Longitude of your location." + } + } + }, + "stop": { + "name": "[%key:common::action::stop%]", + "description": "Stops Home Assistant." + }, + "toggle": { + "name": "Generic toggle", + "description": "Generic service to toggle devices on/off under any domain." + }, + "turn_on": { + "name": "Generic turn on", + "description": "Generic service to turn devices on under any domain." + }, + "turn_off": { + "name": "Generic turn off", + "description": "Generic service to turn devices off under any domain." + }, + "update_entity": { + "name": "Update entity", + "description": "Forces one or more entities to update its data." + }, + "reload_custom_templates": { + "name": "Reload custom Jinja2 templates", + "description": "Reloads Jinja2 templates found in the `custom_templates` folder in your config. New values will be applied on the next render of the template." + }, + "reload_config_entry": { + "name": "Reload config entry", + "description": "Reloads the specified config entry.", + "fields": { + "entry_id": { + "name": "Config entry ID", + "description": "The configuration entry ID of the entry to be reloaded." + } + } + }, + "save_persistent_states": { + "name": "Save persistent states", + "description": "Saves the persistent states immediately. Maintains the normal periodic saving interval." + } } } diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index 7fc780d7976..eec66a560a5 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -10,7 +10,6 @@ from homeassistant import exceptions from homeassistant.const import CONF_ATTRIBUTE, CONF_FOR, CONF_PLATFORM, MATCH_ALL from homeassistant.core import ( CALLBACK_TYPE, - Event, HassJob, HomeAssistant, State, @@ -22,12 +21,13 @@ from homeassistant.helpers import ( template, ) from homeassistant.helpers.event import ( + EventStateChangedData, async_track_same_state, async_track_state_change_event, process_state_match, ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType _LOGGER = logging.getLogger(__name__) @@ -129,11 +129,11 @@ async def async_attach_trigger( _variables = trigger_info["variables"] or {} @callback - def state_automation_listener(event: Event): + def state_automation_listener(event: EventType[EventStateChangedData]) -> None: """Listen for state changes and calls action.""" - entity: str = event.data["entity_id"] - from_s: State | None = event.data.get("old_state") - to_s: State | None = event.data.get("new_state") + entity = event.data["entity_id"] + from_s = event.data["old_state"] + to_s = event.data["new_state"] if from_s is None: old_value = None diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index a29cb5ff6da..5b3cd8590a7 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -12,15 +12,16 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import ( + EventStateChangedData, async_track_point_in_time, async_track_state_change_event, async_track_time_change, ) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType import homeassistant.util.dt as dt_util _TIME_TRIGGER_SCHEMA = vol.Any( @@ -48,7 +49,7 @@ async def async_attach_trigger( """Listen for state changes based on configuration.""" trigger_data = trigger_info["trigger_data"] entities: dict[str, CALLBACK_TYPE] = {} - removes = [] + removes: list[CALLBACK_TYPE] = [] job = HassJob(action, f"time trigger {trigger_info}") @callback @@ -68,12 +69,12 @@ async def async_attach_trigger( ) @callback - def update_entity_trigger_event(event): + def update_entity_trigger_event(event: EventType[EventStateChangedData]) -> None: """update_entity_trigger from the event.""" return update_entity_trigger(event.data["entity_id"], event.data["new_state"]) @callback - def update_entity_trigger(entity_id, new_state=None): + def update_entity_trigger(entity_id: str, new_state: State | None = None) -> None: """Update the entity trigger for the entity_id.""" # If a listener was already set up for entity, remove it. if remove := entities.pop(entity_id, None): @@ -83,6 +84,8 @@ async def async_attach_trigger( if not new_state: return + trigger_dt: datetime | None + # Check state of entity. If valid, set up a listener. if new_state.domain == "input_datetime": if has_date := new_state.attributes["has_date"]: @@ -155,7 +158,7 @@ async def async_attach_trigger( if remove: entities[entity_id] = remove - to_track = [] + to_track: list[str] = [] for at_time in config[CONF_AT]: if isinstance(at_time, str): diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index c5f7049e54f..e4d9902346c 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -138,6 +138,17 @@ class MultiprotocolAddonManager(AddonManager): return tasks + async def async_active_platforms(self) -> list[str]: + """Return a list of platforms using the multipan radio.""" + active_platforms: list[str] = [] + + for integration_domain, platform in self._platforms.items(): + if not await platform.async_using_multipan(self._hass): + continue + active_platforms.append(integration_domain) + + return active_platforms + @callback def async_get_channel(self) -> int | None: """Get the channel.""" @@ -510,7 +521,26 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): ) -> FlowResult: """Reconfigure the addon.""" multipan_manager = await get_addon_manager(self.hass) + active_platforms = await multipan_manager.async_active_platforms() + if set(active_platforms) != {"otbr", "zha"}: + return await self.async_step_notify_unknown_multipan_user() + return await self.async_step_change_channel() + async def async_step_notify_unknown_multipan_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Notify that there may be unknown multipan platforms.""" + if user_input is None: + return self.async_show_form( + step_id="notify_unknown_multipan_user", + ) + return await self.async_step_change_channel() + + async def async_step_change_channel( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Change the channel.""" + multipan_manager = await get_addon_manager(self.hass) if user_input is None: channels = [str(x) for x in range(11, 27)] suggested_channel = DEFAULT_CHANNEL @@ -529,7 +559,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): } ) return self.async_show_form( - step_id="reconfigure_addon", data_schema=data_schema + step_id="change_channel", data_schema=data_schema ) # Change the shared channel diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 60501397557..45e85f5a474 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -18,6 +18,13 @@ "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" } }, + "change_channel": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "data": { + "channel": "Channel" + }, + "description": "Start a channel change for your Zigbee and Thread networks.\n\nNote: this is an advanced operation and can leave your Thread and Zigbee networks inoperable if the new channel is congested. Depending on existing network conditions, many of your devices may not migrate to the new channel and will require re-joining before they start working again. Use with caution.\n\nOnce you selected **Submit**, the channel change starts quietly in the background and will finish after a few minutes. " + }, "install_addon": { "title": "The Silicon Labs Multiprotocol add-on installation has started" }, @@ -25,11 +32,12 @@ "title": "Channel change initiated", "description": "A Zigbee and Thread channel change has been initiated and will finish in {delay_minutes} minutes." }, + "notify_unknown_multipan_user": { + "title": "Manual configuration may be needed", + "description": "Home Assistant can automatically change the channels for otbr and zha. If you have configured another integration to use the radio, for example Zigbee2MQTT, you will have to reconfigure the channel in that integration after completing this guide." + }, "reconfigure_addon": { - "title": "Reconfigure IEEE 802.15.4 radio multiprotocol support", - "data": { - "channel": "Channel" - } + "title": "Reconfigure IEEE 802.15.4 radio multiprotocol support" }, "show_revert_guide": { "title": "Multiprotocol support is enabled for this device", diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 415df2092a1..9bc1a49125b 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -17,6 +17,13 @@ "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]" } }, + "change_channel": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::title%]", + "data": { + "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::data::channel%]" + }, + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::description%]" + }, "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, @@ -24,11 +31,12 @@ "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]", "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]" }, + "notify_unknown_multipan_user": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_unknown_multipan_user::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_unknown_multipan_user::description%]" + }, "reconfigure_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", - "data": { - "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::data::channel%]" - } + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]" }, "show_revert_guide": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]", diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index c1069a7e755..644a3c04553 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -17,6 +17,13 @@ "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]" } }, + "change_channel": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::title%]", + "data": { + "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::data::channel%]" + }, + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::description%]" + }, "hardware_settings": { "title": "Configure hardware settings", "data": { @@ -38,6 +45,10 @@ "multipan_settings": "Configure IEEE 802.15.4 radio multiprotocol support" } }, + "notify_unknown_multipan_user": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_unknown_multipan_user::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_unknown_multipan_user::description%]" + }, "reboot_menu": { "title": "Reboot required", "description": "The settings have changed, but the new settings will not take effect until the system is rebooted", @@ -47,10 +58,7 @@ } }, "reconfigure_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", - "data": { - "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::data::channel%]" - } + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]" }, "show_revert_guide": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]", diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 00168ef3898..f88047795ca 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -41,13 +41,16 @@ from homeassistant.const import ( from homeassistant.core import ( CALLBACK_TYPE, Context, - Event, HomeAssistant, State, callback as ha_callback, split_entity_id, ) -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import EventType from homeassistant.util.decorator import Registry from .const import ( @@ -450,9 +453,11 @@ class HomeAccessory(Accessory): # type: ignore[misc] self.async_update_battery(battery_state, battery_charging_state) @ha_callback - def async_update_event_state_callback(self, event: Event) -> None: + def async_update_event_state_callback( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle state change event listener callback.""" - self.async_update_state_callback(event.data.get("new_state")) + self.async_update_state_callback(event.data["new_state"]) @ha_callback def async_update_state_callback(self, new_state: State | None) -> None: @@ -477,9 +482,11 @@ class HomeAccessory(Accessory): # type: ignore[misc] self.async_update_state(new_state) @ha_callback - def async_update_linked_battery_callback(self, event: Event) -> None: + def async_update_linked_battery_callback( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle linked battery sensor state change listener callback.""" - if (new_state := event.data.get("new_state")) is None: + if (new_state := event.data["new_state"]) is None: return if self.linked_battery_charging_sensor: battery_charging_state = None @@ -488,9 +495,11 @@ class HomeAccessory(Accessory): # type: ignore[misc] self.async_update_battery(new_state.state, battery_charging_state) @ha_callback - def async_update_linked_battery_charging_callback(self, event: Event) -> None: + def async_update_linked_battery_charging_callback( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle linked battery charging sensor state change listener callback.""" - if (new_state := event.data.get("new_state")) is None: + if (new_state := event.data["new_state"]) is None: return self.async_update_battery(None, new_state.state == STATE_ON) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 245dbd0a19e..04ba4cc1a6a 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -9,8 +9,8 @@ "iot_class": "local_push", "loggers": ["pyhap"], "requirements": [ - "HAP-python==4.7.0", - "fnv-hash-fast==0.3.1", + "HAP-python==4.7.1", + "fnv-hash-fast==0.4.0", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/homekit/services.yaml b/homeassistant/components/homekit/services.yaml index a982e9ccf8d..de271db0ad9 100644 --- a/homeassistant/components/homekit/services.yaml +++ b/homeassistant/components/homekit/services.yaml @@ -1,18 +1,11 @@ # Describes the format for available HomeKit services reload: - name: Reload - description: Reload homekit and re-process YAML configuration - reset_accessory: - name: Reset accessory - description: Reset a HomeKit accessory target: entity: {} unpair: - name: Unpair an accessory or bridge - description: Forcefully remove all pairings from an accessory to allow re-pairing. Use this service if the accessory is no longer responsive, and you want to avoid deleting and re-adding the entry. Room locations, and accessory preferences will be lost. target: device: integration: homekit diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 74af388df85..f57536263ca 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -24,14 +24,14 @@ "data": { "entities": "Entities" }, - "description": "Select entities from each domain in “{domains}”. The include will cover the entire domain if you do not select any entities for a given domain.", + "description": "Select entities from each domain in \u201c{domains}\u201d. The include will cover the entire domain if you do not select any entities for a given domain.", "title": "Select the entities to be included" }, "exclude": { "data": { "entities": "[%key:component::homekit::options::step::include::data::entities%]" }, - "description": "All “{domains}” entities will be included except for the excluded entities and categorized entities.", + "description": "All \u201c{domains}\u201d entities will be included except for the excluded entities and categorized entities.", "title": "Select the entities to be excluded" }, "cameras": { @@ -68,5 +68,19 @@ "abort": { "port_name_in_use": "An accessory or bridge with the same name or port is already configured." } + }, + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads homekit and re-process YAML-configuration." + }, + "reset_accessory": { + "name": "Reset accessory", + "description": "Resets a HomeKit accessory." + }, + "unpair": { + "name": "Unpair an accessory or bridge", + "description": "Forcefully removes all pairings from an accessory to allow re-pairing. Use this service if the accessory is no longer responsive, and you want to avoid deleting and re-adding the entry. Room locations, and accessory preferences will be lost." + } } } diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 3bc2b1ed6ae..62d27245a1c 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -14,11 +14,13 @@ from pyhap.const import CATEGORY_CAMERA from homeassistant.components import camera from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.const import STATE_ON -from homeassistant.core import Event, callback +from homeassistant.core import State, callback from homeassistant.helpers.event import ( + EventStateChangedData, async_track_state_change_event, async_track_time_interval, ) +from homeassistant.helpers.typing import EventType from .accessories import TYPES, HomeAccessory from .const import ( @@ -266,13 +268,15 @@ class Camera(HomeAccessory, PyhapCamera): await super().run() @callback - def _async_update_motion_state_event(self, event: Event) -> None: + def _async_update_motion_state_event( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle state change event listener callback.""" if not state_changed_event_is_same_state(event): - self._async_update_motion_state(event.data.get("new_state")) + self._async_update_motion_state(event.data["new_state"]) @callback - def _async_update_motion_state(self, new_state): + def _async_update_motion_state(self, new_state: State | None) -> None: """Handle link motion sensor state change to update HomeKit value.""" if not new_state: return @@ -290,13 +294,15 @@ class Camera(HomeAccessory, PyhapCamera): ) @callback - def _async_update_doorbell_state_event(self, event: Event) -> None: + def _async_update_doorbell_state_event( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle state change event listener callback.""" if not state_changed_event_is_same_state(event): - self._async_update_doorbell_state(event.data.get("new_state")) + self._async_update_doorbell_state(event.data["new_state"]) @callback - def _async_update_doorbell_state(self, new_state): + def _async_update_doorbell_state(self, new_state: State | None) -> None: """Handle link doorbell sensor state change to update HomeKit value.""" if not new_state: return diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 05feb580572..ea0a5054ffd 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -31,7 +31,11 @@ from homeassistant.const import ( STATE_OPENING, ) from homeassistant.core import State, callback -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import EventType from .accessories import TYPES, HomeAccessory from .const import ( @@ -135,12 +139,14 @@ class GarageDoorOpener(HomeAccessory): await super().run() @callback - def _async_update_obstruction_event(self, event): + def _async_update_obstruction_event( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle state change event listener callback.""" - self._async_update_obstruction_state(event.data.get("new_state")) + self._async_update_obstruction_state(event.data["new_state"]) @callback - def _async_update_obstruction_state(self, new_state): + def _async_update_obstruction_state(self, new_state: State | None) -> None: """Handle linked obstruction sensor state change to update HomeKit value.""" if not new_state: return diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index 33c35908cd1..f9f572a096c 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -21,8 +21,12 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import callback -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.core import State, callback +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import EventType from .accessories import TYPES, HomeAccessory from .const import ( @@ -157,12 +161,14 @@ class HumidifierDehumidifier(HomeAccessory): await super().run() @callback - def async_update_current_humidity_event(self, event): + def async_update_current_humidity_event( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle state change event listener callback.""" - self._async_update_current_humidity(event.data.get("new_state")) + self._async_update_current_humidity(event.data["new_state"]) @callback - def _async_update_current_humidity(self, new_state): + def _async_update_current_humidity(self, new_state: State | None) -> None: """Handle linked humidity sensor state change to update HomeKit value.""" if new_state is None: _LOGGER.error( diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 0e3bcbfee86..8287c2b7845 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -37,9 +37,11 @@ from homeassistant.const import ( CONF_TYPE, UnitOfTemperature, ) -from homeassistant.core import Event, HomeAssistant, State, callback, split_entity_id +from homeassistant.core import HomeAssistant, State, callback, split_entity_id import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import EventStateChangedData from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.helpers.typing import EventType from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( @@ -619,9 +621,9 @@ def state_needs_accessory_mode(state: State) -> bool: ) -def state_changed_event_is_same_state(event: Event) -> bool: +def state_changed_event_is_same_state(event: EventType[EventStateChangedData]) -> bool: """Check if a state changed event is the same state.""" event_data = event.data - old_state: State | None = event_data.get("old_state") - new_state: State | None = event_data.get("new_state") + old_state = event_data["old_state"] + new_state = event_data["new_state"] return bool(new_state and old_state and new_state.state == old_state.state) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index f450c38527a..988adbd87a7 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -203,7 +203,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Determine if the device is a homekit bridge or accessory.""" dev_reg = dr.async_get(self.hass) device = dev_reg.async_get_device( - identifiers=set(), connections={(dr.CONNECTION_NETWORK_MAC, hkid)} + connections={(dr.CONNECTION_NETWORK_MAC, hkid)} ) if device is None: diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index b937e7f2e0b..4ba22317644 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -22,11 +22,10 @@ from aiohomekit.model.services import Service, ServicesTypes from homeassistant.components.thread.dataset_store import async_get_preferred_dataset from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_VIA_DEVICE, EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import CoreState, Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.event import async_track_time_interval @@ -96,6 +95,13 @@ class HKDevice: # A list of callbacks that turn HK service metadata into entities self.listeners: list[AddServiceCb] = [] + # A list of callbacks that turn HK service metadata into triggers + self.trigger_factories: list[AddServiceCb] = [] + + # Track aid/iid pairs so we know if we already handle triggers for a HK + # service. + self._triggers: list[tuple[int, int]] = [] + # A list of callbacks that turn HK characteristics into entities self.char_factories: list[AddCharacteristicCb] = [] @@ -116,8 +122,6 @@ class HKDevice: self.available = False - self.signal_state_updated = "_".join((DOMAIN, self.unique_id, "state_updated")) - self.pollable_characteristics: list[tuple[int, int]] = [] # Never allow concurrent polling of the same accessory or bridge @@ -138,6 +142,9 @@ class HKDevice: function=self.async_update, ) + self._availability_callbacks: set[CALLBACK_TYPE] = set() + self._subscriptions: dict[tuple[int, int], set[CALLBACK_TYPE]] = {} + @property def entity_map(self) -> Accessories: """Return the accessories from the pairing.""" @@ -182,7 +189,8 @@ class HKDevice: if self.available == available: return self.available = available - async_dispatcher_send(self.hass, self.signal_state_updated) + for callback_ in self._availability_callbacks: + callback_() async def _async_populate_ble_accessory_state(self, event: Event) -> None: """Populate the BLE accessory state without blocking startup. @@ -636,11 +644,33 @@ class HKDevice: self.listeners.append(add_entities_cb) self._add_new_entities([add_entities_cb]) + def add_trigger_factory(self, add_triggers_cb: AddServiceCb) -> None: + """Add a callback to run when discovering new triggers for services.""" + self.trigger_factories.append(add_triggers_cb) + self._add_new_triggers([add_triggers_cb]) + + def _add_new_triggers(self, callbacks: list[AddServiceCb]) -> None: + for accessory in self.entity_map.accessories: + aid = accessory.aid + for service in accessory.services: + iid = service.iid + entity_key = (aid, iid) + + if entity_key in self._triggers: + # Don't add the same trigger again + continue + + for add_trigger_cb in callbacks: + if add_trigger_cb(service): + self._triggers.append(entity_key) + break + def add_entities(self) -> None: """Process the entity map and create HA entities.""" self._add_new_entities(self.listeners) self._add_new_entities_for_accessory(self.accessory_factories) self._add_new_entities_for_char(self.char_factories) + self._add_new_triggers(self.trigger_factories) def _add_new_entities(self, callbacks) -> None: for accessory in self.entity_map.accessories: @@ -768,7 +798,39 @@ class HKDevice: self.entity_map.process_changes(new_values_dict) - async_dispatcher_send(self.hass, self.signal_state_updated) + to_callback: set[CALLBACK_TYPE] = set() + for aid_iid in new_values_dict: + if callbacks := self._subscriptions.get(aid_iid): + to_callback.update(callbacks) + + for callback_ in to_callback: + callback_() + + @callback + def async_subscribe( + self, characteristics: Iterable[tuple[int, int]], callback_: CALLBACK_TYPE + ) -> CALLBACK_TYPE: + """Add characteristics to the watch list.""" + for aid_iid in characteristics: + self._subscriptions.setdefault(aid_iid, set()).add(callback_) + + def _unsub(): + for aid_iid in characteristics: + self._subscriptions[aid_iid].remove(callback_) + if not self._subscriptions[aid_iid]: + del self._subscriptions[aid_iid] + + return _unsub + + @callback + def async_subscribe_availability(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE: + """Add characteristics to the watch list.""" + self._availability_callbacks.add(callback_) + + def _unsub(): + self._availability_callbacks.remove(callback_) + + return _unsub async def get_characteristics(self, *args: Any, **kwargs: Any) -> dict[str, Any]: """Read latest state from homekit accessory.""" diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 0dfaf6e538c..cde9aa732c3 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -53,6 +53,9 @@ HOMEKIT_ACCESSORY_DISPATCH = { ServicesTypes.TELEVISION: "media_player", ServicesTypes.VALVE: "switch", ServicesTypes.CAMERA_RTP_STREAM_MANAGEMENT: "camera", + ServicesTypes.DOORBELL: "event", + ServicesTypes.STATELESS_PROGRAMMABLE_SWITCH: "event", + ServicesTypes.SERVICE_LABEL: "event", } CHARACTERISTIC_PLATFORMS = { diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index 229c8aecc00..bbc56ddd4a4 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -211,7 +211,7 @@ async def async_setup_triggers_for_entry( conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] @callback - def async_add_service(service): + def async_add_characteristic(service: Service): aid = service.accessory.aid service_type = service.type @@ -238,7 +238,7 @@ async def async_setup_triggers_for_entry( return True - conn.add_listener(async_add_service) + conn.add_trigger_factory(async_add_characteristic) @callback diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index 6171e9406a0..046dc9f17ec 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -11,7 +11,6 @@ from aiohomekit.model.characteristics import ( ) from aiohomekit.model.services import Service, ServicesTypes -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.typing import ConfigType @@ -30,6 +29,7 @@ class HomeKitEntity(Entity): self._aid = devinfo["aid"] self._iid = devinfo["iid"] self._char_name: str | None = None + self.all_characteristics: set[tuple[int, int]] = set() self.setup() super().__init__() @@ -54,13 +54,13 @@ class HomeKitEntity(Entity): async def async_added_to_hass(self) -> None: """Entity added to hass.""" self.async_on_remove( - async_dispatcher_connect( - self.hass, - self._accessory.signal_state_updated, - self.async_write_ha_state, + self._accessory.async_subscribe( + self.all_characteristics, self._async_write_ha_state ) ) - + self.async_on_remove( + self._accessory.async_subscribe_availability(self._async_write_ha_state) + ) self._accessory.add_pollable_characteristics(self.pollable_characteristics) await self._accessory.add_watchable_characteristics( self.watchable_characteristics @@ -105,6 +105,9 @@ class HomeKitEntity(Entity): for char in service.characteristics.filter(char_types=char_types): self._setup_characteristic(char) + self.all_characteristics.update(self.pollable_characteristics) + self.all_characteristics.update(self.watchable_characteristics) + def _setup_characteristic(self, char: Characteristic) -> None: """Configure an entity based on a HomeKit characteristics metadata.""" # Build up a list of (aid, iid) tuples to poll on update() diff --git a/homeassistant/components/homekit_controller/event.py b/homeassistant/components/homekit_controller/event.py new file mode 100644 index 00000000000..9d70127f74a --- /dev/null +++ b/homeassistant/components/homekit_controller/event.py @@ -0,0 +1,160 @@ +"""Support for Homekit motion sensors.""" +from __future__ import annotations + +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics.const import InputEventValues +from aiohomekit.model.services import Service, ServicesTypes +from aiohomekit.utils import clamp_enum_to_char + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import KNOWN_DEVICES +from .connection import HKDevice +from .entity import HomeKitEntity + +INPUT_EVENT_VALUES = { + InputEventValues.SINGLE_PRESS: "single_press", + InputEventValues.DOUBLE_PRESS: "double_press", + InputEventValues.LONG_PRESS: "long_press", +} + + +class HomeKitEventEntity(HomeKitEntity, EventEntity): + """Representation of a Homekit event entity.""" + + _attr_should_poll = False + + def __init__( + self, + connection: HKDevice, + service: Service, + entity_description: EventEntityDescription, + ) -> None: + """Initialise a generic HomeKit event entity.""" + super().__init__( + connection, + { + "aid": service.accessory.aid, + "iid": service.iid, + }, + ) + self._characteristic = service.characteristics_by_type[ + CharacteristicsTypes.INPUT_EVENT + ] + + self.entity_description = entity_description + + # An INPUT_EVENT may support single_press, long_press and double_press. All are optional. So we have to + # clamp InputEventValues for this exact device + self._attr_event_types = [ + INPUT_EVENT_VALUES[v] + for v in clamp_enum_to_char(InputEventValues, self._characteristic) + ] + + def get_characteristic_types(self) -> list[str]: + """Define the homekit characteristics the entity cares about.""" + return [CharacteristicsTypes.INPUT_EVENT] + + async def async_added_to_hass(self) -> None: + """Entity added to hass.""" + await super().async_added_to_hass() + + self.async_on_remove( + self._accessory.async_subscribe( + [(self._aid, self._characteristic.iid)], + self._handle_event, + ) + ) + + @callback + def _handle_event(self): + if self._characteristic.value is None: + # For IP backed devices the characteristic is marked as + # pollable, but always returns None when polled + # Make sure we don't explode if we see that edge case. + return + self._trigger_event(INPUT_EVENT_VALUES[self._characteristic.value]) + self.async_write_ha_state() + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Homekit event.""" + hkid: str = config_entry.data["AccessoryPairingID"] + conn: HKDevice = hass.data[KNOWN_DEVICES][hkid] + + @callback + def async_add_service(service: Service) -> bool: + entities = [] + + if service.type == ServicesTypes.DOORBELL: + entities.append( + HomeKitEventEntity( + conn, + service, + EventEntityDescription( + key=f"{service.accessory.aid}_{service.iid}", + device_class=EventDeviceClass.DOORBELL, + translation_key="doorbell", + ), + ) + ) + + elif service.type == ServicesTypes.SERVICE_LABEL: + switches = list( + service.accessory.services.filter( + service_type=ServicesTypes.STATELESS_PROGRAMMABLE_SWITCH, + child_service=service, + order_by=[CharacteristicsTypes.SERVICE_LABEL_INDEX], + ) + ) + + for switch in switches: + # The Apple docs say that if we number the buttons ourselves + # We do it in service label index order. `switches` is already in + # that order. + entities.append( + HomeKitEventEntity( + conn, + switch, + EventEntityDescription( + key=f"{service.accessory.aid}_{service.iid}", + device_class=EventDeviceClass.BUTTON, + translation_key="button", + ), + ) + ) + + elif service.type == ServicesTypes.STATELESS_PROGRAMMABLE_SWITCH: + # A stateless switch that has a SERVICE_LABEL_INDEX is part of a group + # And is handled separately + if not service.has(CharacteristicsTypes.SERVICE_LABEL_INDEX): + entities.append( + HomeKitEventEntity( + conn, + service, + EventEntityDescription( + key=f"{service.accessory.aid}_{service.iid}", + device_class=EventDeviceClass.BUTTON, + translation_key="button", + ), + ) + ) + + if entities: + async_add_entities(entities) + return True + + return False + + conn.add_listener(async_add_service) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 2a9e2225e9f..8cc80ef864e 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==2.6.7"], + "requirements": ["aiohomekit==2.6.12"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 4d6ad7148d2..d7230de0832 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -333,7 +333,7 @@ SIMPLE_SENSOR: dict[str, HomeKitSensorEntityDescription] = { ), CharacteristicsTypes.FILTER_LIFE_LEVEL: HomeKitSensorEntityDescription( key=CharacteristicsTypes.FILTER_LIFE_LEVEL, - name="Filter Life", + name="Filter lifetime", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index 7420ef7f3f9..901378c8cb9 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -7,7 +7,7 @@ "title": "Device selection", "description": "HomeKit Device communicates over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Select the device you want to pair with:", "data": { - "device": "Device" + "device": "[%key:common::config_flow::data::device%]" } }, "pair": { @@ -71,11 +71,35 @@ } }, "entity": { + "event": { + "doorbell": { + "state_attributes": { + "event_type": { + "state": { + "double_press": "Double press", + "long_press": "Long press", + "single_press": "Single press" + } + } + } + }, + "button": { + "state_attributes": { + "event_type": { + "state": { + "double_press": "[%key:component::homekit_controller::entity::event::doorbell::state_attributes::event_type::state::double_press%]", + "long_press": "[%key:component::homekit_controller::entity::event::doorbell::state_attributes::event_type::state::long_press%]", + "single_press": "[%key:component::homekit_controller::entity::event::doorbell::state_attributes::event_type::state::single_press%]" + } + } + } + } + }, "select": { "ecobee_mode": { "state": { - "away": "Away", - "home": "Home", + "away": "[%key:common::state::not_home%]", + "home": "[%key:common::state::home%]", "sleep": "Sleep" } } @@ -96,7 +120,7 @@ "border_router": "Border Router", "child": "Child", "detached": "Detached", - "disabled": "Disabled", + "disabled": "[%key:common::state::disabled%]", "joining": "Joining", "leader": "Leader", "router": "Router" diff --git a/homeassistant/components/homematic/services.yaml b/homeassistant/components/homematic/services.yaml index 28b6577cdf9..52907966688 100644 --- a/homeassistant/components/homematic/services.yaml +++ b/homeassistant/components/homematic/services.yaml @@ -1,105 +1,73 @@ # Describes the format for available component services virtualkey: - name: Virtual key - description: Press a virtual key from CCU/Homegear or simulate keypress. fields: address: - name: Address - description: Address of homematic device or BidCoS-RF for virtual remote. required: true example: BidCoS-RF selector: text: channel: - name: Channel - description: Channel for calling a keypress. required: true selector: number: min: 1 max: 6 param: - name: Param - description: Event to send i.e. PRESS_LONG, PRESS_SHORT. required: true example: PRESS_LONG selector: text: interface: - name: Interface - description: Set an interface value. example: Interfaces name from config selector: text: set_variable_value: - name: Set variable value - description: Set the name of a node. fields: entity_id: - name: Entity - description: Name(s) of homematic central to set value. selector: entity: domain: homematic name: - name: Name - description: Name of the variable to set. required: true example: "testvariable" selector: text: value: - name: Value - description: New value required: true example: 1 selector: text: set_device_value: - name: Set device value - description: Set a device property on RPC XML interface. fields: address: - name: Address - description: Address of homematic device or BidCoS-RF for virtual remote required: true example: BidCoS-RF selector: text: channel: - name: Channel - description: Channel for calling a keypress required: true selector: number: min: 1 max: 6 param: - name: Param - description: Event to send i.e. PRESS_LONG, PRESS_SHORT required: true example: PRESS_LONG selector: text: interface: - name: Interface - description: Set an interface value example: Interfaces name from config selector: text: value: - name: Value - description: New value required: true example: 1 selector: text: value_type: - name: Value type - description: Type for new value selector: select: options: @@ -110,31 +78,20 @@ set_device_value: - "string" reconnect: - name: Reconnect - description: Reconnect to all Homematic Hubs. - set_install_mode: - name: Set install mode - description: Set a RPC XML interface into installation mode. fields: interface: - name: Interface - description: Select the given interface into install mode required: true example: Interfaces name from config selector: text: mode: - name: Mode - description: 1= Normal mode / 2= Remove exists old links default: 1 selector: number: min: 1 max: 2 time: - name: Time - description: Time to run in install mode default: 60 selector: number: @@ -142,47 +99,33 @@ set_install_mode: max: 3600 unit_of_measurement: seconds address: - name: Address - description: Address of homematic device or BidCoS-RF to learn example: LEQ3948571 selector: text: put_paramset: - name: Put paramset - description: Call to putParamset in the RPC XML interface fields: interface: - name: Interface - description: The interfaces name from the config required: true example: wireless selector: text: address: - name: Address - description: Address of Homematic device required: true example: LEQ3948571:0 selector: text: paramset_key: - name: Paramset key - description: The paramset_key argument to putParamset required: true example: MASTER selector: text: paramset: - name: Paramset - description: A paramset dictionary required: true example: '{"WEEK_PROGRAM_POINTER": 1}' selector: object: rx_mode: - name: RX mode - description: The receive mode used. example: BURST selector: text: diff --git a/homeassistant/components/homematic/strings.json b/homeassistant/components/homematic/strings.json new file mode 100644 index 00000000000..48ebbe5d345 --- /dev/null +++ b/homeassistant/components/homematic/strings.json @@ -0,0 +1,126 @@ +{ + "services": { + "virtualkey": { + "name": "Virtual key", + "description": "Presses a virtual key from CCU/Homegear or simulate keypress.", + "fields": { + "address": { + "name": "Address", + "description": "Address of homematic device or BidCoS-RF for virtual remote." + }, + "channel": { + "name": "Channel", + "description": "Channel for calling a keypress." + }, + "param": { + "name": "Param", + "description": "Event to send i.e. PRESS_LONG, PRESS_SHORT." + }, + "interface": { + "name": "Interface", + "description": "Set an interface value." + } + } + }, + "set_variable_value": { + "name": "Set variable value", + "description": "Sets the name of a node.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name(s) of homematic central to set value." + }, + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "Name of the variable to set." + }, + "value": { + "name": "Value", + "description": "New value." + } + } + }, + "set_device_value": { + "name": "Set device value", + "description": "Sets a device property on RPC XML interface.", + "fields": { + "address": { + "name": "Address", + "description": "[%key:component::homematic::services::virtualkey::fields::address::description%]" + }, + "channel": { + "name": "Channel", + "description": "[%key:component::homematic::services::virtualkey::fields::channel::description%]" + }, + "param": { + "name": "[%key:component::homematic::services::virtualkey::fields::param::name%]", + "description": "[%key:component::homematic::services::virtualkey::fields::param::description%]" + }, + "interface": { + "name": "Interface", + "description": "[%key:component::homematic::services::virtualkey::fields::interface::description%]" + }, + "value": { + "name": "Value", + "description": "[%key:component::homematic::services::set_variable_value::fields::value::description%]" + }, + "value_type": { + "name": "Value type", + "description": "Type for new value." + } + } + }, + "reconnect": { + "name": "Reconnect", + "description": "Reconnects to all Homematic Hubs." + }, + "set_install_mode": { + "name": "Set install mode", + "description": "Set a RPC XML interface into installation mode.", + "fields": { + "interface": { + "name": "Interface", + "description": "Select the given interface into install mode." + }, + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "1= Normal mode / 2= Remove exists old links." + }, + "time": { + "name": "Time", + "description": "Time to run in install mode." + }, + "address": { + "name": "Address", + "description": "Address of homematic device or BidCoS-RF to learn." + } + } + }, + "put_paramset": { + "name": "Put paramset", + "description": "Calls to putParamset in the RPC XML interface.", + "fields": { + "interface": { + "name": "Interface", + "description": "The interfaces name from the config." + }, + "address": { + "name": "Address", + "description": "Address of Homematic device." + }, + "paramset_key": { + "name": "Paramset key", + "description": "The paramset_key argument to putParamset." + }, + "paramset": { + "name": "Paramset", + "description": "A paramset dictionary." + }, + "rx_mode": { + "name": "RX mode", + "description": "The receive mode used." + } + } + } + } +} diff --git a/homeassistant/components/homematicip_cloud/generic_entity.py b/homeassistant/components/homematicip_cloud/generic_entity.py index 7a6e7c18e13..199cbacfa15 100644 --- a/homeassistant/components/homematicip_cloud/generic_entity.py +++ b/homeassistant/components/homematicip_cloud/generic_entity.py @@ -161,10 +161,10 @@ class HomematicipGenericEntity(Entity): if device_id in device_registry.devices: # This will also remove associated entities from entity registry. device_registry.async_remove_device(device_id) - else: + else: # noqa: PLR5501 # Remove from entity registry. # Only relevant for entities that do not belong to a device. - if entity_id := self.registry_entry.entity_id: # noqa: PLR5501 + if entity_id := self.registry_entry.entity_id: entity_registry = er.async_get(self.hass) if entity_id in entity_registry.entities: entity_registry.async_remove(entity_id) diff --git a/homeassistant/components/homematicip_cloud/services.yaml b/homeassistant/components/homematicip_cloud/services.yaml index ebb83a0845f..9e831339787 100644 --- a/homeassistant/components/homematicip_cloud/services.yaml +++ b/homeassistant/components/homematicip_cloud/services.yaml @@ -1,12 +1,8 @@ # Describes the format for available component services activate_eco_mode_with_duration: - name: Activate eco mode with duration - description: Activate eco mode with period. fields: duration: - name: Duration - description: The duration of eco mode in minutes. required: true selector: number: @@ -14,44 +10,30 @@ activate_eco_mode_with_duration: max: 1440 unit_of_measurement: "minutes" accesspoint_id: - name: Accesspoint ID - description: The ID of the Homematic IP Access Point example: 3014xxxxxxxxxxxxxxxxxxxx selector: text: activate_eco_mode_with_period: - name: Activate eco more with period - description: Activate eco mode with period. fields: endtime: - name: Endtime - description: The time when the eco mode should automatically be disabled. required: true example: 2019-02-17 14:00 selector: text: accesspoint_id: - name: Accesspoint ID - description: The ID of the Homematic IP Access Point example: 3014xxxxxxxxxxxxxxxxxxxx selector: text: activate_vacation: - name: Activate vacation - description: Activates the vacation mode until the given time. fields: endtime: - name: Endtime - description: The time when the vacation mode should automatically be disabled. required: true example: 2019-09-17 14:00 selector: text: temperature: - name: Temperature - description: the set temperature during the vacation mode. required: true default: 18 selector: @@ -61,48 +43,32 @@ activate_vacation: step: 0.5 unit_of_measurement: "°" accesspoint_id: - name: Accesspoint ID - description: The ID of the Homematic IP Access Point example: 3014xxxxxxxxxxxxxxxxxxxx selector: text: deactivate_eco_mode: - name: Deactivate eco mode - description: Deactivates the eco mode immediately. fields: accesspoint_id: - name: Accesspoint ID - description: The ID of the Homematic IP Access Point example: 3014xxxxxxxxxxxxxxxxxxxx selector: text: deactivate_vacation: - name: Deactivate vacation - description: Deactivates the vacation mode immediately. fields: accesspoint_id: - name: Accesspoint ID - description: The ID of the Homematic IP Access Point example: 3014xxxxxxxxxxxxxxxxxxxx selector: text: set_active_climate_profile: - name: Set active climate profile - description: Set the active climate profile index. fields: entity_id: - name: Entity - description: The ID of the climate entity. Use 'all' keyword to switch the profile for all entities. required: true example: climate.livingroom selector: text: climate_profile_index: - name: Climate profile index - description: The index of the climate profile. required: true selector: number: @@ -110,36 +76,24 @@ set_active_climate_profile: max: 100 dump_hap_config: - name: Dump hap config - description: Dump the configuration of the Homematic IP Access Point(s). fields: config_output_path: - name: Config output path - description: (Default is 'Your home-assistant config directory') Path where to store the config. example: "/config" selector: text: config_output_file_prefix: - name: Config output file prefix - description: Name of the config file. The SGTIN of the AP will always be appended. example: "hmip-config" default: "hmip-config" selector: text: anonymize: - name: Anonymize - description: Should the Configuration be anonymized? default: true selector: boolean: reset_energy_counter: - name: Reset energy counter - description: Reset the energy counter of a measuring entity. fields: entity_id: - name: Entity - description: The ID of the measuring entity. Use 'all' keyword to reset all energy counters. required: true example: switch.livingroom selector: diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index 3e3c967f972..3795508d75d 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -25,5 +25,115 @@ "connection_aborted": "[%key:common::config_flow::error::cannot_connect%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "services": { + "activate_eco_mode_with_duration": { + "name": "Activate eco mode with duration", + "description": "Activates eco mode with period.", + "fields": { + "duration": { + "name": "Duration", + "description": "The duration of eco mode in minutes." + }, + "accesspoint_id": { + "name": "Accesspoint ID", + "description": "The ID of the Homematic IP Access Point." + } + } + }, + "activate_eco_mode_with_period": { + "name": "Activate eco more with period", + "description": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::description%]", + "fields": { + "endtime": { + "name": "Endtime", + "description": "The time when the eco mode should automatically be disabled." + }, + "accesspoint_id": { + "name": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::fields::accesspoint_id::name%]", + "description": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::fields::accesspoint_id::description%]" + } + } + }, + "activate_vacation": { + "name": "Activate vacation", + "description": "Activates the vacation mode until the given time.", + "fields": { + "endtime": { + "name": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_period::fields::endtime::name%]", + "description": "The time when the vacation mode should automatically be disabled." + }, + "temperature": { + "name": "Temperature", + "description": "The set temperature during the vacation mode." + }, + "accesspoint_id": { + "name": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::fields::accesspoint_id::name%]", + "description": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::fields::accesspoint_id::description%]" + } + } + }, + "deactivate_eco_mode": { + "name": "Deactivate eco mode", + "description": "Deactivates the eco mode immediately.", + "fields": { + "accesspoint_id": { + "name": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::fields::accesspoint_id::name%]", + "description": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::fields::accesspoint_id::description%]" + } + } + }, + "deactivate_vacation": { + "name": "Deactivate vacation", + "description": "Deactivates the vacation mode immediately.", + "fields": { + "accesspoint_id": { + "name": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::fields::accesspoint_id::name%]", + "description": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::fields::accesspoint_id::description%]" + } + } + }, + "set_active_climate_profile": { + "name": "Set active climate profile", + "description": "Sets the active climate profile index.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "The ID of the climate entity. Use 'all' keyword to switch the profile for all entities." + }, + "climate_profile_index": { + "name": "Climate profile index", + "description": "The index of the climate profile." + } + } + }, + "dump_hap_config": { + "name": "Dump hap config", + "description": "Dumps the configuration of the Homematic IP Access Point(s).", + "fields": { + "config_output_path": { + "name": "Config output path", + "description": "(Default is 'Your home-assistant config directory') Path where to store the config." + }, + "config_output_file_prefix": { + "name": "Config output file prefix", + "description": "Name of the config file. The SGTIN of the AP will always be appended." + }, + "anonymize": { + "name": "Anonymize", + "description": "Should the Configuration be anonymized?" + } + } + }, + "reset_energy_counter": { + "name": "Reset energy counter", + "description": "Resets the energy counter of a measuring entity.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "The ID of the measuring entity. Use 'all' keyword to reset all energy counters." + } + } + } } } diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index b1bbd8d0945..36b9631c801 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -3,11 +3,10 @@ "name": "HomeWizard Energy", "codeowners": ["@DCSBL"], "config_flow": true, - "dependencies": [], "documentation": "https://www.home-assistant.io/integrations/homewizard", "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==2.0.1"], + "requirements": ["python-homewizard-energy==2.0.2"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/homeassistant/components/html5/services.yaml b/homeassistant/components/html5/services.yaml index f6b76e67cd7..929eb5a2dc1 100644 --- a/homeassistant/components/html5/services.yaml +++ b/homeassistant/components/html5/services.yaml @@ -1,16 +1,10 @@ dismiss: - name: Dismiss - description: Dismiss a html5 notification. fields: target: - name: Target - description: An array of targets. example: ["my_phone", "my_tablet"] selector: object: data: - name: Data - description: Extended information of notification. Supports tag. example: '{ "tag": "tagname" }' selector: object: diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json new file mode 100644 index 00000000000..fa69025c43c --- /dev/null +++ b/homeassistant/components/html5/strings.json @@ -0,0 +1,18 @@ +{ + "services": { + "dismiss": { + "name": "Dismiss", + "description": "Dismisses a html5 notification.", + "fields": { + "target": { + "name": "Target", + "description": "An array of targets." + }, + "data": { + "name": "Data", + "description": "Extended information of notification. Supports tag." + } + } + } + } +} diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index fda8717c3dd..68602e34d3e 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -18,6 +18,11 @@ from aiohttp.typedefs import JSONDecoder, StrOrURL from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection from aiohttp.web_log import AccessLogger from aiohttp.web_protocol import RequestHandler +from aiohttp.web_urldispatcher import ( + AbstractResource, + UrlDispatcher, + UrlMappingMatchInfo, +) from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -303,6 +308,10 @@ class HomeAssistantHTTP: "max_field_size": MAX_LINE_SIZE, }, ) + # By default aiohttp does a linear search for routing rules, + # we have a lot of routes, so use a dict lookup with a fallback + # to the linear search. + self.app._router = FastUrlDispatcher() # pylint: disable=protected-access self.hass = hass self.ssl_certificate = ssl_certificate self.ssl_peer_certificate = ssl_peer_certificate @@ -565,3 +574,40 @@ async def start_http_server_and_save_config( ] store.async_delay_save(lambda: conf, SAVE_DELAY) + + +class FastUrlDispatcher(UrlDispatcher): + """UrlDispatcher that uses a dict lookup for resolving.""" + + def __init__(self) -> None: + """Initialize the dispatcher.""" + super().__init__() + self._resource_index: dict[str, list[AbstractResource]] = {} + + def register_resource(self, resource: AbstractResource) -> None: + """Register a resource.""" + super().register_resource(resource) + canonical = resource.canonical + if "{" in canonical: # strip at the first { to allow for variables + canonical = canonical.split("{")[0].rstrip("/") + # There may be multiple resources for a canonical path + # so we use a list to avoid falling back to a full linear search + self._resource_index.setdefault(canonical, []).append(resource) + + async def resolve(self, request: web.Request) -> UrlMappingMatchInfo: + """Resolve a request.""" + url_parts = request.rel_url.raw_parts + resource_index = self._resource_index + + # Walk the url parts looking for candidates + for i in range(len(url_parts), 0, -1): + url_part = "/" + "/".join(url_parts[1:i]) + if (resource_candidates := resource_index.get(url_part)) is not None: + for candidate in resource_candidates: + if ( + match_dict := (await candidate.resolve(request))[0] + ) is not None: + return match_dict + + # Finally, fallback to the linear search + return await super().resolve(request) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 77ae80b62ff..fc7b3c03abe 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -224,7 +224,7 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: authenticated = True auth_type = "signed request" - if authenticated: + if authenticated and _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( "Authenticated %s for %s using %s", request.remote, diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 95197dcbb49..3c101dff9cc 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -413,9 +413,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_info = DeviceInfo( configuration_url=router.url, connections=router.device_connections, - default_manufacturer=DEFAULT_MANUFACTURER, identifiers=router.device_identifiers, - manufacturer=entry.data.get(CONF_MANUFACTURER), + manufacturer=entry.data.get(CONF_MANUFACTURER, DEFAULT_MANUFACTURER), name=router.device_name, ) hw_version = None diff --git a/homeassistant/components/huawei_lte/services.yaml b/homeassistant/components/huawei_lte/services.yaml index 711064b435e..9d0cf5d91e6 100644 --- a/homeassistant/components/huawei_lte/services.yaml +++ b/homeassistant/components/huawei_lte/services.yaml @@ -1,46 +1,27 @@ clear_traffic_statistics: - name: Clear traffic statistics - description: Clear traffic statistics. fields: url: - name: URL - description: URL of router to clear; optional when only one is configured. example: http://192.168.100.1/ selector: text: reboot: - name: Reboot - description: Reboot router. fields: url: - name: URL - description: URL of router to reboot; optional when only one is configured. example: http://192.168.100.1/ selector: text: resume_integration: - name: Resume integration - description: Resume suspended integration. fields: url: - name: URL - description: URL of router to resume integration for; optional when only one is configured. example: http://192.168.100.1/ selector: text: suspend_integration: - name: Suspend integration - description: > - Suspend integration. Suspending logs the integration out from the router, and stops accessing it. - Useful e.g. if accessing the router web interface from another source such as a web browser is temporarily required. - Invoke the resume_integration service to resume. fields: url: - name: URL - description: URL of router to resume integration for; optional when only one is configured. example: http://192.168.100.1/ selector: text: diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 0eb68c959ac..41826dc6ae7 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -48,5 +48,47 @@ } } } + }, + "services": { + "clear_traffic_statistics": { + "name": "Clear traffic statistics", + "description": "Clears traffic statistics.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "URL of router to clear; optional when only one is configured." + } + } + }, + "reboot": { + "name": "Reboot", + "description": "Reboots router.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "URL of router to reboot; optional when only one is configured." + } + } + }, + "resume_integration": { + "name": "Resume integration", + "description": "Resumes suspended integration.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "URL of router to resume integration for; optional when only one is configured." + } + } + }, + "suspend_integration": { + "name": "Suspend integration", + "description": "Suspends integration. Suspending logs the integration out from the router, and stops accessing it. Useful e.g. if accessing the router web interface from another source such as a web browser is temporarily required. Invoke the resume_integration service to resume.\n.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "URL of router to suspend integration for; optional when only one is configured." + } + } + } } } diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index c39fbed180c..0e1688221b3 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -29,6 +29,7 @@ HUB_BUSY_SLEEP = 0.5 PLATFORMS_v1 = [Platform.BINARY_SENSOR, Platform.LIGHT, Platform.SENSOR] PLATFORMS_v2 = [ Platform.BINARY_SENSOR, + Platform.EVENT, Platform.LIGHT, Platform.SCENE, Platform.SENSOR, diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py index 798148b92c0..38c2587bc1a 100644 --- a/homeassistant/components/hue/const.py +++ b/homeassistant/components/hue/const.py @@ -1,4 +1,9 @@ """Constants for the Hue component.""" +from aiohue.v2.models.button import ButtonEvent +from aiohue.v2.models.relative_rotary import ( + RelativeRotaryAction, + RelativeRotaryDirection, +) DOMAIN = "hue" @@ -33,3 +38,26 @@ DEFAULT_ALLOW_UNREACHABLE = False # How long to wait to actually do the refresh after requesting it. # We wait some time so if we control multiple lights, we batch requests. REQUEST_REFRESH_DELAY = 0.3 + + +# V2 API SPECIFIC CONSTANTS ################## + +DEFAULT_BUTTON_EVENT_TYPES = ( + # I have never ever seen the `DOUBLE_SHORT_RELEASE` event so leave it out here + ButtonEvent.INITIAL_PRESS, + ButtonEvent.REPEAT, + ButtonEvent.SHORT_RELEASE, + ButtonEvent.LONG_PRESS, + ButtonEvent.LONG_RELEASE, +) + +DEFAULT_ROTARY_EVENT_TYPES = (RelativeRotaryAction.START, RelativeRotaryAction.REPEAT) +DEFAULT_ROTARY_EVENT_SUBTYPES = ( + RelativeRotaryDirection.CLOCK_WISE, + RelativeRotaryDirection.COUNTER_CLOCK_WISE, +) + +DEVICE_SPECIFIC_EVENT_TYPES = { + # device specific overrides of specific supported button events + "Hue tap switch": (ButtonEvent.INITIAL_PRESS,), +} diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py new file mode 100644 index 00000000000..8e34f7a22bf --- /dev/null +++ b/homeassistant/components/hue/event.py @@ -0,0 +1,133 @@ +"""Hue event entities from Button resources.""" +from __future__ import annotations + +from typing import Any + +from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.events import EventType +from aiohue.v2.models.button import Button +from aiohue.v2.models.relative_rotary import ( + RelativeRotary, + RelativeRotaryDirection, +) + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .bridge import HueBridge +from .const import DEFAULT_BUTTON_EVENT_TYPES, DEVICE_SPECIFIC_EVENT_TYPES, DOMAIN +from .v2.entity import HueBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up event platform from Hue button resources.""" + bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] + api: HueBridgeV2 = bridge.api + + if bridge.api_version == 1: + # should not happen, but just in case + raise NotImplementedError("Event support is only available for V2 bridges") + + # add entities for all button and relative rotary resources + @callback + def async_add_entity( + event_type: EventType, + resource: Button | RelativeRotary, + ) -> None: + """Add entity from Hue resource.""" + if isinstance(resource, RelativeRotary): + async_add_entities( + [HueRotaryEventEntity(bridge, api.sensors.relative_rotary, resource)] + ) + else: + async_add_entities( + [HueButtonEventEntity(bridge, api.sensors.button, resource)] + ) + + for controller in (api.sensors.button, api.sensors.relative_rotary): + # add all current items in controller + for item in controller: + async_add_entity(EventType.RESOURCE_ADDED, item) + + # register listener for new items only + config_entry.async_on_unload( + controller.subscribe( + async_add_entity, event_filter=EventType.RESOURCE_ADDED + ) + ) + + +class HueButtonEventEntity(HueBaseEntity, EventEntity): + """Representation of a Hue Event entity from a button resource.""" + + entity_description = EventEntityDescription( + key="button", + device_class=EventDeviceClass.BUTTON, + translation_key="button", + ) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the entity.""" + super().__init__(*args, **kwargs) + # fill the event types based on the features the switch supports + hue_dev_id = self.controller.get_device(self.resource.id).id + model_id = self.bridge.api.devices[hue_dev_id].product_data.product_name + event_types: list[str] = [] + for event_type in DEVICE_SPECIFIC_EVENT_TYPES.get( + model_id, DEFAULT_BUTTON_EVENT_TYPES + ): + event_types.append(event_type.value) + self._attr_event_types = event_types + + @property + def name(self) -> str: + """Return name for the entity.""" + return f"{super().name} {self.resource.metadata.control_id}" + + @callback + def _handle_event(self, event_type: EventType, resource: Button) -> None: + """Handle status event for this resource (or it's parent).""" + if event_type == EventType.RESOURCE_UPDATED and resource.id == self.resource.id: + self._trigger_event(resource.button.last_event.value) + self.async_write_ha_state() + return + super()._handle_event(event_type, resource) + + +class HueRotaryEventEntity(HueBaseEntity, EventEntity): + """Representation of a Hue Event entity from a RelativeRotary resource.""" + + entity_description = EventEntityDescription( + key="rotary", + device_class=EventDeviceClass.BUTTON, + translation_key="rotary", + event_types=[ + RelativeRotaryDirection.CLOCK_WISE.value, + RelativeRotaryDirection.COUNTER_CLOCK_WISE.value, + ], + ) + + @callback + def _handle_event(self, event_type: EventType, resource: RelativeRotary) -> None: + """Handle status event for this resource (or it's parent).""" + if event_type == EventType.RESOURCE_UPDATED and resource.id == self.resource.id: + event_key = resource.relative_rotary.last_event.rotation.direction.value + event_data = { + "duration": resource.relative_rotary.last_event.rotation.duration, + "steps": resource.relative_rotary.last_event.rotation.steps, + "action": resource.relative_rotary.last_event.action.value, + } + self._trigger_event(event_key, event_data) + self.async_write_ha_state() + return + super()._handle_event(event_type, resource) diff --git a/homeassistant/components/hue/logbook.py b/homeassistant/components/hue/logbook.py deleted file mode 100644 index 21d0da074a7..00000000000 --- a/homeassistant/components/hue/logbook.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Describe hue logbook events.""" -from __future__ import annotations - -from collections.abc import Callable - -from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME -from homeassistant.const import CONF_DEVICE_ID, CONF_EVENT, CONF_ID, CONF_TYPE -from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr - -from .const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN - -TRIGGER_SUBTYPE = { - "button_1": "first button", - "button_2": "second button", - "button_3": "third button", - "button_4": "fourth button", - "double_buttons_1_3": "first and third buttons", - "double_buttons_2_4": "second and fourth buttons", - "dim_down": "dim down", - "dim_up": "dim up", - "turn_off": "turn off", - "turn_on": "turn on", - "1": "first button", - "2": "second button", - "3": "third button", - "4": "fourth button", - "clock_wise": "Rotation clockwise", - "counter_clock_wise": "Rotation counter-clockwise", -} -TRIGGER_TYPE = { - "remote_button_long_release": "{subtype} released after long press", - "remote_button_short_press": "{subtype} pressed", - "remote_button_short_release": "{subtype} released", - "remote_double_button_long_press": "both {subtype} released after long press", - "remote_double_button_short_press": "both {subtype} released", - "initial_press": "{subtype} pressed initially", - "long_press": "{subtype} long press", - "repeat": "{subtype} held down", - "short_release": "{subtype} released after short press", - "long_release": "{subtype} released after long press", - "double_short_release": "both {subtype} released", - "start": '"{subtype}" pressed initially', -} - -UNKNOWN_TYPE = "unknown type" -UNKNOWN_SUB_TYPE = "unknown sub type" - - -@callback -def async_describe_events( - hass: HomeAssistant, - async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None], -) -> None: - """Describe hue logbook events.""" - - @callback - def async_describe_hue_event(event: Event) -> dict[str, str]: - """Describe hue logbook event.""" - data = event.data - name: str | None = None - if dev_ent := dr.async_get(hass).async_get(data[CONF_DEVICE_ID]): - name = dev_ent.name - if name is None: - name = data[CONF_ID] - if CONF_TYPE in data: # v2 - subtype = TRIGGER_SUBTYPE.get(str(data[CONF_SUBTYPE]), UNKNOWN_SUB_TYPE) - message = TRIGGER_TYPE.get(data[CONF_TYPE], UNKNOWN_TYPE).format( - subtype=subtype - ) - else: - message = f"Event {data[CONF_EVENT]}" # v1 - return { - LOGBOOK_ENTRY_NAME: name, - LOGBOOK_ENTRY_MESSAGE: message, - } - - async_describe_event(DOMAIN, ATTR_HUE_EVENT, async_describe_hue_event) diff --git a/homeassistant/components/hue/services.yaml b/homeassistant/components/hue/services.yaml index b06c3934152..a9ea57d7828 100644 --- a/homeassistant/components/hue/services.yaml +++ b/homeassistant/components/hue/services.yaml @@ -2,61 +2,42 @@ # legacy hue_activate_scene to activate a scene hue_activate_scene: - name: Activate scene - description: Activate a hue scene stored in the hue hub. fields: group_name: - name: Group - description: Name of hue group/room from the hue app. example: "Living Room" selector: text: scene_name: - name: Scene - description: Name of hue scene from the hue app. example: "Energize" selector: text: dynamic: - name: Dynamic - description: Enable dynamic mode of the scene (V2 bridges and supported scenes only). selector: boolean: # entity service to activate a Hue scene (V2) activate_scene: - name: Activate Hue Scene - description: Activate a Hue scene with more control over the options. target: entity: domain: scene integration: hue fields: transition: - name: Transition - description: Transition duration it takes to bring devices to the state - defined in the scene. selector: number: min: 0 max: 3600 unit_of_measurement: seconds dynamic: - name: Dynamic - description: Enable dynamic mode of the scene. selector: boolean: speed: - name: Speed - description: Speed of dynamic palette for this scene advanced: true selector: number: min: 0 max: 100 brightness: - name: Brightness - description: Set brightness for the scene. advanced: true selector: number: diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index a44eea0fe33..6d65abc8d5f 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -44,12 +44,12 @@ "double_buttons_2_4": "Second and Fourth buttons", "dim_down": "Dim down", "dim_up": "Dim up", - "turn_off": "Turn off", - "turn_on": "Turn on", - "1": "First button", - "2": "Second button", - "3": "Third button", - "4": "Fourth button", + "turn_off": "[%key:common::action::turn_off%]", + "turn_on": "[%key:common::action::turn_on%]", + "1": "[%key:component::hue::device_automation::trigger_subtype::button_1%]", + "2": "[%key:component::hue::device_automation::trigger_subtype::button_2%]", + "3": "[%key:component::hue::device_automation::trigger_subtype::button_3%]", + "4": "[%key:component::hue::device_automation::trigger_subtype::button_4%]", "clock_wise": "Rotation clockwise", "counter_clock_wise": "Rotation counter-clockwise" }, @@ -59,13 +59,41 @@ "remote_button_short_release": "\"{subtype}\" released", "remote_double_button_long_press": "Both \"{subtype}\" released after long press", "remote_double_button_short_press": "Both \"{subtype}\" released", - "initial_press": "\"{subtype}\" pressed initially", "repeat": "\"{subtype}\" held down", "short_release": "\"{subtype}\" released after short press", - "long_release": "\"{subtype}\" released after long press", - "double_short_release": "Both \"{subtype}\" released", - "start": "\"{subtype}\" pressed initially" + "long_release": "[%key:component::hue::device_automation::trigger_type::remote_button_long_release%]", + "double_short_release": "[%key:component::hue::device_automation::trigger_type::remote_double_button_short_press%]", + "start": "[%key:component::hue::device_automation::trigger_type::initial_press%]" + } + }, + "entity": { + "event": { + "button": { + "state_attributes": { + "event_type": { + "state": { + "initial_press": "Initial press", + "repeat": "Repeat", + "short_release": "Short press", + "long_press": "Long press", + "long_release": "Long release", + "double_short_release": "Double press" + } + } + } + }, + "rotary": { + "name": "Rotary", + "state_attributes": { + "event_type": { + "state": { + "clock_wise": "Clockwise", + "counter_clock_wise": "Counter clockwise" + } + } + } + } } }, "options": { @@ -79,5 +107,47 @@ } } } + }, + "services": { + "hue_activate_scene": { + "name": "Activate scene", + "description": "Activates a hue scene stored in the hue hub.", + "fields": { + "group_name": { + "name": "Group", + "description": "Name of hue group/room from the hue app." + }, + "scene_name": { + "name": "Scene", + "description": "Name of hue scene from the hue app." + }, + "dynamic": { + "name": "Dynamic", + "description": "Enable dynamic mode of the scene (V2 bridges and supported scenes only)." + } + } + }, + "activate_scene": { + "name": "Activate Hue scene", + "description": "Activates a Hue scene with more control over the options.", + "fields": { + "transition": { + "name": "Transition", + "description": "Transition duration it takes to bring devices to the state defined in the scene." + }, + "dynamic": { + "name": "[%key:component::hue::services::hue_activate_scene::fields::dynamic::name%]", + "description": "Enable dynamic mode of the scene." + }, + "speed": { + "name": "Speed", + "description": "Speed of dynamic palette for this scene." + }, + "brightness": { + "name": "Brightness", + "description": "Set brightness for the scene." + } + } + } } } diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index bc3ce49cb6b..6fed4bc16d1 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -58,7 +58,7 @@ async def async_setup_devices(bridge: "HueBridge"): @callback def remove_device(hue_device_id: str) -> None: """Remove device from registry.""" - if device := dev_reg.async_get_device({(DOMAIN, hue_device_id)}): + if device := dev_reg.async_get_device(identifiers={(DOMAIN, hue_device_id)}): # note: removal of any underlying entities is handled by core dev_reg.async_remove_device(device.id) diff --git a/homeassistant/components/hue/v2/device_trigger.py b/homeassistant/components/hue/v2/device_trigger.py index 466b593b56a..a3027736661 100644 --- a/homeassistant/components/hue/v2/device_trigger.py +++ b/homeassistant/components/hue/v2/device_trigger.py @@ -3,11 +3,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -from aiohue.v2.models.button import ButtonEvent -from aiohue.v2.models.relative_rotary import ( - RelativeRotaryAction, - RelativeRotaryDirection, -) from aiohue.v2.models.resource import ResourceTypes import voluptuous as vol @@ -24,7 +19,15 @@ from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.typing import ConfigType -from ..const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN +from ..const import ( + ATTR_HUE_EVENT, + CONF_SUBTYPE, + DEFAULT_BUTTON_EVENT_TYPES, + DEFAULT_ROTARY_EVENT_SUBTYPES, + DEFAULT_ROTARY_EVENT_TYPES, + DEVICE_SPECIFIC_EVENT_TYPES, + DOMAIN, +) if TYPE_CHECKING: from aiohue.v2 import HueBridgeV2 @@ -41,26 +44,6 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( } ) -DEFAULT_BUTTON_EVENT_TYPES = ( - # all except `DOUBLE_SHORT_RELEASE` - ButtonEvent.INITIAL_PRESS, - ButtonEvent.REPEAT, - ButtonEvent.SHORT_RELEASE, - ButtonEvent.LONG_PRESS, - ButtonEvent.LONG_RELEASE, -) - -DEFAULT_ROTARY_EVENT_TYPES = (RelativeRotaryAction.START, RelativeRotaryAction.REPEAT) -DEFAULT_ROTARY_EVENT_SUBTYPES = ( - RelativeRotaryDirection.CLOCK_WISE, - RelativeRotaryDirection.COUNTER_CLOCK_WISE, -) - -DEVICE_SPECIFIC_EVENT_TYPES = { - # device specific overrides of specific supported button events - "Hue tap switch": (ButtonEvent.INITIAL_PRESS,), -} - async def async_validate_trigger_config( bridge: HueBridge, diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index 5878f01889b..ef01b2e4693 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -147,7 +147,9 @@ class HueBaseEntity(Entity): # regular devices are removed automatically by the logic in device.py. if resource.type in (ResourceTypes.ROOM, ResourceTypes.ZONE): dev_reg = async_get_device_registry(self.hass) - if device := dev_reg.async_get_device({(DOMAIN, resource.id)}): + if device := dev_reg.async_get_device( + identifiers={(DOMAIN, resource.id)} + ): dev_reg.async_remove_device(device.id) # cleanup entities that are not strictly device-bound and have the bridge as parent if self.device is None: diff --git a/homeassistant/components/hue/v2/hue_event.py b/homeassistant/components/hue/v2/hue_event.py index e0296bcb434..b8521a80af7 100644 --- a/homeassistant/components/hue/v2/hue_event.py +++ b/homeassistant/components/hue/v2/hue_event.py @@ -44,7 +44,7 @@ async def async_setup_hue_events(bridge: "HueBridge"): return hue_device = btn_controller.get_device(hue_resource.id) - device = dev_reg.async_get_device({(DOMAIN, hue_device.id)}) + device = dev_reg.async_get_device(identifiers={(DOMAIN, hue_device.id)}) # Fire event data = { @@ -70,7 +70,7 @@ async def async_setup_hue_events(bridge: "HueBridge"): LOGGER.debug("Received relative_rotary event: %s", hue_resource) hue_device = btn_controller.get_device(hue_resource.id) - device = dev_reg.async_get_device({(DOMAIN, hue_device.id)}) + device = dev_reg.async_get_device(identifiers={(DOMAIN, hue_device.id)}) # Fire event data = { diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index f2c1571fda2..957aa4a7806 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -123,7 +123,7 @@ class HueLight(HueBaseEntity, LightEntity): """Return the color mode of the light.""" if color_temp := self.resource.color_temperature: # Hue lights return `mired_valid` to indicate CT is active - if color_temp.mirek_valid and color_temp.mirek is not None: + if color_temp.mirek is not None: return ColorMode.COLOR_TEMP if self.resource.supports_color: return ColorMode.XY diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 79effa6f0c2..a525c626f14 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -3,12 +3,12 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +from enum import StrEnum import logging from typing import Any, final import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_MODE, diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index 35601cf2b1f..09c0714cbeb 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -1,7 +1,5 @@ """Provides the constants needed for component.""" -from enum import IntFlag - -from homeassistant.backports.enum import StrEnum +from enum import IntFlag, StrEnum MODE_NORMAL = "normal" MODE_ECO = "eco" diff --git a/homeassistant/components/humidifier/services.yaml b/homeassistant/components/humidifier/services.yaml index 9c1b748c9ac..75e34cf5049 100644 --- a/homeassistant/components/humidifier/services.yaml +++ b/homeassistant/components/humidifier/services.yaml @@ -1,28 +1,24 @@ # Describes the format for available humidifier services set_mode: - name: Set mode - description: Set mode for humidifier device. target: entity: domain: humidifier + supported_features: + - humidifier.HumidifierEntityFeature.MODES fields: mode: - description: New mode required: true example: "away" selector: text: set_humidity: - name: Set humidity - description: Set target humidity of humidifier device. target: entity: domain: humidifier fields: humidity: - description: New target humidity for humidifier device. required: true selector: number: @@ -31,22 +27,16 @@ set_humidity: unit_of_measurement: "%" turn_on: - name: Turn on - description: Turn humidifier device on. target: entity: domain: humidifier turn_off: - name: Turn off - description: Turn humidifier device off. target: entity: domain: humidifier toggle: - name: Toggle - description: Toggles a humidifier device. target: entity: domain: humidifier diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 3b4c0bf2dab..19a9a8eab77 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -3,21 +3,21 @@ "device_automation": { "trigger_type": { "target_humidity_changed": "{entity_name} target humidity changed", - "changed_states": "{entity_name} turned on or off", - "turned_on": "{entity_name} turned on", - "turned_off": "{entity_name} turned off" + "changed_states": "[%key:common::device_automation::trigger_type::changed_states%]", + "turned_on": "[%key:common::device_automation::trigger_type::turned_on%]", + "turned_off": "[%key:common::device_automation::trigger_type::turned_off%]" }, "condition_type": { "is_mode": "{entity_name} is set to a specific mode", - "is_on": "{entity_name} is on", - "is_off": "{entity_name} is off" + "is_on": "[%key:common::device_automation::condition_type::is_on%]", + "is_off": "[%key:common::device_automation::condition_type::is_off%]" }, "action_type": { "set_humidity": "Set humidity for {entity_name}", "set_mode": "Change mode on {entity_name}", - "toggle": "Toggle {entity_name}", - "turn_on": "Turn on {entity_name}", - "turn_off": "Turn off {entity_name}" + "toggle": "[%key:common::device_automation::action_type::toggle%]", + "turn_on": "[%key:common::device_automation::action_type::turn_on%]", + "turn_off": "[%key:common::device_automation::action_type::turn_off%]" } }, "entity_component": { @@ -34,7 +34,7 @@ "humidifying": "Humidifying", "drying": "Drying", "idle": "Idle", - "off": "Off" + "off": "[%key:common::state::off%]" } }, "available_modes": { @@ -75,10 +75,38 @@ "name": "[%key:component::humidifier::entity_component::_::name%]" } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "set_mode": { + "name": "Set mode", + "description": "Sets the humidifier operation mode.", + "fields": { + "mode": { + "name": "Mode", + "description": "Operation mode. For example, _normal_, _eco_, or _away_. For a list of possible values, refer to the integration documentation." + } + } + }, + "set_humidity": { + "name": "Set humidity", + "description": "Sets the target humidity.", + "fields": { + "humidity": { + "name": "Humidity", + "description": "Target humidity." + } + } + }, + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Turns the humidifier on." + }, + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Turns the humidifier off." + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles the humidifier on/off." } } } diff --git a/homeassistant/components/hunterdouglas_powerview/strings.json b/homeassistant/components/hunterdouglas_powerview/strings.json index 41a16408783..ec26e423e06 100644 --- a/homeassistant/components/hunterdouglas_powerview/strings.json +++ b/homeassistant/components/hunterdouglas_powerview/strings.json @@ -8,7 +8,7 @@ } }, "link": { - "title": "Connect to the PowerView Hub", + "title": "[%key:component::hunterdouglas_powerview::config::step::user::title%]", "description": "Do you want to set up {name} ({host})?" } }, diff --git a/homeassistant/components/hvv_departures/strings.json b/homeassistant/components/hvv_departures/strings.json index 8f9c06f53fb..a9ec58f12ad 100644 --- a/homeassistant/components/hvv_departures/strings.json +++ b/homeassistant/components/hvv_departures/strings.json @@ -18,7 +18,7 @@ "station_select": { "title": "Select Station/Address", "data": { - "station": "Station/Address" + "station": "[%key:component::hvv_departures::config::step::station::data::station%]" } } }, diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index e09cabb74fc..6d9f2747847 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -1,7 +1,7 @@ """Support for Hydrawise cloud.""" -from hydrawiser.core import Hydrawiser +from pydrawise.legacy import LegacyHydrawise from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol @@ -34,7 +34,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: scan_interval = conf.get(CONF_SCAN_INTERVAL) try: - hydrawise = await hass.async_add_executor_job(Hydrawiser, access_token) + hydrawise = await hass.async_add_executor_job(LegacyHydrawise, access_token) except (ConnectTimeout, HTTPError) as ex: LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) _show_failure_notification(hass, str(ex)) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 2986bbb170e..63fe28cd400 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -1,7 +1,7 @@ """Support for Hydrawise sprinkler binary sensors.""" from __future__ import annotations -from hydrawiser.core import Hydrawiser +from pydrawise.legacy import LegacyHydrawise import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -55,7 +55,7 @@ def setup_platform( ) -> None: """Set up a sensor for a Hydrawise device.""" coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] - hydrawise: Hydrawiser = coordinator.api + hydrawise: LegacyHydrawise = coordinator.api monitored_conditions = config[CONF_MONITORED_CONDITIONS] entities = [] @@ -92,6 +92,6 @@ class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): if self.entity_description.key == "status": self._attr_is_on = self.coordinator.api.status == "All good!" elif self.entity_description.key == "is_watering": - relay_data = self.coordinator.api.relays[self.data["relay"] - 1] + relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] self._attr_is_on = relay_data["timestr"] == "Now" super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index ea2e2dd2c4c..007b15d2403 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta -from hydrawiser.core import Hydrawiser +from pydrawise.legacy import LegacyHydrawise from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -16,7 +16,7 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[None]): """The Hydrawise Data Update Coordinator.""" def __init__( - self, hass: HomeAssistant, api: Hydrawiser, scan_interval: timedelta + self, hass: HomeAssistant, api: LegacyHydrawise, scan_interval: timedelta ) -> None: """Initialize HydrawiseDataUpdateCoordinator.""" super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index fc88c08b27a..d9e6d809960 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -4,6 +4,6 @@ "codeowners": ["@dknowles2", "@ptcryan"], "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", - "loggers": ["hydrawiser"], - "requirements": ["Hydrawiser==0.2"] + "loggers": ["pydrawise"], + "requirements": ["pydrawise==2023.7.1"] } diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index d1334143375..fa82c058f5b 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -1,7 +1,7 @@ """Support for Hydrawise sprinkler sensors.""" from __future__ import annotations -from hydrawiser.core import Hydrawiser +from pydrawise.legacy import LegacyHydrawise import voluptuous as vol from homeassistant.components.sensor import ( @@ -57,7 +57,7 @@ def setup_platform( ) -> None: """Set up a sensor for a Hydrawise device.""" coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] - hydrawise: Hydrawiser = coordinator.api + hydrawise: LegacyHydrawise = coordinator.api monitored_conditions = config[CONF_MONITORED_CONDITIONS] entities = [ @@ -77,7 +77,7 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): def _handle_coordinator_update(self) -> None: """Get the latest data and updates the states.""" LOGGER.debug("Updating Hydrawise sensor: %s", self.name) - relay_data = self.coordinator.api.relays[self.data["relay"] - 1] + relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] if self.entity_description.key == "watering_time": if relay_data["timestr"] == "Now": self._attr_native_value = int(relay_data["run"] / 60) diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 00089bb8774..0dd694a47d6 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from hydrawiser.core import Hydrawiser +from pydrawise.legacy import LegacyHydrawise import voluptuous as vol from homeassistant.components.switch import ( @@ -63,7 +63,7 @@ def setup_platform( ) -> None: """Set up a sensor for a Hydrawise device.""" coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] - hydrawise: Hydrawiser = coordinator.api + hydrawise: LegacyHydrawise = coordinator.api monitored_conditions: list[str] = config[CONF_MONITORED_CONDITIONS] default_watering_timer: int = config[CONF_WATERING_TIME] @@ -99,26 +99,26 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - relay_data = self.data["relay"] - 1 + zone_number = self.data["relay"] if self.entity_description.key == "manual_watering": - self.coordinator.api.run_zone(self._default_watering_timer, relay_data) + self.coordinator.api.run_zone(self._default_watering_timer, zone_number) elif self.entity_description.key == "auto_watering": - self.coordinator.api.suspend_zone(0, relay_data) + self.coordinator.api.suspend_zone(0, zone_number) def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - relay_data = self.data["relay"] - 1 + zone_number = self.data["relay"] if self.entity_description.key == "manual_watering": - self.coordinator.api.run_zone(0, relay_data) + self.coordinator.api.run_zone(0, zone_number) elif self.entity_description.key == "auto_watering": - self.coordinator.api.suspend_zone(365, relay_data) + self.coordinator.api.suspend_zone(365, zone_number) @callback def _handle_coordinator_update(self) -> None: """Update device state.""" - relay_data = self.data["relay"] - 1 + zone_number = self.data["relay"] LOGGER.debug("Updating Hydrawise switch: %s", self.name) - timestr = self.coordinator.api.relays[relay_data]["timestr"] + timestr = self.coordinator.api.relays_by_zone_number[zone_number]["timestr"] if self.entity_description.key == "manual_watering": self._attr_is_on = timestr == "Now" elif self.entity_description.key == "auto_watering": diff --git a/homeassistant/components/ialarm/alarm_control_panel.py b/homeassistant/components/ialarm/alarm_control_panel.py index 6a4e3d191eb..1981a56e211 100644 --- a/homeassistant/components/ialarm/alarm_control_panel.py +++ b/homeassistant/components/ialarm/alarm_control_panel.py @@ -30,7 +30,8 @@ class IAlarmPanel( ): """Representation of an iAlarm device.""" - _attr_name = "iAlarm" + _attr_has_entity_name = True + _attr_name = None _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index df77d60c141..8834a538be9 100644 --- a/homeassistant/components/iaqualink/manifest.json +++ b/homeassistant/components/iaqualink/manifest.json @@ -3,7 +3,7 @@ "name": "Jandy iAqualink", "codeowners": ["@flz"], "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/iaqualink/", + "documentation": "https://www.home-assistant.io/integrations/iaqualink", "iot_class": "cloud_polling", "loggers": ["iaqualink"], "requirements": ["iaqualink==0.5.0", "h2==4.1.0"] diff --git a/homeassistant/components/ibeacon/coordinator.py b/homeassistant/components/ibeacon/coordinator.py index 2e9af4ad9e6..537b4b8f860 100644 --- a/homeassistant/components/ibeacon/coordinator.py +++ b/homeassistant/components/ibeacon/coordinator.py @@ -200,7 +200,9 @@ class IBeaconCoordinator: def _async_purge_untrackable_entities(self, unique_ids: set[str]) -> None: """Remove entities that are no longer trackable.""" for unique_id in unique_ids: - if device := self._dev_reg.async_get_device({(DOMAIN, unique_id)}): + if device := self._dev_reg.async_get_device( + identifiers={(DOMAIN, unique_id)} + ): self._dev_reg.async_remove_device(device.id) self._last_ibeacon_advertisement_by_unique_id.pop(unique_id, None) diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index d9bd215d2a1..6cabe51fff5 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -56,6 +56,9 @@ def add_entities(account: IcloudAccount, async_add_entities, tracked): class IcloudTrackerEntity(TrackerEntity): """Represent a tracked device.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, account: IcloudAccount, device: IcloudDevice) -> None: """Set up the iCloud tracker entity.""" self._account = account @@ -67,11 +70,6 @@ class IcloudTrackerEntity(TrackerEntity): """Return a unique ID.""" return self._device.unique_id - @property - def name(self) -> str: - """Return the name of the device.""" - return self._device.name - @property def location_accuracy(self): """Return the location accuracy of the device.""" diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index e7c982607cb..01aabc5871c 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -56,6 +56,7 @@ class IcloudDeviceBatterySensor(SensorEntity): _attr_device_class = SensorDeviceClass.BATTERY _attr_native_unit_of_measurement = PERCENTAGE _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, account: IcloudAccount, device: IcloudDevice) -> None: """Initialize the battery sensor.""" @@ -68,11 +69,6 @@ class IcloudDeviceBatterySensor(SensorEntity): """Return a unique ID.""" return f"{self._device.unique_id}_battery" - @property - def name(self) -> str: - """Sensor name.""" - return f"{self._device.name} battery state" - @property def native_value(self) -> int | None: """Battery state percentage.""" diff --git a/homeassistant/components/icloud/services.yaml b/homeassistant/components/icloud/services.yaml index ddeae448f8a..5ffbc2a49ae 100644 --- a/homeassistant/components/icloud/services.yaml +++ b/homeassistant/components/icloud/services.yaml @@ -1,93 +1,63 @@ update: - name: Update - description: Update iCloud devices. fields: account: - name: Account - description: Your iCloud account username (email) or account name. required: true example: "steve@apple.com" selector: text: play_sound: - name: Play sound - description: Play sound on an Apple device. fields: account: - name: Account - description: Your iCloud account username (email) or account name. required: true example: "steve@apple.com" selector: text: device_name: - name: Device Name - description: The name of the Apple device to play a sound. required: true example: "stevesiphone" selector: text: display_message: - name: Display message - description: Display a message on an Apple device. fields: account: - name: Account - description: Your iCloud account username (email) or account name. required: true example: "steve@apple.com" selector: text: device_name: - name: Device Name - description: The name of the Apple device to display the message. required: true example: "stevesiphone" selector: text: message: - name: Message - description: The content of your message. required: true example: "Hey Steve !" selector: text: sound: - name: Sound - description: To make a sound when displaying the message. selector: boolean: lost_device: - name: Lost device - description: Make an Apple device in lost state. fields: account: - name: Account - description: Your iCloud account username (email) or account name. required: true example: "steve@apple.com" selector: text: device_name: - name: Device Name - description: The name of the Apple device to set lost. required: true example: "stevesiphone" selector: text: number: - name: Number - description: The phone number to call in lost mode (must contain country code). required: true example: "+33450020100" selector: text: message: - name: Message - description: The message to display in lost mode. required: true example: "Call me" selector: diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json index 385dc74a0ab..96db11d4656 100644 --- a/homeassistant/components/icloud/strings.json +++ b/homeassistant/components/icloud/strings.json @@ -42,5 +42,75 @@ "no_device": "None of your devices have \"Find my iPhone\" activated", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "services": { + "update": { + "name": "Update", + "description": "Updates iCloud devices.", + "fields": { + "account": { + "name": "Account", + "description": "Your iCloud account username (email) or account name." + } + } + }, + "play_sound": { + "name": "Play sound", + "description": "Plays sound on an Apple device.", + "fields": { + "account": { + "name": "Account", + "description": "[%key:component::icloud::services::update::fields::account::description%]" + }, + "device_name": { + "name": "Device name", + "description": "The name of the Apple device to play a sound." + } + } + }, + "display_message": { + "name": "Display message", + "description": "Displays a message on an Apple device.", + "fields": { + "account": { + "name": "Account", + "description": "[%key:component::icloud::services::update::fields::account::description%]" + }, + "device_name": { + "name": "Device name", + "description": "The name of the Apple device to display the message." + }, + "message": { + "name": "Message", + "description": "The content of your message." + }, + "sound": { + "name": "Sound", + "description": "To make a sound when displaying the message." + } + } + }, + "lost_device": { + "name": "Lost device", + "description": "Makes an Apple device in lost state.", + "fields": { + "account": { + "name": "Account", + "description": "[%key:component::icloud::services::update::fields::account::description%]" + }, + "device_name": { + "name": "Device name", + "description": "The name of the Apple device to set lost." + }, + "number": { + "name": "Number", + "description": "The phone number to call in lost mode (must contain country code)." + }, + "message": { + "name": "Message", + "description": "The message to display in lost mode." + } + } + } } } diff --git a/homeassistant/components/ifttt/services.yaml b/homeassistant/components/ifttt/services.yaml index 9c02284d4f8..550aecad56b 100644 --- a/homeassistant/components/ifttt/services.yaml +++ b/homeassistant/components/ifttt/services.yaml @@ -1,49 +1,34 @@ # Describes the format for available ifttt services push_alarm_state: - name: Push alarm state - description: Update the alarm state to the specified value. fields: entity_id: - description: Name of the alarm control panel which state has to be updated. required: true selector: entity: domain: alarm_control_panel state: - name: State - description: The state to which the alarm control panel has to be set. required: true example: "armed_night" selector: text: trigger: - name: Trigger - description: Triggers the configured IFTTT Webhook. fields: event: - name: Event - description: The name of the event to send. required: true example: "MY_HA_EVENT" selector: text: value1: - name: Value 1 - description: Generic field to send data via the event. example: "Hello World" selector: text: value2: - name: Value 2 - description: Generic field to send data via the event. example: "some additional data" selector: text: value3: - name: Value 3 - description: Generic field to send data via the event. example: "even more data" selector: text: diff --git a/homeassistant/components/ifttt/strings.json b/homeassistant/components/ifttt/strings.json index 179d62b463c..5ba0812697f 100644 --- a/homeassistant/components/ifttt/strings.json +++ b/homeassistant/components/ifttt/strings.json @@ -14,5 +14,43 @@ "create_entry": { "default": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data." } + }, + "services": { + "push_alarm_state": { + "name": "Push alarm state", + "description": "Updates the alarm state to the specified value.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Name of the alarm control panel which state has to be updated." + }, + "state": { + "name": "State", + "description": "The state to which the alarm control panel has to be set." + } + } + }, + "trigger": { + "name": "Trigger", + "description": "Triggers the configured IFTTT Webhook.", + "fields": { + "event": { + "name": "Event", + "description": "The name of the event to send." + }, + "value1": { + "name": "Value 1", + "description": "Generic field to send data via the event." + }, + "value2": { + "name": "Value 2", + "description": "[%key:component::ifttt::services::trigger::fields::value1::description%]" + }, + "value3": { + "name": "Value 3", + "description": "[%key:component::ifttt::services::trigger::fields::value1::description%]" + } + } + } } } diff --git a/homeassistant/components/ihc/services.yaml b/homeassistant/components/ihc/services.yaml index 33f6c8ca31d..1e1727abea8 100644 --- a/homeassistant/components/ihc/services.yaml +++ b/homeassistant/components/ihc/services.yaml @@ -1,22 +1,14 @@ # Describes the format for available IHC services set_runtime_value_bool: - name: Set runtime value boolean - description: Set a boolean runtime value on the IHC controller. fields: controller_id: - name: Controller ID - description: | - If you have multiple controller, this is the index of you controller - starting with 0. default: 0 selector: number: min: 0 max: 100 ihc_id: - name: IHC ID - description: The integer IHC resource ID. required: true selector: number: @@ -24,29 +16,19 @@ set_runtime_value_bool: max: 1000000 mode: box value: - name: Value - description: The boolean value to set. required: true selector: boolean: set_runtime_value_int: - name: Set runtime value integer - description: Set an integer runtime value on the IHC controller. fields: controller_id: - name: Controller ID - description: | - If you have multiple controller, this is the index of you controller - starting with 0. default: 0 selector: number: min: 0 max: 100 ihc_id: - name: IHC ID - description: The integer IHC resource ID. required: true selector: number: @@ -54,8 +36,6 @@ set_runtime_value_int: max: 1000000 mode: box value: - name: Value - description: The integer value to set. required: true selector: number: @@ -64,22 +44,14 @@ set_runtime_value_int: mode: box set_runtime_value_float: - name: Set runtime value float - description: Set a float runtime value on the IHC controller. fields: controller_id: - name: Controller ID - description: | - If you have multiple controller, this is the index of you controller - starting with 0. default: 0 selector: number: min: 0 max: 100 ihc_id: - name: IHC ID - description: The integer IHC resource ID. required: true selector: number: @@ -87,8 +59,6 @@ set_runtime_value_float: max: 1000000 mode: box value: - name: Value - description: The float value to set. required: true selector: number: @@ -98,22 +68,14 @@ set_runtime_value_float: mode: box pulse: - name: Pulse - description: Pulses an input on the IHC controller. fields: controller_id: - name: Controller ID - description: | - If you have multiple controller, this is the index of you controller - starting with 0. default: 0 selector: number: min: 0 max: 100 ihc_id: - name: IHC ID - description: The integer IHC resource ID. required: true selector: number: diff --git a/homeassistant/components/ihc/strings.json b/homeassistant/components/ihc/strings.json new file mode 100644 index 00000000000..af2152a88bb --- /dev/null +++ b/homeassistant/components/ihc/strings.json @@ -0,0 +1,72 @@ +{ + "services": { + "set_runtime_value_bool": { + "name": "Set runtime value boolean", + "description": "Sets a boolean runtime value on the IHC controller.", + "fields": { + "controller_id": { + "name": "Controller ID", + "description": "If you have multiple controller, this is the index of you controller\nstarting with 0.\n." + }, + "ihc_id": { + "name": "IHC ID", + "description": "The integer IHC resource ID." + }, + "value": { + "name": "Value", + "description": "The boolean value to set." + } + } + }, + "set_runtime_value_int": { + "name": "Set runtime value integer", + "description": "Sets an integer runtime value on the IHC controller.", + "fields": { + "controller_id": { + "name": "[%key:component::ihc::services::set_runtime_value_bool::fields::controller_id::name%]", + "description": "[%key:component::ihc::services::set_runtime_value_bool::fields::controller_id::description%]" + }, + "ihc_id": { + "name": "[%key:component::ihc::services::set_runtime_value_bool::fields::ihc_id::name%]", + "description": "[%key:component::ihc::services::set_runtime_value_bool::fields::ihc_id::description%]" + }, + "value": { + "name": "Value", + "description": "The integer value to set." + } + } + }, + "set_runtime_value_float": { + "name": "Set runtime value float", + "description": "Sets a float runtime value on the IHC controller.", + "fields": { + "controller_id": { + "name": "[%key:component::ihc::services::set_runtime_value_bool::fields::controller_id::name%]", + "description": "[%key:component::ihc::services::set_runtime_value_bool::fields::controller_id::description%]" + }, + "ihc_id": { + "name": "[%key:component::ihc::services::set_runtime_value_bool::fields::ihc_id::name%]", + "description": "[%key:component::ihc::services::set_runtime_value_bool::fields::ihc_id::description%]" + }, + "value": { + "name": "Value", + "description": "The float value to set." + } + } + }, + "pulse": { + "name": "Pulse", + "description": "Pulses an input on the IHC controller.", + "fields": { + "controller_id": { + "name": "[%key:component::ihc::services::set_runtime_value_bool::fields::controller_id::name%]", + "description": "[%key:component::ihc::services::set_runtime_value_bool::fields::controller_id::description%]" + }, + "ihc_id": { + "name": "[%key:component::ihc::services::set_runtime_value_bool::fields::ihc_id::name%]", + "description": "[%key:component::ihc::services::set_runtime_value_bool::fields::ihc_id::description%]" + } + } + } + } +} diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 8daea2cdd46..e4bc1664fd9 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -167,18 +167,14 @@ class ImageEntity(Entity): """Return bytes of image.""" raise NotImplementedError() - async def _async_load_image_from_url(self, url: str) -> Image | None: - """Load an image by url.""" + async def _fetch_url(self, url: str) -> httpx.Response | None: + """Fetch a URL.""" try: response = await self._client.get( url, timeout=GET_IMAGE_TIMEOUT, follow_redirects=True ) response.raise_for_status() - content_type = response.headers.get("content-type") - return Image( - content=response.content, - content_type=valid_image_content_type(content_type), - ) + return response except httpx.TimeoutException: _LOGGER.error("%s: Timeout getting image from %s", self.entity_id, url) return None @@ -190,14 +186,25 @@ class ImageEntity(Entity): err, ) return None - except ImageContentTypeError: - _LOGGER.error( - "%s: Image from %s has invalid content type: %s", - self.entity_id, - url, - content_type, - ) - return None + + async def _async_load_image_from_url(self, url: str) -> Image | None: + """Load an image by url.""" + if response := await self._fetch_url(url): + content_type = response.headers.get("content-type") + try: + return Image( + content=response.content, + content_type=valid_image_content_type(content_type), + ) + except ImageContentTypeError: + _LOGGER.error( + "%s: Image from %s has invalid content type: %s", + self.entity_id, + url, + content_type, + ) + return None + return None async def async_image(self) -> bytes | None: """Return bytes of image.""" diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 733a1344538..7640925451a 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -4,12 +4,12 @@ from __future__ import annotations import asyncio from dataclasses import dataclass from datetime import timedelta +from enum import StrEnum import logging from typing import Any, Final, TypedDict, final import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.components.camera import Image from homeassistant.const import ( ATTR_ENTITY_ID, diff --git a/homeassistant/components/image_processing/services.yaml b/homeassistant/components/image_processing/services.yaml index 620bd351806..6309bafcfb9 100644 --- a/homeassistant/components/image_processing/services.yaml +++ b/homeassistant/components/image_processing/services.yaml @@ -1,8 +1,6 @@ # Describes the format for available image processing services scan: - name: Scan - description: Process an image immediately target: entity: domain: image_processing diff --git a/homeassistant/components/image_processing/strings.json b/homeassistant/components/image_processing/strings.json index 861a2acc1f1..2e630cfb4de 100644 --- a/homeassistant/components/image_processing/strings.json +++ b/homeassistant/components/image_processing/strings.json @@ -12,5 +12,11 @@ } } } + }, + "services": { + "scan": { + "name": "Scan", + "description": "Processes an image immediately." + } } } diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index 48c57fb5d03..4f139785cd3 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==9.5.0"] + "requirements": ["Pillow==10.0.0"] } diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index 04069d42d7d..3914e0c52c1 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -14,7 +14,7 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, ) -from .const import DOMAIN +from .const import CONF_ENABLE_PUSH, DOMAIN from .coordinator import ( ImapPollingDataUpdateCoordinator, ImapPushDataUpdateCoordinator, @@ -39,7 +39,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator_class: type[ ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator ] - if imap_client.has_capability("IDLE"): + enable_push: bool = entry.data.get(CONF_ENABLE_PUSH, True) + if enable_push and imap_client.has_capability("IDLE"): coordinator_class = ImapPushDataUpdateCoordinator else: coordinator_class = ImapPollingDataUpdateCoordinator diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 00be545fb67..4c4a2e2a35c 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -33,6 +33,7 @@ from homeassistant.util.ssl import SSLCipherList from .const import ( CONF_CHARSET, CONF_CUSTOM_EVENT_DATA_TEMPLATE, + CONF_ENABLE_PUSH, CONF_FOLDER, CONF_MAX_MESSAGE_SIZE, CONF_SEARCH, @@ -87,6 +88,7 @@ OPTIONS_SCHEMA_ADVANCED = { cv.positive_int, vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT), ), + vol.Optional(CONF_ENABLE_PUSH, default=True): BOOLEAN_SELECTOR, } diff --git a/homeassistant/components/imap/const.py b/homeassistant/components/imap/const.py index 2e36dd41e16..fd3da28971e 100644 --- a/homeassistant/components/imap/const.py +++ b/homeassistant/components/imap/const.py @@ -11,6 +11,7 @@ CONF_CHARSET: Final = "charset" CONF_MAX_MESSAGE_SIZE = "max_message_size" CONF_CUSTOM_EVENT_DATA_TEMPLATE: Final = "custom_event_data_template" CONF_SSL_CIPHER_LIST: Final = "ssl_cipher_list" +CONF_ENABLE_PUSH: Final = "enable_push" DEFAULT_PORT: Final = 993 diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index c3cd21e6b2d..b644c300979 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -298,7 +298,8 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): except (AioImapException, asyncio.TimeoutError): if log_error: _LOGGER.debug("Error while cleaning up imap connection") - self.imap_client = None + finally: + self.imap_client = None async def shutdown(self, *_: Any) -> None: """Close resources.""" @@ -370,7 +371,6 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): async def _async_wait_push_loop(self) -> None: """Wait for data push from server.""" - cleanup = False while True: try: number_of_messages = await self._async_fetch_number_of_messages() @@ -412,9 +412,6 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): await idle # From python 3.11 asyncio.TimeoutError is an alias of TimeoutError - except asyncio.CancelledError as ex: - cleanup = True - raise asyncio.CancelledError from ex except (AioImapException, asyncio.TimeoutError): _LOGGER.debug( "Lost %s (will attempt to reconnect after %s s)", @@ -423,9 +420,6 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): ) await self._cleanup() await asyncio.sleep(BACKOFF_TIME) - finally: - if cleanup: - await self._cleanup() async def shutdown(self, *_: Any) -> None: """Close resources.""" diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index 6fad8895931..c332e3e8edb 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -28,7 +28,7 @@ "invalid_charset": "The specified charset is not supported", "invalid_folder": "The selected folder is invalid", "invalid_search": "The selected search is invalid", - "ssl_error": "An SSL error occurred. Change SSL cipher list and try again" + "ssl_error": "An SSL error occurred. Change SSL cipher list and try again." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -42,12 +42,13 @@ "folder": "[%key:component::imap::config::step::user::data::folder%]", "search": "[%key:component::imap::config::step::user::data::search%]", "custom_event_data_template": "Template to create custom event data", - "max_message_size": "Max message size (2048 < size < 30000)" + "max_message_size": "Max message size (2048 < size < 30000)", + "enable_push": "Enable Push-IMAP if the server supports it. Turn off if Push-IMAP updates are unreliable." } } }, "error": { - "already_configured": "An entry with these folder and search options already exists", + "already_configured": "An entry with these folder and search options already exists.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_charset": "[%key:component::imap::config::error::invalid_charset%]", diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index 33cb4b9e576..a074b3b9b65 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -2,9 +2,8 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, Self -from typing_extensions import Self import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/input_boolean/services.yaml b/homeassistant/components/input_boolean/services.yaml index d294d61fd4d..9de0368ba35 100644 --- a/homeassistant/components/input_boolean/services.yaml +++ b/homeassistant/components/input_boolean/services.yaml @@ -1,24 +1,16 @@ toggle: - name: Toggle - description: Toggle an input boolean target: entity: domain: input_boolean turn_off: - name: Turn off - description: Turn off an input boolean target: entity: domain: input_boolean turn_on: - name: Turn on - description: Turn on an input boolean target: entity: domain: input_boolean reload: - name: Reload - description: Reload the input_boolean configuration diff --git a/homeassistant/components/input_boolean/strings.json b/homeassistant/components/input_boolean/strings.json index d8e1e133f55..a2087f1247a 100644 --- a/homeassistant/components/input_boolean/strings.json +++ b/homeassistant/components/input_boolean/strings.json @@ -17,5 +17,23 @@ } } } + }, + "services": { + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles the helper on/off." + }, + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Turns off the helper." + }, + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Turns on the helper." + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads helpers from the YAML-configuration." + } } } diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py index 8a1f0785435..c04b18b0c25 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -2,9 +2,8 @@ from __future__ import annotations import logging -from typing import cast +from typing import Self, cast -from typing_extensions import Self import voluptuous as vol from homeassistant.components.button import SERVICE_PRESS, ButtonEntity diff --git a/homeassistant/components/input_button/services.yaml b/homeassistant/components/input_button/services.yaml index 899ead91cb5..7c57fcff272 100644 --- a/homeassistant/components/input_button/services.yaml +++ b/homeassistant/components/input_button/services.yaml @@ -1,6 +1,4 @@ press: - name: Press - description: Press the input button entity. target: entity: domain: input_button diff --git a/homeassistant/components/input_button/strings.json b/homeassistant/components/input_button/strings.json index cfd616fd5e7..b51d04926f5 100644 --- a/homeassistant/components/input_button/strings.json +++ b/homeassistant/components/input_button/strings.json @@ -13,5 +13,11 @@ } } } + }, + "services": { + "press": { + "name": "Press", + "description": "Mimics the physical button press on the device." + } } } diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 8762769194f..81882137fad 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -3,9 +3,8 @@ from __future__ import annotations import datetime as py_datetime import logging -from typing import Any +from typing import Any, Self -from typing_extensions import Self import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/input_datetime/services.yaml b/homeassistant/components/input_datetime/services.yaml index 51b1d6b00c1..386f0096a5f 100644 --- a/homeassistant/components/input_datetime/services.yaml +++ b/homeassistant/components/input_datetime/services.yaml @@ -1,33 +1,21 @@ set_datetime: - name: Set - description: This can be used to dynamically set the date and/or time. target: entity: domain: input_datetime fields: date: - name: Date - description: The target date the entity should be set to. example: '"2019-04-20"' selector: text: time: - name: Time - description: The target time the entity should be set to. example: '"05:04:20"' selector: time: datetime: - name: Date & Time - description: The target date & time the entity should be set to. example: '"2019-04-20 05:04:20"' selector: text: timestamp: - name: Timestamp - description: - The target date & time the entity should be set to as expressed by a - UNIX timestamp. selector: number: min: 0 @@ -35,5 +23,3 @@ set_datetime: mode: box reload: - name: Reload - description: Reload the input_datetime configuration. diff --git a/homeassistant/components/input_datetime/strings.json b/homeassistant/components/input_datetime/strings.json index 0c3a4b0b0d2..e4a2b6349b7 100644 --- a/homeassistant/components/input_datetime/strings.json +++ b/homeassistant/components/input_datetime/strings.json @@ -34,5 +34,33 @@ } } } + }, + "services": { + "set_datetime": { + "name": "Set", + "description": "Sets the date and/or time.", + "fields": { + "date": { + "name": "Date", + "description": "The target date." + }, + "time": { + "name": "Time", + "description": "The target time." + }, + "datetime": { + "name": "Date & time", + "description": "The target date & time." + }, + "timestamp": { + "name": "Timestamp", + "description": "The target date & time, expressed by a UNIX timestamp." + } + } + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads helpers from the YAML-configuration." + } } } diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 061b388ace5..197a35246d2 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -3,8 +3,8 @@ from __future__ import annotations from contextlib import suppress import logging +from typing import Self -from typing_extensions import Self import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/input_number/services.yaml b/homeassistant/components/input_number/services.yaml index 41164a7ccf5..e5de48a1262 100644 --- a/homeassistant/components/input_number/services.yaml +++ b/homeassistant/components/input_number/services.yaml @@ -1,27 +1,19 @@ decrement: - name: Decrement - description: Decrement the value of an input number entity by its stepping. target: entity: domain: input_number increment: - name: Increment - description: Increment the value of an input number entity by its stepping. target: entity: domain: input_number set_value: - name: Set - description: Set the value of an input number entity. target: entity: domain: input_number fields: value: - name: Value - description: The target value the entity should be set to. required: true selector: number: @@ -31,5 +23,3 @@ set_value: mode: box reload: - name: Reload - description: Reload the input_number configuration. diff --git a/homeassistant/components/input_number/strings.json b/homeassistant/components/input_number/strings.json index 11ed2f8bf10..8a2351ebad4 100644 --- a/homeassistant/components/input_number/strings.json +++ b/homeassistant/components/input_number/strings.json @@ -33,5 +33,29 @@ } } } + }, + "services": { + "decrement": { + "name": "Decrement", + "description": "Decrements the current value by 1 step." + }, + "increment": { + "name": "Increment", + "description": "Increments the value by 1 step." + }, + "set_value": { + "name": "Set", + "description": "Sets the value.", + "fields": { + "value": { + "name": "Value", + "description": "The target value." + } + } + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads helpers from the YAML-configuration." + } } } diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 2c5a1c87f29..e1354cb26a5 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -2,9 +2,8 @@ from __future__ import annotations import logging -from typing import Any, cast +from typing import Any, Self, cast -from typing_extensions import Self import voluptuous as vol from homeassistant.components.select import ( diff --git a/homeassistant/components/input_select/services.yaml b/homeassistant/components/input_select/services.yaml index 8b8828eaa92..92279e58a54 100644 --- a/homeassistant/components/input_select/services.yaml +++ b/homeassistant/components/input_select/services.yaml @@ -1,75 +1,53 @@ select_next: - name: Next - description: Select the next options of an input select entity. target: entity: domain: input_select fields: cycle: - name: Cycle - description: If the option should cycle from the last to the first. default: true selector: boolean: select_option: - name: Select - description: Select an option of an input select entity. target: entity: domain: input_select fields: option: - name: Option - description: Option to be selected. required: true example: '"Item A"' selector: text: select_previous: - name: Previous - description: Select the previous options of an input select entity. target: entity: domain: input_select fields: cycle: - name: Cycle - description: If the option should cycle from the first to the last. default: true selector: boolean: select_first: - name: First - description: Select the first option of an input select entity. target: entity: domain: input_select select_last: - name: Last - description: Select the last option of an input select entity. target: entity: domain: input_select set_options: - name: Set options - description: Set the options of an input select entity. target: entity: domain: input_select fields: options: - name: Options - description: Options for the input select entity. required: true example: '["Item A", "Item B", "Item C"]' selector: object: reload: - name: Reload - description: Reload the input_select configuration. diff --git a/homeassistant/components/input_select/strings.json b/homeassistant/components/input_select/strings.json index f0dead7a1dd..faa47c979a1 100644 --- a/homeassistant/components/input_select/strings.json +++ b/homeassistant/components/input_select/strings.json @@ -16,5 +16,59 @@ } } } + }, + "services": { + "select_next": { + "name": "Next", + "description": "Select the next option.", + "fields": { + "cycle": { + "name": "Cycle", + "description": "If the option should cycle from the last to the first option on the list." + } + } + }, + "select_option": { + "name": "Select", + "description": "Selects an option.", + "fields": { + "option": { + "name": "Option", + "description": "Option to be selected." + } + } + }, + "select_previous": { + "name": "Previous", + "description": "Selects the previous option.", + "fields": { + "cycle": { + "name": "[%key:component::input_select::services::select_next::fields::cycle::name%]", + "description": "[%key:component::input_select::services::select_next::fields::cycle::description%]" + } + } + }, + "select_first": { + "name": "First", + "description": "Selects the first option." + }, + "select_last": { + "name": "Last", + "description": "Selects the last option." + }, + "set_options": { + "name": "Set options", + "description": "Sets the options.", + "fields": { + "options": { + "name": "Options", + "description": "List of options." + } + } + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads helpers from the YAML-configuration." + } } } diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index efd58e38e72..096e7cbb105 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations import logging +from typing import Self -from typing_extensions import Self import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/input_text/services.yaml b/homeassistant/components/input_text/services.yaml index cf19e15d7ae..6cb5c1352c6 100644 --- a/homeassistant/components/input_text/services.yaml +++ b/homeassistant/components/input_text/services.yaml @@ -1,18 +1,12 @@ set_value: - name: Set - description: Set the value of an input text entity. target: entity: domain: input_text fields: value: - name: Value - description: The target value the entity should be set to. required: true example: This is an example text selector: text: reload: - name: Reload - description: Reload the input_text configuration. diff --git a/homeassistant/components/input_text/strings.json b/homeassistant/components/input_text/strings.json index d713c395b67..49eab33848c 100644 --- a/homeassistant/components/input_text/strings.json +++ b/homeassistant/components/input_text/strings.json @@ -29,5 +29,21 @@ } } } + }, + "services": { + "set_value": { + "name": "Set", + "description": "Sets the value.", + "fields": { + "value": { + "name": "Value", + "description": "The target value." + } + } + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads helpers from the YAML-configuration." + } } } diff --git a/homeassistant/components/insteon/api/device.py b/homeassistant/components/insteon/api/device.py index bffda965456..d48d87fa347 100644 --- a/homeassistant/components/insteon/api/device.py +++ b/homeassistant/components/insteon/api/device.py @@ -43,9 +43,7 @@ def get_insteon_device_from_ha_device(ha_device): async def async_device_name(dev_registry, address): """Get the Insteon device name from a device registry id.""" - ha_device = dev_registry.async_get_device( - identifiers={(DOMAIN, str(address))}, connections=set() - ) + ha_device = dev_registry.async_get_device(identifiers={(DOMAIN, str(address))}) if not ha_device: if device := devices[address]: return f"{device.description} ({device.model})" diff --git a/homeassistant/components/insteon/services.yaml b/homeassistant/components/insteon/services.yaml index 164c917c793..a58dfb4b8ce 100644 --- a/homeassistant/components/insteon/services.yaml +++ b/homeassistant/components/insteon/services.yaml @@ -1,18 +1,12 @@ add_all_link: - name: Add all link - description: Tells the Insteom Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking. fields: group: - name: Group - description: All-Link group number. required: true selector: number: min: 0 max: 255 mode: - name: Mode - description: Linking mode controller - IM is controller responder - IM is responder required: true selector: select: @@ -20,55 +14,35 @@ add_all_link: - "controller" - "responder" delete_all_link: - name: Delete all link - description: Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link Database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process. fields: group: - name: Group - description: All-Link group number. required: true selector: number: min: 0 max: 255 load_all_link_database: - name: Load all link database - description: Load the All-Link Database for a device. WARNING - Loading a device All-LInk database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records. fields: entity_id: - name: Entity - description: Name of the device to load. Use "all" to load the database of all devices. required: true example: "light.1a2b3c" selector: text: reload: - name: Reload - description: Reload all records. If true the current records are cleared from memory (does not effect the device) and the records are reloaded. If false the existing records are left in place and only missing records are added. Default is false. default: false selector: boolean: print_all_link_database: - name: Print all link database - description: Print the All-Link Database for a device. Requires that the All-Link Database is loaded into memory. fields: entity_id: - name: Entity - description: Name of the device to print required: true selector: entity: integration: insteon print_im_all_link_database: - name: Print IM all link database - description: Print the All-Link Database for the INSTEON Modem (IM). x10_all_units_off: - name: X10 all units off - description: Send X10 All Units Off command fields: housecode: - name: Housecode - description: X10 house code required: true selector: select: @@ -90,12 +64,8 @@ x10_all_units_off: - "o" - "p" x10_all_lights_on: - name: X10 all lights on - description: Send X10 All Lights On command fields: housecode: - name: Housecode - description: X10 house code required: true selector: select: @@ -117,12 +87,8 @@ x10_all_lights_on: - "o" - "p" x10_all_lights_off: - name: X10 all lights off - description: Send X10 All Lights Off command fields: housecode: - name: Housecode - description: X10 house code required: true selector: select: @@ -144,36 +110,24 @@ x10_all_lights_off: - "o" - "p" scene_on: - name: Scene on - description: Trigger an INSTEON scene to turn ON. fields: group: - name: Group - description: INSTEON group or scene number required: true selector: number: min: 0 max: 255 scene_off: - name: Scene off - description: Trigger an INSTEON scene to turn OFF. fields: group: - name: Group - description: INSTEON group or scene number required: true selector: number: min: 0 max: 255 add_default_links: - name: Add default links - description: Add the default links between the device and the Insteon Modem (IM) fields: entity_id: - name: Entity - description: Name of the device to load. Use "all" to load the database of all devices. required: true example: "light.1a2b3c" selector: diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index a93ba4a7476..37cdd5c0343 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -76,7 +76,7 @@ } }, "add_override": { - "description": "Add a device override.", + "description": "[%key:component::insteon::options::step::init::menu_options::add_override%]", "data": { "address": "Device address (i.e. 1a2b3c)", "cat": "Device category (i.e. 0x10)", @@ -101,7 +101,7 @@ "remove_x10": { "description": "Remove an X10 device", "data": { - "address": "Select a device address to remove" + "address": "[%key:component::insteon::options::step::remove_override::data::address%]" } } }, @@ -109,5 +109,119 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "input_error": "Invalid entries, please check your values." } + }, + "services": { + "add_all_link": { + "name": "Add all link", + "description": "Tells the Insteom Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.", + "fields": { + "group": { + "name": "Group", + "description": "All-Link group number." + }, + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "Linking mode controller - IM is controller responder - IM is responder." + } + } + }, + "delete_all_link": { + "name": "Delete all link", + "description": "Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link Database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process.", + "fields": { + "group": { + "name": "Group", + "description": "[%key:component::insteon::services::add_all_link::fields::group::description%]" + } + } + }, + "load_all_link_database": { + "name": "Load all link database", + "description": "Load the All-Link Database for a device. WARNING - Loading a device All-LInk database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name of the device to load. Use \"all\" to load the database of all devices." + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads all records. If true the current records are cleared from memory (does not effect the device) and the records are reloaded. If false the existing records are left in place and only missing records are added. Default is false." + } + } + }, + "print_all_link_database": { + "name": "Print all link database", + "description": "Prints the All-Link Database for a device. Requires that the All-Link Database is loaded into memory.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name of the device to print." + } + } + }, + "print_im_all_link_database": { + "name": "Print IM all link database", + "description": "Prints the All-Link Database for the INSTEON Modem (IM)." + }, + "x10_all_units_off": { + "name": "X10 all units off", + "description": "[%key:component::insteon::services::add_all_link::description%]", + "fields": { + "housecode": { + "name": "Housecode", + "description": "X10 house code." + } + } + }, + "x10_all_lights_on": { + "name": "X10 all lights on", + "description": "Sends X10 All Lights On command.", + "fields": { + "housecode": { + "name": "[%key:component::insteon::services::x10_all_units_off::fields::housecode::name%]", + "description": "[%key:component::insteon::services::x10_all_units_off::fields::housecode::description%]" + } + } + }, + "x10_all_lights_off": { + "name": "X10 all lights off", + "description": "Sends X10 All Lights Off command.", + "fields": { + "housecode": { + "name": "[%key:component::insteon::services::x10_all_units_off::fields::housecode::name%]", + "description": "[%key:component::insteon::services::x10_all_units_off::fields::housecode::description%]" + } + } + }, + "scene_on": { + "name": "Scene on", + "description": "Triggers an INSTEON scene to turn ON.", + "fields": { + "group": { + "name": "Group", + "description": "INSTEON group or scene number." + } + } + }, + "scene_off": { + "name": "Scene off", + "description": "Triggers an INSTEON scene to turn OFF.", + "fields": { + "group": { + "name": "Group", + "description": "[%key:component::insteon::services::scene_on::fields::group::description%]" + } + } + }, + "add_default_links": { + "name": "Add default links", + "description": "Adds the default links between the device and the Insteon Modem (IM).", + "fields": { + "entity_id": { + "name": "Entity", + "description": "[%key:component::insteon::services::load_all_link_database::fields::entity_id::description%]" + } + } + } } } diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index af4248e5e3b..5ce64de9b33 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -4,9 +4,8 @@ from __future__ import annotations from dataclasses import dataclass from decimal import Decimal, DecimalException, InvalidOperation import logging -from typing import Any, Final +from typing import Any, Final, Self -from typing_extensions import Self import voluptuous as vol from homeassistant.components.sensor import ( @@ -27,7 +26,7 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTime, ) -from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -35,8 +34,11 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from .const import ( CONF_ROUND_DIGITS, @@ -291,10 +293,10 @@ class IntegrationSensor(RestoreSensor): self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @callback - def calc_integration(event: Event) -> None: + def calc_integration(event: EventType[EventStateChangedData]) -> None: """Handle the sensor state changes.""" - old_state: State | None = event.data.get("old_state") - new_state: State | None = event.data.get("new_state") + old_state = event.data["old_state"] + new_state = event.data["new_state"] # We may want to update our state before an early return, # based on the source sensor's unit_of_measurement diff --git a/homeassistant/components/integration/strings.json b/homeassistant/components/integration/strings.json index 3a3940ffc2c..74c2b3ee440 100644 --- a/homeassistant/components/integration/strings.json +++ b/homeassistant/components/integration/strings.json @@ -7,7 +7,7 @@ "description": "Create a sensor that calculates a Riemann sum to estimate the integral of a sensor.", "data": { "method": "Integration method", - "name": "Name", + "name": "[%key:common::config_flow::data::name%]", "round": "Precision", "source": "Input sensor", "unit_prefix": "Metric prefix", diff --git a/homeassistant/components/intellifire/binary_sensor.py b/homeassistant/components/intellifire/binary_sensor.py index 5a7407836f2..b19c592a5cf 100644 --- a/homeassistant/components/intellifire/binary_sensor.py +++ b/homeassistant/components/intellifire/binary_sensor.py @@ -38,45 +38,45 @@ class IntellifireBinarySensorEntityDescription( INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] = ( IntellifireBinarySensorEntityDescription( key="on_off", # This is the sensor name - name="Flame", # This is the human readable name + translation_key="flame", # This is the translation key icon="mdi:fire", value_fn=lambda data: data.is_on, ), IntellifireBinarySensorEntityDescription( key="timer_on", - name="Timer on", + translation_key="timer_on", icon="mdi:camera-timer", value_fn=lambda data: data.timer_on, ), IntellifireBinarySensorEntityDescription( key="pilot_light_on", - name="Pilot light on", + translation_key="pilot_light_on", icon="mdi:fire-alert", value_fn=lambda data: data.pilot_on, ), IntellifireBinarySensorEntityDescription( key="thermostat_on", - name="Thermostat on", + translation_key="thermostat_on", icon="mdi:home-thermometer-outline", value_fn=lambda data: data.thermostat_on, ), IntellifireBinarySensorEntityDescription( key="error_pilot_flame", - name="Pilot flame error", + translation_key="pilot_flame_error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_pilot_flame, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_flame", - name="Flame Error", + translation_key="flame_error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_flame, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_fan_delay", - name="Fan delay error", + translation_key="fan_delay_error", icon="mdi:fan-alert", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_fan_delay, @@ -84,21 +84,21 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] ), IntellifireBinarySensorEntityDescription( key="error_maintenance", - name="Maintenance error", + translation_key="maintenance_error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_maintenance, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_disabled", - name="Disabled error", + translation_key="disabled_error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_disabled, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_fan", - name="Fan error", + translation_key="fan_error", icon="mdi:fan-alert", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_fan, @@ -106,35 +106,35 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...] ), IntellifireBinarySensorEntityDescription( key="error_lights", - name="Lights error", + translation_key="lights_error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_lights, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_accessory", - name="Accessory error", + translation_key="accessory_error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_accessory, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_soft_lock_out", - name="Soft lock out error", + translation_key="soft_lock_out_error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_soft_lock_out, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_ecm_offline", - name="ECM offline error", + translation_key="ecm_offline_error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_ecm_offline, device_class=BinarySensorDeviceClass.PROBLEM, ), IntellifireBinarySensorEntityDescription( key="error_offline", - name="Offline error", + translation_key="offline_error", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.error_offline, device_class=BinarySensorDeviceClass.PROBLEM, diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index debc8237fc8..3911efeb5b9 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -45,7 +45,7 @@ class IntellifireFanEntityDescription( INTELLIFIRE_FANS: tuple[IntellifireFanEntityDescription, ...] = ( IntellifireFanEntityDescription( key="fan", - name="Fan", + translation_key="fan", set_fn=lambda control_api, speed: control_api.set_fan_speed(speed=speed), value_fn=lambda data: data.fanspeed, speed_range=(1, 4), diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index 383d61b8d41..05994919296 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -40,7 +40,7 @@ class IntellifireLightEntityDescription( INTELLIFIRE_LIGHTS: tuple[IntellifireLightEntityDescription, ...] = ( IntellifireLightEntityDescription( key="lights", - name="Lights", + translation_key="lights", set_fn=lambda control_api, level: control_api.set_lights(level=level), value_fn=lambda data: data.light_level, ), diff --git a/homeassistant/components/intellifire/number.py b/homeassistant/components/intellifire/number.py index efa567d55cb..5da3c3cdbf8 100644 --- a/homeassistant/components/intellifire/number.py +++ b/homeassistant/components/intellifire/number.py @@ -27,7 +27,7 @@ async def async_setup_entry( description = NumberEntityDescription( key="flame_control", - name="Flame control", + translation_key="flame_control", icon="mdi:arrow-expand-vertical", ) @@ -54,7 +54,7 @@ class IntellifireFlameControlEntity(IntellifireEntity, NumberEntity): coordinator: IntellifireDataUpdateCoordinator, description: NumberEntityDescription, ) -> None: - """Initilaize Flame height Sensor.""" + """Initialize Flame height Sensor.""" super().__init__(coordinator, description) @property diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py index e888ea1bbcf..bc42b977f12 100644 --- a/homeassistant/components/intellifire/sensor.py +++ b/homeassistant/components/intellifire/sensor.py @@ -56,15 +56,14 @@ def _downtime_to_timestamp(data: IntellifirePollData) -> datetime | None: INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( IntellifireSensorEntityDescription( key="flame_height", + translation_key="flame_height", icon="mdi:fire-circle", - name="Flame height", state_class=SensorStateClass.MEASUREMENT, # UI uses 1-5 for flame height, backing lib uses 0-4 value_fn=lambda data: (data.flameheight + 1), ), IntellifireSensorEntityDescription( key="temperature", - name="Temperature", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -72,7 +71,7 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( ), IntellifireSensorEntityDescription( key="target_temp", - name="Target temperature", + translation_key="target_temp", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -80,50 +79,50 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = ( ), IntellifireSensorEntityDescription( key="fan_speed", + translation_key="fan_speed", icon="mdi:fan", - name="Fan Speed", state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.fanspeed, ), IntellifireSensorEntityDescription( key="timer_end_timestamp", + translation_key="timer_end_timestamp", icon="mdi:timer-sand", - name="Timer End", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TIMESTAMP, value_fn=_time_remaining_to_timestamp, ), IntellifireSensorEntityDescription( key="downtime", - name="Downtime", + translation_key="downtime", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.TIMESTAMP, value_fn=_downtime_to_timestamp, ), IntellifireSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="uptime", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: utcnow() - timedelta(seconds=data.uptime), ), IntellifireSensorEntityDescription( key="connection_quality", - name="Connection Quality", + translation_key="connection_quality", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.connection_quality, entity_registry_enabled_default=False, ), IntellifireSensorEntityDescription( key="ecm_latency", - name="ECM latency", + translation_key="ecm_latency", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.ecm_latency, entity_registry_enabled_default=False, ), IntellifireSensorEntityDescription( key="ipv4_address", - name="IP", + translation_key="ipv4_address", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.ipv4_address, ), diff --git a/homeassistant/components/intellifire/strings.json b/homeassistant/components/intellifire/strings.json index a8c8d76a601..6393a4e070d 100644 --- a/homeassistant/components/intellifire/strings.json +++ b/homeassistant/components/intellifire/strings.json @@ -35,5 +35,106 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "not_intellifire_device": "Not an IntelliFire Device." } + }, + "entity": { + "binary_sensor": { + "flame": { + "name": "Flame" + }, + "timer_on": { + "name": "Timer on" + }, + "pilot_light_on": { + "name": "Pilot light on" + }, + "thermostat_on": { + "name": "Thermostat on" + }, + "pilot_flame_error": { + "name": "Pilot flame error" + }, + "flame_error": { + "name": "Flame Error" + }, + "fan_delay_error": { + "name": "Fan delay error" + }, + "maintenance_error": { + "name": "Maintenance error" + }, + "disabled_error": { + "name": "Disabled error" + }, + "fan_error": { + "name": "Fan error" + }, + "lights_error": { + "name": "Lights error" + }, + "accessory_error": { + "name": "Accessory error" + }, + "soft_lock_out_error": { + "name": "Soft lock out error" + }, + "ecm_offline_error": { + "name": "ECM offline error" + }, + "offline_error": { + "name": "Offline error" + } + }, + "fan": { + "fan": { + "name": "[%key:component::fan::title%]" + } + }, + "light": { + "lights": { + "name": "Lights" + } + }, + "number": { + "flame_control": { + "name": "Flame control" + } + }, + "sensor": { + "flame_height": { + "name": "Flame height" + }, + "target_temp": { + "name": "Target temperature" + }, + "fan_speed": { + "name": "Fan Speed" + }, + "timer_end_timestamp": { + "name": "Timer end" + }, + "downtime": { + "name": "Downtime" + }, + "uptime": { + "name": "Uptime" + }, + "connection_quality": { + "name": "Connection quality" + }, + "ecm_latency": { + "name": "ECM latency" + }, + "ipv4_address": { + "name": "IP address" + } + }, + "switch": { + "flame": { + "name": "Flame" + }, + "pilot_light": { + "name": "Pilot light" + } + } } } diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py index 98abaa38849..1af4d8c0e91 100644 --- a/homeassistant/components/intellifire/switch.py +++ b/homeassistant/components/intellifire/switch.py @@ -37,14 +37,14 @@ class IntellifireSwitchEntityDescription( INTELLIFIRE_SWITCHES: tuple[IntellifireSwitchEntityDescription, ...] = ( IntellifireSwitchEntityDescription( key="on_off", - name="Flame", + translation_key="flame", on_fn=lambda control_api: control_api.flame_on(), off_fn=lambda control_api: control_api.flame_off(), value_fn=lambda data: data.is_on, ), IntellifireSwitchEntityDescription( key="pilot", - name="Pilot light", + translation_key="pilot_light", icon="mdi:fire-alert", on_fn=lambda control_api: control_api.pilot_on(), off_fn=lambda control_api: control_api.pilot_off(), diff --git a/homeassistant/components/intent_script/services.yaml b/homeassistant/components/intent_script/services.yaml index bb981dbc69c..c983a105c93 100644 --- a/homeassistant/components/intent_script/services.yaml +++ b/homeassistant/components/intent_script/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload the intent_script configuration. diff --git a/homeassistant/components/intent_script/strings.json b/homeassistant/components/intent_script/strings.json new file mode 100644 index 00000000000..74ddd45c1af --- /dev/null +++ b/homeassistant/components/intent_script/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads the intent script from the YAML-configuration." + } + } +} diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index f4dab9e301b..f3767be9f3d 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -1,7 +1,11 @@ """Support for Home Assistant iOS app sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant, callback @@ -17,12 +21,12 @@ from .const import DOMAIN SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="level", - name="Battery Level", native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, ), SensorEntityDescription( key="state", - name="Battery State", + translation_key="battery_state", ), ) @@ -59,6 +63,7 @@ class IOSSensor(SensorEntity): """Representation of an iOS sensor.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, device_name, device, description: SensorEntityDescription @@ -67,9 +72,6 @@ class IOSSensor(SensorEntity): self.entity_description = description self._device = device - device_name = device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_NAME] - self._attr_name = f"{device_name} {description.key}" - device_id = device[ios.ATTR_DEVICE_ID] self._attr_unique_id = f"{description.key}_{device_id}" diff --git a/homeassistant/components/ios/strings.json b/homeassistant/components/ios/strings.json index 2b486cc0c04..6c77209e317 100644 --- a/homeassistant/components/ios/strings.json +++ b/homeassistant/components/ios/strings.json @@ -8,5 +8,12 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "entity": { + "sensor": { + "battery_state": { + "name": "Battery state" + } + } } } diff --git a/homeassistant/components/iperf3/services.yaml b/homeassistant/components/iperf3/services.yaml index ba0fdb89712..b0cc4f11639 100644 --- a/homeassistant/components/iperf3/services.yaml +++ b/homeassistant/components/iperf3/services.yaml @@ -1,10 +1,6 @@ speedtest: - name: Speedtest - description: Immediately execute a speed test with iperf3 fields: host: - name: Host - description: The host name of the iperf3 server (already configured) to run a test with. example: "iperf.he.net" default: None selector: diff --git a/homeassistant/components/iperf3/strings.json b/homeassistant/components/iperf3/strings.json new file mode 100644 index 00000000000..4c6c68b9573 --- /dev/null +++ b/homeassistant/components/iperf3/strings.json @@ -0,0 +1,14 @@ +{ + "services": { + "speedtest": { + "name": "Speedtest", + "description": "Immediately executes a speed test with iperf3.", + "fields": { + "host": { + "name": "[%key:common::config_flow::data::host%]", + "description": "The host name of the iperf3 server (already configured) to run a test with." + } + } + } + } +} diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index 59b8b4b070e..7cdf6767362 100644 --- a/homeassistant/components/ipp/manifest.json +++ b/homeassistant/components/ipp/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["deepmerge", "pyipp"], "quality_scale": "platinum", - "requirements": ["pyipp==0.14.0"], + "requirements": ["pyipp==0.14.2"], "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] } diff --git a/homeassistant/components/ipp/strings.json b/homeassistant/components/ipp/strings.json index fa7dd9b6bf8..f3ea929c9ec 100644 --- a/homeassistant/components/ipp/strings.json +++ b/homeassistant/components/ipp/strings.json @@ -37,7 +37,7 @@ "printer": { "state": { "printing": "Printing", - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "stopped": "Stopped" } } diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index abaefec4082..2552be7717a 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -19,31 +19,31 @@ from .const import DOMAIN, NAME SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="Fajr", - name="Fajr prayer", + translation_key="fajr", ), SensorEntityDescription( key="Sunrise", - name="Sunrise time", + translation_key="sunrise", ), SensorEntityDescription( key="Dhuhr", - name="Dhuhr prayer", + translation_key="dhuhr", ), SensorEntityDescription( key="Asr", - name="Asr prayer", + translation_key="asr", ), SensorEntityDescription( key="Maghrib", - name="Maghrib prayer", + translation_key="maghrib", ), SensorEntityDescription( key="Isha", - name="Isha prayer", + translation_key="isha", ), SensorEntityDescription( key="Midnight", - name="Midnight time", + translation_key="midnight", ), ) diff --git a/homeassistant/components/islamic_prayer_times/strings.json b/homeassistant/components/islamic_prayer_times/strings.json index 73998913f41..7c09cc605bd 100644 --- a/homeassistant/components/islamic_prayer_times/strings.json +++ b/homeassistant/components/islamic_prayer_times/strings.json @@ -19,5 +19,30 @@ } } } + }, + "entity": { + "sensor": { + "fajr": { + "name": "Fajr prayer" + }, + "sunrise": { + "name": "Sunrise time" + }, + "dhuhr": { + "name": "Dhuhr prayer" + }, + "asr": { + "name": "Asr prayer" + }, + "maghrib": { + "name": "Maghrib prayer" + }, + "isha": { + "name": "Isha prayer" + }, + "midnight": { + "name": "Midnight time" + } + } } } diff --git a/homeassistant/components/iss/config_flow.py b/homeassistant/components/iss/config_flow.py index ebfd445f62c..f8ebd9db723 100644 --- a/homeassistant/components/iss/config_flow.py +++ b/homeassistant/components/iss/config_flow.py @@ -3,13 +3,11 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME, CONF_SHOW_ON_MAP +from homeassistant.const import CONF_SHOW_ON_MAP from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from .const import DOMAIN - -DEFAULT_NAME = "ISS" +from .const import DEFAULT_NAME, DOMAIN class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -33,7 +31,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: return self.async_create_entry( - title=user_input.get(CONF_NAME, DEFAULT_NAME), + title=DEFAULT_NAME, data={}, options={CONF_SHOW_ON_MAP: user_input.get(CONF_SHOW_ON_MAP, False)}, ) diff --git a/homeassistant/components/iss/const.py b/homeassistant/components/iss/const.py index 3d240041b67..c3bdcf6fa32 100644 --- a/homeassistant/components/iss/const.py +++ b/homeassistant/components/iss/const.py @@ -1,3 +1,5 @@ """Constants for iss.""" DOMAIN = "iss" + +DEFAULT_NAME = "ISS" diff --git a/homeassistant/components/iss/sensor.py b/homeassistant/components/iss/sensor.py index fac23dfd9fa..32516ee99c9 100644 --- a/homeassistant/components/iss/sensor.py +++ b/homeassistant/components/iss/sensor.py @@ -8,6 +8,8 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -15,7 +17,7 @@ from homeassistant.helpers.update_coordinator import ( ) from . import IssData -from .const import DOMAIN +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -28,23 +30,32 @@ async def async_setup_entry( """Set up the sensor platform.""" coordinator: DataUpdateCoordinator[IssData] = hass.data[DOMAIN] - name = entry.title show_on_map = entry.options.get(CONF_SHOW_ON_MAP, False) - async_add_entities([IssSensor(coordinator, name, show_on_map)]) + async_add_entities([IssSensor(coordinator, entry, show_on_map)]) class IssSensor(CoordinatorEntity[DataUpdateCoordinator[IssData]], SensorEntity): """Implementation of the ISS sensor.""" + _attr_has_entity_name = True + _attr_name = None + def __init__( - self, coordinator: DataUpdateCoordinator[IssData], name: str, show: bool + self, + coordinator: DataUpdateCoordinator[IssData], + entry: ConfigEntry, + show: bool, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._state = None - self._attr_name = name + self._attr_unique_id = f"{entry.entry_id}_people" self._show_on_map = show + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=DEFAULT_NAME, + entry_type=DeviceEntryType.SERVICE, + ) @property def native_value(self) -> int: diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index 83fea57a9fa..8fc90efaabc 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -37,6 +37,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.enum import try_parse_enum from .const import ( _LOGGER, @@ -131,7 +132,10 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): if self._node.protocol == PROTO_INSTEON else UOM_HVAC_MODE_GENERIC ) - return UOM_TO_STATES[uom].get(hvac_mode.value, HVACMode.OFF) + return ( + try_parse_enum(HVACMode, UOM_TO_STATES[uom].get(hvac_mode.value)) + or HVACMode.OFF + ) @property def hvac_action(self) -> HVACAction | None: @@ -139,7 +143,9 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): hvac_action = self._node.aux_properties.get(PROP_HEAT_COOL_STATE) if not hvac_action: return None - return UOM_TO_STATES[UOM_HVAC_ACTIONS].get(hvac_action.value) + return try_parse_enum( + HVACAction, UOM_TO_STATES[UOM_HVAC_ACTIONS].get(hvac_action.value) + ) @property def current_temperature(self) -> float | None: diff --git a/homeassistant/components/isy994/services.yaml b/homeassistant/components/isy994/services.yaml index b84fcdd73ef..7ce44f9edae 100644 --- a/homeassistant/components/isy994/services.yaml +++ b/homeassistant/components/isy994/services.yaml @@ -4,52 +4,36 @@ # flooding the ISY with requests. To control multiple devices with a service call # the recommendation is to add a scene in the ISY and control that scene. send_raw_node_command: - name: Send raw node command - description: Send a "raw" ISY REST Device Command to a Node using its Home Assistant Entity ID. target: entity: integration: isy994 fields: command: - name: Command - description: The ISY REST Command to be sent to the device required: true example: "DON" selector: text: value: - name: Value - description: The integer value to be sent with the command. selector: number: min: 0 max: 255 parameters: - name: Parameters - description: A dict of parameters to be sent in the query string (e.g. for controlling colored bulbs). example: { GV2: 0, GV3: 0, GV4: 255 } default: {} selector: object: unit_of_measurement: - name: Unit of measurement - description: The ISY Unit of Measurement (UOM) to send with the command, if required. selector: number: min: 0 max: 120 send_node_command: - name: Send node command - description: >- - Send a command to an ISY Device using its Home Assistant entity ID. Valid commands are: beep, brighten, dim, disable, - enable, fade_down, fade_stop, fade_up, fast_off, fast_on, and query. target: entity: integration: isy994 fields: command: - name: Command - description: The command to be sent to the device. required: true selector: select: @@ -66,34 +50,22 @@ send_node_command: - "fast_on" - "query" get_zwave_parameter: - name: Get Z-Wave Parameter - description: >- - Request a Z-Wave Device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name "ZW_#" - where "#" is the parameter number. target: entity: integration: isy994 fields: parameter: - name: Parameter - description: The parameter number to retrieve from the device. example: 8 selector: number: min: 1 max: 255 set_zwave_parameter: - name: Set Z-Wave Parameter - description: >- - Update a Z-Wave Device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name "ZW_#" - where "#" is the parameter number. target: entity: integration: isy994 fields: parameter: - name: Parameter - description: The parameter number to set on the end device. required: true example: 8 selector: @@ -101,15 +73,11 @@ set_zwave_parameter: min: 1 max: 255 value: - name: Value - description: The value to set for the parameter. May be an integer or byte string (e.g. "0xFFFF"). required: true example: 33491663 selector: text: size: - name: Size - description: The size of the parameter, either 1, 2, or 4 bytes. required: true example: 4 selector: @@ -119,17 +87,12 @@ set_zwave_parameter: - "2" - "4" set_zwave_lock_user_code: - name: Set Z-Wave Lock User Code - description: >- - Set a Z-Wave Lock User Code via the ISY. target: entity: integration: isy994 domain: lock fields: user_num: - name: User Number - description: The user slot number on the lock required: true example: 8 selector: @@ -137,8 +100,6 @@ set_zwave_lock_user_code: min: 1 max: 255 code: - name: Code - description: The code to set for the user. required: true example: 33491663 selector: @@ -147,17 +108,12 @@ set_zwave_lock_user_code: max: 99999999 mode: box delete_zwave_lock_user_code: - name: Delete Z-Wave Lock User Code - description: >- - Delete a Z-Wave Lock User Code via the ISY. target: entity: integration: isy994 domain: lock fields: user_num: - name: User Number - description: The user slot number on the lock required: true example: 8 selector: @@ -165,43 +121,26 @@ delete_zwave_lock_user_code: min: 1 max: 255 rename_node: - name: Rename Node on ISY - description: >- - Rename a node or group (scene) on the ISY. Note: this will not automatically change the Home Assistant Entity Name or Entity ID to match. - The entity name and ID will only be updated after calling `isy994.reload` or restarting Home Assistant, and ONLY IF you have not already customized the - name within Home Assistant. target: entity: integration: isy994 fields: name: - name: New Name - description: The new name to use within the ISY. required: true example: "Front Door Light" selector: text: send_program_command: - name: Send program command - description: >- - Send a command to control an ISY program or folder. Valid commands are run, run_then, run_else, stop, enable, disable, - enable_run_at_startup, and disable_run_at_startup. fields: address: - name: Address - description: The address of the program to control (use either address or name). example: "04B1" selector: text: name: - name: Name - description: The name of the program to control (use either address or name). example: "My Program" selector: text: command: - name: Command - description: The ISY Program Command to be sent. required: true selector: select: @@ -215,8 +154,6 @@ send_program_command: - "run_then" - "stop" isy: - name: ISY - description: If you have more than one ISY connected, provide the name of the ISY to query (as shown on the Device Registry or as the top-first node in the ISY Admin Console). If you have the same program name or address on multiple ISYs, omitting this will run the command on them all. example: "ISY" selector: text: diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 821f8889978..b39bad14d45 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -53,5 +53,123 @@ "last_heartbeat": "Last Heartbeat Time", "websocket_status": "Event Socket Status" } + }, + "services": { + "send_raw_node_command": { + "name": "Send raw node command", + "description": "[%key:component::isy994::options::step::init::description%]", + "fields": { + "command": { + "name": "Command", + "description": "The ISY REST Command to be sent to the device." + }, + "value": { + "name": "Value", + "description": "The integer value to be sent with the command." + }, + "parameters": { + "name": "Parameters", + "description": "A dict of parameters to be sent in the query string (e.g. for controlling colored bulbs)." + }, + "unit_of_measurement": { + "name": "Unit of measurement", + "description": "The ISY Unit of Measurement (UOM) to send with the command, if required." + } + } + }, + "send_node_command": { + "name": "Send node command", + "description": "Sends a command to an ISY Device using its Home Assistant entity ID. Valid commands are: beep, brighten, dim, disable, enable, fade_down, fade_stop, fade_up, fast_off, fast_on, and query.", + "fields": { + "command": { + "name": "Command", + "description": "The command to be sent to the device." + } + } + }, + "get_zwave_parameter": { + "name": "Get Z-Wave Parameter", + "description": "Requests a Z-Wave Device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", + "fields": { + "parameter": { + "name": "Parameter", + "description": "The parameter number to retrieve from the device." + } + } + }, + "set_zwave_parameter": { + "name": "Set Z-Wave Parameter", + "description": "Updates a Z-Wave Device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.", + "fields": { + "parameter": { + "name": "[%key:component::isy994::services::get_zwave_parameter::fields::parameter::name%]", + "description": "The parameter number to set on the end device." + }, + "value": { + "name": "Value", + "description": "The value to set for the parameter. May be an integer or byte string (e.g. \"0xFFFF\")." + }, + "size": { + "name": "Size", + "description": "The size of the parameter, either 1, 2, or 4 bytes." + } + } + }, + "set_zwave_lock_user_code": { + "name": "Set Z-Wave Lock User Code", + "description": "Sets a Z-Wave Lock User Code via the ISY.", + "fields": { + "user_num": { + "name": "User Number", + "description": "The user slot number on the lock." + }, + "code": { + "name": "Code", + "description": "The code to set for the user." + } + } + }, + "delete_zwave_lock_user_code": { + "name": "Delete Z-Wave Lock User Code", + "description": "Delete a Z-Wave Lock User Code via the ISY.", + "fields": { + "user_num": { + "name": "[%key:component::isy994::services::set_zwave_lock_user_code::fields::user_num::name%]", + "description": "[%key:component::isy994::services::set_zwave_lock_user_code::fields::user_num::description%]" + } + } + }, + "rename_node": { + "name": "Rename Node on ISY", + "description": "Renames a node or group (scene) on the ISY. Note: this will not automatically change the Home Assistant Entity Name or Entity ID to match. The entity name and ID will only be updated after calling `isy994.reload` or restarting Home Assistant, and ONLY IF you have not already customized the name within Home Assistant.", + "fields": { + "name": { + "name": "New Name", + "description": "The new name to use within the ISY." + } + } + }, + "send_program_command": { + "name": "Send program command", + "description": "Sends a command to control an ISY program or folder. Valid commands are run, run_then, run_else, stop, enable, disable, enable_run_at_startup, and disable_run_at_startup.", + "fields": { + "address": { + "name": "Address", + "description": "The address of the program to control (use either address or name)." + }, + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "The name of the program to control (use either address or name)." + }, + "command": { + "name": "Command", + "description": "The ISY Program Command to be sent." + }, + "isy": { + "name": "ISY", + "description": "If you have more than one ISY connected, provide the name of the ISY to query (as shown on the Device Registry or as the top-first node in the ISY Admin Console). If you have the same program name or address on multiple ISYs, omitting this will run the command on them all." + } + } + } } } diff --git a/homeassistant/components/izone/services.yaml b/homeassistant/components/izone/services.yaml index 5cecbb68a9f..f1a8fe5c8e5 100644 --- a/homeassistant/components/izone/services.yaml +++ b/homeassistant/components/izone/services.yaml @@ -1,14 +1,10 @@ airflow_min: - name: Set minimum airflow - description: Set the airflow minimum percent for a zone target: entity: integration: izone domain: climate fields: airflow: - name: Percent - description: Airflow percent. required: true selector: number: @@ -17,16 +13,12 @@ airflow_min: step: 5 unit_of_measurement: "%" airflow_max: - name: Set maximum airflow - description: Set the airflow maximum percent for a zone target: entity: integration: izone domain: climate fields: airflow: - name: Percent - description: Airflow percent. required: true selector: number: diff --git a/homeassistant/components/izone/strings.json b/homeassistant/components/izone/strings.json index 7d1e8f1d476..707d7d71d34 100644 --- a/homeassistant/components/izone/strings.json +++ b/homeassistant/components/izone/strings.json @@ -9,5 +9,27 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "services": { + "airflow_min": { + "name": "Set minimum airflow", + "description": "Sets the airflow minimum percent for a zone.", + "fields": { + "airflow": { + "name": "Percent", + "description": "Airflow percent." + } + } + }, + "airflow_max": { + "name": "Set maximum airflow", + "description": "Sets the airflow maximum percent for a zone.", + "fields": { + "airflow": { + "name": "[%key:component::izone::services::airflow_min::fields::airflow::name%]", + "description": "[%key:component::izone::services::airflow_min::fields::airflow::description%]" + } + } + } } } diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index 4ee97020724..f25c3410edb 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -4,6 +4,7 @@ from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, LOGGER, PLATFORMS @@ -60,3 +61,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return True + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove device from a config entry.""" + data = hass.data[DOMAIN][config_entry.entry_id] + coordinator = data.coordinators["sessions"] + + return not device_entry.identifiers.intersection( + ( + (DOMAIN, coordinator.server_id), + *((DOMAIN, id) for id in coordinator.device_ids), + ) + ) diff --git a/homeassistant/components/jellyfin/coordinator.py b/homeassistant/components/jellyfin/coordinator.py index 3d5b150f39f..f4ab98ca268 100644 --- a/homeassistant/components/jellyfin/coordinator.py +++ b/homeassistant/components/jellyfin/coordinator.py @@ -47,6 +47,7 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[JellyfinDataT], ABC): self.user_id: str = user_id self.session_ids: set[str] = set() + self.device_ids: set[str] = set() async def _async_update_data(self) -> JellyfinDataT: """Get the latest data from Jellyfin.""" @@ -75,4 +76,6 @@ class SessionsDataUpdateCoordinator( and session["Client"] != USER_APP_NAME } + self.device_ids = {session["DeviceId"] for session in sessions_by_id.values()} + return sessions_by_id diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index f9c73443d00..3bbe3e0b184 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -21,7 +21,6 @@ from homeassistant.core import HomeAssistant from .const import ( COLLECTION_TYPE_MOVIES, COLLECTION_TYPE_MUSIC, - COLLECTION_TYPE_TVSHOWS, DOMAIN, ITEM_KEY_COLLECTION_TYPE, ITEM_KEY_ID, @@ -155,10 +154,7 @@ class JellyfinSource(MediaSource): return await self._build_music_library(library, include_children) if collection_type == COLLECTION_TYPE_MOVIES: return await self._build_movie_library(library, include_children) - if collection_type == COLLECTION_TYPE_TVSHOWS: - return await self._build_tv_library(library, include_children) - - raise BrowseError(f"Unsupported collection type {collection_type}") + return await self._build_tv_library(library, include_children) async def _build_music_library( self, library: dict[str, Any], include_children: bool @@ -189,7 +185,15 @@ class JellyfinSource(MediaSource): async def _build_artists(self, library_id: str) -> list[BrowseMediaSource]: """Return all artists in the music library.""" artists = await self._get_children(library_id, ITEM_TYPE_ARTIST) - artists = sorted(artists, key=lambda k: k[ITEM_KEY_NAME]) + artists = sorted( + artists, + # Sort by whether an artist has an name first, then by name + # This allows for sorting artists with, without and with missing names + key=lambda k: ( + ITEM_KEY_NAME not in k, + k.get(ITEM_KEY_NAME), + ), + ) return [await self._build_artist(artist, False) for artist in artists] async def _build_artist( @@ -220,7 +224,15 @@ class JellyfinSource(MediaSource): async def _build_albums(self, parent_id: str) -> list[BrowseMediaSource]: """Return all albums of a single artist as browsable media sources.""" albums = await self._get_children(parent_id, ITEM_TYPE_ALBUM) - albums = sorted(albums, key=lambda k: k[ITEM_KEY_NAME]) + albums = sorted( + albums, + # Sort by whether an album has an name first, then by name + # This allows for sorting albums with, without and with missing names + key=lambda k: ( + ITEM_KEY_NAME not in k, + k.get(ITEM_KEY_NAME), + ), + ) return [await self._build_album(album, False) for album in albums] async def _build_album( @@ -253,9 +265,11 @@ class JellyfinSource(MediaSource): tracks = await self._get_children(album_id, ITEM_TYPE_AUDIO) tracks = sorted( tracks, + # Sort by whether a track has an index first, then by index + # This allows for sorting tracks with, without and with missing indices key=lambda k: ( ITEM_KEY_INDEX_NUMBER not in k, - k.get(ITEM_KEY_INDEX_NUMBER, None), + k.get(ITEM_KEY_INDEX_NUMBER), ), ) return [ @@ -310,7 +324,15 @@ class JellyfinSource(MediaSource): async def _build_movies(self, library_id: str) -> list[BrowseMediaSource]: """Return all movies in the movie library.""" movies = await self._get_children(library_id, ITEM_TYPE_MOVIE) - movies = sorted(movies, key=lambda k: k[ITEM_KEY_NAME]) + movies = sorted( + movies, + # Sort by whether a movies has an name first, then by name + # This allows for sorting moveis with, without and with missing names + key=lambda k: ( + ITEM_KEY_NAME not in k, + k.get(ITEM_KEY_NAME), + ), + ) return [ self._build_movie(movie) for movie in movies @@ -363,7 +385,15 @@ class JellyfinSource(MediaSource): async def _build_tvshow(self, library_id: str) -> list[BrowseMediaSource]: """Return all series in the tv library.""" series = await self._get_children(library_id, ITEM_TYPE_SERIES) - series = sorted(series, key=lambda k: k[ITEM_KEY_NAME]) + series = sorted( + series, + # Sort by whether a seroes has an name first, then by name + # This allows for sorting series with, without and with missing names + key=lambda k: ( + ITEM_KEY_NAME not in k, + k.get(ITEM_KEY_NAME), + ), + ) return [await self._build_series(serie, False) for serie in series] async def _build_series( @@ -394,7 +424,15 @@ class JellyfinSource(MediaSource): async def _build_seasons(self, series_id: str) -> list[BrowseMediaSource]: """Return all seasons in the series.""" seasons = await self._get_children(series_id, ITEM_TYPE_SEASON) - seasons = sorted(seasons, key=lambda k: k[ITEM_KEY_NAME]) + seasons = sorted( + seasons, + # Sort by whether a season has an index first, then by index + # This allows for sorting seasons with, without and with missing indices + key=lambda k: ( + ITEM_KEY_INDEX_NUMBER not in k, + k.get(ITEM_KEY_INDEX_NUMBER), + ), + ) return [await self._build_season(season, False) for season in seasons] async def _build_season( @@ -425,7 +463,15 @@ class JellyfinSource(MediaSource): async def _build_episodes(self, season_id: str) -> list[BrowseMediaSource]: """Return all episode in the season.""" episodes = await self._get_children(season_id, ITEM_TYPE_EPISODE) - episodes = sorted(episodes, key=lambda k: k[ITEM_KEY_NAME]) + episodes = sorted( + episodes, + # Sort by whether an episode has an index first, then by index + # This allows for sorting episodes with, without and with missing indices + key=lambda k: ( + ITEM_KEY_INDEX_NUMBER not in k, + k.get(ITEM_KEY_INDEX_NUMBER), + ), + ) return [ self._build_episode(episode) for episode in episodes diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py index 0f3811bef6f..2f25a934e7f 100644 --- a/homeassistant/components/juicenet/entity.py +++ b/homeassistant/components/juicenet/entity.py @@ -14,6 +14,8 @@ from .const import DOMAIN class JuiceNetDevice(CoordinatorEntity): """Represent a base JuiceNet device.""" + _attr_has_entity_name = True + def __init__( self, device: Charger, key: str, coordinator: DataUpdateCoordinator ) -> None: diff --git a/homeassistant/components/juicenet/number.py b/homeassistant/components/juicenet/number.py index 45be1dd9004..e78f6189baf 100644 --- a/homeassistant/components/juicenet/number.py +++ b/homeassistant/components/juicenet/number.py @@ -37,7 +37,7 @@ class JuiceNetNumberEntityDescription( NUMBER_TYPES: tuple[JuiceNetNumberEntityDescription, ...] = ( JuiceNetNumberEntityDescription( - name="Amperage Limit", + translation_key="amperage_limit", key="current_charging_amperage_limit", native_min_value=6, native_max_value_key="max_charging_amperage", @@ -80,8 +80,6 @@ class JuiceNetNumber(JuiceNetDevice, NumberEntity): super().__init__(device, description.key, coordinator) self.entity_description = description - self._attr_name = f"{self.device.name} {description.name}" - @property def native_value(self) -> float | None: """Return the value of the entity.""" diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index fdc40211d77..5f71e066b9c 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -29,40 +29,36 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="temperature", - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="voltage", - name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, ), SensorEntityDescription( key="amps", - name="Amps", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="watts", - name="Watts", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="charge_time", - name="Charge time", + translation_key="charge_time", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:timer-outline", ), SensorEntityDescription( key="energy_added", - name="Energy added", + translation_key="energy_added", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -97,7 +93,6 @@ class JuiceNetSensorDevice(JuiceNetDevice, SensorEntity): """Initialise the sensor.""" super().__init__(device, description.key, coordinator) self.entity_description = description - self._attr_name = f"{self.device.name} {description.name}" @property def icon(self): diff --git a/homeassistant/components/juicenet/strings.json b/homeassistant/components/juicenet/strings.json index bc4a66e72d4..0e3732c66d2 100644 --- a/homeassistant/components/juicenet/strings.json +++ b/homeassistant/components/juicenet/strings.json @@ -17,5 +17,25 @@ "title": "Connect to JuiceNet" } } + }, + "entity": { + "number": { + "amperage_limit": { + "name": "Amperage limit" + } + }, + "sensor": { + "charge_time": { + "name": "Charge time" + }, + "energy_added": { + "name": "Energy added" + } + }, + "switch": { + "charge_now": { + "name": "Charge now" + } + } } } diff --git a/homeassistant/components/juicenet/switch.py b/homeassistant/components/juicenet/switch.py index 576c66c0841..7c373eeeb24 100644 --- a/homeassistant/components/juicenet/switch.py +++ b/homeassistant/components/juicenet/switch.py @@ -29,15 +29,12 @@ async def async_setup_entry( class JuiceNetChargeNowSwitch(JuiceNetDevice, SwitchEntity): """Implementation of a JuiceNet switch.""" + _attr_translation_key = "charge_now" + def __init__(self, device, coordinator): """Initialise the switch.""" super().__init__(device, "charge_now", coordinator) - @property - def name(self): - """Return the name of the device.""" - return f"{self.device.name} Charge Now" - @property def is_on(self): """Return true if switch is on.""" diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index 11e2f66f91e..1f85c20fc72 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -29,7 +29,7 @@ "error": { "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "Password authentication failed" + "invalid_auth": "[%key:component::jvc_projector::config::step::reauth_confirm::description%]" } } } diff --git a/homeassistant/components/kaleidescape/entity.py b/homeassistant/components/kaleidescape/entity.py index cab55c20c02..87a9fa4da0e 100644 --- a/homeassistant/components/kaleidescape/entity.py +++ b/homeassistant/components/kaleidescape/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from homeassistant.core import callback from homeassistant.helpers.entity import DeviceInfo, Entity @@ -19,18 +19,19 @@ _LOGGER = logging.getLogger(__name__) class KaleidescapeEntity(Entity): """Defines a base Kaleidescape entity.""" + _attr_has_entity_name = True + _attr_should_poll = False + def __init__(self, device: KaleidescapeDevice) -> None: """Initialize entity.""" self._device = device - self._attr_should_poll = False self._attr_unique_id = device.serial_number - self._attr_name = f"{KALEIDESCAPE_NAME} {device.system.friendly_name}" self._attr_device_info = DeviceInfo( identifiers={(KALEIDESCAPE_DOMAIN, self._device.serial_number)}, # Instead of setting the device name to the entity name, kaleidescape # should be updated to set has_entity_name = True - name=cast(str | None, self.name), + name=f"{KALEIDESCAPE_NAME} {device.system.friendly_name}", model=self._device.system.type, manufacturer=KALEIDESCAPE_NAME, sw_version=f"{self._device.system.kos_version}", diff --git a/homeassistant/components/kaleidescape/media_player.py b/homeassistant/components/kaleidescape/media_player.py index cbae7f0df76..7751f6b6a29 100644 --- a/homeassistant/components/kaleidescape/media_player.py +++ b/homeassistant/components/kaleidescape/media_player.py @@ -56,6 +56,7 @@ class KaleidescapeMediaPlayer(KaleidescapeEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.PREVIOUS_TRACK ) + _attr_name = None async def async_turn_on(self) -> None: """Send leave standby command.""" diff --git a/homeassistant/components/kaleidescape/remote.py b/homeassistant/components/kaleidescape/remote.py index 61080052ee5..2d35ad2787f 100644 --- a/homeassistant/components/kaleidescape/remote.py +++ b/homeassistant/components/kaleidescape/remote.py @@ -47,6 +47,8 @@ VALID_COMMANDS = { class KaleidescapeRemote(KaleidescapeEntity, RemoteEntity): """Representation of a Kaleidescape device.""" + _attr_name = None + @property def is_on(self) -> bool: """Return true if device is on.""" diff --git a/homeassistant/components/kaleidescape/sensor.py b/homeassistant/components/kaleidescape/sensor.py index 23d40684c13..183036f3973 100644 --- a/homeassistant/components/kaleidescape/sensor.py +++ b/homeassistant/components/kaleidescape/sensor.py @@ -39,67 +39,67 @@ class KaleidescapeSensorEntityDescription( SENSOR_TYPES: tuple[KaleidescapeSensorEntityDescription, ...] = ( KaleidescapeSensorEntityDescription( key="media_location", - name="Media Location", + translation_key="media_location", icon="mdi:monitor", value_fn=lambda device: device.automation.movie_location, ), KaleidescapeSensorEntityDescription( key="play_status", - name="Play Status", + translation_key="play_status", icon="mdi:monitor", value_fn=lambda device: device.movie.play_status, ), KaleidescapeSensorEntityDescription( key="play_speed", - name="Play Speed", + translation_key="play_speed", icon="mdi:monitor", value_fn=lambda device: device.movie.play_speed, ), KaleidescapeSensorEntityDescription( key="video_mode", - name="Video Mode", + translation_key="video_mode", icon="mdi:monitor-screenshot", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.video_mode, ), KaleidescapeSensorEntityDescription( key="video_color_eotf", - name="Video Color EOTF", + translation_key="video_color_eotf", icon="mdi:monitor-eye", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.video_color_eotf, ), KaleidescapeSensorEntityDescription( key="video_color_space", - name="Video Color Space", + translation_key="video_color_space", icon="mdi:monitor-eye", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.video_color_space, ), KaleidescapeSensorEntityDescription( key="video_color_depth", - name="Video Color Depth", + translation_key="video_color_depth", icon="mdi:monitor-eye", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.video_color_depth, ), KaleidescapeSensorEntityDescription( key="video_color_sampling", - name="Video Color Sampling", + translation_key="video_color_sampling", icon="mdi:monitor-eye", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.video_color_sampling, ), KaleidescapeSensorEntityDescription( key="screen_mask_ratio", - name="Screen Mask Ratio", + translation_key="screen_mask_ratio", icon="mdi:monitor-screenshot", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.screen_mask_ratio, ), KaleidescapeSensorEntityDescription( key="screen_mask_top_trim_rel", - name="Screen Mask Top Trim Rel", + translation_key="screen_mask_top_trim_rel", icon="mdi:monitor-screenshot", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, @@ -107,7 +107,7 @@ SENSOR_TYPES: tuple[KaleidescapeSensorEntityDescription, ...] = ( ), KaleidescapeSensorEntityDescription( key="screen_mask_bottom_trim_rel", - name="Screen Mask Bottom Trim Rel", + translation_key="screen_mask_bottom_trim_rel", icon="mdi:monitor-screenshot", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, @@ -115,14 +115,14 @@ SENSOR_TYPES: tuple[KaleidescapeSensorEntityDescription, ...] = ( ), KaleidescapeSensorEntityDescription( key="screen_mask_conservative_ratio", - name="Screen Mask Conservative Ratio", + translation_key="screen_mask_conservative_ratio", icon="mdi:monitor-screenshot", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.screen_mask_conservative_ratio, ), KaleidescapeSensorEntityDescription( key="screen_mask_top_mask_abs", - name="Screen Mask Top Mask Abs", + translation_key="screen_mask_top_mask_abs", icon="mdi:monitor-screenshot", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, @@ -130,7 +130,7 @@ SENSOR_TYPES: tuple[KaleidescapeSensorEntityDescription, ...] = ( ), KaleidescapeSensorEntityDescription( key="screen_mask_bottom_mask_abs", - name="Screen Mask Bottom Mask Abs", + translation_key="screen_mask_bottom_mask_abs", icon="mdi:monitor-screenshot", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, @@ -138,14 +138,14 @@ SENSOR_TYPES: tuple[KaleidescapeSensorEntityDescription, ...] = ( ), KaleidescapeSensorEntityDescription( key="cinemascape_mask", - name="Cinemascape Mask", + translation_key="cinemascape_mask", icon="mdi:monitor-star", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.cinemascape_mask, ), KaleidescapeSensorEntityDescription( key="cinemascape_mode", - name="Cinemascape Mode", + translation_key="cinemascape_mode", icon="mdi:monitor-star", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.automation.cinemascape_mode, @@ -177,7 +177,6 @@ class KaleidescapeSensor(KaleidescapeEntity, SensorEntity): super().__init__(device) self.entity_description = entity_description self._attr_unique_id = f"{self._attr_unique_id}-{entity_description.key}" - self._attr_name = f"{self._attr_name} {entity_description.name}" @property def native_value(self) -> StateType: diff --git a/homeassistant/components/kaleidescape/strings.json b/homeassistant/components/kaleidescape/strings.json index 92b9c931acd..0cebfd4bf5c 100644 --- a/homeassistant/components/kaleidescape/strings.json +++ b/homeassistant/components/kaleidescape/strings.json @@ -19,7 +19,59 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unsupported": "Unsupported device" + "unsupported": "[%key:component::kaleidescape::config::abort::unsupported%]" + } + }, + "entity": { + "sensor": { + "media_location": { + "name": "Media location" + }, + "play_status": { + "name": "Play status" + }, + "play_speed": { + "name": "Play speed" + }, + "video_mode": { + "name": "Video mode" + }, + "video_color_eotf": { + "name": "Video color EOTF" + }, + "video_color_space": { + "name": "Video color space" + }, + "video_color_depth": { + "name": "Video color depth" + }, + "video_color_sampling": { + "name": "Video color sampling" + }, + "screen_mask_ratio": { + "name": "Screen mask ratio" + }, + "screen_mask_top_trim_rel": { + "name": "Screen mask top trim relative" + }, + "screen_mask_bottom_trim_rel": { + "name": "Screen mask bottom trim relative" + }, + "screen_mask_conservative_ratio": { + "name": "Screen mask conservative ratio" + }, + "screen_mask_top_mask_abs": { + "name": "Screen mask top mask absolute" + }, + "screen_mask_bottom_mask_abs": { + "name": "Screen mask bottom mask absolute" + }, + "cinemascape_mask": { + "name": "Cinemascape mask" + }, + "cinemascape_mode": { + "name": "Cinemascape mode" + } } } } diff --git a/homeassistant/components/keba/services.yaml b/homeassistant/components/keba/services.yaml index 8e5e8cd91f8..daa1749a34c 100644 --- a/homeassistant/components/keba/services.yaml +++ b/homeassistant/components/keba/services.yaml @@ -1,28 +1,11 @@ # Describes the format for available services for KEBA charging staitons request_data: - name: Request data - description: > - Request new data from the charging station. - authorize: - name: Authorize - description: > - Authorizes a charging process with the predefined RFID tag of the configuration file. - deauthorize: - name: Deauthorize - description: > - Deauthorizes the running charging process with the predefined RFID tag of the configuration file. - set_energy: - name: Set energy - description: Sets the energy target after which the charging process stops. fields: energy: - name: Energy - description: > - The energy target to stop charging. Setting 0 disables the limit. selector: number: min: 0 @@ -31,15 +14,8 @@ set_energy: unit_of_measurement: "kWh" set_current: - name: Set current - description: Sets the maximum current for charging processes. fields: current: - name: Current - description: > - The maximum current used for the charging process. - The value is depending on the DIP-switch settings and the used cable of the - charging station. default: 6 selector: number: @@ -49,24 +25,10 @@ set_current: unit_of_measurement: "A" enable: - name: Enable - description: > - Starts a charging process if charging station is authorized. - disable: - name: Disable - description: > - Stops the charging process if charging station is authorized. - set_failsafe: - name: Set failsafe - description: > - Set the failsafe mode of the charging station. If all parameters are 0, the failsafe mode will be disabled. fields: failsafe_timeout: - name: Failsafe timeout - description: > - Timeout after which the failsafe mode is triggered, if set_current was not executed during this time. default: 30 selector: number: @@ -74,9 +36,6 @@ set_failsafe: max: 3600 unit_of_measurement: seconds failsafe_fallback: - name: Failsafe fallback - description: > - Fallback current to be set after timeout. default: 6 selector: number: @@ -85,9 +44,6 @@ set_failsafe: step: 0.1 unit_of_measurement: "A" failsafe_persist: - name: Failsafe persist - description: > - If failsafe_persist is 0, the failsafe option is only until charging station reboot. If failsafe_persist is 1, the failsafe option will survive a reboot. default: 0 selector: number: diff --git a/homeassistant/components/keba/strings.json b/homeassistant/components/keba/strings.json new file mode 100644 index 00000000000..ed8594a1068 --- /dev/null +++ b/homeassistant/components/keba/strings.json @@ -0,0 +1,62 @@ +{ + "services": { + "request_data": { + "name": "Request data", + "description": "Requesta new data from the charging station." + }, + "authorize": { + "name": "Authorize", + "description": "Authorizes a charging process with the predefined RFID tag of the configuration file." + }, + "deauthorize": { + "name": "Deauthorize", + "description": "Deauthorizes the running charging process with the predefined RFID tag of the configuration file." + }, + "set_energy": { + "name": "Set energy", + "description": "Sets the energy target after which the charging process stops.", + "fields": { + "energy": { + "name": "Energy", + "description": "The energy target to stop charging. Setting 0 disables the limit." + } + } + }, + "set_current": { + "name": "Set current", + "description": "Sets the maximum current for charging processes.", + "fields": { + "current": { + "name": "Current", + "description": "The maximum current used for the charging process. The value is depending on the DIP-switch settings and the used cable of the charging station." + } + } + }, + "enable": { + "name": "[%key:common::action::enable%]", + "description": "Starts a charging process if charging station is authorized." + }, + "disable": { + "name": "[%key:common::action::disable%]", + "description": "Stops the charging process if charging station is authorized." + }, + "set_failsafe": { + "name": "Set failsafe", + "description": "Sets the failsafe mode of the charging station. If all parameters are 0, the failsafe mode will be disabled.", + "fields": { + "failsafe_timeout": { + "name": "Failsafe timeout", + "description": "Timeout after which the failsafe mode is triggered, if set_current was not executed during this time." + }, + "failsafe_fallback": { + "name": "Failsafe fallback", + "description": "Fallback current to be set after timeout." + }, + "failsafe_persist": { + "name": "Failsafe persist", + "description": "If failsafe_persist is 0, the failsafe option is only until charging station reboot. If failsafe_persist is 1, the failsafe option will survive a reboot." + } + } + } + } +} diff --git a/homeassistant/components/keenetic_ndms2/binary_sensor.py b/homeassistant/components/keenetic_ndms2/binary_sensor.py index fa9b1fd48dd..f39c92519e4 100644 --- a/homeassistant/components/keenetic_ndms2/binary_sensor.py +++ b/homeassistant/components/keenetic_ndms2/binary_sensor.py @@ -28,16 +28,12 @@ class RouterOnlineBinarySensor(BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, router: KeeneticRouter) -> None: """Initialize the APCUPSd binary device.""" self._router = router - @property - def name(self): - """Return the name of the online status sensor.""" - return f"{self._router.name} Online" - @property def unique_id(self) -> str: """Return a unique identifier for this device.""" diff --git a/homeassistant/components/kef/services.yaml b/homeassistant/components/kef/services.yaml index cf364edcf21..9c5e5083794 100644 --- a/homeassistant/components/kef/services.yaml +++ b/homeassistant/components/kef/services.yaml @@ -1,14 +1,10 @@ update_dsp: - name: Update DSP - description: Update all DSP settings. target: entity: integration: kef domain: media_player set_mode: - name: Set mode - description: Set the mode of the speaker. target: entity: integration: kef @@ -16,36 +12,24 @@ set_mode: fields: desk_mode: - name: Desk mode - description: Desk mode. selector: boolean: wall_mode: - name: Wall mode - description: Wall mode. selector: boolean: phase_correction: - name: Phase correction - description: Phase correction. selector: boolean: high_pass: - name: High pass - description: High-pass mode". selector: boolean: sub_polarity: - name: Subwoofer polarity - description: Sub polarity. selector: select: options: - "-" - "+" bass_extension: - name: Base extension - description: Bass extension. selector: select: options: @@ -54,16 +38,12 @@ set_mode: - "Extra" set_desk_db: - name: Set desk dB - description: Set the "Desk mode" slider of the speaker in dB. target: entity: integration: kef domain: media_player fields: db_value: - name: dB value - description: Value of the slider example: 0.0 selector: number: @@ -73,16 +53,12 @@ set_desk_db: unit_of_measurement: dB set_wall_db: - name: Set wall dB - description: Set the "Wall mode" slider of the speaker in dB. target: entity: integration: kef domain: media_player fields: db_value: - name: dB value - description: Value of the slider. selector: number: min: -6 @@ -91,16 +67,12 @@ set_wall_db: unit_of_measurement: dB set_treble_db: - name: Set treble dB - description: Set desk the "Treble trim" slider of the speaker in dB. target: entity: integration: kef domain: media_player fields: db_value: - name: dB value - description: Value of the slider. selector: number: min: -2 @@ -109,16 +81,12 @@ set_treble_db: unit_of_measurement: dB set_high_hz: - name: Set high hertz - description: Set the "High-pass mode" slider of the speaker in Hz. target: entity: integration: kef domain: media_player fields: hz_value: - name: Hertz value - description: Value of the slider. selector: number: min: 50 @@ -127,16 +95,12 @@ set_high_hz: unit_of_measurement: Hz set_low_hz: - name: Set low Hertz - description: Set the "Sub out low-pass frequency" slider of the speaker in Hz. target: entity: integration: kef domain: media_player fields: hz_value: - name: Hertz value - description: Value of the slider. selector: number: min: 40 @@ -145,16 +109,12 @@ set_low_hz: unit_of_measurement: Hz set_sub_db: - name: Set subwoofer dB - description: Set the "Sub gain" slider of the speaker in dB. target: entity: integration: kef domain: media_player fields: db_value: - name: dB value - description: Value of the slider. selector: number: min: -10 diff --git a/homeassistant/components/kef/strings.json b/homeassistant/components/kef/strings.json new file mode 100644 index 00000000000..e5ffff68162 --- /dev/null +++ b/homeassistant/components/kef/strings.json @@ -0,0 +1,98 @@ +{ + "services": { + "update_dsp": { + "name": "Update DSP", + "description": "Updates all DSP settings." + }, + "set_mode": { + "name": "Set mode", + "description": "Sets the mode of the speaker.", + "fields": { + "desk_mode": { + "name": "Desk mode", + "description": "Desk mode." + }, + "wall_mode": { + "name": "Wall mode", + "description": "Wall mode." + }, + "phase_correction": { + "name": "Phase correction", + "description": "Phase correction." + }, + "high_pass": { + "name": "High pass", + "description": "High-pass mode\"." + }, + "sub_polarity": { + "name": "Subwoofer polarity", + "description": "Sub polarity." + }, + "bass_extension": { + "name": "Base extension", + "description": "Bass extension." + } + } + }, + "set_desk_db": { + "name": "Set desk dB", + "description": "Sets the \"Desk mode\" slider of the speaker in dB.", + "fields": { + "db_value": { + "name": "DB value", + "description": "Value of the slider." + } + } + }, + "set_wall_db": { + "name": "Set wall dB", + "description": "Sets the \"Wall mode\" slider of the speaker in dB.", + "fields": { + "db_value": { + "name": "[%key:component::kef::services::set_desk_db::fields::db_value::name%]", + "description": "[%key:component::kef::services::set_desk_db::fields::db_value::description%]" + } + } + }, + "set_treble_db": { + "name": "Set treble dB", + "description": "Sets desk the \"Treble trim\" slider of the speaker in dB.", + "fields": { + "db_value": { + "name": "[%key:component::kef::services::set_desk_db::fields::db_value::name%]", + "description": "[%key:component::kef::services::set_desk_db::fields::db_value::description%]" + } + } + }, + "set_high_hz": { + "name": "Set high hertz", + "description": "Sets the \"High-pass mode\" slider of the speaker in Hz.", + "fields": { + "hz_value": { + "name": "Hertz value", + "description": "[%key:component::kef::services::set_desk_db::fields::db_value::description%]" + } + } + }, + "set_low_hz": { + "name": "Sets low Hertz", + "description": "Set the \"Sub out low-pass frequency\" slider of the speaker in Hz.", + "fields": { + "hz_value": { + "name": "[%key:component::kef::services::set_high_hz::fields::hz_value::name%]", + "description": "[%key:component::kef::services::set_desk_db::fields::db_value::description%]" + } + } + }, + "set_sub_db": { + "name": "Sets subwoofer dB", + "description": "Set the \"Sub gain\" slider of the speaker in dB.", + "fields": { + "db_value": { + "name": "[%key:component::kef::services::set_desk_db::fields::db_value::name%]", + "description": "[%key:component::kef::services::set_desk_db::fields::db_value::description%]" + } + } + } + } +} diff --git a/homeassistant/components/keyboard/services.yaml b/homeassistant/components/keyboard/services.yaml index 07f02959c39..b236f8eb80e 100644 --- a/homeassistant/components/keyboard/services.yaml +++ b/homeassistant/components/keyboard/services.yaml @@ -1,35 +1,6 @@ volume_up: - name: Volume up - description: - Simulates a key press of the "Volume Up" button on Home Assistant's host - machine - volume_down: - name: Volume down - description: - Simulates a key press of the "Volume Down" button on Home Assistant's host - machine - volume_mute: - name: Volume mute - description: - Simulates a key press of the "Volume Mute" button on Home Assistant's host - machine - media_play_pause: - name: Media play/pause - description: - Simulates a key press of the "Media Play/Pause" button on Home Assistant's - host machine - media_next_track: - name: Media next track - description: - Simulates a key press of the "Media Next Track" button on Home Assistant's - host machine - media_prev_track: - name: Media previous track - description: - Simulates a key press of the "Media Previous Track" button on Home - Assistant's host machine diff --git a/homeassistant/components/keyboard/strings.json b/homeassistant/components/keyboard/strings.json new file mode 100644 index 00000000000..1b744cb7a71 --- /dev/null +++ b/homeassistant/components/keyboard/strings.json @@ -0,0 +1,28 @@ +{ + "services": { + "volume_up": { + "name": "Volume up", + "description": "Simulates a key press of the \"Volume Up\" button on Home Assistant's host machine." + }, + "volume_down": { + "name": "Volume down", + "description": "Simulates a key press of the \"Volume Down\" button on Home Assistant's host machine." + }, + "volume_mute": { + "name": "Volume mute", + "description": "Simulates a key press of the \"Volume Mute\" button on Home Assistant's host machine." + }, + "media_play_pause": { + "name": "Media play/pause", + "description": "Simulates a key press of the \"Media Play/Pause\" button on Home Assistant's host machine." + }, + "media_next_track": { + "name": "Media next track", + "description": "Simulates a key press of the \"Media Next Track\" button on Home Assistant's host machine." + }, + "media_prev_track": { + "name": "Media previous track", + "description": "Simulates a key press of the \"Media Previous Track\" button on Home Assistant's host machine." + } + } +} diff --git a/homeassistant/components/keymitt_ble/services.yaml b/homeassistant/components/keymitt_ble/services.yaml index c611577eb26..2be5c07c804 100644 --- a/homeassistant/components/keymitt_ble/services.yaml +++ b/homeassistant/components/keymitt_ble/services.yaml @@ -1,17 +1,11 @@ calibrate: - name: Calibrate - description: Calibration - Set depth, press & hold duration, and operation mode. Warning - this will send a push command to the device fields: entity_id: - name: Entity - description: Name of entity to calibrate selector: entity: integration: keymitt_ble domain: switch depth: - name: Depth - description: Depth in percent example: 50 required: true selector: @@ -22,8 +16,6 @@ calibrate: max: 100 unit_of_measurement: "%" duration: - name: Duration - description: Duration in seconds example: 1 required: true selector: @@ -34,8 +26,6 @@ calibrate: max: 60 unit_of_measurement: seconds mode: - name: Mode - description: normal | invert | toggle example: "normal" required: true selector: diff --git a/homeassistant/components/keymitt_ble/strings.json b/homeassistant/components/keymitt_ble/strings.json index fd8e1f4825d..ab2d4ad9440 100644 --- a/homeassistant/components/keymitt_ble/strings.json +++ b/homeassistant/components/keymitt_ble/strings.json @@ -6,7 +6,7 @@ "title": "Set up MicroBot device", "data": { "address": "Device address", - "name": "Name" + "name": "[%key:common::config_flow::data::name%]" } }, "link": { @@ -23,5 +23,29 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "services": { + "calibrate": { + "name": "Calibrate", + "description": "Calibration - Set depth, press & hold duration, and operation mode. Warning - this will send a push command to the device.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name of entity to calibrate." + }, + "depth": { + "name": "Depth", + "description": "Depth in percent." + }, + "duration": { + "name": "Duration", + "description": "Duration in seconds." + }, + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "Normal | invert | toggle." + } + } + } } } diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 7857e6b3149..a85221108f8 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -26,7 +26,12 @@ import homeassistant.util.dt as dt_util DOMAIN = "kitchen_sink" -COMPONENTS_WITH_DEMO_PLATFORM = [Platform.SENSOR, Platform.LOCK, Platform.IMAGE] +COMPONENTS_WITH_DEMO_PLATFORM = [ + Platform.SENSOR, + Platform.LOCK, + Platform.IMAGE, + Platform.WEATHER, +] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json new file mode 100644 index 00000000000..ce907a3368d --- /dev/null +++ b/homeassistant/components/kitchen_sink/strings.json @@ -0,0 +1,43 @@ +{ + "issues": { + "bad_psu": { + "title": "The power supply is not stable", + "fix_flow": { + "step": { + "confirm": { + "title": "The power supply needs to be replaced", + "description": "Press SUBMIT to confirm the power supply has been replaced" + } + } + } + }, + "out_of_blinker_fluid": { + "title": "The blinker fluid is empty and needs to be refilled", + "fix_flow": { + "step": { + "confirm": { + "title": "Blinker fluid needs to be refilled", + "description": "Press SUBMIT when blinker fluid has been refilled" + } + } + } + }, + "cold_tea": { + "title": "The tea is cold", + "fix_flow": { + "step": {}, + "abort": { + "not_tea_time": "Can not re-heat the tea at this time" + } + } + }, + "transmogrifier_deprecated": { + "title": "The transmogrifier component is deprecated", + "description": "The transmogrifier component is now deprecated due to the lack of local control available in the new API" + }, + "unfixable_problem": { + "title": "This is not a fixable problem", + "description": "This issue is never going to give up." + } + } +} diff --git a/homeassistant/components/kitchen_sink/weather.py b/homeassistant/components/kitchen_sink/weather.py new file mode 100644 index 00000000000..aba30013746 --- /dev/null +++ b/homeassistant/components/kitchen_sink/weather.py @@ -0,0 +1,446 @@ +"""Demo platform that offers fake meteorological data.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.components.weather import ( + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_EXCEPTIONAL, + ATTR_CONDITION_FOG, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING, + ATTR_CONDITION_LIGHTNING_RAINY, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, + ATTR_CONDITION_WINDY_VARIANT, + Forecast, + WeatherEntity, + WeatherEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval +import homeassistant.util.dt as dt_util + +CONDITION_CLASSES: dict[str, list[str]] = { + ATTR_CONDITION_CLOUDY: [], + ATTR_CONDITION_FOG: [], + ATTR_CONDITION_HAIL: [], + ATTR_CONDITION_LIGHTNING: [], + ATTR_CONDITION_LIGHTNING_RAINY: [], + ATTR_CONDITION_PARTLYCLOUDY: [], + ATTR_CONDITION_POURING: [], + ATTR_CONDITION_RAINY: ["shower rain"], + ATTR_CONDITION_SNOWY: [], + ATTR_CONDITION_SNOWY_RAINY: [], + ATTR_CONDITION_SUNNY: ["sunshine"], + ATTR_CONDITION_WINDY: [], + ATTR_CONDITION_WINDY_VARIANT: [], + ATTR_CONDITION_EXCEPTIONAL: [], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + async_add_entities( + [ + DemoWeather( + "Legacy weather", + "Sunshine", + 21.6414, + 92, + 1099, + 0.5, + UnitOfTemperature.CELSIUS, + UnitOfPressure.HPA, + UnitOfSpeed.METERS_PER_SECOND, + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10], + [ATTR_CONDITION_SUNNY, 0, 12, 6, 0], + [ATTR_CONDITION_PARTLYCLOUDY, 2, 14, 7, 20], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0], + [ATTR_CONDITION_FOG, 0.2, 21, 12, 100], + ], + None, + None, + None, + ), + DemoWeather( + "Legacy + daily weather", + "Sunshine", + 21.6414, + 92, + 1099, + 0.5, + UnitOfTemperature.CELSIUS, + UnitOfPressure.HPA, + UnitOfSpeed.METERS_PER_SECOND, + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10], + [ATTR_CONDITION_SUNNY, 0, 12, 6, 0], + [ATTR_CONDITION_PARTLYCLOUDY, 2, 14, 7, 20], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0], + [ATTR_CONDITION_FOG, 0.2, 21, 12, 100], + ], + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10], + [ATTR_CONDITION_SUNNY, 0, 12, 6, 0], + [ATTR_CONDITION_PARTLYCLOUDY, 2, 14, 7, 20], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0], + [ATTR_CONDITION_FOG, 0.2, 21, 12, 100], + ], + None, + None, + ), + DemoWeather( + "Daily + hourly weather", + "Shower rain", + -12, + 54, + 987, + 4.8, + UnitOfTemperature.FAHRENHEIT, + UnitOfPressure.INHG, + UnitOfSpeed.MILES_PER_HOUR, + None, + [ + [ATTR_CONDITION_SNOWY, 2, -10, -15, 60], + [ATTR_CONDITION_PARTLYCLOUDY, 1, -13, -14, 25], + [ATTR_CONDITION_SUNNY, 0, -18, -22, 70], + [ATTR_CONDITION_SUNNY, 0.1, -23, -23, 90], + [ATTR_CONDITION_SNOWY, 4, -19, -20, 40], + [ATTR_CONDITION_SUNNY, 0.3, -14, -19, 0], + [ATTR_CONDITION_SUNNY, 0, -9, -12, 0], + ], + [ + [ATTR_CONDITION_SUNNY, 2, -10, -15, 60], + [ATTR_CONDITION_SUNNY, 1, -13, -14, 25], + [ATTR_CONDITION_SUNNY, 0, -18, -22, 70], + [ATTR_CONDITION_SUNNY, 0.1, -23, -23, 90], + [ATTR_CONDITION_SUNNY, 4, -19, -20, 40], + [ATTR_CONDITION_SUNNY, 0.3, -14, -19, 0], + [ATTR_CONDITION_SUNNY, 0, -9, -12, 0], + ], + None, + ), + DemoWeather( + "Daily + bi-daily + hourly weather", + "Sunshine", + 21.6414, + 92, + 1099, + 0.5, + UnitOfTemperature.CELSIUS, + UnitOfPressure.HPA, + UnitOfSpeed.METERS_PER_SECOND, + None, + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30], + [ATTR_CONDITION_RAINY, 0, 15, 9, 10], + [ATTR_CONDITION_RAINY, 0, 12, 6, 0], + [ATTR_CONDITION_RAINY, 2, 14, 7, 20], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0], + [ATTR_CONDITION_RAINY, 0.2, 21, 12, 100], + ], + [ + [ATTR_CONDITION_CLOUDY, 1, 22, 15, 60], + [ATTR_CONDITION_CLOUDY, 5, 19, 8, 30], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10], + [ATTR_CONDITION_CLOUDY, 0, 12, 6, 0], + [ATTR_CONDITION_CLOUDY, 2, 14, 7, 20], + [ATTR_CONDITION_CLOUDY, 15, 18, 7, 0], + [ATTR_CONDITION_CLOUDY, 0.2, 21, 12, 100], + ], + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60, True], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30, False], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10, True], + [ATTR_CONDITION_SUNNY, 0, 12, 6, 0, False], + [ATTR_CONDITION_PARTLYCLOUDY, 2, 14, 7, 20, True], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0, False], + [ATTR_CONDITION_FOG, 0.2, 21, 12, 100, True], + ], + ), + DemoWeather( + "Hourly + bi-daily weather", + "Sunshine", + 21.6414, + 92, + 1099, + 0.5, + UnitOfTemperature.CELSIUS, + UnitOfPressure.HPA, + UnitOfSpeed.METERS_PER_SECOND, + None, + None, + [ + [ATTR_CONDITION_CLOUDY, 1, 22, 15, 60], + [ATTR_CONDITION_CLOUDY, 5, 19, 8, 30], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10], + [ATTR_CONDITION_CLOUDY, 0, 12, 6, 0], + [ATTR_CONDITION_CLOUDY, 2, 14, 7, 20], + [ATTR_CONDITION_CLOUDY, 15, 18, 7, 0], + [ATTR_CONDITION_CLOUDY, 0.2, 21, 12, 100], + ], + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60, True], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30, False], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10, True], + [ATTR_CONDITION_SUNNY, 0, 12, 6, 0, False], + [ATTR_CONDITION_PARTLYCLOUDY, 2, 14, 7, 20, True], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0, False], + [ATTR_CONDITION_FOG, 0.2, 21, 12, 100, True], + ], + ), + DemoWeather( + "Daily + broken bi-daily weather", + "Sunshine", + 21.6414, + 92, + 1099, + 0.5, + UnitOfTemperature.CELSIUS, + UnitOfPressure.HPA, + UnitOfSpeed.METERS_PER_SECOND, + None, + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30], + [ATTR_CONDITION_RAINY, 0, 15, 9, 10], + [ATTR_CONDITION_RAINY, 0, 12, 6, 0], + [ATTR_CONDITION_RAINY, 2, 14, 7, 20], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0], + [ATTR_CONDITION_RAINY, 0.2, 21, 12, 100], + ], + None, + [ + [ATTR_CONDITION_RAINY, 1, 22, 15, 60], + [ATTR_CONDITION_RAINY, 5, 19, 8, 30], + [ATTR_CONDITION_CLOUDY, 0, 15, 9, 10], + [ATTR_CONDITION_SUNNY, 0, 12, 6, 0], + [ATTR_CONDITION_PARTLYCLOUDY, 2, 14, 7, 20], + [ATTR_CONDITION_RAINY, 15, 18, 7, 0], + [ATTR_CONDITION_FOG, 0.2, 21, 12, 100], + ], + ), + ] + ) + + +class DemoWeather(WeatherEntity): + """Representation of a weather condition.""" + + _attr_attribution = "Powered by Home Assistant" + _attr_should_poll = False + + def __init__( + self, + name: str, + condition: str, + temperature: float, + humidity: float, + pressure: float, + wind_speed: float, + temperature_unit: str, + pressure_unit: str, + wind_speed_unit: str, + forecast: list[list] | None, + forecast_daily: list[list] | None, + forecast_hourly: list[list] | None, + forecast_twice_daily: list[list] | None, + ) -> None: + """Initialize the Demo weather.""" + self._attr_name = f"Test Weather {name}" + self._attr_unique_id = f"test-weather-{name.lower()}" + self._condition = condition + self._native_temperature = temperature + self._native_temperature_unit = temperature_unit + self._humidity = humidity + self._native_pressure = pressure + self._native_pressure_unit = pressure_unit + self._native_wind_speed = wind_speed + self._native_wind_speed_unit = wind_speed_unit + self._forecast = forecast + self._forecast_daily = forecast_daily + self._forecast_hourly = forecast_hourly + self._forecast_twice_daily = forecast_twice_daily + self._attr_supported_features = 0 + if self._forecast_daily: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_DAILY + if self._forecast_hourly: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_HOURLY + if self._forecast_twice_daily: + self._attr_supported_features |= WeatherEntityFeature.FORECAST_TWICE_DAILY + + async def async_added_to_hass(self) -> None: + """Set up a timer updating the forecasts.""" + + async def update_forecasts(_) -> None: + if self._forecast_daily: + self._forecast_daily = ( + self._forecast_daily[1:] + self._forecast_daily[:1] + ) + if self._forecast_hourly: + self._forecast_hourly = ( + self._forecast_hourly[1:] + self._forecast_hourly[:1] + ) + if self._forecast_twice_daily: + self._forecast_twice_daily = ( + self._forecast_twice_daily[1:] + self._forecast_twice_daily[:1] + ) + await self.async_update_listeners(None) + + self.async_on_remove( + async_track_time_interval( + self.hass, update_forecasts, timedelta(seconds=30) + ) + ) + + @property + def native_temperature(self) -> float: + """Return the temperature.""" + return self._native_temperature + + @property + def native_temperature_unit(self) -> str: + """Return the unit of measurement.""" + return self._native_temperature_unit + + @property + def humidity(self) -> float: + """Return the humidity.""" + return self._humidity + + @property + def native_wind_speed(self) -> float: + """Return the wind speed.""" + return self._native_wind_speed + + @property + def native_wind_speed_unit(self) -> str: + """Return the wind speed.""" + return self._native_wind_speed_unit + + @property + def native_pressure(self) -> float: + """Return the pressure.""" + return self._native_pressure + + @property + def native_pressure_unit(self) -> str: + """Return the pressure.""" + return self._native_pressure_unit + + @property + def condition(self) -> str: + """Return the weather condition.""" + return [ + k for k, v in CONDITION_CLASSES.items() if self._condition.lower() in v + ][0] + + @property + def forecast(self) -> list[Forecast]: + """Return legacy forecast.""" + if self._forecast is None: + return [] + reftime = dt_util.now().replace(hour=16, minute=00) + + forecast_data = [] + for entry in self._forecast: + data_dict = Forecast( + datetime=reftime.isoformat(), + condition=entry[0], + precipitation=entry[1], + temperature=entry[2], + templow=entry[3], + precipitation_probability=entry[4], + ) + reftime = reftime + timedelta(hours=24) + forecast_data.append(data_dict) + + return forecast_data + + async def async_forecast_daily(self) -> list[Forecast]: + """Return the daily forecast.""" + if self._forecast_daily is None: + return [] + reftime = dt_util.now().replace(hour=16, minute=00) + + forecast_data = [] + for entry in self._forecast_daily: + data_dict = Forecast( + datetime=reftime.isoformat(), + condition=entry[0], + precipitation=entry[1], + temperature=entry[2], + templow=entry[3], + precipitation_probability=entry[4], + ) + reftime = reftime + timedelta(hours=24) + forecast_data.append(data_dict) + + return forecast_data + + async def async_forecast_hourly(self) -> list[Forecast]: + """Return the hourly forecast.""" + if self._forecast_hourly is None: + return [] + reftime = dt_util.now().replace(hour=16, minute=00) + + forecast_data = [] + for entry in self._forecast_hourly: + data_dict = Forecast( + datetime=reftime.isoformat(), + condition=entry[0], + precipitation=entry[1], + temperature=entry[2], + templow=entry[3], + precipitation_probability=entry[4], + ) + reftime = reftime + timedelta(hours=1) + forecast_data.append(data_dict) + + return forecast_data + + async def async_forecast_twice_daily(self) -> list[Forecast]: + """Return the twice daily forecast.""" + if self._forecast_twice_daily is None: + return [] + reftime = dt_util.now().replace(hour=11, minute=00) + + forecast_data = [] + for entry in self._forecast_twice_daily: + try: + data_dict = Forecast( + datetime=reftime.isoformat(), + condition=entry[0], + precipitation=entry[1], + temperature=entry[2], + templow=entry[3], + precipitation_probability=entry[4], + is_daytime=entry[5], + ) + reftime = reftime + timedelta(hours=12) + forecast_data.append(data_dict) + except IndexError: + continue + + return forecast_data diff --git a/homeassistant/components/kmtronic/switch.py b/homeassistant/components/kmtronic/switch.py index 860e5bf832e..ed54315de90 100644 --- a/homeassistant/components/kmtronic/switch.py +++ b/homeassistant/components/kmtronic/switch.py @@ -5,6 +5,7 @@ import urllib.parse from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -38,12 +39,12 @@ class KMtronicSwitch(CoordinatorEntity, SwitchEntity): self._reverse = reverse hostname = urllib.parse.urlsplit(hub.host).hostname - self._attr_device_info = { - "identifiers": {(DOMAIN, config_entry_id)}, - "name": f"Controller {hostname}", - "manufacturer": MANUFACTURER, - "configuration_url": hub.host, - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry_id)}, + name=f"Controller {hostname}", + manufacturer=MANUFACTURER, + configuration_url=hub.host, + ) self._attr_name = f"Relay{relay.id}" self._attr_unique_id = f"{config_entry_id}_relay{relay.id}" diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index e8c237114b5..1bb6d9bbdd2 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -74,12 +74,14 @@ from .const import ( ) from .device import KNXInterfaceDevice from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure -from .project import KNXProject +from .project import STORAGE_KEY as PROJECT_STORAGE_KEY, KNXProject from .schema import ( BinarySensorSchema, ButtonSchema, ClimateSchema, CoverSchema, + DateSchema, + DateTimeSchema, EventSchema, ExposeSchema, FanSchema, @@ -96,7 +98,7 @@ from .schema import ( ga_validator, sensor_type_validator, ) -from .telegrams import Telegrams +from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams from .websocket import register_panel _LOGGER = logging.getLogger(__name__) @@ -136,6 +138,8 @@ CONFIG_SCHEMA = vol.Schema( **ButtonSchema.platform_node(), **ClimateSchema.platform_node(), **CoverSchema.platform_node(), + **DateSchema.platform_node(), + **DateTimeSchema.platform_node(), **FanSchema.platform_node(), **LightSchema.platform_node(), **NotifySchema.platform_node(), @@ -360,16 +364,21 @@ async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Remove a config entry.""" - def remove_keyring_files(file_path: Path) -> None: - """Remove keyring files.""" + def remove_files(storage_dir: Path, knxkeys_filename: str | None) -> None: + """Remove KNX files.""" + if knxkeys_filename is not None: + with contextlib.suppress(FileNotFoundError): + (storage_dir / knxkeys_filename).unlink() with contextlib.suppress(FileNotFoundError): - file_path.unlink() + (storage_dir / PROJECT_STORAGE_KEY).unlink() + with contextlib.suppress(FileNotFoundError): + (storage_dir / TELEGRAMS_STORAGE_KEY).unlink() with contextlib.suppress(FileNotFoundError, OSError): - file_path.parent.rmdir() + (storage_dir / DOMAIN).rmdir() - if (_knxkeys_file := entry.data.get(CONF_KNX_KNXKEY_FILENAME)) is not None: - file_path = Path(hass.config.path(STORAGE_DIR)) / _knxkeys_file - await hass.async_add_executor_job(remove_keyring_files, file_path) + storage_dir = Path(hass.config.path(STORAGE_DIR)) + knxkeys_filename = entry.data.get(CONF_KNX_KNXKEY_FILENAME) + await hass.async_add_executor_job(remove_files, storage_dir, knxkeys_filename) class KNXModule: @@ -420,11 +429,13 @@ class KNXModule: async def start(self) -> None: """Start XKNX object. Connect to tunneling or Routing device.""" await self.project.load_project() + await self.telegrams.load_history() await self.xknx.start() async def stop(self, event: Event | None = None) -> None: """Stop XKNX object. Disconnect from tunneling or Routing device.""" await self.xknx.stop() + await self.telegrams.save_history() def connection_config(self) -> ConnectionConfig: """Return the connection_config.""" diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index a9f5341fbfd..519d5d0742d 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -53,7 +53,7 @@ CONF_KNX_DEFAULT_RATE_LIMIT: Final = 0 DEFAULT_ROUTING_IA: Final = "0.0.240" CONF_KNX_TELEGRAM_LOG_SIZE: Final = "telegram_log_size" -TELEGRAM_LOG_DEFAULT: Final = 50 +TELEGRAM_LOG_DEFAULT: Final = 200 TELEGRAM_LOG_MAX: Final = 5000 # ~2 MB or ~5 hours of reasonable bus load ## @@ -127,6 +127,8 @@ SUPPORTED_PLATFORMS: Final = [ Platform.BUTTON, Platform.CLIMATE, Platform.COVER, + Platform.DATE, + Platform.DATETIME, Platform.FAN, Platform.LIGHT, Platform.NOTIFY, diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 29bd9b4f6a9..9e86fc8b36e 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -163,11 +163,17 @@ class KNXCover(KnxEntity, CoverEntity): async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" - await self._device.set_short_up() + if self._device.angle.writable: + await self._device.set_angle(0) + else: + await self._device.set_short_up() async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - await self._device.set_short_down() + if self._device.angle.writable: + await self._device.set_angle(100) + else: + await self._device.set_short_down() async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py new file mode 100644 index 00000000000..1f286d59ecb --- /dev/null +++ b/homeassistant/components/knx/date.py @@ -0,0 +1,100 @@ +"""Support for KNX/IP date.""" +from __future__ import annotations + +from datetime import date as dt_date +import time +from typing import Final + +from xknx import XKNX +from xknx.devices import DateTime as XknxDateTime + +from homeassistant import config_entries +from homeassistant.components.date import DateEntity +from homeassistant.const import ( + CONF_ENTITY_CATEGORY, + CONF_NAME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType + +from .const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, + DATA_KNX_CONFIG, + DOMAIN, + KNX_ADDRESS, +) +from .knx_entity import KnxEntity + +_DATE_TRANSLATION_FORMAT: Final = "%Y-%m-%d" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for KNX platform.""" + xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.DATE] + + async_add_entities(KNXDate(xknx, entity_config) for entity_config in config) + + +def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime: + """Return a XKNX DateTime object to be used within XKNX.""" + return XknxDateTime( + xknx, + name=config[CONF_NAME], + broadcast_type="DATE", + localtime=False, + group_address=config[KNX_ADDRESS], + group_address_state=config.get(CONF_STATE_ADDRESS), + respond_to_read=config[CONF_RESPOND_TO_READ], + sync_state=config[CONF_SYNC_STATE], + ) + + +class KNXDate(KnxEntity, DateEntity, RestoreEntity): + """Representation of a KNX date.""" + + _device: XknxDateTime + + def __init__(self, xknx: XKNX, config: ConfigType) -> None: + """Initialize a KNX time.""" + super().__init__(_create_xknx_device(xknx, config)) + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_unique_id = str(self._device.remote_value.group_address) + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + if ( + not self._device.remote_value.readable + and (last_state := await self.async_get_last_state()) is not None + and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + ): + self._device.remote_value.value = time.strptime( + last_state.state, _DATE_TRANSLATION_FORMAT + ) + + @property + def native_value(self) -> dt_date | None: + """Return the latest value.""" + if (time_struct := self._device.remote_value.value) is None: + return None + return dt_date( + year=time_struct.tm_year, + month=time_struct.tm_mon, + day=time_struct.tm_mday, + ) + + async def async_set_value(self, value: dt_date) -> None: + """Change the value.""" + await self._device.set(value.timetuple()) diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py new file mode 100644 index 00000000000..fc63df04233 --- /dev/null +++ b/homeassistant/components/knx/datetime.py @@ -0,0 +1,103 @@ +"""Support for KNX/IP datetime.""" +from __future__ import annotations + +from datetime import datetime + +from xknx import XKNX +from xknx.devices import DateTime as XknxDateTime + +from homeassistant import config_entries +from homeassistant.components.datetime import DateTimeEntity +from homeassistant.const import ( + CONF_ENTITY_CATEGORY, + CONF_NAME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType +import homeassistant.util.dt as dt_util + +from .const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, + DATA_KNX_CONFIG, + DOMAIN, + KNX_ADDRESS, +) +from .knx_entity import KnxEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for KNX platform.""" + xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.DATETIME] + + async_add_entities(KNXDateTime(xknx, entity_config) for entity_config in config) + + +def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime: + """Return a XKNX DateTime object to be used within XKNX.""" + return XknxDateTime( + xknx, + name=config[CONF_NAME], + broadcast_type="DATETIME", + localtime=False, + group_address=config[KNX_ADDRESS], + group_address_state=config.get(CONF_STATE_ADDRESS), + respond_to_read=config[CONF_RESPOND_TO_READ], + sync_state=config[CONF_SYNC_STATE], + ) + + +class KNXDateTime(KnxEntity, DateTimeEntity, RestoreEntity): + """Representation of a KNX datetime.""" + + _device: XknxDateTime + + def __init__(self, xknx: XKNX, config: ConfigType) -> None: + """Initialize a KNX time.""" + super().__init__(_create_xknx_device(xknx, config)) + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_unique_id = str(self._device.remote_value.group_address) + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + if ( + not self._device.remote_value.readable + and (last_state := await self.async_get_last_state()) is not None + and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + ): + self._device.remote_value.value = ( + datetime.fromisoformat(last_state.state) + .astimezone(dt_util.DEFAULT_TIME_ZONE) + .timetuple() + ) + + @property + def native_value(self) -> datetime | None: + """Return the latest value.""" + if (time_struct := self._device.remote_value.value) is None: + return None + return datetime( + year=time_struct.tm_year, + month=time_struct.tm_mon, + day=time_struct.tm_mday, + hour=time_struct.tm_hour, + minute=time_struct.tm_min, + second=min(time_struct.tm_sec, 59), # account for leap seconds + tzinfo=dt_util.DEFAULT_TIME_ZONE, + ) + + async def async_set_value(self, value: datetime) -> None: + """Change the value.""" + await self._device.set(value.astimezone(dt_util.DEFAULT_TIME_ZONE).timetuple()) diff --git a/homeassistant/components/knx/device.py b/homeassistant/components/knx/device.py index 452de577ce0..18e6197360a 100644 --- a/homeassistant/components/knx/device.py +++ b/homeassistant/components/knx/device.py @@ -25,7 +25,7 @@ class KNXInterfaceDevice: _device_id = (DOMAIN, f"_{entry.entry_id}_interface") self.device = self.device_registry.async_get_or_create( config_entry_id=entry.entry_id, - default_name="KNX Interface", + name="KNX Interface", identifiers={_device_id}, ) self.device_info = DeviceInfo(identifiers={_device_id}) diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 308fc4eacd1..e14ee501d7b 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -17,9 +17,12 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, State, callback -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, StateType +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, EventType, StateType from .const import CONF_RESPOND_TO_READ, KNX_ADDRESS from .schema import ExposeSchema @@ -145,12 +148,14 @@ class KNXExposeSensor: return str(value)[:14] return value - async def _async_entity_changed(self, event: Event) -> None: + async def _async_entity_changed( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle entity change.""" - new_state = event.data.get("new_state") + new_state = event.data["new_state"] if (new_value := self._get_expose_value(new_state)) is None: return - old_state = event.data.get("old_state") + old_state = event.data["old_state"] # don't use default value for comparison on first state change (old_state is None) old_value = self._get_expose_value(old_state) if old_state is not None else None # don't send same value sequentially diff --git a/homeassistant/components/knx/knx_entity.py b/homeassistant/components/knx/knx_entity.py index fff7f9b9f4f..9545510e635 100644 --- a/homeassistant/components/knx/knx_entity.py +++ b/homeassistant/components/knx/knx_entity.py @@ -42,8 +42,5 @@ class KnxEntity(Entity): async def async_added_to_hass(self) -> None: """Store register state change callback.""" self._device.register_device_updated_cb(self.after_update_callback) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect device object when removed.""" - # will also remove all callbacks - self._device.shutdown() + # will remove all callbacks and xknx tasks + self.async_on_remove(self._device.shutdown) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 30e239a65a9..a915d886138 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -11,7 +11,7 @@ "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", "requirements": [ - "xknx==2.11.1", + "xknx==2.11.2", "xknxproject==3.2.0", "knx-frontend==2023.6.23.191712" ] diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 86bf790a077..8240fbaf3c1 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -556,6 +556,44 @@ class CoverSchema(KNXPlatformSchema): ) +class DateSchema(KNXPlatformSchema): + """Voluptuous schema for KNX date.""" + + PLATFORM = Platform.DATE + + DEFAULT_NAME = "KNX Date" + + ENTITY_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean, + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Required(KNX_ADDRESS): ga_list_validator, + vol.Optional(CONF_STATE_ADDRESS): ga_list_validator, + vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, + } + ) + + +class DateTimeSchema(KNXPlatformSchema): + """Voluptuous schema for KNX date.""" + + PLATFORM = Platform.DATETIME + + DEFAULT_NAME = "KNX DateTime" + + ENTITY_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean, + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Required(KNX_ADDRESS): ga_list_validator, + vol.Optional(CONF_STATE_ADDRESS): ga_list_validator, + vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, + } + ) + + class ExposeSchema(KNXPlatformSchema): """Voluptuous schema for KNX exposures.""" diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 4400c304193..dbfe8e9bd5e 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta +from functools import partial from typing import Any from xknx import XKNX @@ -221,9 +222,9 @@ class KNXSystemSensor(SensorEntity): self.knx.xknx.connection_manager.register_connection_state_changed_cb( self.after_update_callback ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect device object when removed.""" - self.knx.xknx.connection_manager.unregister_connection_state_changed_cb( - self.after_update_callback + self.async_on_remove( + partial( + self.knx.xknx.connection_manager.unregister_connection_state_changed_cb, + self.after_update_callback, + ) ) diff --git a/homeassistant/components/knx/services.yaml b/homeassistant/components/knx/services.yaml index 0ad497a30a2..813bf758eb0 100644 --- a/homeassistant/components/knx/services.yaml +++ b/homeassistant/components/knx/services.yaml @@ -1,111 +1,73 @@ send: - name: "Send to KNX bus" - description: "Send arbitrary data directly to the KNX bus." fields: address: - name: "Group address" - description: "Group address(es) to write to. Lists will send to multiple group addresses successively." required: true example: "1/1/0" selector: object: payload: - name: "Payload" - description: "Payload to send to the bus. Integers are treated as DPT 1/2/3 payloads. For DPTs > 6 bits send a list. Each value represents 1 octet (0-255). Pad with 0 to DPT byte length." required: true example: "[0, 4]" selector: object: type: - name: "Value type" - description: "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)." required: false example: "temperature" selector: text: response: - name: "Send as Response" - description: "If set to `True`, the telegram will be sent as a `GroupValueResponse` instead of a `GroupValueWrite`." default: false selector: boolean: read: - name: "Read from KNX bus" - description: "Send GroupValueRead requests to the KNX bus. Response can be used from `knx_event` and will be processed in KNX entities." fields: address: - name: "Group address" - description: "Group address(es) to send read request to. Lists will read multiple group addresses." required: true example: "1/1/0" selector: object: event_register: - name: "Register knx_event" - description: "Add or remove group addresses to knx_event filter for triggering `knx_event`s. Only addresses added with this service can be removed." fields: address: - name: "Group address" - description: "Group address(es) that shall be added or removed. Lists are allowed." required: true example: "1/1/0" selector: object: type: - name: "Value type" - description: "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)." required: false example: "2byte_float" selector: text: remove: - name: "Remove event registration" - description: "If `True` the group address(es) will be removed." default: false selector: boolean: exposure_register: - name: "Expose to KNX bus" - description: "Add or remove exposures to KNX bus. Only exposures added with this service can be removed." fields: address: - name: "Group address" - description: "Group address state or attribute updates will be sent to. GroupValueRead requests will be answered. Per address only one exposure can be registered." required: true example: "1/1/0" selector: text: type: - name: "Value type" - description: "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)" required: true example: "percentU8" selector: text: entity_id: - name: "Entity" - description: "Entity id whose state or attribute shall be exposed." required: true selector: entity: attribute: - name: "Entity attribute" - description: "Attribute of the entity that shall be sent to the KNX bus. If not set the state will be sent. Eg. for a light the state is eigther “on” or “off” - with attribute you can expose its “brightness”." example: "brightness" selector: text: default: - name: "Default value" - description: "Default value to send to the bus if the state or attribute value is None. Eg. a light with state “off” has no brightness attribute so a default value of 0 could be used. If not set (or None) no value would be sent to the bus and a GroupReadRequest to the address would return the last known value." example: "0" selector: object: remove: - name: "Remove exposure" - description: "If `True` the exposure will be removed. Only `address` is required for removal." default: false selector: boolean: reload: - name: Reload - description: Reload the KNX integration. diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index cdd61379567..1ff008653d4 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -75,7 +75,7 @@ }, "secure_routing_manual": { "title": "Secure routing", - "description": "Please enter your IP secure information.", + "description": "[%key:component::knx::config::step::secure_tunnel_manual::description%]", "data": { "backbone_key": "Backbone key", "sync_latency_tolerance": "Network latency tolerance" @@ -130,7 +130,7 @@ } }, "communication_settings": { - "title": "Communication settings", + "title": "[%key:component::knx::options::step::options_init::menu_options::communication_settings%]", "data": { "state_updater": "State updater", "rate_limit": "Rate limit", @@ -144,9 +144,9 @@ }, "connection_type": { "title": "[%key:component::knx::config::step::connection_type::title%]", - "description": "Please enter the connection type we should use for your KNX connection. \n AUTOMATIC - The integration takes care of the connectivity to your KNX Bus by performing a gateway scan. \n TUNNELING - The integration will connect to your KNX bus via tunneling. \n ROUTING - The integration will connect to your KNX bus via routing.", + "description": "[%key:component::knx::config::step::connection_type::description%]", "data": { - "connection_type": "KNX Connection Type" + "connection_type": "[%key:component::knx::config::step::connection_type::data::connection_type%]" } }, "tunnel": { @@ -259,7 +259,7 @@ "entity": { "sensor": { "individual_address": { - "name": "Individual address" + "name": "[%key:component::knx::config::step::routing::data::individual_address%]" }, "connected_since": { "name": "Connection established" @@ -288,5 +288,91 @@ "trigger_type": { "telegram": "Telegram sent or received" } + }, + "services": { + "send": { + "name": "Send to KNX bus", + "description": "Sends arbitrary data directly to the KNX bus.", + "fields": { + "address": { + "name": "Group address", + "description": "Group address(es) to write to. Lists will send to multiple group addresses successively." + }, + "payload": { + "name": "Payload", + "description": "Payload to send to the bus. Integers are treated as DPT 1/2/3 payloads. For DPTs > 6 bits send a list. Each value represents 1 octet (0-255). Pad with 0 to DPT byte length." + }, + "type": { + "name": "Value type", + "description": "If set, the payload will not be sent as raw bytes, but encoded as given DPT. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)." + }, + "response": { + "name": "Send as Response", + "description": "If set to `True`, the telegram will be sent as a `GroupValueResponse` instead of a `GroupValueWrite`." + } + } + }, + "read": { + "name": "Reads from KNX bus", + "description": "Send GroupValueRead requests to the KNX bus. Response can be used from `knx_event` and will be processed in KNX entities.", + "fields": { + "address": { + "name": "[%key:component::knx::services::send::fields::address::name%]", + "description": "Group address(es) to send read request to. Lists will read multiple group addresses." + } + } + }, + "event_register": { + "name": "Registers knx_event", + "description": "Add or remove group addresses to knx_event filter for triggering `knx_event`s. Only addresses added with this service can be removed.", + "fields": { + "address": { + "name": "[%key:component::knx::services::send::fields::address::name%]", + "description": "Group address(es) that shall be added or removed. Lists are allowed." + }, + "type": { + "name": "Value type", + "description": "If set, the payload will be decoded as given DPT in the event data `value` key. KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)." + }, + "remove": { + "name": "Remove event registration", + "description": "If `True` the group address(es) will be removed." + } + } + }, + "exposure_register": { + "name": "Expose to KNX bus", + "description": "Adds or remove exposures to KNX bus. Only exposures added with this service can be removed.", + "fields": { + "address": { + "name": "[%key:component::knx::services::send::fields::address::name%]", + "description": "Group address state or attribute updates will be sent to. GroupValueRead requests will be answered. Per address only one exposure can be registered." + }, + "type": { + "name": "Value type", + "description": "Telegrams will be encoded as given DPT. 'binary' and all KNX sensor types are valid values (see https://www.home-assistant.io/integrations/knx/#value-types)." + }, + "entity_id": { + "name": "Entity", + "description": "Entity id whose state or attribute shall be exposed." + }, + "attribute": { + "name": "Entity attribute", + "description": "Attribute of the entity that shall be sent to the KNX bus. If not set the state will be sent. Eg. for a light the state is eigther “on” or “off” - with attribute you can expose its “brightness”." + }, + "default": { + "name": "Default value", + "description": "Default value to send to the bus if the state or attribute value is None. Eg. a light with state “off” has no brightness attribute so a default value of 0 could be used. If not set (or None) no value would be sent to the bus and a GroupReadRequest to the address would return the last known value." + }, + "remove": { + "name": "Remove exposure", + "description": "If `True` the exposure will be removed. Only `address` is required for removal." + } + } + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads the KNX integration." + } } } diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py index 09307794066..87c1a8b6052 100644 --- a/homeassistant/components/knx/telegrams.py +++ b/homeassistant/components/knx/telegrams.py @@ -3,8 +3,7 @@ from __future__ import annotations from collections import deque from collections.abc import Callable -import datetime as dt -from typing import TypedDict +from typing import Final, TypedDict from xknx import XKNX from xknx.exceptions import XKNXException @@ -12,10 +11,15 @@ from xknx.telegram import Telegram from xknx.telegram.apci import GroupValueResponse, GroupValueWrite from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers.storage import Store import homeassistant.util.dt as dt_util +from .const import DOMAIN from .project import KNXProject +STORAGE_VERSION: Final = 1 +STORAGE_KEY: Final = f"{DOMAIN}/telegrams_history.json" + class TelegramDict(TypedDict): """Represent a Telegram as a dict.""" @@ -31,7 +35,7 @@ class TelegramDict(TypedDict): source: str source_name: str telegramtype: str - timestamp: dt.datetime + timestamp: str # ISO format unit: str | None value: str | int | float | bool | None @@ -49,6 +53,9 @@ class Telegrams: """Initialize Telegrams class.""" self.hass = hass self.project = project + self._history_store = Store[list[TelegramDict]]( + hass, STORAGE_VERSION, STORAGE_KEY + ) self._jobs: list[HassJob[[TelegramDict], None]] = [] self._xknx_telegram_cb_handle = ( xknx.telegram_queue.register_telegram_received_cb( @@ -58,6 +65,24 @@ class Telegrams: ) self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size) + async def load_history(self) -> None: + """Load history from store.""" + if (telegrams := await self._history_store.async_load()) is None: + return + if self.recent_telegrams.maxlen == 0: + await self._history_store.async_remove() + return + for telegram in telegrams: + # tuples are stored as lists in JSON + if isinstance(telegram["payload"], list): + telegram["payload"] = tuple(telegram["payload"]) # type: ignore[unreachable] + self.recent_telegrams.extend(telegrams) + + async def save_history(self) -> None: + """Save history to store.""" + if self.recent_telegrams: + await self._history_store.async_save(list(self.recent_telegrams)) + async def _xknx_telegram_cb(self, telegram: Telegram) -> None: """Handle incoming and outgoing telegrams from xknx.""" telegram_dict = self.telegram_to_dict(telegram) @@ -129,7 +154,7 @@ class Telegrams: source=f"{telegram.source_address}", source_name=src_name, telegramtype=telegram.payload.__class__.__name__, - timestamp=dt_util.as_local(dt_util.utcnow()), + timestamp=dt_util.now().isoformat(), unit=unit, value=value, ) diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index af4e5700805..4a7f30506b2 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -422,7 +422,7 @@ class KodiEntity(MediaPlayerEntity): version = (await self._kodi.get_application_properties(["version"]))["version"] sw_version = f"{version['major']}.{version['minor']}" dev_reg = dr.async_get(self.hass) - device = dev_reg.async_get_device({(DOMAIN, self.unique_id)}) + device = dev_reg.async_get_device(identifiers={(DOMAIN, self.unique_id)}) dev_reg.async_update_device(device.id, sw_version=sw_version) self._device_id = device.id diff --git a/homeassistant/components/kodi/services.yaml b/homeassistant/components/kodi/services.yaml index cf6cdfc240d..76ed0aca22d 100644 --- a/homeassistant/components/kodi/services.yaml +++ b/homeassistant/components/kodi/services.yaml @@ -1,50 +1,36 @@ # Describes the format for available Kodi services add_to_playlist: - name: Add to playlist - description: Add music to the default playlist (i.e. playlistid=0). target: entity: integration: kodi domain: media_player fields: media_type: - name: Media type - description: Media type identifier. It must be one of SONG or ALBUM. required: true example: ALBUM selector: text: media_id: - name: Media ID - description: Unique Id of the media entry to add (`songid` or albumid`). If not defined, `media_name` and `artist_name` are needed to search the Kodi music library. example: 123456 selector: text: media_name: - name: Media Name - description: Optional media name for filtering media. Can be 'ALL' when `media_type` is 'ALBUM' and `artist_name` is specified, to add all songs from one artist. example: "Highway to Hell" selector: text: artist_name: - name: Artist name - description: Optional artist name for filtering media. example: "AC/DC" selector: text: call_method: - name: Call method - description: "Call a Kodi JSONRPC API method with optional parameters. Results of the Kodi API call will be redirected in a Home Assistant event: `kodi_call_method_result`." target: entity: integration: kodi domain: media_player fields: method: - name: Method - description: Name of the Kodi JSONRPC API method to be called. required: true example: "VideoLibrary.GetRecentlyAddedEpisodes" selector: diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index 6315fffb193..f7ee375f990 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -43,8 +43,42 @@ }, "device_automation": { "trigger_type": { - "turn_on": "{entity_name} was requested to turn on", - "turn_off": "{entity_name} was requested to turn off" + "turn_on": "[%key:common::device_automation::action_type::turn_on%]", + "turn_off": "[%key:common::device_automation::action_type::turn_off%]" + } + }, + "services": { + "add_to_playlist": { + "name": "Add to playlist", + "description": "Adds music to the default playlist (i.e. playlistid=0).", + "fields": { + "media_type": { + "name": "Media type", + "description": "Media type identifier. It must be one of SONG or ALBUM." + }, + "media_id": { + "name": "Media ID", + "description": "Unique Id of the media entry to add (`songid` or albumid`). If not defined, `media_name` and `artist_name` are needed to search the Kodi music library." + }, + "media_name": { + "name": "Media name", + "description": "Optional media name for filtering media. Can be 'ALL' when `media_type` is 'ALBUM' and `artist_name` is specified, to add all songs from one artist." + }, + "artist_name": { + "name": "Artist name", + "description": "Optional artist name for filtering media." + } + } + }, + "call_method": { + "name": "Call method", + "description": "Calls a Kodi JSONRPC API method with optional parameters. Results of the Kodi API call will be redirected in a Home Assistant event: `kodi_call_method_result`.", + "fields": { + "method": { + "name": "Method", + "description": "Name of the Kodi JSONRPC API method to be called." + } + } } } } diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json index cd08638c775..e1a6863a199 100644 --- a/homeassistant/components/konnected/strings.json +++ b/homeassistant/components/konnected/strings.json @@ -69,7 +69,7 @@ }, "options_digital": { "title": "Configure Digital Sensor", - "description": "{zone} options", + "description": "[%key:component::konnected::options::step::options_binary::description%]", "data": { "type": "Sensor Type", "name": "[%key:common::config_flow::data::name%]", @@ -103,7 +103,7 @@ "bad_host": "Invalid Override API host URL" }, "abort": { - "not_konn_panel": "Not a recognized Konnected.io device" + "not_konn_panel": "[%key:component::konnected::config::abort::not_konn_panel%]" } } } diff --git a/homeassistant/components/kraken/const.py b/homeassistant/components/kraken/const.py index 816fb35fadb..8a5f7fa828f 100644 --- a/homeassistant/components/kraken/const.py +++ b/homeassistant/components/kraken/const.py @@ -1,13 +1,8 @@ """Constants for the kraken integration.""" from __future__ import annotations -from collections.abc import Callable -from dataclasses import dataclass from typing import TypedDict -from homeassistant.components.sensor import SensorEntityDescription -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - class KrakenResponseEntry(TypedDict): """Dict describing a single response entry.""" @@ -33,111 +28,3 @@ DISPATCH_CONFIG_UPDATED = "kraken_config_updated" CONF_TRACKED_ASSET_PAIRS = "tracked_asset_pairs" DOMAIN = "kraken" - - -@dataclass -class KrakenRequiredKeysMixin: - """Mixin for required keys.""" - - value_fn: Callable[[DataUpdateCoordinator[KrakenResponse], str], float | int] - - -@dataclass -class KrakenSensorEntityDescription(SensorEntityDescription, KrakenRequiredKeysMixin): - """Describes Kraken sensor entity.""" - - -SENSOR_TYPES: tuple[KrakenSensorEntityDescription, ...] = ( - KrakenSensorEntityDescription( - key="ask", - name="Ask", - value_fn=lambda x, y: x.data[y]["ask"][0], - ), - KrakenSensorEntityDescription( - key="ask_volume", - name="Ask Volume", - value_fn=lambda x, y: x.data[y]["ask"][1], - entity_registry_enabled_default=False, - ), - KrakenSensorEntityDescription( - key="bid", - name="Bid", - value_fn=lambda x, y: x.data[y]["bid"][0], - ), - KrakenSensorEntityDescription( - key="bid_volume", - name="Bid Volume", - value_fn=lambda x, y: x.data[y]["bid"][1], - entity_registry_enabled_default=False, - ), - KrakenSensorEntityDescription( - key="volume_today", - name="Volume Today", - value_fn=lambda x, y: x.data[y]["volume"][0], - entity_registry_enabled_default=False, - ), - KrakenSensorEntityDescription( - key="volume_last_24h", - name="Volume last 24h", - value_fn=lambda x, y: x.data[y]["volume"][1], - entity_registry_enabled_default=False, - ), - KrakenSensorEntityDescription( - key="volume_weighted_average_today", - name="Volume weighted average today", - value_fn=lambda x, y: x.data[y]["volume_weighted_average"][0], - entity_registry_enabled_default=False, - ), - KrakenSensorEntityDescription( - key="volume_weighted_average_last_24h", - name="Volume weighted average last 24h", - value_fn=lambda x, y: x.data[y]["volume_weighted_average"][1], - entity_registry_enabled_default=False, - ), - KrakenSensorEntityDescription( - key="number_of_trades_today", - name="Number of trades today", - value_fn=lambda x, y: x.data[y]["number_of_trades"][0], - entity_registry_enabled_default=False, - ), - KrakenSensorEntityDescription( - key="number_of_trades_last_24h", - name="Number of trades last 24h", - value_fn=lambda x, y: x.data[y]["number_of_trades"][1], - entity_registry_enabled_default=False, - ), - KrakenSensorEntityDescription( - key="last_trade_closed", - name="Last trade closed", - value_fn=lambda x, y: x.data[y]["last_trade_closed"][0], - entity_registry_enabled_default=False, - ), - KrakenSensorEntityDescription( - key="low_today", - name="Low today", - value_fn=lambda x, y: x.data[y]["low"][0], - ), - KrakenSensorEntityDescription( - key="low_last_24h", - name="Low last 24h", - value_fn=lambda x, y: x.data[y]["low"][1], - entity_registry_enabled_default=False, - ), - KrakenSensorEntityDescription( - key="high_today", - name="High today", - value_fn=lambda x, y: x.data[y]["high"][0], - ), - KrakenSensorEntityDescription( - key="high_last_24h", - name="High last 24h", - value_fn=lambda x, y: x.data[y]["high"][1], - entity_registry_enabled_default=False, - ), - KrakenSensorEntityDescription( - key="opening_price_today", - name="Opening price today", - value_fn=lambda x, y: x.data[y]["opening_price"], - entity_registry_enabled_default=False, - ), -) diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index 0250f17052b..4bbf232f84b 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -1,9 +1,15 @@ """The kraken integration.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import logging -from homeassistant.components.sensor import SensorEntity, SensorStateClass +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -20,14 +26,120 @@ from .const import ( CONF_TRACKED_ASSET_PAIRS, DISPATCH_CONFIG_UPDATED, DOMAIN, - SENSOR_TYPES, KrakenResponse, - KrakenSensorEntityDescription, ) _LOGGER = logging.getLogger(__name__) +@dataclass +class KrakenRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[DataUpdateCoordinator[KrakenResponse], str], float | int] + + +@dataclass +class KrakenSensorEntityDescription(SensorEntityDescription, KrakenRequiredKeysMixin): + """Describes Kraken sensor entity.""" + + +SENSOR_TYPES: tuple[KrakenSensorEntityDescription, ...] = ( + KrakenSensorEntityDescription( + key="ask", + name="Ask", + value_fn=lambda x, y: x.data[y]["ask"][0], + ), + KrakenSensorEntityDescription( + key="ask_volume", + name="Ask Volume", + value_fn=lambda x, y: x.data[y]["ask"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="bid", + name="Bid", + value_fn=lambda x, y: x.data[y]["bid"][0], + ), + KrakenSensorEntityDescription( + key="bid_volume", + name="Bid Volume", + value_fn=lambda x, y: x.data[y]["bid"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="volume_today", + name="Volume Today", + value_fn=lambda x, y: x.data[y]["volume"][0], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="volume_last_24h", + name="Volume last 24h", + value_fn=lambda x, y: x.data[y]["volume"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="volume_weighted_average_today", + name="Volume weighted average today", + value_fn=lambda x, y: x.data[y]["volume_weighted_average"][0], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="volume_weighted_average_last_24h", + name="Volume weighted average last 24h", + value_fn=lambda x, y: x.data[y]["volume_weighted_average"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="number_of_trades_today", + name="Number of trades today", + value_fn=lambda x, y: x.data[y]["number_of_trades"][0], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="number_of_trades_last_24h", + name="Number of trades last 24h", + value_fn=lambda x, y: x.data[y]["number_of_trades"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="last_trade_closed", + name="Last trade closed", + value_fn=lambda x, y: x.data[y]["last_trade_closed"][0], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="low_today", + name="Low today", + value_fn=lambda x, y: x.data[y]["low"][0], + ), + KrakenSensorEntityDescription( + key="low_last_24h", + name="Low last 24h", + value_fn=lambda x, y: x.data[y]["low"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="high_today", + name="High today", + value_fn=lambda x, y: x.data[y]["high"][0], + ), + KrakenSensorEntityDescription( + key="high_last_24h", + name="High last 24h", + value_fn=lambda x, y: x.data[y]["high"][1], + entity_registry_enabled_default=False, + ), + KrakenSensorEntityDescription( + key="opening_price_today", + name="Opening price today", + value_fn=lambda x, y: x.data[y]["opening_price"], + entity_registry_enabled_default=False, + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py index c6763e6d9f6..91f19dbdd08 100644 --- a/homeassistant/components/kulersky/light.py +++ b/homeassistant/components/kulersky/light.py @@ -62,7 +62,10 @@ async def async_setup_entry( class KulerskyLight(LightEntity): - """Representation of an Kuler Sky Light.""" + """Representation of a Kuler Sky Light.""" + + _attr_has_entity_name = True + _attr_name = None def __init__(self, light: pykulersky.Light) -> None: """Initialize a Kuler Sky light.""" @@ -88,11 +91,6 @@ class KulerskyLight(LightEntity): "Exception disconnected from %s", self._light.address, exc_info=True ) - @property - def name(self): - """Return the display name of this light.""" - return self._light.name - @property def unique_id(self): """Return the ID of this light.""" @@ -104,7 +102,7 @@ class KulerskyLight(LightEntity): return DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, manufacturer="Brightech", - name=self.name, + name=self._light.name, ) @property diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index e001450fab0..547772cad09 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -23,6 +23,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -66,7 +67,6 @@ SENSOR_DESCRIPTIONS = { "Temperature": LaCrosseSensorEntityDescription( key="Temperature", device_class=SensorDeviceClass.TEMPERATURE, - name="Temperature", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -74,22 +74,20 @@ SENSOR_DESCRIPTIONS = { "Humidity": LaCrosseSensorEntityDescription( key="Humidity", device_class=SensorDeviceClass.HUMIDITY, - name="Humidity", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=PERCENTAGE, ), "HeatIndex": LaCrosseSensorEntityDescription( key="HeatIndex", + translation_key="heat_index", device_class=SensorDeviceClass.TEMPERATURE, - name="Heat index", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), "WindSpeed": LaCrosseSensorEntityDescription( key="WindSpeed", - name="Wind speed", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, @@ -97,7 +95,6 @@ SENSOR_DESCRIPTIONS = { ), "Rain": LaCrosseSensorEntityDescription( key="Rain", - name="Rain", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, @@ -105,23 +102,23 @@ SENSOR_DESCRIPTIONS = { ), "WindHeading": LaCrosseSensorEntityDescription( key="WindHeading", - name="Wind heading", + translation_key="wind_heading", value_fn=get_value, native_unit_of_measurement=DEGREE, ), "WetDry": LaCrosseSensorEntityDescription( key="WetDry", - name="Wet/Dry", + translation_key="wet_dry", value_fn=get_value, ), "Flex": LaCrosseSensorEntityDescription( key="Flex", - name="Flex", + translation_key="flex", value_fn=get_value, ), "BarometricPressure": LaCrosseSensorEntityDescription( key="BarometricPressure", - name="Barometric pressure", + translation_key="barometric_pressure", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, @@ -129,7 +126,7 @@ SENSOR_DESCRIPTIONS = { ), "FeelsLike": LaCrosseSensorEntityDescription( key="FeelsLike", - name="Feels like", + translation_key="feels_like", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, @@ -137,7 +134,7 @@ SENSOR_DESCRIPTIONS = { ), "WindChill": LaCrosseSensorEntityDescription( key="WindChill", - name="Wind chill", + translation_key="wind_chill", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, @@ -192,7 +189,7 @@ class LaCrosseViewSensor( """LaCrosse View sensor.""" entity_description: LaCrosseSensorEntityDescription - _attr_has_entity_name: bool = True + _attr_has_entity_name = True def __init__( self, @@ -206,13 +203,13 @@ class LaCrosseViewSensor( self.entity_description = description self._attr_unique_id = f"{sensor.sensor_id}-{description.key}" - self._attr_device_info = { - "identifiers": {(DOMAIN, sensor.sensor_id)}, - "name": sensor.name, - "manufacturer": "LaCrosse Technology", - "model": sensor.model, - "via_device": (DOMAIN, sensor.location.id), - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, sensor.sensor_id)}, + name=sensor.name, + manufacturer="LaCrosse Technology", + model=sensor.model, + via_device=(DOMAIN, sensor.location.id), + ) self.index = index @property diff --git a/homeassistant/components/lacrosse_view/strings.json b/homeassistant/components/lacrosse_view/strings.json index 160517793d8..8dc27ba259e 100644 --- a/homeassistant/components/lacrosse_view/strings.json +++ b/homeassistant/components/lacrosse_view/strings.json @@ -17,5 +17,30 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "heat_index": { + "name": "Heat index" + }, + "wind_heading": { + "name": "Wind heading" + }, + "wet_dry": { + "name": "Wet/Dry" + }, + "flex": { + "name": "Flex" + }, + "barometric_pressure": { + "name": "Barometric pressure" + }, + "feels_like": { + "name": "Feels like" + }, + "wind_chill": { + "name": "Wind chill" + } + } } } diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index 0c26d2c7dd5..6cddf81b2bf 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -39,7 +39,6 @@ SENSORS = [ LaMetricSensorEntityDescription( key="rssi", translation_key="rssi", - name="Wi-Fi signal", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/lametric/services.yaml b/homeassistant/components/lametric/services.yaml index 3299245fbc0..af04eac5f33 100644 --- a/homeassistant/components/lametric/services.yaml +++ b/homeassistant/components/lametric/services.yaml @@ -1,129 +1,70 @@ chart: - name: Display a chart - description: Display a chart on a LaMetric device. fields: device_id: &device_id - name: Device - description: The LaMetric device to display the chart on. required: true selector: device: integration: lametric data: - name: Data - description: The list of data points in the chart required: true example: "[1,2,3,4,5,4,3,2,1]" selector: object: sound: &sound - name: Sound - description: The notification sound to play. required: false selector: select: options: - - label: "Alarm 1" - value: "alarm1" - - label: "Alarm 2" - value: "alarm2" - - label: "Alarm 3" - value: "alarm3" - - label: "Alarm 4" - value: "alarm4" - - label: "Alarm 5" - value: "alarm5" - - label: "Alarm 6" - value: "alarm6" - - label: "Alarm 7" - value: "alarm7" - - label: "Alarm 8" - value: "alarm8" - - label: "Alarm 9" - value: "alarm9" - - label: "Alarm 10" - value: "alarm10" - - label: "Alarm 11" - value: "alarm11" - - label: "Alarm 12" - value: "alarm12" - - label: "Alarm 13" - value: "alarm13" - - label: "Bicycle" - value: "bicycle" - - label: "Car" - value: "car" - - label: "Cash" - value: "cash" - - label: "Cat" - value: "cat" - - label: "Dog 1" - value: "dog" - - label: "Dog 2" - value: "dog2" - - label: "Energy" - value: "energy" - - label: "Knock knock" - value: "knock-knock" - - label: "Letter email" - value: "letter_email" - - label: "Lose 1" - value: "lose1" - - label: "Lose 2" - value: "lose2" - - label: "Negative 1" - value: "negative1" - - label: "Negative 2" - value: "negative2" - - label: "Negative 3" - value: "negative3" - - label: "Negative 4" - value: "negative4" - - label: "Negative 5" - value: "negative5" - - label: "Notification 1" - value: "notification" - - label: "Notification 2" - value: "notification2" - - label: "Notification 3" - value: "notification3" - - label: "Notification 4" - value: "notification4" - - label: "Open door" - value: "open_door" - - label: "Positive 1" - value: "positive1" - - label: "Positive 2" - value: "positive2" - - label: "Positive 3" - value: "positive3" - - label: "Positive 4" - value: "positive4" - - label: "Positive 5" - value: "positive5" - - label: "Positive 6" - value: "positive6" - - label: "Statistic" - value: "statistic" - - label: "Thunder" - value: "thunder" - - label: "Water 1" - value: "water1" - - label: "Water 2" - value: "water2" - - label: "Win 1" - value: "win" - - label: "Win 2" - value: "win2" - - label: "Wind" - value: "wind" - - label: "Wind short" - value: "wind_short" + - "alarm1" + - "alarm2" + - "alarm3" + - "alarm4" + - "alarm5" + - "alarm6" + - "alarm7" + - "alarm8" + - "alarm9" + - "alarm10" + - "alarm11" + - "alarm12" + - "alarm13" + - "bicycle" + - "car" + - "cash" + - "cat" + - "dog" + - "dog2" + - "energy" + - "knock-knock" + - "letter_email" + - "lose1" + - "lose2" + - "negative1" + - "negative2" + - "negative3" + - "negative4" + - "negative5" + - "notification" + - "notification2" + - "notification3" + - "notification4" + - "open_door" + - "positive1" + - "positive2" + - "positive3" + - "positive4" + - "positive5" + - "positive6" + - "statistic" + - "thunder" + - "water1" + - "water2" + - "win" + - "win2" + - "wind" + - "wind_short" + translation_key: sound cycles: &cycles - name: Cycles - description: >- - The number of times to display the message. When set to 0, the message - will be displayed until dismissed. required: false default: 1 selector: @@ -132,56 +73,35 @@ chart: max: 10 mode: slider icon_type: &icon_type - name: Icon type - description: >- - The type of icon to display, indicating the nature of the notification. required: false default: "none" selector: select: mode: dropdown options: - - label: "None" - value: "none" - - label: "Info" - value: "info" - - label: "Alert" - value: "alert" + - "none" + - "info" + - "alert" + translation_key: icon_type priority: &priority - name: Priority - description: >- - The priority of the notification. When the device is running in - screensaver or kiosk mode, only critical priority notifications - will be accepted. required: false default: "info" selector: select: mode: dropdown options: - - label: "Info" - value: "info" - - label: "Warning" - value: "warning" - - label: "Critical" - value: "critical" - + - "info" + - "warning" + - "critical" + translation_key: priority message: - name: Display a message - description: Display a message with an optional icon on a LaMetric device. fields: device_id: *device_id message: - name: Message - description: The message to display. required: true selector: text: icon: - name: Icon - description: >- - The ID number of the icon or animation to display. List of all icons - and their IDs can be found at: https://developer.lametric.com/icons required: false selector: text: diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 21cebe46f26..21d2bdc84bd 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -78,5 +78,139 @@ "name": "Bluetooth" } } + }, + "services": { + "chart": { + "name": "Display a chart", + "description": "Displays a chart on a LaMetric device.", + "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "The LaMetric device to display the chart on." + }, + "data": { + "name": "Data", + "description": "The list of data points in the chart." + }, + "sound": { + "name": "Sound", + "description": "The notification sound to play." + }, + "cycles": { + "name": "Cycles", + "description": "The number of times to display the message. When set to 0, the message will be displayed until dismissed." + }, + "icon_type": { + "name": "Icon type", + "description": "The type of icon to display, indicating the nature of the notification." + }, + "priority": { + "name": "Priority", + "description": "The priority of the notification. When the device is running in screensaver or kiosk mode, only critical priority notifications will be accepted." + } + } + }, + "message": { + "name": "Display a message", + "description": "Displays a message with an optional icon on a LaMetric device.", + "fields": { + "device_id": { + "name": "[%key:component::lametric::services::chart::fields::device_id::name%]", + "description": "The LaMetric device to display the message on." + }, + "message": { + "name": "Message", + "description": "The message to display." + }, + "icon": { + "name": "Icon", + "description": "The ID number of the icon or animation to display. List of all icons and their IDs can be found at: https://developer.lametric.com/icons." + }, + "sound": { + "name": "[%key:component::lametric::services::chart::fields::sound::name%]", + "description": "[%key:component::lametric::services::chart::fields::sound::description%]" + }, + "cycles": { + "name": "[%key:component::lametric::services::chart::fields::cycles::name%]", + "description": "[%key:component::lametric::services::chart::fields::cycles::description%]" + }, + "icon_type": { + "name": "[%key:component::lametric::services::chart::fields::icon_type::name%]", + "description": "[%key:component::lametric::services::chart::fields::icon_type::description%]" + }, + "priority": { + "name": "[%key:component::lametric::services::chart::fields::priority::name%]", + "description": "[%key:component::lametric::services::chart::fields::priority::description%]" + } + } + } + }, + "selector": { + "sound": { + "options": { + "alarm1": "Alarm 1", + "alarm2": "Alarm 2", + "alarm3": "Alarm 3", + "alarm4": "Alarm 4", + "alarm5": "Alarm 5", + "alarm6": "Alarm 6", + "alarm7": "Alarm 7", + "alarm8": "Alarm 8", + "alarm9": "Alarm 9", + "alarm10": "Alarm 10", + "alarm11": "Alarm 11", + "alarm12": "Alarm 12", + "alarm13": "Alarm 13", + "bicycle": "Bicycle", + "car": "Car", + "cash": "Cash", + "cat": "Cat", + "dog": "Dog 1", + "dog2": "Dog 2", + "energy": "Energy", + "knock-knock": "Knock knock", + "letter_email": "Letter email", + "lose1": "Lose 1", + "lose2": "Lose 2", + "negative1": "Negative 1", + "negative2": "Negative 2", + "negative3": "Negative 3", + "negative4": "Negative 4", + "negative5": "Negative 5", + "notification": "Notification 1", + "notification2": "Notification 2", + "notification3": "Notification 3", + "notification4": "Notification 4", + "open_door": "Open door", + "positive1": "Positive 1", + "positive2": "Positive 2", + "positive3": "Positive 3", + "positive4": "Positive 4", + "positive5": "Positive 5", + "positive6": "Positive 6", + "statistic": "Statistic", + "thunder": "Thunder", + "water1": "Water 1", + "water2": "Water 2", + "win": "Win 1", + "win2": "Win 2", + "wind": "Wind", + "wind_short": "Wind short" + } + }, + "icon_type": { + "options": { + "none": "None", + "info": "Info", + "alert": "Alert" + } + }, + "priority": { + "options": { + "info": "[%key:component::lametric::selector::icon_type::options::info%]", + "warning": "Warning", + "critical": "Critical" + } + } } } diff --git a/homeassistant/components/lastfm/__init__.py b/homeassistant/components/lastfm/__init__.py index fc26dd85ea3..72dcf08a2d0 100644 --- a/homeassistant/components/lastfm/__init__.py +++ b/homeassistant/components/lastfm/__init__.py @@ -4,12 +4,17 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import PLATFORMS +from .const import DOMAIN, PLATFORMS +from .coordinator import LastFMDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up lastfm from a config entry.""" + coordinator = LastFMDataUpdateCoordinator(hass) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/lastfm/coordinator.py b/homeassistant/components/lastfm/coordinator.py new file mode 100644 index 00000000000..533f9ec3b09 --- /dev/null +++ b/homeassistant/components/lastfm/coordinator.py @@ -0,0 +1,89 @@ +"""DataUpdateCoordinator for the LastFM integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from pylast import LastFMNetwork, PyLastError, Track + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_USERS, + DOMAIN, + LOGGER, +) + + +def format_track(track: Track | None) -> str | None: + """Format the track.""" + if track is None: + return None + return f"{track.artist} - {track.title}" + + +@dataclass +class LastFMUserData: + """Data holder for LastFM data.""" + + play_count: int + image: str + now_playing: str | None + top_track: str | None + last_track: str | None + + +class LastFMDataUpdateCoordinator(DataUpdateCoordinator[dict[str, LastFMUserData]]): + """A LastFM Data Update Coordinator.""" + + config_entry: ConfigEntry + _client: LastFMNetwork + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the LastFM data coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self._client = LastFMNetwork(api_key=self.config_entry.options[CONF_API_KEY]) + + async def _async_update_data(self) -> dict[str, LastFMUserData]: + res = {} + for username in self.config_entry.options[CONF_USERS]: + data = await self.hass.async_add_executor_job(self._get_user_data, username) + if data is not None: + res[username] = data + if not res: + raise UpdateFailed + return res + + def _get_user_data(self, username: str) -> LastFMUserData | None: + user = self._client.get_user(username) + try: + play_count = user.get_playcount() + image = user.get_image() + now_playing = format_track(user.get_now_playing()) + top_tracks = user.get_top_tracks(limit=1) + last_tracks = user.get_recent_tracks(limit=1) + except PyLastError as exc: + if self.last_update_success: + LOGGER.error("LastFM update for %s failed: %r", username, exc) + return None + top_track = None + if len(top_tracks) > 0: + top_track = format_track(top_tracks[0].item) + last_track = None + if len(last_tracks) > 0: + last_track = format_track(last_tracks[0].track) + return LastFMUserData( + play_count, + image, + now_playing, + top_track, + last_track, + ) diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index b4776b19c50..116a0813387 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -2,20 +2,23 @@ from __future__ import annotations import hashlib +from typing import Any -from pylast import LastFMNetwork, PyLastError, Track, User import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, +) from .const import ( ATTR_LAST_PLAYED, @@ -24,9 +27,9 @@ from .const import ( CONF_USERS, DEFAULT_NAME, DOMAIN, - LOGGER, STATE_NOT_SCROBBLING, ) +from .coordinator import LastFMDataUpdateCoordinator, LastFMUserData PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -36,11 +39,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def format_track(track: Track) -> str: - """Format the track.""" - return f"{track.artist} - {track.title}" - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -51,12 +49,17 @@ async def async_setup_platform( async_create_issue( hass, - DOMAIN, - "deprecated_yaml", + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", breaks_in_ha_version="2023.12.0", is_fixable=False, + issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "LastFM", + }, ) hass.async_create_task( @@ -73,61 +76,76 @@ async def async_setup_entry( ) -> None: """Initialize the entries.""" - lastfm_api = LastFMNetwork(api_key=entry.options[CONF_API_KEY]) + coordinator: LastFMDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( ( - LastFmSensor(lastfm_api.get_user(user), entry.entry_id) - for user in entry.options[CONF_USERS] + LastFmSensor(coordinator, username, entry.entry_id) + for username in entry.options[CONF_USERS] ), - True, ) -class LastFmSensor(SensorEntity): +class LastFmSensor(CoordinatorEntity[LastFMDataUpdateCoordinator], SensorEntity): """A class for the Last.fm account.""" _attr_attribution = "Data provided by Last.fm" _attr_icon = "mdi:radio-fm" - def __init__(self, user: User, entry_id: str) -> None: + def __init__( + self, + coordinator: LastFMDataUpdateCoordinator, + username: str, + entry_id: str, + ) -> None: """Initialize the sensor.""" - self._user = user - self._attr_unique_id = hashlib.sha256(user.name.encode("utf-8")).hexdigest() - self._attr_name = user.name + super().__init__(coordinator) + self._username = username + self._attr_unique_id = hashlib.sha256(username.encode("utf-8")).hexdigest() + self._attr_name = username self._attr_device_info = DeviceInfo( configuration_url="https://www.last.fm", entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, f"{entry_id}_{self._attr_unique_id}")}, manufacturer=DEFAULT_NAME, - name=f"{DEFAULT_NAME} {user.name}", + name=f"{DEFAULT_NAME} {username}", ) - def update(self) -> None: - """Update device state.""" - self._attr_native_value = STATE_NOT_SCROBBLING - try: - play_count = self._user.get_playcount() - self._attr_entity_picture = self._user.get_image() - now_playing = self._user.get_now_playing() - top_tracks = self._user.get_top_tracks(limit=1) - last_tracks = self._user.get_recent_tracks(limit=1) - except PyLastError as exc: - self._attr_available = False - LOGGER.error("Failed to load LastFM user `%s`: %r", self._user.name, exc) - return - self._attr_available = True - if now_playing: - self._attr_native_value = format_track(now_playing) - self._attr_extra_state_attributes = { + @property + def user_data(self) -> LastFMUserData | None: + """Returns the user from the coordinator.""" + return self.coordinator.data.get(self._username) + + @property + def available(self) -> bool: + """If user not found in coordinator, entity is unavailable.""" + return super().available and self.user_data is not None + + @property + def entity_picture(self) -> str | None: + """Return user avatar.""" + if self.user_data and self.user_data.image is not None: + return self.user_data.image + return None + + @property + def native_value(self) -> str: + """Return value of sensor.""" + if self.user_data and self.user_data.now_playing is not None: + return self.user_data.now_playing + return STATE_NOT_SCROBBLING + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return state attributes.""" + play_count = None + last_track = None + top_track = None + if self.user_data: + play_count = self.user_data.play_count + last_track = self.user_data.last_track + top_track = self.user_data.top_track + return { ATTR_PLAY_COUNT: play_count, - ATTR_LAST_PLAYED: None, - ATTR_TOP_PLAYED: None, + ATTR_LAST_PLAYED: last_track, + ATTR_TOP_PLAYED: top_track, } - if len(last_tracks) > 0: - self._attr_extra_state_attributes[ATTR_LAST_PLAYED] = format_track( - last_tracks[0].track - ) - if len(top_tracks) > 0: - self._attr_extra_state_attributes[ATTR_TOP_PLAYED] = format_track( - top_tracks[0].item - ) diff --git a/homeassistant/components/lastfm/strings.json b/homeassistant/components/lastfm/strings.json index f9156bed658..006fd5ebcc7 100644 --- a/homeassistant/components/lastfm/strings.json +++ b/homeassistant/components/lastfm/strings.json @@ -24,22 +24,16 @@ "options": { "step": { "init": { - "description": "Fill in other users you want to add.", + "description": "[%key:component::lastfm::config::step::friends::description%]", "data": { - "users": "Last.fm usernames" + "users": "[%key:component::lastfm::config::step::friends::data::users%]" } } }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_account": "Invalid username", + "invalid_account": "[%key:component::lastfm::config::error::invalid_account%]", "unknown": "[%key:common::config_flow::error::unknown%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The LastFM YAML configuration is being removed", - "description": "Configuring LastFM using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the LastFM YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index fb9bac987bb..3e865bd4c0c 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -42,6 +42,8 @@ class LaundrifyPowerPlug( _attr_device_class = BinarySensorDeviceClass.RUNNING _attr_icon = "mdi:washing-machine" _attr_unique_id: str + _attr_has_entity_name = True + _attr_name = None def __init__( self, coordinator: LaundrifyUpdateCoordinator, device: LaundrifyDevice @@ -56,7 +58,7 @@ class LaundrifyPowerPlug( """Configure the Device of this Entity.""" return DeviceInfo( identifiers={(DOMAIN, self._device["_id"])}, - name=self.name, + name=self._device["name"], manufacturer=MANUFACTURER, model=MODEL, sw_version=self._device["firmwareVersion"], @@ -70,11 +72,6 @@ class LaundrifyPowerPlug( and self.coordinator.last_update_success ) - @property - def name(self) -> str: - """Name of the entity.""" - return self._device["name"] - @property def is_on(self) -> bool: """Return entity state.""" diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 2e1185fd692..7ef7eb73673 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -180,7 +180,7 @@ def async_host_input_received( logical_address.is_group, ) identifiers = {(DOMAIN, generate_unique_id(config_entry.entry_id, address))} - device = device_registry.async_get_device(identifiers, set()) + device = device_registry.async_get_device(identifiers=identifiers) if device is None: return @@ -276,16 +276,16 @@ class LcnEntity(Entity): f" ({get_device_model(self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA])})" ) - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": f"{address}.{self.config[CONF_RESOURCE]}", - "model": model, - "manufacturer": "Issendorff", - "via_device": ( + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + name=f"{address}.{self.config[CONF_RESOURCE]}", + model=model, + manufacturer="Issendorff", + via_device=( DOMAIN, generate_unique_id(self.entry_id, self.config[CONF_ADDRESS]), ), - } + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 776ad116f4a..e190b25eded 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -291,7 +291,7 @@ def purge_device_registry( # Find device that references the host. references_host = set() - host_device = device_registry.async_get_device({(DOMAIN, entry_id)}) + host_device = device_registry.async_get_device(identifiers={(DOMAIN, entry_id)}) if host_device is not None: references_host.add(host_device.id) @@ -299,7 +299,9 @@ def purge_device_registry( references_entry_data = set() for device_data in imported_entry_data[CONF_DEVICES]: device_unique_id = generate_unique_id(entry_id, device_data[CONF_ADDRESS]) - device = device_registry.async_get_device({(DOMAIN, device_unique_id)}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, device_unique_id)} + ) if device is not None: references_entry_data.add(device.id) diff --git a/homeassistant/components/lcn/services.yaml b/homeassistant/components/lcn/services.yaml index 52aa8863872..d62a1e72d45 100644 --- a/homeassistant/components/lcn/services.yaml +++ b/homeassistant/components/lcn/services.yaml @@ -1,19 +1,13 @@ # Describes the format for available LCN services output_abs: - name: Output absolute brightness - description: Set absolute brightness of output port in percent. fields: address: - name: Address - description: Module address required: true example: "myhome.s0.m7" selector: text: output: - name: Output - description: Output port required: true selector: select: @@ -23,8 +17,6 @@ output_abs: - "output3" - "output4" brightness: - name: Brightness - description: Absolute brightness. required: true selector: number: @@ -32,8 +24,6 @@ output_abs: max: 100 unit_of_measurement: "%" transition: - name: Transition - description: Transition time. default: 0 selector: number: @@ -43,19 +33,13 @@ output_abs: unit_of_measurement: seconds output_rel: - name: Output relative brightness - description: Set relative brightness of output port in percent. fields: address: - name: Address - description: Module address required: true example: "myhome.s0.m7" selector: text: output: - name: Output - description: Output port required: true selector: select: @@ -65,8 +49,6 @@ output_rel: - "output3" - "output4" brightness: - name: Brightness - description: Relative brightness. required: true selector: number: @@ -75,19 +57,13 @@ output_rel: unit_of_measurement: "%" output_toggle: - name: Toggle output - description: Toggle output port. fields: address: - name: Address - description: Module address required: true example: "myhome.s0.m7" selector: text: output: - name: Output - description: Output port required: true selector: select: @@ -97,8 +73,6 @@ output_toggle: - "output3" - "output4" transition: - name: Transition - description: Transition time. default: 0 selector: number: @@ -108,38 +82,26 @@ output_toggle: unit_of_measurement: seconds relays: - name: Relays - description: Set the relays status. fields: address: - name: Address - description: Module address required: true example: "myhome.s0.m7" selector: text: state: - name: State - description: Relays states as string (1=on, 2=off, t=toggle, -=no change) required: true example: "t---001-" selector: text: led: - name: LED - description: Set the led state. fields: address: - name: Address - description: Module address required: true example: "myhome.s0.m7" selector: text: led: - name: LED - description: Led required: true selector: select: @@ -157,8 +119,6 @@ led: - "led11" - "led12" state: - name: State - description: Led state required: true selector: select: @@ -169,19 +129,13 @@ led: - "on" var_abs: - name: Set absolute variable - description: Set absolute value of a variable or setpoint. fields: address: - name: Address - description: Module address required: true example: "myhome.s0.m7" selector: text: variable: - name: Variable - description: Variable or setpoint name required: true default: native selector: @@ -208,16 +162,12 @@ var_abs: - "var11" - "var12" value: - name: Value - description: Value to set. default: 0 selector: number: min: 0 max: 100000 unit_of_measurement: - name: Unit of measurement - description: Unit of value. selector: select: options: @@ -246,19 +196,13 @@ var_abs: - "volt" var_reset: - name: Reset variable - description: Reset value of variable or setpoint. fields: address: - name: Address - description: Module address required: true example: "myhome.s0.m7" selector: text: variable: - name: Variable - description: Variable or setpoint name. required: true selector: select: @@ -285,19 +229,13 @@ var_reset: - "var12" var_rel: - name: Shift variable - description: Shift value of a variable, setpoint or threshold. fields: address: - name: Address - description: Module address required: true example: "myhome.s0.m7" selector: text: variable: - name: Variable - description: Variable or setpoint name required: true selector: select: @@ -340,16 +278,12 @@ var_rel: - "var11" - "var12" value: - name: Value - description: Shift value default: 0 selector: number: min: 0 max: 100000 unit_of_measurement: - name: Unit of measurement - description: Unit of value default: native selector: select: @@ -378,8 +312,6 @@ var_rel: - "v" - "volt" value_reference: - name: Reference value - description: Reference value for setpoint and threshold default: current selector: select: @@ -388,19 +320,13 @@ var_rel: - "prog" lock_regulator: - name: Lock regulator - description: Lock a regulator setpoint. fields: address: - name: Address - description: Module address required: true example: "myhome.s0.m7" selector: text: setpoint: - name: Setpoint - description: Setpoint name required: true selector: select: @@ -423,33 +349,23 @@ lock_regulator: - "thrs4_3" - "thrs4_4" state: - name: State - description: New setpoint state default: false selector: boolean: send_keys: - name: Send keys - description: Send keys (which executes bound commands). fields: address: - name: Address - description: Module address required: true example: "myhome.s0.m7" selector: text: keys: - name: Keys - description: Keys to send required: true example: "a1a5d8" selector: text: state: - name: State - description: "Key state upon sending (must be hit for deferred)" default: hit selector: select: @@ -459,16 +375,12 @@ send_keys: - "break" - "dontsend" time: - name: Time - description: Send delay. default: 0 selector: number: min: 0 max: 60 time_unit: - name: Time unit - description: Time unit of send delay. default: s selector: select: @@ -489,41 +401,29 @@ send_keys: - "seconds" lock_keys: - name: Lock keys - description: Lock keys. fields: address: - name: Address - description: Module address required: true example: "myhome.s0.m7" selector: text: table: - name: Table - description: "Table with keys to lock (must be A for interval)." example: "a" default: a selector: text: state: - name: State - description: Key lock states as string (1=on, 2=off, T=toggle, -=nochange) required: true example: "1---t0--" selector: text: time: - name: Time - description: Lock interval. default: 0 selector: number: min: 0 max: 60 time_unit: - name: Time unit - description: Time unit of lock interval. default: s selector: select: @@ -544,46 +444,32 @@ lock_keys: - "seconds" dyn_text: - name: Dynamic text - description: Send dynamic text to LCN-GTxD displays. fields: address: - name: Address - description: Module address required: true example: "myhome.s0.m7" selector: text: row: - name: Row - description: Text row. required: true selector: number: min: 1 max: 4 text: - name: Text - description: Text to send (up to 60 characters encoded as UTF-8) required: true example: "text up to 60 characters" selector: text: pck: - name: PCK - description: Send arbitrary PCK command. fields: address: - name: Address - description: Module address required: true example: "myhome.s0.m7" selector: text: pck: - name: PCK - description: PCK command (without address header) required: true example: "PIN4" selector: diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index bee6c0f0e29..e441832926b 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -7,5 +7,261 @@ "codelock": "Code lock code received", "send_keys": "Send keys received" } + }, + "services": { + "output_abs": { + "name": "Output absolute brightness", + "description": "Sets absolute brightness of output port in percent.", + "fields": { + "address": { + "name": "Address", + "description": "Module address." + }, + "output": { + "name": "Output", + "description": "Output port." + }, + "brightness": { + "name": "Brightness", + "description": "Absolute brightness." + }, + "transition": { + "name": "Transition", + "description": "Transition time." + } + } + }, + "output_rel": { + "name": "Output relative brightness", + "description": "Sets relative brightness of output port in percent.", + "fields": { + "address": { + "name": "Address", + "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" + }, + "output": { + "name": "[%key:component::lcn::services::output_abs::fields::output::name%]", + "description": "[%key:component::lcn::services::output_abs::fields::output::description%]" + }, + "brightness": { + "name": "Brightness", + "description": "Relative brightness." + } + } + }, + "output_toggle": { + "name": "Toggle output", + "description": "Toggles output port.", + "fields": { + "address": { + "name": "Address", + "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" + }, + "output": { + "name": "[%key:component::lcn::services::output_abs::fields::output::name%]", + "description": "[%key:component::lcn::services::output_abs::fields::output::description%]" + }, + "transition": { + "name": "Transition", + "description": "[%key:component::lcn::services::output_abs::fields::transition::description%]" + } + } + }, + "relays": { + "name": "Relays", + "description": "Sets the relays status.", + "fields": { + "address": { + "name": "Address", + "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" + }, + "state": { + "name": "State", + "description": "Relays states as string (1=on, 2=off, t=toggle, -=no change)." + } + } + }, + "led": { + "name": "LED", + "description": "Sets the led state.", + "fields": { + "address": { + "name": "Address", + "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" + }, + "led": { + "name": "[%key:component::lcn::services::led::name%]", + "description": "Led." + }, + "state": { + "name": "State", + "description": "Led state." + } + } + }, + "var_abs": { + "name": "Set absolute variable", + "description": "Sets absolute value of a variable or setpoint.", + "fields": { + "address": { + "name": "Address", + "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" + }, + "variable": { + "name": "Variable", + "description": "Variable or setpoint name." + }, + "value": { + "name": "Value", + "description": "Value to set." + }, + "unit_of_measurement": { + "name": "Unit of measurement", + "description": "Unit of value." + } + } + }, + "var_reset": { + "name": "Reset variable", + "description": "Resets value of variable or setpoint.", + "fields": { + "address": { + "name": "Address", + "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" + }, + "variable": { + "name": "[%key:component::lcn::services::var_abs::fields::variable::name%]", + "description": "[%key:component::lcn::services::var_abs::fields::variable::description%]" + } + } + }, + "var_rel": { + "name": "Shift variable", + "description": "Shift value of a variable, setpoint or threshold.", + "fields": { + "address": { + "name": "Address", + "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" + }, + "variable": { + "name": "[%key:component::lcn::services::var_abs::fields::variable::name%]", + "description": "[%key:component::lcn::services::var_abs::fields::variable::description%]" + }, + "value": { + "name": "Value", + "description": "Shift value." + }, + "unit_of_measurement": { + "name": "Unit of measurement", + "description": "[%key:component::lcn::services::var_abs::fields::unit_of_measurement::description%]" + }, + "value_reference": { + "name": "Reference value", + "description": "Reference value for setpoint and threshold." + } + } + }, + "lock_regulator": { + "name": "Lock regulator", + "description": "Locks a regulator setpoint.", + "fields": { + "address": { + "name": "Address", + "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" + }, + "setpoint": { + "name": "Setpoint", + "description": "Setpoint name." + }, + "state": { + "name": "State", + "description": "New setpoint state." + } + } + }, + "send_keys": { + "name": "Send keys", + "description": "Sends keys (which executes bound commands).", + "fields": { + "address": { + "name": "Address", + "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" + }, + "keys": { + "name": "Keys", + "description": "Keys to send." + }, + "state": { + "name": "State", + "description": "Key state upon sending (must be hit for deferred)." + }, + "time": { + "name": "Time", + "description": "Send delay." + }, + "time_unit": { + "name": "Time unit", + "description": "Time unit of send delay." + } + } + }, + "lock_keys": { + "name": "Lock keys", + "description": "Locks keys.", + "fields": { + "address": { + "name": "Address", + "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" + }, + "table": { + "name": "Table", + "description": "Table with keys to lock (must be A for interval)." + }, + "state": { + "name": "State", + "description": "Key lock states as string (1=on, 2=off, T=toggle, -=nochange)." + }, + "time": { + "name": "Time", + "description": "Lock interval." + }, + "time_unit": { + "name": "[%key:component::lcn::services::send_keys::fields::time_unit::name%]", + "description": "Time unit of lock interval." + } + } + }, + "dyn_text": { + "name": "Dynamic text", + "description": "Sends dynamic text to LCN-GTxD displays.", + "fields": { + "address": { + "name": "Address", + "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" + }, + "row": { + "name": "Row", + "description": "Text row." + }, + "text": { + "name": "Text", + "description": "Text to send (up to 60 characters encoded as UTF-8)." + } + } + }, + "pck": { + "name": "PCK", + "description": "Sends arbitrary PCK command.", + "fields": { + "address": { + "name": "Address", + "description": "[%key:component::lcn::services::output_abs::fields::address::description%]" + }, + "pck": { + "name": "[%key:component::lcn::services::pck::name%]", + "description": "PCK command (without address header)." + } + } + } } } diff --git a/homeassistant/components/ld2410_ble/binary_sensor.py b/homeassistant/components/ld2410_ble/binary_sensor.py index ab3c8ddea0b..59580d5725e 100644 --- a/homeassistant/components/ld2410_ble/binary_sensor.py +++ b/homeassistant/components/ld2410_ble/binary_sensor.py @@ -21,14 +21,10 @@ ENTITY_DESCRIPTIONS = ( BinarySensorEntityDescription( key="is_moving", device_class=BinarySensorDeviceClass.MOTION, - has_entity_name=True, - name="Motion", ), BinarySensorEntityDescription( key="is_static", device_class=BinarySensorDeviceClass.OCCUPANCY, - has_entity_name=True, - name="Occupancy", ), ) @@ -51,6 +47,8 @@ class LD2410BLEBinarySensor( ): """Moving/static sensor for LD2410BLE.""" + _attr_has_entity_name = True + def __init__( self, coordinator: LD2410BLECoordinator, diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 6eaf2885d89..1a613a82098 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -17,8 +17,8 @@ "codeowners": ["@930913"], "config_flow": true, "dependencies": ["bluetooth_adapters"], - "documentation": "https://www.home-assistant.io/integrations/ld2410_ble/", + "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.3.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.6.1", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/ld2410_ble/sensor.py b/homeassistant/components/ld2410_ble/sensor.py index 6d0e8e4feb9..806832e9fca 100644 --- a/homeassistant/components/ld2410_ble/sensor.py +++ b/homeassistant/components/ld2410_ble/sensor.py @@ -21,84 +21,76 @@ from .models import LD2410BLEData MOVING_TARGET_DISTANCE_DESCRIPTION = SensorEntityDescription( key="moving_target_distance", + translation_key="moving_target_distance", device_class=SensorDeviceClass.DISTANCE, entity_registry_enabled_default=False, entity_registry_visible_default=True, - has_entity_name=True, - name="Moving Target Distance", native_unit_of_measurement=UnitOfLength.CENTIMETERS, state_class=SensorStateClass.MEASUREMENT, ) STATIC_TARGET_DISTANCE_DESCRIPTION = SensorEntityDescription( key="static_target_distance", + translation_key="static_target_distance", device_class=SensorDeviceClass.DISTANCE, entity_registry_enabled_default=False, entity_registry_visible_default=True, - has_entity_name=True, - name="Static Target Distance", native_unit_of_measurement=UnitOfLength.CENTIMETERS, state_class=SensorStateClass.MEASUREMENT, ) DETECTION_DISTANCE_DESCRIPTION = SensorEntityDescription( key="detection_distance", + translation_key="detection_distance", device_class=SensorDeviceClass.DISTANCE, entity_registry_enabled_default=False, entity_registry_visible_default=True, - has_entity_name=True, - name="Detection Distance", native_unit_of_measurement=UnitOfLength.CENTIMETERS, state_class=SensorStateClass.MEASUREMENT, ) MOVING_TARGET_ENERGY_DESCRIPTION = SensorEntityDescription( key="moving_target_energy", + translation_key="moving_target_energy", device_class=None, entity_registry_enabled_default=False, entity_registry_visible_default=True, - has_entity_name=True, - name="Moving Target Energy", native_unit_of_measurement="Target Energy", state_class=SensorStateClass.MEASUREMENT, ) STATIC_TARGET_ENERGY_DESCRIPTION = SensorEntityDescription( key="static_target_energy", + translation_key="static_target_energy", device_class=None, entity_registry_enabled_default=False, entity_registry_visible_default=True, - has_entity_name=True, - name="Static Target Energy", native_unit_of_measurement="Target Energy", state_class=SensorStateClass.MEASUREMENT, ) MAX_MOTION_GATES_DESCRIPTION = SensorEntityDescription( key="max_motion_gates", + translation_key="max_motion_gates", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - has_entity_name=True, - name="Max Motion Gates", native_unit_of_measurement="Gates", ) MAX_STATIC_GATES_DESCRIPTION = SensorEntityDescription( key="max_static_gates", + translation_key="max_static_gates", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - has_entity_name=True, - name="Max Static Gates", native_unit_of_measurement="Gates", ) MOTION_ENERGY_GATES = [ SensorEntityDescription( key=f"motion_energy_gate_{i}", + translation_key=f"motion_energy_gate_{i}", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - has_entity_name=True, - name=f"Motion Energy Gate {i}", native_unit_of_measurement="Target Energy", ) for i in range(0, 9) @@ -107,10 +99,9 @@ MOTION_ENERGY_GATES = [ STATIC_ENERGY_GATES = [ SensorEntityDescription( key=f"static_energy_gate_{i}", + translation_key=f"static_energy_gate_{i}", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, - has_entity_name=True, - name=f"Static Energy Gate {i}", native_unit_of_measurement="Target Energy", ) for i in range(0, 9) @@ -152,6 +143,8 @@ async def async_setup_entry( class LD2410BLESensor(CoordinatorEntity[LD2410BLECoordinator], SensorEntity): """Generic sensor for LD2410BLE.""" + _attr_has_entity_name = True + def __init__( self, coordinator: LD2410BLECoordinator, diff --git a/homeassistant/components/ld2410_ble/strings.json b/homeassistant/components/ld2410_ble/strings.json index e2be7e6deff..7e919675426 100644 --- a/homeassistant/components/ld2410_ble/strings.json +++ b/homeassistant/components/ld2410_ble/strings.json @@ -18,5 +18,84 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "sensor": { + "moving_target_distance": { + "name": "Moving target distance" + }, + "static_target_distance": { + "name": "Static target distance" + }, + "detection_distance": { + "name": "Detection distance" + }, + "moving_target_energy": { + "name": "Moving target energy" + }, + "static_target_energy": { + "name": "Static target energy" + }, + "max_motion_gates": { + "name": "Max motion gates" + }, + "max_static_gates": { + "name": "Max static gates" + }, + "motion_energy_gate_0": { + "name": "Motion energy gate 0" + }, + "motion_energy_gate_1": { + "name": "Motion energy gate 1" + }, + "motion_energy_gate_2": { + "name": "Motion energy gate 2" + }, + "motion_energy_gate_3": { + "name": "Motion energy gate 3" + }, + "motion_energy_gate_4": { + "name": "Motion energy gate 4" + }, + "motion_energy_gate_5": { + "name": "Motion energy gate 5" + }, + "motion_energy_gate_6": { + "name": "Motion energy gate 6" + }, + "motion_energy_gate_7": { + "name": "Motion energy gate 7" + }, + "motion_energy_gate_8": { + "name": "Motion energy gate 8" + }, + "static_energy_gate_0": { + "name": "Static energy gate 0" + }, + "static_energy_gate_1": { + "name": "Static energy gate 1" + }, + "static_energy_gate_2": { + "name": "Static energy gate 2" + }, + "static_energy_gate_3": { + "name": "Static energy gate 3" + }, + "static_energy_gate_4": { + "name": "Static energy gate 4" + }, + "static_energy_gate_5": { + "name": "Static energy gate 5" + }, + "static_energy_gate_6": { + "name": "Static energy gate 6" + }, + "static_energy_gate_7": { + "name": "Static energy gate 7" + }, + "static_energy_gate_8": { + "name": "Static energy gate 8" + } + } } } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index cdc270f2e99..5a1eef40001 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -30,7 +30,7 @@ "codeowners": ["@bdraco"], "config_flow": true, "dependencies": ["bluetooth_adapters"], - "documentation": "https://www.home-assistant.io/integrations/led_ble/", + "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.3.0", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==1.6.1", "led-ble==1.0.0"] } diff --git a/homeassistant/components/life360/manifest.json b/homeassistant/components/life360/manifest.json index bfecce8d3ed..18b83013d70 100644 --- a/homeassistant/components/life360/manifest.json +++ b/homeassistant/components/life360/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/life360", "iot_class": "cloud_polling", "loggers": ["life360"], - "requirements": ["life360==5.5.0"] + "requirements": ["life360==6.0.0"] } diff --git a/homeassistant/components/lifx/binary_sensor.py b/homeassistant/components/lifx/binary_sensor.py index 110661b1c5c..5719c881d1f 100644 --- a/homeassistant/components/lifx/binary_sensor.py +++ b/homeassistant/components/lifx/binary_sensor.py @@ -18,7 +18,7 @@ from .util import lifx_features HEV_CYCLE_STATE_SENSOR = BinarySensorEntityDescription( key=HEV_CYCLE_STATE, - name="Clean Cycle", + translation_key="clean_cycle", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.RUNNING, ) @@ -39,8 +39,6 @@ async def async_setup_entry( class LIFXHevCycleBinarySensorEntity(LIFXEntity, BinarySensorEntity): """LIFX HEV cycle state binary sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: LIFXUpdateCoordinator, diff --git a/homeassistant/components/lifx/button.py b/homeassistant/components/lifx/button.py index 00d216351a0..86e3bc569b1 100644 --- a/homeassistant/components/lifx/button.py +++ b/homeassistant/components/lifx/button.py @@ -17,7 +17,6 @@ from .entity import LIFXEntity RESTART_BUTTON_DESCRIPTION = ButtonEntityDescription( key=RESTART, - name="Restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, ) @@ -45,8 +44,7 @@ async def async_setup_entry( class LIFXButton(LIFXEntity, ButtonEntity): """Base LIFX button.""" - _attr_has_entity_name: bool = True - _attr_should_poll: bool = False + _attr_should_poll = False def __init__(self, coordinator: LIFXUpdateCoordinator) -> None: """Initialise a LIFX button.""" diff --git a/homeassistant/components/lifx/entity.py b/homeassistant/components/lifx/entity.py index a86bda53cfd..5f08b6e7884 100644 --- a/homeassistant/components/lifx/entity.py +++ b/homeassistant/components/lifx/entity.py @@ -14,6 +14,8 @@ from .coordinator import LIFXUpdateCoordinator class LIFXEntity(CoordinatorEntity[LIFXUpdateCoordinator]): """Representation of a LIFX entity with a coordinator.""" + _attr_has_entity_name = True + def __init__(self, coordinator: LIFXUpdateCoordinator) -> None: """Initialise the light.""" super().__init__(coordinator) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index cb901dcbe47..0e56155832f 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -112,6 +112,7 @@ class LIFXLight(LIFXEntity, LightEntity): """Representation of a LIFX light.""" _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT + _attr_name = None def __init__( self, @@ -131,7 +132,6 @@ class LIFXLight(LIFXEntity, LightEntity): self.postponed_update: CALLBACK_TYPE | None = None self.entry = entry self._attr_unique_id = self.coordinator.serial_number - self._attr_name = self.bulb.label self._attr_min_color_temp_kelvin = bulb_features["min_kelvin"] self._attr_max_color_temp_kelvin = bulb_features["max_kelvin"] if bulb_features["min_kelvin"] != bulb_features["max_kelvin"]: diff --git a/homeassistant/components/lifx/select.py b/homeassistant/components/lifx/select.py index 9ad457e0270..183e31dec1f 100644 --- a/homeassistant/components/lifx/select.py +++ b/homeassistant/components/lifx/select.py @@ -23,14 +23,14 @@ THEME_NAMES = [theme_name.lower() for theme_name in ThemeLibrary().themes] INFRARED_BRIGHTNESS_ENTITY = SelectEntityDescription( key=INFRARED_BRIGHTNESS, - name="Infrared brightness", + translation_key="infrared_brightness", entity_category=EntityCategory.CONFIG, options=list(INFRARED_BRIGHTNESS_VALUES_MAP.values()), ) THEME_ENTITY = SelectEntityDescription( key=ATTR_THEME, - name="Theme", + translation_key="theme", entity_category=EntityCategory.CONFIG, options=THEME_NAMES, ) @@ -58,8 +58,6 @@ async def async_setup_entry( class LIFXInfraredBrightnessSelectEntity(LIFXEntity, SelectEntity): """LIFX Nightvision infrared brightness configuration entity.""" - _attr_has_entity_name = True - def __init__( self, coordinator: LIFXUpdateCoordinator, @@ -90,8 +88,6 @@ class LIFXInfraredBrightnessSelectEntity(LIFXEntity, SelectEntity): class LIFXThemeSelectEntity(LIFXEntity, SelectEntity): """Theme entity for LIFX multizone devices.""" - _attr_has_entity_name = True - def __init__( self, coordinator: LIFXUpdateCoordinator, diff --git a/homeassistant/components/lifx/sensor.py b/homeassistant/components/lifx/sensor.py index 654b5285756..e10f9579bc3 100644 --- a/homeassistant/components/lifx/sensor.py +++ b/homeassistant/components/lifx/sensor.py @@ -22,7 +22,7 @@ SCAN_INTERVAL = timedelta(seconds=30) RSSI_SENSOR = SensorEntityDescription( key=ATTR_RSSI, - name="RSSI", + translation_key="rssi", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -41,8 +41,6 @@ async def async_setup_entry( class LIFXRssiSensor(LIFXEntity, SensorEntity): """LIFX RSSI sensor.""" - _attr_has_entity_name = True - def __init__( self, coordinator: LIFXUpdateCoordinator, diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml index 6613bb6a329..83d31439666 100644 --- a/homeassistant/components/lifx/services.yaml +++ b/homeassistant/components/lifx/services.yaml @@ -1,21 +1,15 @@ set_hev_cycle_state: - name: Set HEV cycle state - description: Control the HEV LEDs on a LIFX Clean bulb. target: entity: integration: lifx domain: light fields: power: - name: enable - description: Start or stop a Clean cycle. required: true example: true selector: boolean: duration: - name: Duration - description: How long the HEV LEDs will remain on. Uses the configured default duration if not specified. required: false default: 7200 example: 3600 @@ -25,51 +19,37 @@ set_hev_cycle_state: max: 86400 unit_of_measurement: seconds set_state: - name: Set State - description: Set a color/brightness and possibly turn the light on/off. target: entity: integration: lifx domain: light fields: infrared: - name: infrared - description: Automatic infrared level when light brightness is low. selector: number: min: 0 max: 255 zones: - name: Zones - description: List of zone numbers to affect (8 per LIFX Z, starts at 0). example: "[0,5]" selector: object: transition: - name: Transition - description: Duration it takes to get to the final state. selector: number: min: 0 max: 3600 unit_of_measurement: seconds power: - name: Power - description: Turn the light on or off. Leave out to keep the power as it is. selector: boolean: effect_pulse: - name: Pulse effect - description: Run a flash effect by changing to a color and back. target: entity: integration: lifx domain: light fields: mode: - name: Mode - description: "Decides how colors are changed." selector: select: options: @@ -79,35 +59,25 @@ effect_pulse: - "strobe" - "solid" brightness: - name: Brightness value - description: Number indicating brightness of the temporary color, where 1 is the minimum brightness and 255 is the maximum brightness supported by the light. selector: number: min: 1 max: 255 brightness_pct: - name: Brightness - description: Percentage indicating the brightness of the temporary color, where 1 is the minimum brightness and 100 is the maximum brightness supported by the light. selector: number: min: 1 max: 100 unit_of_measurement: "%" color_name: - name: Color name - description: A human readable color name. example: "red" selector: text: rgb_color: - name: RGB color - description: The temporary color in RGB-format. example: "[255, 100, 100]" selector: object: period: - name: Period - description: Duration of the effect. default: 1.0 selector: number: @@ -116,46 +86,34 @@ effect_pulse: step: 0.05 unit_of_measurement: seconds cycles: - name: Cycles - description: Number of times the effect should run. default: 1 selector: number: min: 1 max: 10000 power_on: - name: Power on - description: Powered off lights are temporarily turned on during the effect. default: true selector: boolean: effect_colorloop: - name: Color loop effect - description: Run an effect with looping colors. target: entity: integration: lifx domain: light fields: brightness: - name: Brightness value - description: Number indicating brightness of the color loop, where 1 is the minimum brightness and 255 is the maximum brightness supported by the light. selector: number: min: 0 max: 255 brightness_pct: - name: Brightness - description: Percentage indicating the brightness of the color loop, where 1 is the minimum brightness and 100 is the maximum brightness supported by the light. selector: number: min: 0 max: 100 unit_of_measurement: "%" saturation_min: - name: Minimum saturation - description: Percentage indicating the minimum saturation of the colors in the loop. default: 80 selector: number: @@ -163,8 +121,6 @@ effect_colorloop: max: 100 unit_of_measurement: "%" saturation_max: - name: Maximum saturation - description: Percentage indicating the maximum saturation of the colors in the loop. default: 100 selector: number: @@ -172,8 +128,6 @@ effect_colorloop: max: 100 unit_of_measurement: "%" period: - name: Period - description: Duration between color changes. default: 60 selector: number: @@ -182,8 +136,6 @@ effect_colorloop: step: 0.05 unit_of_measurement: seconds change: - name: Change - description: Hue movement per period, in degrees on a color wheel. default: 20 selector: number: @@ -191,8 +143,6 @@ effect_colorloop: max: 360 unit_of_measurement: "°" spread: - name: Spread - description: Maximum hue difference between participating lights, in degrees on a color wheel. default: 30 selector: number: @@ -200,22 +150,16 @@ effect_colorloop: max: 360 unit_of_measurement: "°" power_on: - name: Power on - description: Powered off lights are temporarily turned on during the effect. default: true selector: boolean: effect_move: - name: Move effect - description: Start the firmware-based Move effect on a LIFX Z, Lightstrip or Beam. target: entity: integration: lifx domain: light fields: speed: - name: Speed - description: How long in seconds for the effect to move across the length of the light. default: 3.0 example: 3.0 selector: @@ -225,8 +169,6 @@ effect_move: step: 0.1 unit_of_measurement: seconds direction: - name: Direction - description: Direction the effect will move across the device. default: right example: right selector: @@ -236,8 +178,6 @@ effect_move: - right - left theme: - name: Theme - description: (Optional) set one of the predefined themes onto the device before starting the effect. example: exciting default: exciting selector: @@ -269,22 +209,16 @@ effect_move: - "tranquil" - "warming" power_on: - name: Power on - description: Powered off lights will be turned on before starting the effect. default: true selector: boolean: effect_flame: - name: Flame effect - description: Start the firmware-based Flame effect on LIFX Tiles or Candle. target: entity: integration: lifx domain: light fields: speed: - name: Speed - description: How fast the flames will move. default: 3 selector: number: @@ -293,22 +227,16 @@ effect_flame: step: 1 unit_of_measurement: seconds power_on: - name: Power on - description: Powered off lights will be turned on before starting the effect. default: true selector: boolean: effect_morph: - name: Morph effect - description: Start the firmware-based Morph effect on LIFX Tiles on Candle. target: entity: integration: lifx domain: light fields: speed: - name: Speed - description: How fast the colors will move. default: 3 selector: number: @@ -317,15 +245,11 @@ effect_morph: step: 1 unit_of_measurement: seconds palette: - name: Palette - description: List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-900) values to use for this effect. Overrides the theme attribute. example: - "[[0, 100, 100, 3500], [60, 100, 100, 3500]]" selector: object: theme: - name: Theme - description: Predefined color theme to use for the effect. Overridden by the palette attribute. selector: select: options: @@ -354,14 +278,10 @@ effect_morph: - "tranquil" - "warming" power_on: - name: Power on - description: Powered off lights will be turned on before starting the effect. default: true selector: boolean: effect_stop: - name: Stop effect - description: Stop a running effect. target: entity: integration: lifx diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index 93d3bd62abe..9d155ae32ae 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -10,7 +10,7 @@ }, "pick_device": { "data": { - "device": "Device" + "device": "[%key:common::config_flow::data::device%]" } }, "discovery_confirm": { @@ -25,5 +25,201 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "binary_sensor": { + "clean_cycle": { + "name": "Clean cycle" + } + }, + "select": { + "infrared_brightness": { + "name": "Infrared brightness" + }, + "theme": { + "name": "Theme" + } + }, + "sensor": { + "rssi": { + "name": "RSSI" + } + } + }, + "services": { + "set_hev_cycle_state": { + "name": "Set HEV cycle state", + "description": "Controls the HEV LEDs on a LIFX Clean bulb.", + "fields": { + "power": { + "name": "[%key:common::action::enable%]", + "description": "Start or stop a Clean cycle." + }, + "duration": { + "name": "Duration", + "description": "How long the HEV LEDs will remain on. Uses the configured default duration if not specified." + } + } + }, + "set_state": { + "name": "Set State", + "description": "Sets a color/brightness and possibly turn the light on/off.", + "fields": { + "infrared": { + "name": "Infrared", + "description": "Automatic infrared level when light brightness is low." + }, + "zones": { + "name": "Zones", + "description": "List of zone numbers to affect (8 per LIFX Z, starts at 0)." + }, + "transition": { + "name": "Transition", + "description": "Duration it takes to get to the final state." + }, + "power": { + "name": "Power", + "description": "Turn the light on or off. Leave out to keep the power as it is." + } + } + }, + "effect_pulse": { + "name": "Pulse effect", + "description": "Runs a flash effect by changing to a color and back.", + "fields": { + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "Decides how colors are changed." + }, + "brightness": { + "name": "Brightness value", + "description": "Number indicating brightness of the temporary color, where 1 is the minimum brightness and 255 is the maximum brightness supported by the light." + }, + "brightness_pct": { + "name": "Brightness", + "description": "Percentage indicating the brightness of the temporary color, where 1 is the minimum brightness and 100 is the maximum brightness supported by the light." + }, + "color_name": { + "name": "Color name", + "description": "A human readable color name." + }, + "rgb_color": { + "name": "RGB color", + "description": "The temporary color in RGB-format." + }, + "period": { + "name": "Period", + "description": "Duration of the effect." + }, + "cycles": { + "name": "Cycles", + "description": "Number of times the effect should run." + }, + "power_on": { + "name": "Power on", + "description": "Powered off lights are temporarily turned on during the effect." + } + } + }, + "effect_colorloop": { + "name": "Color loop effect", + "description": "Runs an effect with looping colors.", + "fields": { + "brightness": { + "name": "Brightness value", + "description": "Number indicating brightness of the color loop, where 1 is the minimum brightness and 255 is the maximum brightness supported by the light." + }, + "brightness_pct": { + "name": "Brightness", + "description": "Percentage indicating the brightness of the color loop, where 1 is the minimum brightness and 100 is the maximum brightness supported by the light." + }, + "saturation_min": { + "name": "Minimum saturation", + "description": "Percentage indicating the minimum saturation of the colors in the loop." + }, + "saturation_max": { + "name": "Maximum saturation", + "description": "Percentage indicating the maximum saturation of the colors in the loop." + }, + "period": { + "name": "[%key:component::lifx::services::effect_pulse::fields::period::name%]", + "description": "Duration between color changes." + }, + "change": { + "name": "Change", + "description": "Hue movement per period, in degrees on a color wheel." + }, + "spread": { + "name": "Spread", + "description": "Maximum hue difference between participating lights, in degrees on a color wheel." + }, + "power_on": { + "name": "Power on", + "description": "[%key:component::lifx::services::effect_pulse::fields::power_on::description%]" + } + } + }, + "effect_move": { + "name": "Move effect", + "description": "Starts the firmware-based Move effect on a LIFX Z, Lightstrip or Beam.", + "fields": { + "speed": { + "name": "Speed", + "description": "How long in seconds for the effect to move across the length of the light." + }, + "direction": { + "name": "Direction", + "description": "Direction the effect will move across the device." + }, + "theme": { + "name": "[%key:component::lifx::entity::select::theme::name%]", + "description": "(Optional) set one of the predefined themes onto the device before starting the effect." + }, + "power_on": { + "name": "Power on", + "description": "Powered off lights will be turned on before starting the effect." + } + } + }, + "effect_flame": { + "name": "Flame effect", + "description": "Starts the firmware-based Flame effect on LIFX Tiles or Candle.", + "fields": { + "speed": { + "name": "Speed", + "description": "How fast the flames will move." + }, + "power_on": { + "name": "Power on", + "description": "[%key:component::lifx::services::effect_move::fields::power_on::description%]" + } + } + }, + "effect_morph": { + "name": "Morph effect", + "description": "Starts the firmware-based Morph effect on LIFX Tiles on Candle.", + "fields": { + "speed": { + "name": "Speed", + "description": "How fast the colors will move." + }, + "palette": { + "name": "Palette", + "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-900) values to use for this effect. Overrides the theme attribute." + }, + "theme": { + "name": "[%key:component::lifx::entity::select::theme::name%]", + "description": "Predefined color theme to use for the effect. Overridden by the palette attribute." + }, + "power_on": { + "name": "Power on", + "description": "[%key:component::lifx::services::effect_move::fields::power_on::description%]" + } + } + }, + "effect_stop": { + "name": "Stop effect", + "description": "Stops a running effect." + } } } diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 0c3a711a738..f7f0150bdd2 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -5,15 +5,13 @@ from collections.abc import Iterable import csv import dataclasses from datetime import timedelta -from enum import IntFlag +from enum import IntFlag, StrEnum import logging import os -from typing import Any, cast, final +from typing import Any, Self, cast, final -from typing_extensions import Self import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_TOGGLE, diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index d1221dd1210..1ba204e5eda 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -1,17 +1,11 @@ # Describes the format for available light services turn_on: - name: Turn on - description: > - Turn on one or more lights and adjust properties of the light, even when - they are turned on already. target: entity: domain: light fields: transition: - name: Transition - description: Duration it takes to get to next state. filter: supported_features: - light.LightEntityFeature.TRANSITION @@ -21,8 +15,6 @@ turn_on: max: 300 unit_of_measurement: seconds rgb_color: - name: Color - description: The color for the light (based on RGB - red, green, blue). filter: attribute: supported_color_modes: @@ -34,8 +26,6 @@ turn_on: selector: color_rgb: rgbw_color: - name: RGBW-color - description: A list containing four integers between 0 and 255 representing the RGBW (red, green, blue, white) color for the light. filter: attribute: supported_color_modes: @@ -49,8 +39,6 @@ turn_on: selector: object: rgbww_color: - name: RGBWW-color - description: A list containing five integers between 0 and 255 representing the RGBWW (red, green, blue, cold white, warm white) color for the light. filter: attribute: supported_color_modes: @@ -64,8 +52,6 @@ turn_on: selector: object: color_name: - name: Color name - description: A human readable color name. filter: attribute: supported_color_modes: @@ -77,6 +63,7 @@ turn_on: advanced: true selector: select: + translation_key: color_name options: - "homeassistant" - "aliceblue" @@ -228,8 +215,6 @@ turn_on: - "yellow" - "yellowgreen" hs_color: - name: Hue/Sat color - description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. filter: attribute: supported_color_modes: @@ -243,8 +228,6 @@ turn_on: selector: object: xy_color: - name: XY-color - description: Color for the light in XY-format. filter: attribute: supported_color_modes: @@ -258,8 +241,6 @@ turn_on: selector: object: color_temp: - name: Color temperature - description: Color temperature for the light in mireds. filter: attribute: supported_color_modes: @@ -274,8 +255,6 @@ turn_on: min_mireds: 153 max_mireds: 500 kelvin: - name: Color temperature (Kelvin) - description: Color temperature for the light in Kelvin. filter: attribute: supported_color_modes: @@ -293,10 +272,6 @@ turn_on: step: 100 unit_of_measurement: K brightness: - name: Brightness value - description: Number indicating brightness, where 0 turns the light - off, 1 is the minimum brightness and 255 is the maximum brightness - supported by the light. filter: attribute: supported_color_modes: @@ -313,10 +288,6 @@ turn_on: min: 0 max: 255 brightness_pct: - name: Brightness - description: Number indicating percentage of full brightness, where 0 - turns the light off, 1 is the minimum brightness and 100 is the maximum - brightness supported by the light. filter: attribute: supported_color_modes: @@ -333,8 +304,6 @@ turn_on: max: 100 unit_of_measurement: "%" brightness_step: - name: Brightness step value - description: Change brightness by an amount. filter: attribute: supported_color_modes: @@ -351,8 +320,6 @@ turn_on: min: -225 max: 255 brightness_step_pct: - name: Brightness step - description: Change brightness by a percentage. filter: attribute: supported_color_modes: @@ -369,8 +336,6 @@ turn_on: max: 100 unit_of_measurement: "%" white: - name: White - description: Set the light to white mode. filter: attribute: supported_color_modes: @@ -381,15 +346,11 @@ turn_on: value: true label: Enabled profile: - name: Profile - description: Name of a light profile to use. advanced: true example: relax selector: text: flash: - name: Flash - description: If the light should flash. filter: supported_features: - light.LightEntityFeature.FLASH @@ -402,8 +363,6 @@ turn_on: - label: "Short" value: "short" effect: - name: Effect - description: Light effect. filter: supported_features: - light.LightEntityFeature.EFFECT @@ -411,15 +370,11 @@ turn_on: text: turn_off: - name: Turn off - description: Turns off one or more lights. target: entity: domain: light fields: transition: - name: Transition - description: Duration it takes to get to next state. filter: supported_features: - light.LightEntityFeature.TRANSITION @@ -429,8 +384,6 @@ turn_off: max: 300 unit_of_measurement: seconds flash: - name: Flash - description: If the light should flash. filter: supported_features: - light.LightEntityFeature.FLASH @@ -444,17 +397,11 @@ turn_off: value: "short" toggle: - name: Toggle - description: > - Toggles one or more lights, from on to off, or, off to on, based on their - current state. target: entity: domain: light fields: transition: - name: Transition - description: Duration it takes to get to next state. filter: supported_features: - light.LightEntityFeature.TRANSITION @@ -464,8 +411,6 @@ toggle: max: 300 unit_of_measurement: seconds rgb_color: - name: RGB-color - description: Color for the light in RGB-format. filter: attribute: supported_color_modes: @@ -479,8 +424,6 @@ toggle: selector: object: color_name: - name: Color name - description: A human readable color name. filter: attribute: supported_color_modes: @@ -492,6 +435,7 @@ toggle: advanced: true selector: select: + translation_key: color_name options: - "homeassistant" - "aliceblue" @@ -643,8 +587,6 @@ toggle: - "yellow" - "yellowgreen" hs_color: - name: Hue/Sat color - description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100. filter: attribute: supported_color_modes: @@ -658,8 +600,6 @@ toggle: selector: object: xy_color: - name: XY-color - description: Color for the light in XY-format. filter: attribute: supported_color_modes: @@ -673,8 +613,6 @@ toggle: selector: object: color_temp: - name: Color temperature (mireds) - description: Color temperature for the light in mireds. filter: attribute: supported_color_modes: @@ -688,8 +626,6 @@ toggle: selector: color_temp: kelvin: - name: Color temperature (Kelvin) - description: Color temperature for the light in Kelvin. filter: attribute: supported_color_modes: @@ -707,10 +643,6 @@ toggle: step: 100 unit_of_measurement: K brightness: - name: Brightness value - description: Number indicating brightness, where 0 turns the light - off, 1 is the minimum brightness and 255 is the maximum brightness - supported by the light. filter: attribute: supported_color_modes: @@ -727,10 +659,6 @@ toggle: min: 0 max: 255 brightness_pct: - name: Brightness - description: Number indicating percentage of full brightness, where 0 - turns the light off, 1 is the minimum brightness and 100 is the maximum - brightness supported by the light. filter: attribute: supported_color_modes: @@ -747,8 +675,6 @@ toggle: max: 100 unit_of_measurement: "%" white: - name: White - description: Set the light to white mode. filter: attribute: supported_color_modes: @@ -759,15 +685,11 @@ toggle: value: true label: Enabled profile: - name: Profile - description: Name of a light profile to use. advanced: true example: relax selector: text: flash: - name: Flash - description: If the light should flash. filter: supported_features: - light.LightEntityFeature.FLASH @@ -780,8 +702,6 @@ toggle: - label: "Short" value: "short" effect: - name: Effect - description: Light effect. filter: supported_features: - light.LightEntityFeature.EFFECT diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index f89497b5ef9..5398d38ca5d 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -4,19 +4,19 @@ "action_type": { "brightness_decrease": "Decrease {entity_name} brightness", "brightness_increase": "Increase {entity_name} brightness", - "toggle": "Toggle {entity_name}", - "turn_on": "Turn on {entity_name}", - "turn_off": "Turn off {entity_name}", + "toggle": "[%key:common::device_automation::action_type::toggle%]", + "turn_on": "[%key:common::device_automation::action_type::turn_on%]", + "turn_off": "[%key:common::device_automation::action_type::turn_off%]", "flash": "Flash {entity_name}" }, "condition_type": { - "is_on": "{entity_name} is on", - "is_off": "{entity_name} is off" + "is_on": "[%key:common::device_automation::condition_type::is_on%]", + "is_off": "[%key:common::device_automation::condition_type::is_off%]" }, "trigger_type": { - "changed_states": "{entity_name} turned on or off", - "turned_on": "{entity_name} turned on", - "turned_off": "{entity_name} turned off" + "changed_states": "[%key:common::device_automation::trigger_type::changed_states%]", + "turned_on": "[%key:common::device_automation::trigger_type::turned_on%]", + "turned_off": "[%key:common::device_automation::trigger_type::turned_off%]" } }, "entity_component": { @@ -87,10 +87,306 @@ } } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "selector": { + "color_name": { + "options": { + "homeassistant": "Home Assistant", + "aliceblue": "Alice blue", + "antiquewhite": "Antique white", + "aqua": "Aqua", + "aquamarine": "Aquamarine", + "azure": "Azure", + "beige": "Beige", + "bisque": "Bisque", + "blanchedalmond": "Blanched almond", + "blue": "Blue", + "blueviolet": "Blue violet", + "brown": "Brown", + "burlywood": "Burlywood", + "cadetblue": "Cadet blue", + "chartreuse": "Chartreuse", + "chocolate": "Chocolate", + "coral": "Coral", + "cornflowerblue": "Cornflower blue", + "cornsilk": "Cornsilk", + "crimson": "Crimson", + "cyan": "Cyan", + "darkblue": "Dark blue", + "darkcyan": "Dark cyan", + "darkgoldenrod": "Dark goldenrod", + "darkgray": "Dark gray", + "darkgreen": "Dark green", + "darkgrey": "Dark grey", + "darkkhaki": "Dark khaki", + "darkmagenta": "Dark magenta", + "darkolivegreen": "Dark olive green", + "darkorange": "Dark orange", + "darkorchid": "Dark orchid", + "darkred": "Dark red", + "darksalmon": "Dark salmon", + "darkseagreen": "Dark sea green", + "darkslateblue": "Dark slate blue", + "darkslategray": "Dark slate gray", + "darkslategrey": "Dark slate grey", + "darkturquoise": "Dark turquoise", + "darkviolet": "Dark violet", + "deeppink": "Deep pink", + "deepskyblue": "Deep sky blue", + "dimgray": "Dim gray", + "dimgrey": "Dim grey", + "dodgerblue": "Dodger blue", + "firebrick": "Fire brick", + "floralwhite": "Floral white", + "forestgreen": "Forest green", + "fuchsia": "Fuchsia", + "gainsboro": "Gainsboro", + "ghostwhite": "Ghost white", + "gold": "Gold", + "goldenrod": "Goldenrod", + "gray": "Gray", + "green": "Green", + "greenyellow": "Green yellow", + "grey": "Grey", + "honeydew": "Honeydew", + "hotpink": "Hot pink", + "indianred": "Indian red", + "indigo": "Indigo", + "ivory": "Ivory", + "khaki": "Khaki", + "lavender": "Lavender", + "lavenderblush": "Lavender blush", + "lawngreen": "Lawn green", + "lemonchiffon": "Lemon chiffon", + "lightblue": "Light blue", + "lightcoral": "Light coral", + "lightcyan": "Light cyan", + "lightgoldenrodyellow": "Light goldenrod yellow", + "lightgray": "Light gray", + "lightgreen": "Light green", + "lightgrey": "Light grey", + "lightpink": "Light pink", + "lightsalmon": "Light salmon", + "lightseagreen": "Light sea green", + "lightskyblue": "Light sky blue", + "lightslategray": "Light slate gray", + "lightslategrey": "Light slate grey", + "lightsteelblue": "Light steel blue", + "lightyellow": "Light yellow", + "lime": "Lime", + "limegreen": "Lime green", + "linen": "Linen", + "magenta": "Magenta", + "maroon": "Maroon", + "mediumaquamarine": "Medium aquamarine", + "mediumblue": "Medium blue", + "mediumorchid": "Medium orchid", + "mediumpurple": "Medium purple", + "mediumseagreen": "Medium sea green", + "mediumslateblue": "Medium slate blue", + "mediumspringgreen": "Medium spring green", + "mediumturquoise": "Medium turquoise", + "mediumvioletred": "Medium violet red", + "midnightblue": "Midnight blue", + "mintcream": "Mint cream", + "mistyrose": "Misty rose", + "moccasin": "Moccasin", + "navajowhite": "Navajo white", + "navy": "Navy", + "navyblue": "Navy blue", + "oldlace": "Old lace", + "olive": "Olive", + "olivedrab": "Olive drab", + "orange": "Orange", + "orangered": "Orange red", + "orchid": "Orchid", + "palegoldenrod": "Pale goldenrod", + "palegreen": "Pale green", + "paleturquoise": "Pale turquoise", + "palevioletred": "Pale violet red", + "papayawhip": "Papaya whip", + "peachpuff": "Peach puff", + "peru": "Peru", + "pink": "Pink", + "plum": "Plum", + "powderblue": "Powder blue", + "purple": "Purple", + "red": "Red", + "rosybrown": "Rosy brown", + "royalblue": "Royal blue", + "saddlebrown": "Saddle brown", + "salmon": "Salmon", + "sandybrown": "Sandy brown", + "seagreen": "Sea green", + "seashell": "Seashell", + "sienna": "Sienna", + "silver": "Silver", + "skyblue": "Sky blue", + "slateblue": "Slate blue", + "slategray": "Slate gray", + "slategrey": "Slate grey", + "snow": "Snow", + "springgreen": "Spring green", + "steelblue": "Steel blue", + "tan": "Tan", + "teal": "Teal", + "thistle": "Thistle", + "tomato": "Tomato", + "turquoise": "Turquoise", + "violet": "Violet", + "wheat": "Wheat", + "white": "White", + "whitesmoke": "White smoke", + "yellow": "Yellow", + "yellowgreen": "Yellow green" + } + } + }, + "services": { + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Turn on one or more lights and adjust properties of the light, even when they are turned on already.", + "fields": { + "transition": { + "name": "Transition", + "description": "Duration it takes to get to next state." + }, + "rgb_color": { + "name": "Color", + "description": "The color in RGB format. A list of three integers between 0 and 255 representing the values of red, green, and blue." + }, + "rgbw_color": { + "name": "RGBW-color", + "description": "The color in RGBW format. A list of four integers between 0 and 255 representing the values of red, green, blue, and white." + }, + "rgbww_color": { + "name": "RGBWW-color", + "description": "The color in RGBWW format. A list of five integers between 0 and 255 representing the values of red, green, blue, cold white, and warm white." + }, + "color_name": { + "name": "Color name", + "description": "A human readable color name." + }, + "hs_color": { + "name": "Hue/Sat color", + "description": "Color in hue/sat format. A list of two integers. Hue is 0-360 and Sat is 0-100." + }, + "xy_color": { + "name": "XY-color", + "description": "Color in XY-format. A list of two decimal numbers between 0 and 1." + }, + "color_temp": { + "name": "Color temperature", + "description": "Color temperature in mireds." + }, + "kelvin": { + "name": "Color temperature", + "description": "Color temperature in Kelvin." + }, + "brightness": { + "name": "Brightness value", + "description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness, and 255 is the maximum brightness." + }, + "brightness_pct": { + "name": "Brightness", + "description": "Number indicating the percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness, and 100 is the maximum brightness." + }, + "brightness_step": { + "name": "Brightness step value", + "description": "Change brightness by an amount." + }, + "brightness_step_pct": { + "name": "Brightness step", + "description": "Change brightness by a percentage." + }, + "white": { + "name": "White", + "description": "Set the light to white mode." + }, + "profile": { + "name": "Profile", + "description": "Name of a light profile to use." + }, + "flash": { + "name": "Flash", + "description": "If the light should flash." + }, + "effect": { + "name": "Effect", + "description": "Light effect." + } + } + }, + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Turn off one or more lights.", + "fields": { + "transition": { + "name": "[%key:component::light::services::turn_on::fields::transition::name%]", + "description": "[%key:component::light::services::turn_on::fields::transition::description%]" + }, + "flash": { + "name": "[%key:component::light::services::turn_on::fields::flash::name%]", + "description": "[%key:component::light::services::turn_on::fields::flash::description%]" + } + } + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles one or more lights, from on to off, or, off to on, based on their current state.", + "fields": { + "transition": { + "name": "[%key:component::light::services::turn_on::fields::transition::name%]", + "description": "[%key:component::light::services::turn_on::fields::transition::description%]" + }, + "rgb_color": { + "name": "[%key:component::light::services::turn_on::fields::rgb_color::name%]", + "description": "[%key:component::light::services::turn_on::fields::rgb_color::description%]" + }, + "color_name": { + "name": "[%key:component::light::services::turn_on::fields::color_name::name%]", + "description": "[%key:component::light::services::turn_on::fields::color_name::description%]" + }, + "hs_color": { + "name": "[%key:component::light::services::turn_on::fields::hs_color::name%]", + "description": "[%key:component::light::services::turn_on::fields::hs_color::description%]" + }, + "xy_color": { + "name": "[%key:component::light::services::turn_on::fields::xy_color::name%]", + "description": "[%key:component::light::services::turn_on::fields::xy_color::description%]" + }, + "color_temp": { + "name": "[%key:component::light::services::turn_on::fields::color_temp::name%]", + "description": "[%key:component::light::services::turn_on::fields::color_temp::description%]" + }, + "kelvin": { + "name": "[%key:component::light::services::turn_on::fields::kelvin::name%]", + "description": "[%key:component::light::services::turn_on::fields::kelvin::description%]" + }, + "brightness": { + "name": "[%key:component::light::services::turn_on::fields::brightness::name%]", + "description": "[%key:component::light::services::turn_on::fields::brightness::description%]" + }, + "brightness_pct": { + "name": "[%key:component::light::services::turn_on::fields::brightness_pct::name%]", + "description": "[%key:component::light::services::turn_on::fields::brightness_pct::description%]" + }, + "white": { + "name": "[%key:component::light::services::turn_on::fields::white::name%]", + "description": "[%key:component::light::services::turn_on::fields::white::description%]" + }, + "profile": { + "name": "[%key:component::light::services::turn_on::fields::profile::name%]", + "description": "[%key:component::light::services::turn_on::fields::profile::description%]" + }, + "flash": { + "name": "[%key:component::light::services::turn_on::fields::flash::name%]", + "description": "[%key:component::light::services::turn_on::fields::flash::description%]" + }, + "effect": { + "name": "[%key:component::light::services::turn_on::fields::effect::name%]", + "description": "[%key:component::light::services::turn_on::fields::effect::description%]" + } + } } } } diff --git a/homeassistant/components/litterrobot/services.yaml b/homeassistant/components/litterrobot/services.yaml index 164445e375f..48d17dfdcf7 100644 --- a/homeassistant/components/litterrobot/services.yaml +++ b/homeassistant/components/litterrobot/services.yaml @@ -1,21 +1,15 @@ # Describes the format for available Litter-Robot services set_sleep_mode: - name: Set sleep mode - description: Set the sleep mode and start time. target: entity: integration: litterrobot fields: enabled: - name: Enabled - description: Whether sleep mode should be enabled. required: true selector: boolean: start_time: - name: Start time - description: The start time at which the Litter-Robot will enter sleep mode and prevent an automatic clean cycle for 8 hours. required: false example: '"22:30:00"' selector: diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 00a8a6122db..8436d24902c 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -25,12 +25,6 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, - "issues": { - "migrated_attributes": { - "title": "Litter-Robot attributes are now their own sensors", - "description": "The vacuum entity attributes are now available as diagnostic sensors.\n\nPlease adjust any automations or scripts you may have that use these attributes." - } - }, "entity": { "binary_sensor": { "sleeping": { @@ -130,7 +124,7 @@ }, "time": { "sleep_mode_start_time": { - "name": "Sleep mode start time" + "name": "[%key:component::litterrobot::entity::sensor::sleep_mode_start_time::name%]" } }, "vacuum": { @@ -143,5 +137,21 @@ "name": "Firmware" } } + }, + "services": { + "set_sleep_mode": { + "name": "Set sleep mode", + "description": "Sets the sleep mode and start time.", + "fields": { + "enabled": { + "name": "[%key:common::state::enabled%]", + "description": "Whether sleep mode should be enabled." + }, + "start_time": { + "name": "Start time", + "description": "The start time at which the Litter-Robot will enter sleep mode and prevent an automatic clean cycle for 8 hours." + } + } + } } } diff --git a/homeassistant/components/local_calendar/__init__.py b/homeassistant/components/local_calendar/__init__.py index 33ad67cc81a..7c1d2f09b04 100644 --- a/homeassistant/components/local_calendar/__init__.py +++ b/homeassistant/components/local_calendar/__init__.py @@ -39,3 +39,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle removal of an entry.""" + key = slugify(entry.data[CONF_CALENDAR_NAME]) + path = Path(hass.config.path(STORAGE_PATH.format(key=key))) + + def unlink(path: Path) -> None: + path.unlink(missing_ok=True) + + await hass.async_add_executor_job(unlink, path) diff --git a/homeassistant/components/local_calendar/strings.json b/homeassistant/components/local_calendar/strings.json index f49c92e5438..c6eb36ee88f 100644 --- a/homeassistant/components/local_calendar/strings.json +++ b/homeassistant/components/local_calendar/strings.json @@ -1,4 +1,5 @@ { + "title": "Local Calendar", "config": { "step": { "user": { diff --git a/homeassistant/components/local_file/services.yaml b/homeassistant/components/local_file/services.yaml index f4382decb0f..5fc0b11f4c2 100644 --- a/homeassistant/components/local_file/services.yaml +++ b/homeassistant/components/local_file/services.yaml @@ -1,17 +1,11 @@ update_file_path: - name: Update file path - description: Use this service to change the file displayed by the camera. fields: entity_id: - name: Entity - description: Name of the entity_id of the camera to update. required: true selector: entity: domain: camera file_path: - name: file path - description: The full path to the new image file to be displayed. required: true example: "/config/www/images/image.jpg" selector: diff --git a/homeassistant/components/local_file/strings.json b/homeassistant/components/local_file/strings.json new file mode 100644 index 00000000000..3f977fc941e --- /dev/null +++ b/homeassistant/components/local_file/strings.json @@ -0,0 +1,18 @@ +{ + "services": { + "update_file_path": { + "name": "Updates file path", + "description": "Use this service to change the file displayed by the camera.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name of the entity_id of the camera to update." + }, + "file_path": { + "name": "File path", + "description": "The full path to the new image file to be displayed." + } + } + } + } +} diff --git a/homeassistant/components/local_ip/strings.json b/homeassistant/components/local_ip/strings.json index 7e214df2592..a4d9138d88e 100644 --- a/homeassistant/components/local_ip/strings.json +++ b/homeassistant/components/local_ip/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "title": "Local IP Address", + "title": "[%key:component::local_ip::title%]", "description": "[%key:common::config_flow::description::confirm_setup%]" } }, diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml index 740d107d625..c80517d1fe1 100644 --- a/homeassistant/components/lock/services.yaml +++ b/homeassistant/components/lock/services.yaml @@ -1,43 +1,33 @@ # Describes the format for available lock services lock: - name: Lock - description: Lock all or specified locks. target: entity: domain: lock fields: code: - name: Code - description: An optional code to lock the lock with. example: 1234 selector: text: open: - name: Open - description: Open all or specified locks. target: entity: domain: lock + supported_features: + - lock.LockEntityFeature.OPEN fields: code: - name: Code - description: An optional code to open the lock with. example: 1234 selector: text: unlock: - name: Unlock - description: Unlock all or specified locks. target: entity: domain: lock fields: code: - name: Code - description: An optional code to unlock the lock with. example: 1234 selector: text: diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index b77bf5e6900..d041d6ac61a 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -35,10 +35,36 @@ } } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "lock": { + "name": "Lock", + "description": "Locks a lock.", + "fields": { + "code": { + "name": "Code", + "description": "Code used to lock the lock." + } + } + }, + "open": { + "name": "[%key:common::action::open%]", + "description": "Opens a lock.", + "fields": { + "code": { + "name": "[%key:component::lock::services::lock::fields::code::name%]", + "description": "Code used to open the lock." + } + } + }, + "unlock": { + "name": "Unlock", + "description": "Unlocks a lock.", + "fields": { + "code": { + "name": "[%key:component::lock::services::lock::fields::code::name%]", + "description": "Code used to unlock the lock." + } + } } } } diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index 3a1ec971b54..c2ea9823535 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -23,7 +23,11 @@ from homeassistant.core import ( split_entity_id, ) from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import EventType from .const import ALWAYS_CONTINUOUS_DOMAINS, AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN from .models import LogbookConfig @@ -184,11 +188,11 @@ def async_subscribe_events( return @callback - def _forward_state_events_filtered(event: Event) -> None: - if event.data.get("old_state") is None or event.data.get("new_state") is None: + def _forward_state_events_filtered(event: EventType[EventStateChangedData]) -> None: + if (old_state := event.data["old_state"]) is None or ( + new_state := event.data["new_state"] + ) is None: return - new_state: State = event.data["new_state"] - old_state: State = event.data["old_state"] if _is_state_filtered(ent_reg, new_state, old_state) or ( entities_filter and not entities_filter(new_state.entity_id) ): @@ -207,7 +211,7 @@ def async_subscribe_events( subscriptions.append( hass.bus.async_listen( EVENT_STATE_CHANGED, - _forward_state_events_filtered, + _forward_state_events_filtered, # type: ignore[arg-type] run_immediately=True, ) ) diff --git a/homeassistant/components/logbook/services.yaml b/homeassistant/components/logbook/services.yaml index 3f688628032..c6722dad10b 100644 --- a/homeassistant/components/logbook/services.yaml +++ b/homeassistant/components/logbook/services.yaml @@ -1,29 +1,19 @@ log: - name: Log - description: Create a custom entry in your logbook. fields: name: - name: Name - description: Custom name for an entity, can be referenced with entity_id. required: true example: "Kitchen" selector: text: message: - name: Message - description: Message of the custom logbook entry. required: true example: "is being used" selector: text: entity_id: - name: Entity ID - description: Entity to reference in custom logbook entry. selector: entity: domain: - name: Domain - description: Icon of domain to display in custom logbook entry. example: "light" selector: text: diff --git a/homeassistant/components/logbook/strings.json b/homeassistant/components/logbook/strings.json new file mode 100644 index 00000000000..aad9c122d23 --- /dev/null +++ b/homeassistant/components/logbook/strings.json @@ -0,0 +1,26 @@ +{ + "services": { + "log": { + "name": "Log", + "description": "Creates a custom entry in the logbook.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "Custom name for an entity, can be referenced using an `entity_id`." + }, + "message": { + "name": "Message", + "description": "Message of the logbook entry." + }, + "entity_id": { + "name": "Entity ID", + "description": "Entity to reference in the logbook entry." + }, + "domain": { + "name": "Domain", + "description": "Determines which icon is used in the logbook entry. The icon illustrates the integration domain related to this logbook entry." + } + } + } + } +} diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py index dcd4348a561..49996408a1d 100644 --- a/homeassistant/components/logger/helpers.py +++ b/homeassistant/components/logger/helpers.py @@ -5,10 +5,10 @@ from collections import defaultdict from collections.abc import Mapping import contextlib from dataclasses import asdict, dataclass +from enum import StrEnum import logging from typing import Any, cast -from homeassistant.backports.enum import StrEnum from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/logger/services.yaml b/homeassistant/components/logger/services.yaml index c20d1171bb2..d7d2a5b32e8 100644 --- a/homeassistant/components/logger/services.yaml +++ b/homeassistant/components/logger/services.yaml @@ -1,26 +1,14 @@ set_default_level: - name: Set default level - description: Set the default log level for integrations. fields: level: - name: Level - description: Default severity level for all integrations. selector: select: options: - - label: "Debug" - value: "debug" - - label: "Info" - value: "info" - - label: "Warning" - value: "warning" - - label: "Error" - value: "error" - - label: "Fatal" - value: "fatal" - - label: "Critical" - value: "critical" - + - "debug" + - "info" + - "warning" + - "error" + - "fatal" + - "critical" + translation_key: level set_level: - name: Set level - description: Set log level for integrations. diff --git a/homeassistant/components/logger/strings.json b/homeassistant/components/logger/strings.json new file mode 100644 index 00000000000..aedaec42035 --- /dev/null +++ b/homeassistant/components/logger/strings.json @@ -0,0 +1,30 @@ +{ + "services": { + "set_default_level": { + "name": "Set default level", + "description": "Sets the default log level for integrations.", + "fields": { + "level": { + "name": "Level", + "description": "Default severity level for all integrations." + } + } + }, + "set_level": { + "name": "Set level", + "description": "Sets the log level for one or more integrations." + } + }, + "selector": { + "level": { + "options": { + "debug": "Debug", + "info": "Info", + "warning": "Warning", + "error": "Error", + "fatal": "Fatal", + "critical": "Critical" + } + } + } +} diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py index 7e5d0df0259..93e23be5d8d 100644 --- a/homeassistant/components/logi_circle/__init__.py +++ b/homeassistant/components/logi_circle/__init__.py @@ -35,11 +35,11 @@ from .const import ( DOMAIN, LED_MODE_KEY, RECORDING_MODE_KEY, - SENSOR_TYPES, SIGNAL_LOGI_CIRCLE_RECONFIGURE, SIGNAL_LOGI_CIRCLE_RECORD, SIGNAL_LOGI_CIRCLE_SNAPSHOT, ) +from .sensor import SENSOR_TYPES NOTIFICATION_ID = "logi_circle_notification" NOTIFICATION_TITLE = "Logi Circle Setup" diff --git a/homeassistant/components/logi_circle/const.py b/homeassistant/components/logi_circle/const.py index 02e51993198..3e74611f767 100644 --- a/homeassistant/components/logi_circle/const.py +++ b/homeassistant/components/logi_circle/const.py @@ -1,9 +1,6 @@ """Constants in Logi Circle component.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntityDescription -from homeassistant.const import PERCENTAGE - DOMAIN = "logi_circle" DATA_LOGI = DOMAIN @@ -15,41 +12,6 @@ DEFAULT_CACHEDB = ".logi_cache.pickle" LED_MODE_KEY = "LED" RECORDING_MODE_KEY = "RECORDING_MODE" -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="battery_level", - name="Battery", - native_unit_of_measurement=PERCENTAGE, - icon="mdi:battery-50", - ), - SensorEntityDescription( - key="last_activity_time", - name="Last Activity", - icon="mdi:history", - ), - SensorEntityDescription( - key="recording", - name="Recording Mode", - icon="mdi:eye", - ), - SensorEntityDescription( - key="signal_strength_category", - name="WiFi Signal Category", - icon="mdi:wifi", - ), - SensorEntityDescription( - key="signal_strength_percentage", - name="WiFi Signal Strength", - native_unit_of_measurement=PERCENTAGE, - icon="mdi:wifi", - ), - SensorEntityDescription( - key="streaming", - name="Streaming Mode", - icon="mdi:camera", - ), -) - SIGNAL_LOGI_CIRCLE_RECONFIGURE = "logi_circle_reconfigure" SIGNAL_LOGI_CIRCLE_SNAPSHOT = "logi_circle_snapshot" SIGNAL_LOGI_CIRCLE_RECORD = "logi_circle_record" diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py index 7d4697adb64..b27ba30128f 100644 --- a/homeassistant/components/logi_circle/sensor.py +++ b/homeassistant/components/logi_circle/sensor.py @@ -10,6 +10,7 @@ from homeassistant.const import ( ATTR_BATTERY_CHARGING, CONF_MONITORED_CONDITIONS, CONF_SENSORS, + PERCENTAGE, STATE_OFF, STATE_ON, ) @@ -20,11 +21,47 @@ from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import as_local -from .const import ATTRIBUTION, DEVICE_BRAND, DOMAIN as LOGI_CIRCLE_DOMAIN, SENSOR_TYPES +from .const import ATTRIBUTION, DEVICE_BRAND, DOMAIN as LOGI_CIRCLE_DOMAIN _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="battery_level", + name="Battery", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:battery-50", + ), + SensorEntityDescription( + key="last_activity_time", + name="Last Activity", + icon="mdi:history", + ), + SensorEntityDescription( + key="recording", + name="Recording Mode", + icon="mdi:eye", + ), + SensorEntityDescription( + key="signal_strength_category", + name="WiFi Signal Category", + icon="mdi:wifi", + ), + SensorEntityDescription( + key="signal_strength_percentage", + name="WiFi Signal Strength", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:wifi", + ), + SensorEntityDescription( + key="streaming", + name="Streaming Mode", + icon="mdi:camera", + ), +) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, diff --git a/homeassistant/components/logi_circle/services.yaml b/homeassistant/components/logi_circle/services.yaml index 10df6c564b4..cb855a953a6 100644 --- a/homeassistant/components/logi_circle/services.yaml +++ b/homeassistant/components/logi_circle/services.yaml @@ -1,19 +1,13 @@ # Describes the format for available Logi Circle services set_config: - name: Set config - description: Set a configuration property. fields: entity_id: - name: Entity - description: Name(s) of entities to apply the operation mode to. selector: entity: integration: logi_circle domain: camera mode: - name: Mode - description: "Operation mode. Allowed values: LED, RECORDING_MODE." required: true selector: select: @@ -21,52 +15,36 @@ set_config: - "LED" - "RECORDING_MODE" value: - name: Value - description: "Operation value." required: true selector: boolean: livestream_snapshot: - name: Livestream snapshot - description: Take a snapshot from the camera's livestream. Will wake the camera from sleep if required. fields: entity_id: - name: Entity - description: Name(s) of entities to create snapshots from. selector: entity: integration: logi_circle domain: camera filename: - name: File name - description: Template of a Filename. Variable is entity_id. required: true example: "/tmp/snapshot_{{ entity_id }}.jpg" selector: text: livestream_record: - name: Livestream record - description: Take a video recording from the camera's livestream. fields: entity_id: - name: Entity - description: Name(s) of entities to create recordings from. selector: entity: integration: logi_circle domain: camera filename: - name: File name - description: Template of a Filename. Variable is entity_id. required: true example: "/tmp/snapshot_{{ entity_id }}.mp4" selector: text: duration: - name: Duration - description: Recording duration. required: true selector: number: diff --git a/homeassistant/components/logi_circle/strings.json b/homeassistant/components/logi_circle/strings.json index a73ade9311c..4f641238a49 100644 --- a/homeassistant/components/logi_circle/strings.json +++ b/homeassistant/components/logi_circle/strings.json @@ -4,7 +4,9 @@ "user": { "title": "Authentication Provider", "description": "Pick via which authentication provider you want to authenticate with Logi Circle.", - "data": { "flow_impl": "Provider" } + "data": { + "flow_impl": "Provider" + } }, "auth": { "title": "Authenticate with Logi Circle", @@ -22,5 +24,57 @@ "external_setup": "Logi Circle successfully configured from another flow.", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]" } + }, + "services": { + "set_config": { + "name": "Set config", + "description": "Sets a configuration property.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name(s) of entities to apply the operation mode to." + }, + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "Operation mode. Allowed values: LED, RECORDING_MODE." + }, + "value": { + "name": "Value", + "description": "Operation value." + } + } + }, + "livestream_snapshot": { + "name": "Livestream snapshot", + "description": "Takes a snapshot from the camera's livestream. Will wake the camera from sleep if required.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name(s) of entities to create snapshots from." + }, + "filename": { + "name": "File name", + "description": "Template of a Filename. Variable is entity_id." + } + } + }, + "livestream_record": { + "name": "Livestream record", + "description": "Takes a video recording from the camera's livestream.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name(s) of entities to create recordings from." + }, + "filename": { + "name": "File name", + "description": "[%key:component::logi_circle::services::livestream_snapshot::fields::filename::description%]" + }, + "duration": { + "name": "Duration", + "description": "Recording duration." + } + } + } } } diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py index e970f040b5f..98cc4c4b4e8 100644 --- a/homeassistant/components/london_air/sensor.py +++ b/homeassistant/components/london_air/sensor.py @@ -218,10 +218,12 @@ def parse_api_response(response): for authority in AUTHORITIES: for entry in response["HourlyAirQualityIndex"]["LocalAuthority"]: if entry["@LocalAuthorityName"] == authority: - if isinstance(entry["Site"], dict): - entry_sites_data = [entry["Site"]] - else: - entry_sites_data = entry["Site"] + entry_sites_data = [] + if "Site" in entry: + if isinstance(entry["Site"], dict): + entry_sites_data = [entry["Site"]] + else: + entry_sites_data = entry["Site"] data[authority] = parse_site(entry_sites_data) diff --git a/homeassistant/components/lookin/manifest.json b/homeassistant/components/lookin/manifest.json index 232493234bb..63da470c5cd 100644 --- a/homeassistant/components/lookin/manifest.json +++ b/homeassistant/components/lookin/manifest.json @@ -3,7 +3,7 @@ "name": "LOOKin", "codeowners": ["@ANMalko", "@bdraco"], "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/lookin/", + "documentation": "https://www.home-assistant.io/integrations/lookin", "iot_class": "local_push", "loggers": ["aiolookin"], "requirements": ["aiolookin==1.0.0"], diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index 1248c75612f..e6c69e0751e 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN from .coordinator import LoqedDataCoordinator -PLATFORMS: list[str] = [Platform.LOCK] +PLATFORMS: list[str] = [Platform.LOCK, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/loqed/const.py b/homeassistant/components/loqed/const.py index 6b1c0311a2d..59011f26566 100644 --- a/homeassistant/components/loqed/const.py +++ b/homeassistant/components/loqed/const.py @@ -2,3 +2,4 @@ DOMAIN = "loqed" +CONF_CLOUDHOOK_URL = "cloudhook_url" diff --git a/homeassistant/components/loqed/coordinator.py b/homeassistant/components/loqed/coordinator.py index 507debc02ab..42e0d523aba 100644 --- a/homeassistant/components/loqed/coordinator.py +++ b/homeassistant/components/loqed/coordinator.py @@ -6,13 +6,13 @@ from aiohttp.web import Request import async_timeout from loqedAPI import loqed -from homeassistant.components import webhook +from homeassistant.components import cloud, webhook from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN +from .const import CONF_CLOUDHOOK_URL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -114,7 +114,14 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): webhook.async_register( self.hass, DOMAIN, "Loqed", webhook_id, self._handle_webhook ) - webhook_url = webhook.async_generate_url(self.hass, webhook_id) + + if cloud.async_active_subscription(self.hass): + webhook_url = await async_cloudhook_generate_url(self.hass, self._entry) + else: + webhook_url = webhook.async_generate_url( + self.hass, self._entry.data[CONF_WEBHOOK_ID] + ) + _LOGGER.debug("Webhook URL: %s", webhook_url) webhooks = await self.lock.getWebhooks() @@ -128,18 +135,22 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): webhooks = await self.lock.getWebhooks() webhook_index = next(x["id"] for x in webhooks if x["url"] == webhook_url) - _LOGGER.info("Webhook got index %s", webhook_index) + _LOGGER.debug("Webhook got index %s", webhook_index) async def remove_webhooks(self) -> None: """Remove webhook from LOQED bridge.""" webhook_id = self._entry.data[CONF_WEBHOOK_ID] - webhook_url = webhook.async_generate_url(self.hass, webhook_id) + + if CONF_CLOUDHOOK_URL in self._entry.data: + webhook_url = self._entry.data[CONF_CLOUDHOOK_URL] + else: + webhook_url = webhook.async_generate_url(self.hass, webhook_id) webhook.async_unregister( self.hass, webhook_id, ) - _LOGGER.info("Webhook URL: %s", webhook_url) + _LOGGER.debug("Webhook URL: %s", webhook_url) webhooks = await self.lock.getWebhooks() @@ -149,3 +160,15 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): if webhook_index: await self.lock.deleteWebhook(webhook_index) + + +async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str: + """Generate the full URL for a webhook_id.""" + if CONF_CLOUDHOOK_URL not in entry.data: + webhook_url = await cloud.async_create_cloudhook( + hass, entry.data[CONF_WEBHOOK_ID] + ) + data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url} + hass.config_entries.async_update_entry(entry, data=data) + return webhook_url + return str(entry.data[CONF_CLOUDHOOK_URL]) diff --git a/homeassistant/components/loqed/manifest.json b/homeassistant/components/loqed/manifest.json index 1000d8f804d..25d1f15486d 100644 --- a/homeassistant/components/loqed/manifest.json +++ b/homeassistant/components/loqed/manifest.json @@ -1,6 +1,7 @@ { "domain": "loqed", "name": "LOQED Touch Smart Lock", + "after_dependencies": ["cloud"], "codeowners": ["@mikewoudenberg"], "config_flow": true, "dependencies": ["webhook"], diff --git a/homeassistant/components/loqed/sensor.py b/homeassistant/components/loqed/sensor.py new file mode 100644 index 00000000000..ee4fa7ecd74 --- /dev/null +++ b/homeassistant/components/loqed/sensor.py @@ -0,0 +1,71 @@ +"""Creates LOQED sensors.""" +from typing import Final + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import LoqedDataCoordinator, StatusMessage +from .entity import LoqedEntity + +SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + key="ble_strength", + translation_key="ble_strength", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="battery_percentage", + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Loqed lock platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities(LoqedSensor(coordinator, sensor) for sensor in SENSORS) + + +class LoqedSensor(LoqedEntity, SensorEntity): + """Representation of Sensor state.""" + + def __init__( + self, coordinator: LoqedDataCoordinator, description: SensorEntityDescription + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{self.coordinator.lock.id}_{description.key}" + + @property + def data(self) -> StatusMessage: + """Return data object from DataUpdateCoordinator.""" + return self.coordinator.lock + + @property + def native_value(self) -> int: + """Return state of sensor.""" + return getattr(self.data, self.entity_description.key) diff --git a/homeassistant/components/loqed/strings.json b/homeassistant/components/loqed/strings.json index 6f3316b283f..59b91fea195 100644 --- a/homeassistant/components/loqed/strings.json +++ b/homeassistant/components/loqed/strings.json @@ -6,7 +6,7 @@ "description": "Login at {config_url} and: \n* Create an API-key by clicking 'Create' \n* Copy the created access token.", "data": { "name": "Name of your lock in the LOQED app.", - "api_key": "[%key:common::config_flow::data::api_key%]" + "api_token": "[%key:common::config_flow::data::api_token%]" } } }, @@ -17,5 +17,12 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "ble_strength": { + "name": "Bluetooth signal" + } + } } } diff --git a/homeassistant/components/lovelace/services.yaml b/homeassistant/components/lovelace/services.yaml index f9fc5999da6..7cf6d8e4027 100644 --- a/homeassistant/components/lovelace/services.yaml +++ b/homeassistant/components/lovelace/services.yaml @@ -1,5 +1,3 @@ # Describes the format for available lovelace services reload_resources: - name: Reload resources - description: Reload Lovelace resources from YAML configuration diff --git a/homeassistant/components/lovelace/strings.json b/homeassistant/components/lovelace/strings.json index 87f8407d93c..d0e456f142b 100644 --- a/homeassistant/components/lovelace/strings.json +++ b/homeassistant/components/lovelace/strings.json @@ -2,9 +2,15 @@ "system_health": { "info": { "dashboards": "Dashboards", - "mode": "Mode", + "mode": "[%key:common::config_flow::data::mode%]", "resources": "Resources", "views": "Views" } + }, + "services": { + "reload_resources": { + "name": "Reload resources", + "description": "Reloads dashboard resources from the YAML-configuration." + } } } diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 64abf6e54c4..da2c03745fa 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -142,7 +142,7 @@ async def _async_migrate_unique_ids( return None sensor_id = unique_id.split("_")[1] new_unique_id = f"occupancygroup_{bridge_unique_id}_{sensor_id}" - if dev_entry := dev_reg.async_get_device({(DOMAIN, unique_id)}): + if dev_entry := dev_reg.async_get_device(identifiers={(DOMAIN, unique_id)}): dev_reg.async_update_device( dev_entry.id, new_identifiers={(DOMAIN, new_unique_id)} ) @@ -219,14 +219,14 @@ def _async_register_bridge_device( """Register the bridge device in the device registry.""" device_registry = dr.async_get(hass) - device_args: DeviceInfo = { - "name": bridge_device["name"], - "manufacturer": MANUFACTURER, - "identifiers": {(DOMAIN, bridge_device["serial"])}, - "model": f"{bridge_device['model']} ({bridge_device['type']})", - "via_device": (DOMAIN, bridge_device["serial"]), - "configuration_url": "https://device-login.lutron.com", - } + device_args = DeviceInfo( + name=bridge_device["name"], + manufacturer=MANUFACTURER, + identifiers={(DOMAIN, bridge_device["serial"])}, + model=f"{bridge_device['model']} ({bridge_device['type']})", + via_device=(DOMAIN, bridge_device["serial"]), + configuration_url="https://device-login.lutron.com", + ) area = _area_name_from_id(bridge.areas, bridge_device["area"]) if area != UNASSIGNED_AREA: diff --git a/homeassistant/components/lutron_caseta/strings.json b/homeassistant/components/lutron_caseta/strings.json index bc546321da3..b5ec175d1c9 100644 --- a/homeassistant/components/lutron_caseta/strings.json +++ b/homeassistant/components/lutron_caseta/strings.json @@ -40,9 +40,9 @@ "group_1_button_2": "First Group second button", "group_2_button_1": "Second Group first button", "group_2_button_2": "Second Group second button", - "on": "On", + "on": "[%key:common::state::on%]", "stop": "Stop (favorite)", - "off": "Off", + "off": "[%key:common::state::off%]", "raise": "Raise", "lower": "Lower", "open_all": "Open all", diff --git a/homeassistant/components/lyric/services.yaml b/homeassistant/components/lyric/services.yaml index 69c802d90aa..c3c4bc640bf 100644 --- a/homeassistant/components/lyric/services.yaml +++ b/homeassistant/components/lyric/services.yaml @@ -1,6 +1,4 @@ set_hold_time: - name: Set Hold Time - description: "Sets the time to hold until" target: device: integration: lyric @@ -9,8 +7,6 @@ set_hold_time: domain: climate fields: time_period: - name: Time Period - description: Time to hold until default: "01:00:00" example: "01:00:00" required: true diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 3c9cd6043df..2271d4201f6 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -17,5 +17,17 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } + }, + "services": { + "set_hold_time": { + "name": "Set Hold Time", + "description": "Sets the time to hold until.", + "fields": { + "time_period": { + "name": "Time Period", + "description": "Time to hold until." + } + } + } } } diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index adb251bd71a..69cd1ef3d11 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -33,10 +33,11 @@ from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( + EventStateChangedData, async_track_point_in_time, async_track_state_change_event, ) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -481,9 +482,11 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): self.hass, self._command_topic, message_received, self._qos ) - async def _async_state_changed_listener(self, event): + async def _async_state_changed_listener( + self, event: EventType[EventStateChangedData] + ) -> None: """Publish state change to MQTT.""" - if (new_state := event.data.get("new_state")) is None: + if (new_state := event.data["new_state"]) is None: return await mqtt.async_publish( self.hass, self._state_topic, new_state.state, self._qos, True diff --git a/homeassistant/components/matrix/services.yaml b/homeassistant/components/matrix/services.yaml index 9b5171d1483..f2ce72397d4 100644 --- a/homeassistant/components/matrix/services.yaml +++ b/homeassistant/components/matrix/services.yaml @@ -1,24 +1,16 @@ send_message: - name: Send message - description: Send message to target room(s) fields: message: - name: Message - description: The message to be sent. required: true example: This is a message I am sending to matrix selector: text: target: - name: Target - description: A list of room(s) to send the message to. required: true example: "#hasstest:matrix.org" selector: text: data: - name: Data - description: Extended information of notification. Supports list of images. Supports message format. Optional. example: "{'images': ['/tmp/test.jpg'], 'format': 'text'}" selector: object: diff --git a/homeassistant/components/matrix/strings.json b/homeassistant/components/matrix/strings.json new file mode 100644 index 00000000000..03d4c5728a5 --- /dev/null +++ b/homeassistant/components/matrix/strings.json @@ -0,0 +1,22 @@ +{ + "services": { + "send_message": { + "name": "Send message", + "description": "Sends message to target room(s).", + "fields": { + "message": { + "name": "Message", + "description": "The message to be sent." + }, + "target": { + "name": "Target", + "description": "A list of room(s) to send the message to." + }, + "data": { + "name": "Data", + "description": "Extended information of notification. Supports list of images. Supports message format. Optional." + } + } + } + } +} diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index 8e76706b7fd..52b8e905b4b 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -80,7 +80,7 @@ class MatterAdapter: node.endpoints[data["endpoint_id"]], ) identifier = (DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}") - if device := device_registry.async_get_device({identifier}): + if device := device_registry.async_get_device(identifiers={identifier}): device_registry.async_remove_device(device.id) def node_removed_callback(event: EventType, node_id: int) -> None: diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 7c94c07c8cd..aabfc12eefb 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -99,6 +99,7 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="BatteryChargeLevel", device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, measurement_to_ha=lambda x: x != clusters.PowerSource.Enums.BatChargeLevelEnum.kOk, ), diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 0b4bacf00ca..c971bf8465e 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -12,6 +12,7 @@ from homeassistant.core import callback from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS from .climate import DISCOVERY_SCHEMAS as CLIMATE_SENSOR_SCHEMAS from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS +from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS from .models import MatterDiscoverySchema, MatterEntityInfo @@ -22,6 +23,7 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = { Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, Platform.CLIMATE: CLIMATE_SENSOR_SCHEMAS, Platform.COVER: COVER_SCHEMAS, + Platform.EVENT: EVENT_SCHEMAS, Platform.LIGHT: LIGHT_SCHEMAS, Platform.LOCK: LOCK_SCHEMAS, Platform.SENSOR: SENSOR_SCHEMAS, diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 0457cfaa810..0082370d5ff 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -7,7 +7,7 @@ from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast -from chip.clusters.Objects import ClusterAttributeDescriptor +from chip.clusters.Objects import ClusterAttributeDescriptor, NullValue from matter_server.common.helpers.util import create_attribute_path from matter_server.common.models import EventType, ServerInfoMessage @@ -78,6 +78,9 @@ class MatterEntity(Entity): sub_paths: list[str] = [] for attr_cls in self._entity_info.attributes_to_watch: attr_path = self.get_matter_attribute_path(attr_cls) + if attr_path in sub_paths: + # prevent duplicate subscriptions + continue self._attributes_map[attr_cls] = attr_path sub_paths.append(attr_path) self._unsubscribes.append( @@ -122,10 +125,13 @@ class MatterEntity(Entity): @callback def get_matter_attribute_value( - self, attribute: type[ClusterAttributeDescriptor] + self, attribute: type[ClusterAttributeDescriptor], null_as_none: bool = True ) -> Any: """Get current value for given attribute.""" - return self._endpoint.get_attribute_value(None, attribute) + value = self._endpoint.get_attribute_value(None, attribute) + if null_as_none and value == NullValue: + return None + return value @callback def get_matter_attribute_path( diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py new file mode 100644 index 00000000000..3a1faa6dcbe --- /dev/null +++ b/homeassistant/components/matter/event.py @@ -0,0 +1,135 @@ +"""Matter event entities from Node events.""" +from __future__ import annotations + +from typing import Any + +from chip.clusters import Objects as clusters +from matter_server.client.models import device_types +from matter_server.common.models import EventType, MatterNodeEvent + +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import MatterEntity +from .helpers import get_matter +from .models import MatterDiscoverySchema + +SwitchFeature = clusters.Switch.Bitmaps.SwitchFeature + +EVENT_TYPES_MAP = { + # mapping from raw event id's to translation keys + 0: "switch_latched", # clusters.Switch.Events.SwitchLatched + 1: "initial_press", # clusters.Switch.Events.InitialPress + 2: "long_press", # clusters.Switch.Events.LongPress + 3: "short_release", # clusters.Switch.Events.ShortRelease + 4: "long_release", # clusters.Switch.Events.LongRelease + 5: "multi_press_ongoing", # clusters.Switch.Events.MultiPressOngoing + 6: "multi_press_complete", # clusters.Switch.Events.MultiPressComplete +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Matter switches from Config Entry.""" + matter = get_matter(hass) + matter.register_platform_handler(Platform.EVENT, async_add_entities) + + +class MatterEventEntity(MatterEntity, EventEntity): + """Representation of a Matter Event entity.""" + + _attr_translation_key = "push" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the entity.""" + super().__init__(*args, **kwargs) + # fill the event types based on the features the switch supports + event_types: list[str] = [] + feature_map = int( + self.get_matter_attribute_value(clusters.Switch.Attributes.FeatureMap) + ) + if feature_map & SwitchFeature.kLatchingSwitch: + event_types.append("switch_latched") + if feature_map & SwitchFeature.kMomentarySwitch: + event_types.append("initial_press") + if feature_map & SwitchFeature.kMomentarySwitchRelease: + event_types.append("short_release") + if feature_map & SwitchFeature.kMomentarySwitchLongPress: + event_types.append("long_press_ongoing") + event_types.append("long_release") + if feature_map & SwitchFeature.kMomentarySwitchMultiPress: + event_types.append("multi_press_ongoing") + event_types.append("multi_press_complete") + self._attr_event_types = event_types + # the optional label attribute could be used to identify multiple buttons + # e.g. in case of a dimmer switch with 4 buttons, each button + # will have its own name, prefixed by the device name. + if labels := self.get_matter_attribute_value( + clusters.FixedLabel.Attributes.LabelList + ): + for label in labels: + if label.label == "Label": + label_value: str = label.value + # in the case the label is only the label id, prettify it a bit + if label_value.isnumeric(): + self._attr_name = f"Button {label_value}" + else: + self._attr_name = label_value + break + + async def async_added_to_hass(self) -> None: + """Handle being added to Home Assistant.""" + await super().async_added_to_hass() + + # subscribe to NodeEvent events + self._unsubscribes.append( + self.matter_client.subscribe_events( + callback=self._on_matter_node_event, + event_filter=EventType.NODE_EVENT, + node_filter=self._endpoint.node.node_id, + ) + ) + + def _update_from_device(self) -> None: + """Call when Node attribute(s) changed.""" + + @callback + def _on_matter_node_event( + self, event: EventType, data: MatterNodeEvent + ) -> None: # noqa: F821 + """Call on NodeEvent.""" + if data.endpoint_id != self._endpoint.endpoint_id: + return + self._trigger_event(EVENT_TYPES_MAP[data.event_id], data.data) + self.async_write_ha_state() + + +# Discovery schema(s) to map Matter Attributes to HA entities +DISCOVERY_SCHEMAS = [ + MatterDiscoverySchema( + platform=Platform.EVENT, + entity_description=EventEntityDescription( + key="GenericSwitch", device_class=EventDeviceClass.BUTTON, name=None + ), + entity_class=MatterEventEntity, + required_attributes=( + clusters.Switch.Attributes.CurrentPosition, + clusters.Switch.Attributes.FeatureMap, + ), + device_type=(device_types.GenericSwitch,), + optional_attributes=( + clusters.Switch.Attributes.NumberOfPositions, + clusters.FixedLabel.Attributes.LabelList, + ), + ), +] diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 02919baa8f1..52a6b4162fe 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -337,10 +337,16 @@ class MatterLight(MatterEntity, LightEntity): # set current values if self.supports_color: - self._attr_color_mode = self._get_color_mode() - if self._attr_color_mode == ColorMode.HS: + self._attr_color_mode = color_mode = self._get_color_mode() + if ( + ColorMode.HS in self._attr_supported_color_modes + and color_mode == ColorMode.HS + ): self._attr_hs_color = self._get_hs_color() - else: + elif ( + ColorMode.XY in self._attr_supported_color_modes + and color_mode == ColorMode.XY + ): self._attr_xy_color = self._get_xy_color() if self.supports_color_temperature: diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 85434407a10..2237f0ade98 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==3.6.3"] + "requirements": ["python-matter-server==3.7.0"] } diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 027dcda65a7..5021ed7fa0d 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, + EntityCategory, Platform, UnitOfPressure, UnitOfTemperature, @@ -127,6 +128,7 @@ DISCOVERY_SCHEMAS = [ key="PowerSource", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, # value has double precision measurement_to_ha=lambda x: int(x / 2), ), diff --git a/homeassistant/components/matter/services.yaml b/homeassistant/components/matter/services.yaml index ff59a7efe63..c72187b2ffe 100644 --- a/homeassistant/components/matter/services.yaml +++ b/homeassistant/components/matter/services.yaml @@ -1,11 +1,6 @@ open_commissioning_window: - name: Open Commissioning Window - description: > - Allow adding one of your devices to another Matter network by opening the commissioning window for this Matter device for 60 seconds. fields: device_id: - name: Device - description: The Matter device to add to the other Matter network. required: true selector: device: diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index dc5eb30df51..c68b38bbb8c 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -45,10 +45,39 @@ } }, "entity": { + "event": { + "push": { + "state_attributes": { + "event_type": { + "state": { + "switch_latched": "Switch latched", + "initial_press": "Initial press", + "long_press": "Long press", + "short_release": "Short release", + "long_release": "Long release", + "multi_press_ongoing": "Multi press ongoing", + "multi_press_complete": "Multi press complete" + } + } + } + } + }, "sensor": { "flow": { "name": "Flow" } } + }, + "services": { + "open_commissioning_window": { + "name": "Open commissioning window", + "description": "Allows adding one of your devices to another Matter network by opening the commissioning window for this Matter device for 60 seconds.", + "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "The Matter device to add to the other Matter network." + } + } + } } } diff --git a/homeassistant/components/mazda/binary_sensor.py b/homeassistant/components/mazda/binary_sensor.py index c2727654525..36c3ba27463 100644 --- a/homeassistant/components/mazda/binary_sensor.py +++ b/homeassistant/components/mazda/binary_sensor.py @@ -46,49 +46,49 @@ def _plugged_in_supported(data): BINARY_SENSOR_ENTITIES = [ MazdaBinarySensorEntityDescription( key="driver_door", - name="Driver door", + translation_key="driver_door", icon="mdi:car-door", device_class=BinarySensorDeviceClass.DOOR, value_fn=lambda data: data["status"]["doors"]["driverDoorOpen"], ), MazdaBinarySensorEntityDescription( key="passenger_door", - name="Passenger door", + translation_key="passenger_door", icon="mdi:car-door", device_class=BinarySensorDeviceClass.DOOR, value_fn=lambda data: data["status"]["doors"]["passengerDoorOpen"], ), MazdaBinarySensorEntityDescription( key="rear_left_door", - name="Rear left door", + translation_key="rear_left_door", icon="mdi:car-door", device_class=BinarySensorDeviceClass.DOOR, value_fn=lambda data: data["status"]["doors"]["rearLeftDoorOpen"], ), MazdaBinarySensorEntityDescription( key="rear_right_door", - name="Rear right door", + translation_key="rear_right_door", icon="mdi:car-door", device_class=BinarySensorDeviceClass.DOOR, value_fn=lambda data: data["status"]["doors"]["rearRightDoorOpen"], ), MazdaBinarySensorEntityDescription( key="trunk", - name="Trunk", + translation_key="trunk", icon="mdi:car-back", device_class=BinarySensorDeviceClass.DOOR, value_fn=lambda data: data["status"]["doors"]["trunkOpen"], ), MazdaBinarySensorEntityDescription( key="hood", - name="Hood", + translation_key="hood", icon="mdi:car", device_class=BinarySensorDeviceClass.DOOR, value_fn=lambda data: data["status"]["doors"]["hoodOpen"], ), MazdaBinarySensorEntityDescription( key="ev_plugged_in", - name="Plugged in", + translation_key="ev_plugged_in", device_class=BinarySensorDeviceClass.PLUG, is_supported=_plugged_in_supported, value_fn=lambda data: data["evStatus"]["chargeInfo"]["pluggedIn"], diff --git a/homeassistant/components/mazda/button.py b/homeassistant/components/mazda/button.py index 1b1e51db035..ced1094981f 100644 --- a/homeassistant/components/mazda/button.py +++ b/homeassistant/components/mazda/button.py @@ -76,31 +76,31 @@ class MazdaButtonEntityDescription(ButtonEntityDescription): BUTTON_ENTITIES = [ MazdaButtonEntityDescription( key="start_engine", - name="Start engine", + translation_key="start_engine", icon="mdi:engine", is_supported=lambda data: not data["isElectric"], ), MazdaButtonEntityDescription( key="stop_engine", - name="Stop engine", + translation_key="stop_engine", icon="mdi:engine-off", is_supported=lambda data: not data["isElectric"], ), MazdaButtonEntityDescription( key="turn_on_hazard_lights", - name="Turn on hazard lights", + translation_key="turn_on_hazard_lights", icon="mdi:hazard-lights", is_supported=lambda data: not data["isElectric"], ), MazdaButtonEntityDescription( key="turn_off_hazard_lights", - name="Turn off hazard lights", + translation_key="turn_off_hazard_lights", icon="mdi:hazard-lights", is_supported=lambda data: not data["isElectric"], ), MazdaButtonEntityDescription( key="refresh_vehicle_status", - name="Refresh status", + translation_key="refresh_vehicle_status", icon="mdi:refresh", async_press=handle_refresh_vehicle_status, is_supported=lambda data: data["isElectric"], diff --git a/homeassistant/components/mazda/climate.py b/homeassistant/components/mazda/climate.py index 02c4e7ce923..43dc4b4151d 100644 --- a/homeassistant/components/mazda/climate.py +++ b/homeassistant/components/mazda/climate.py @@ -66,7 +66,7 @@ async def async_setup_entry( class MazdaClimateEntity(MazdaEntity, ClimateEntity): """Class for a Mazda climate entity.""" - _attr_name = "Climate" + _attr_translation_key = "climate" _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) diff --git a/homeassistant/components/mazda/device_tracker.py b/homeassistant/components/mazda/device_tracker.py index 67702ba5455..2af191f97bc 100644 --- a/homeassistant/components/mazda/device_tracker.py +++ b/homeassistant/components/mazda/device_tracker.py @@ -28,7 +28,7 @@ async def async_setup_entry( class MazdaDeviceTracker(MazdaEntity, TrackerEntity): """Class for the device tracker.""" - _attr_name = "Device tracker" + _attr_translation_key = "device_tracker" _attr_icon = "mdi:car" _attr_force_update = False diff --git a/homeassistant/components/mazda/lock.py b/homeassistant/components/mazda/lock.py index 1f42c5dce48..d095ac81955 100644 --- a/homeassistant/components/mazda/lock.py +++ b/homeassistant/components/mazda/lock.py @@ -32,7 +32,7 @@ async def async_setup_entry( class MazdaLock(MazdaEntity, LockEntity): """Class for the lock.""" - _attr_name = "Lock" + _attr_translation_key = "lock" def __init__(self, client, coordinator, index) -> None: """Initialize Mazda lock.""" diff --git a/homeassistant/components/mazda/sensor.py b/homeassistant/components/mazda/sensor.py index 5815f931029..f50533e339a 100644 --- a/homeassistant/components/mazda/sensor.py +++ b/homeassistant/components/mazda/sensor.py @@ -135,7 +135,7 @@ def _ev_remaining_range_value(data): SENSOR_ENTITIES = [ MazdaSensorEntityDescription( key="fuel_remaining_percentage", - name="Fuel remaining percentage", + translation_key="fuel_remaining_percentage", icon="mdi:gas-station", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -144,7 +144,7 @@ SENSOR_ENTITIES = [ ), MazdaSensorEntityDescription( key="fuel_distance_remaining", - name="Fuel distance remaining", + translation_key="fuel_distance_remaining", icon="mdi:gas-station", device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.KILOMETERS, @@ -154,7 +154,7 @@ SENSOR_ENTITIES = [ ), MazdaSensorEntityDescription( key="odometer", - name="Odometer", + translation_key="odometer", icon="mdi:speedometer", device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.KILOMETERS, @@ -164,7 +164,7 @@ SENSOR_ENTITIES = [ ), MazdaSensorEntityDescription( key="front_left_tire_pressure", - name="Front left tire pressure", + translation_key="front_left_tire_pressure", icon="mdi:car-tire-alert", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.PSI, @@ -174,7 +174,7 @@ SENSOR_ENTITIES = [ ), MazdaSensorEntityDescription( key="front_right_tire_pressure", - name="Front right tire pressure", + translation_key="front_right_tire_pressure", icon="mdi:car-tire-alert", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.PSI, @@ -184,7 +184,7 @@ SENSOR_ENTITIES = [ ), MazdaSensorEntityDescription( key="rear_left_tire_pressure", - name="Rear left tire pressure", + translation_key="rear_left_tire_pressure", icon="mdi:car-tire-alert", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.PSI, @@ -194,7 +194,7 @@ SENSOR_ENTITIES = [ ), MazdaSensorEntityDescription( key="rear_right_tire_pressure", - name="Rear right tire pressure", + translation_key="rear_right_tire_pressure", icon="mdi:car-tire-alert", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.PSI, @@ -204,7 +204,7 @@ SENSOR_ENTITIES = [ ), MazdaSensorEntityDescription( key="ev_charge_level", - name="Charge level", + translation_key="ev_charge_level", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -213,7 +213,7 @@ SENSOR_ENTITIES = [ ), MazdaSensorEntityDescription( key="ev_remaining_range", - name="Remaining range", + translation_key="ev_remaining_range", icon="mdi:ev-station", device_class=SensorDeviceClass.DISTANCE, native_unit_of_measurement=UnitOfLength.KILOMETERS, diff --git a/homeassistant/components/mazda/services.yaml b/homeassistant/components/mazda/services.yaml index 1abf8bd5dea..b401c01f3a3 100644 --- a/homeassistant/components/mazda/services.yaml +++ b/homeassistant/components/mazda/services.yaml @@ -1,17 +1,11 @@ send_poi: - name: Send POI - description: Send a GPS location to the vehicle's navigation system as a POI (Point of Interest). Requires a navigation SD card installed in the vehicle. fields: device_id: - name: Vehicle - description: The vehicle to send the GPS location to required: true selector: device: integration: mazda latitude: - name: Latitude - description: The latitude of the location to send example: 12.34567 required: true selector: @@ -21,8 +15,6 @@ send_poi: unit_of_measurement: ° mode: box longitude: - name: Longitude - description: The longitude of the location to send example: -34.56789 required: true selector: @@ -32,8 +24,6 @@ send_poi: unit_of_measurement: ° mode: box poi_name: - name: POI name - description: A friendly name for the location example: Work required: true selector: diff --git a/homeassistant/components/mazda/strings.json b/homeassistant/components/mazda/strings.json index d2cc1bcfec9..6c1214f76c6 100644 --- a/homeassistant/components/mazda/strings.json +++ b/homeassistant/components/mazda/strings.json @@ -20,5 +20,120 @@ "description": "Please enter the email address and password you use to log into the MyMazda mobile app." } } + }, + "entity": { + "binary_sensor": { + "driver_door": { + "name": "Driver door" + }, + "passenger_door": { + "name": "Passenger door" + }, + "rear_left_door": { + "name": "Rear left door" + }, + "rear_right_door": { + "name": "Rear right door" + }, + "trunk": { + "name": "Trunk" + }, + "hood": { + "name": "Hood" + }, + "ev_plugged_in": { + "name": "Plugged in" + } + }, + "button": { + "start_engine": { + "name": "Start engine" + }, + "stop_engine": { + "name": "Stop engine" + }, + "turn_on_hazard_lights": { + "name": "Turn on hazard lights" + }, + "turn_off_hazard_lights": { + "name": "Turn off hazard lights" + }, + "refresh_vehicle_status": { + "name": "Refresh status" + } + }, + "climate": { + "climate": { + "name": "[%key:component::climate::title%]" + } + }, + "device_tracker": { + "device_tracker": { + "name": "[%key:component::device_tracker::title%]" + } + }, + "lock": { + "lock": { + "name": "[%key:component::lock::title%]" + } + }, + "sensor": { + "fuel_remaining_percentage": { + "name": "Fuel remaining percentage" + }, + "fuel_distance_remaining": { + "name": "Fuel distance remaining" + }, + "odometer": { + "name": "Odometer" + }, + "front_left_tire_pressure": { + "name": "Front left tire pressure" + }, + "front_right_tire_pressure": { + "name": "Front right tire pressure" + }, + "rear_left_tire_pressure": { + "name": "Rear left tire pressure" + }, + "rear_right_tire_pressure": { + "name": "Rear right tire pressure" + }, + "ev_charge_level": { + "name": "Charge level" + }, + "ev_remaining_range": { + "name": "Remaining range" + } + }, + "switch": { + "charging": { + "name": "Charging" + } + } + }, + "services": { + "send_poi": { + "name": "Send POI", + "description": "Sends a GPS location to the vehicle's navigation system as a POI (Point of Interest). Requires a navigation SD card installed in the vehicle.", + "fields": { + "device_id": { + "name": "Vehicle", + "description": "The vehicle to send the GPS location to." + }, + "latitude": { + "name": "[%key:common::config_flow::data::latitude%]", + "description": "The latitude of the location to send." + }, + "longitude": { + "name": "[%key:common::config_flow::data::longitude%]", + "description": "The longitude of the location to send." + }, + "poi_name": { + "name": "POI name", + "description": "A friendly name for the location." + } + } + } } } diff --git a/homeassistant/components/mazda/switch.py b/homeassistant/components/mazda/switch.py index 7097237bc5d..327d371769b 100644 --- a/homeassistant/components/mazda/switch.py +++ b/homeassistant/components/mazda/switch.py @@ -32,7 +32,7 @@ async def async_setup_entry( class MazdaChargingSwitch(MazdaEntity, SwitchEntity): """Class for the charging switch.""" - _attr_name = "Charging" + _attr_translation_key = "charging" _attr_icon = "mdi:ev-station" def __init__( diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index 0a1240c7471..cf71455a81b 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -16,6 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -63,8 +64,8 @@ SENSOR_TYPES = ( # Ambient temperature MeaterSensorEntityDescription( key="ambient", + translation_key="ambient", device_class=SensorDeviceClass.TEMPERATURE, - name="Ambient", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, available=lambda probe: probe is not None, @@ -73,8 +74,8 @@ SENSOR_TYPES = ( # Internal temperature (probe tip) MeaterSensorEntityDescription( key="internal", + translation_key="internal", device_class=SensorDeviceClass.TEMPERATURE, - name="Internal", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, available=lambda probe: probe is not None, @@ -83,7 +84,7 @@ SENSOR_TYPES = ( # Name of selected meat in user language or user given custom name MeaterSensorEntityDescription( key="cook_name", - name="Cooking", + translation_key="cook_name", available=lambda probe: probe is not None and probe.cook is not None, value=lambda probe: probe.cook.name if probe.cook else None, ), @@ -91,15 +92,15 @@ SENSOR_TYPES = ( # Slightly Underdone, Finished, Slightly Overdone, OVERCOOK!. Not translated. MeaterSensorEntityDescription( key="cook_state", - name="Cook state", + translation_key="cook_state", available=lambda probe: probe is not None and probe.cook is not None, value=lambda probe: probe.cook.state if probe.cook else None, ), # Target temperature MeaterSensorEntityDescription( key="cook_target_temp", + translation_key="cook_target_temp", device_class=SensorDeviceClass.TEMPERATURE, - name="Target", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, available=lambda probe: probe is not None and probe.cook is not None, @@ -110,8 +111,8 @@ SENSOR_TYPES = ( # Peak temperature MeaterSensorEntityDescription( key="cook_peak_temp", + translation_key="cook_peak_temp", device_class=SensorDeviceClass.TEMPERATURE, - name="Peak", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, available=lambda probe: probe is not None and probe.cook is not None, @@ -123,8 +124,8 @@ SENSOR_TYPES = ( # Exposed as a TIMESTAMP sensor where the timestamp is current time + remaining time. MeaterSensorEntityDescription( key="cook_time_remaining", + translation_key="cook_time_remaining", device_class=SensorDeviceClass.TIMESTAMP, - name="Remaining time", available=lambda probe: probe is not None and probe.cook is not None, value=_remaining_time_to_timestamp, ), @@ -132,8 +133,8 @@ SENSOR_TYPES = ( # where the timestamp is current time - elapsed time. MeaterSensorEntityDescription( key="cook_time_elapsed", + translation_key="cook_time_elapsed", device_class=SensorDeviceClass.TIMESTAMP, - name="Elapsed time", available=lambda probe: probe is not None and probe.cook is not None, value=_elapsed_time_to_timestamp, ), @@ -191,16 +192,15 @@ class MeaterProbeTemperature( ) -> None: """Initialise the sensor.""" super().__init__(coordinator) - self._attr_name = f"Meater Probe {description.name}" - self._attr_device_info = { - "identifiers": { + self._attr_device_info = DeviceInfo( + identifiers={ # Serial numbers are unique identifiers within a specific domain (DOMAIN, device_id) }, - "manufacturer": "Apption Labs", - "model": "Meater Probe", - "name": f"Meater Probe {device_id}", - } + manufacturer="Apption Labs", + model="Meater Probe", + name=f"Meater Probe {device_id}", + ) self._attr_unique_id = f"{device_id}-{description.key}" self.device_id = device_id diff --git a/homeassistant/components/meater/strings.json b/homeassistant/components/meater/strings.json index 7f4a97a5b19..279841bb147 100644 --- a/homeassistant/components/meater/strings.json +++ b/homeassistant/components/meater/strings.json @@ -26,5 +26,33 @@ "unknown_auth_error": "[%key:common::config_flow::error::unknown%]", "service_unavailable_error": "The API is currently unavailable, please try again later." } + }, + "entity": { + "sensor": { + "ambient": { + "name": "Ambient temperature" + }, + "internal": { + "name": "Internal temperature" + }, + "cook_name": { + "name": "Cooking" + }, + "cook_state": { + "name": "Cook state" + }, + "cook_target_temp": { + "name": "Target temperature" + }, + "cook_peak_temp": { + "name": "Peak temperature" + }, + "cook_time_remaining": { + "name": "Time remaining" + }, + "cook_time_elapsed": { + "name": "Time elapsed" + } + } } } diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index a35650f0092..d00f1b33ccc 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -127,7 +127,12 @@ class MediaExtractor: _LOGGER.error("Could not extract stream for the query: %s", query) raise MEQueryException() from err - return requested_stream["webpage_url"] + if "formats" in requested_stream: + best_stream = requested_stream["formats"][ + len(requested_stream["formats"]) - 1 + ] + return best_stream["url"] + return requested_stream["url"] return stream_selector diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index ccab196032f..0e5d9ead0f8 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -7,5 +7,5 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp==2023.3.4"] + "requirements": ["yt-dlp==2023.7.6"] } diff --git a/homeassistant/components/media_extractor/services.yaml b/homeassistant/components/media_extractor/services.yaml index 0b7295bd7bf..8af2d12d0e9 100644 --- a/homeassistant/components/media_extractor/services.yaml +++ b/homeassistant/components/media_extractor/services.yaml @@ -1,20 +1,14 @@ play_media: - name: Play media - description: Downloads file from given URL. target: entity: domain: media_player fields: media_content_id: - name: Media content ID - description: The ID of the content to play. Platform dependent. required: true example: "https://soundcloud.com/bruttoband/brutto-11" selector: text: media_content_type: - name: Media content type - description: The type of the content to play. Must be one of MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST MUSIC. required: true selector: select: diff --git a/homeassistant/components/media_extractor/strings.json b/homeassistant/components/media_extractor/strings.json new file mode 100644 index 00000000000..0cdffd5d508 --- /dev/null +++ b/homeassistant/components/media_extractor/strings.json @@ -0,0 +1,18 @@ +{ + "services": { + "play_media": { + "name": "Play media", + "description": "Downloads file from given URL.", + "fields": { + "media_content_id": { + "name": "Media content ID", + "description": "The ID of the content to play. Platform dependent." + }, + "media_content_type": { + "name": "Media content type", + "description": "The type of the content to play. Must be one of MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST MUSIC." + } + } + } + } +} diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 0f827d60736..39b67477f97 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -7,23 +7,22 @@ from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass import datetime as dt +from enum import StrEnum import functools as ft import hashlib from http import HTTPStatus import logging import secrets -from typing import Any, Final, TypedDict, final +from typing import Any, Final, Required, TypedDict, final from urllib.parse import quote, urlparse from aiohttp import web from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE from aiohttp.typedefs import LooseHeaders import async_timeout -from typing_extensions import Required import voluptuous as vol from yarl import URL -from homeassistant.backports.enum import StrEnum from homeassistant.components import websocket_api from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR @@ -1001,13 +1000,14 @@ class MediaPlayerEntity(Entity): def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} + supported_features = self.supported_features - if self.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE and ( + if supported_features & MediaPlayerEntityFeature.SELECT_SOURCE and ( source_list := self.source_list ): data[ATTR_INPUT_SOURCE_LIST] = source_list - if self.supported_features & MediaPlayerEntityFeature.SELECT_SOUND_MODE and ( + if supported_features & MediaPlayerEntityFeature.SELECT_SOUND_MODE and ( sound_mode_list := self.sound_mode_list ): data[ATTR_SOUND_MODE_LIST] = sound_mode_list diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 1cc90aa4904..2c609750153 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -1,7 +1,5 @@ """Provides the constants needed for component.""" -from enum import IntFlag - -from homeassistant.backports.enum import StrEnum +from enum import IntFlag, StrEnum # How long our auth signature on the content should be valid for CONTENT_AUTH_EXPIRY_TIME = 3600 * 24 @@ -199,6 +197,8 @@ class MediaPlayerEntityFeature(IntFlag): BROWSE_MEDIA = 131072 REPEAT_SET = 262144 GROUPING = 524288 + MEDIA_ANNOUNCE = 1048576 + MEDIA_ENQUEUE = 2097152 # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 5a513e4f3a0..7338747b545 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -1,64 +1,63 @@ # Describes the format for available media player services turn_on: - name: Turn on - description: Turn a media player power on. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.TURN_ON turn_off: - name: Turn off - description: Turn a media player power off. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.TURN_OFF toggle: - name: Toggle - description: Toggles a media player power state. target: entity: domain: media_player + supported_features: + - - media_player.MediaPlayerEntityFeature.TURN_OFF + - media_player.MediaPlayerEntityFeature.TURN_ON volume_up: - name: Turn up volume - description: Turn a media player volume up. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.VOLUME_SET + - media_player.MediaPlayerEntityFeature.VOLUME_STEP volume_down: - name: Turn down volume - description: Turn a media player volume down. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.VOLUME_SET + - media_player.MediaPlayerEntityFeature.VOLUME_STEP volume_mute: - name: Mute volume - description: Mute a media player's volume. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.VOLUME_MUTE fields: is_volume_muted: - name: Muted - description: True/false for mute/unmute. required: true selector: boolean: volume_set: - name: Set volume - description: Set a media player's volume level. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.VOLUME_SET fields: volume_level: - name: Level - description: Volume level to set as float. required: true selector: number: @@ -67,57 +66,56 @@ volume_set: step: 0.01 media_play_pause: - name: Play/Pause - description: Toggle media player play/pause state. target: entity: domain: media_player + supported_features: + - - media_player.MediaPlayerEntityFeature.PAUSE + - media_player.MediaPlayerEntityFeature.PLAY media_play: - name: Play - description: Send the media player the command for play. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.PLAY media_pause: - name: Pause - description: Send the media player the command for pause. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.PAUSE media_stop: - name: Stop - description: Send the media player the stop command. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.STOP media_next_track: - name: Next - description: Send the media player the command for next track. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.NEXT_TRACK media_previous_track: - name: Previous - description: Send the media player the command for previous track. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.PREVIOUS_TRACK media_seek: - name: Seek - description: Send the media player the command to seek in current playing media. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.SEEK fields: seek_position: - name: Position - description: Position to seek to. The format is platform dependent. required: true selector: number: @@ -127,136 +125,114 @@ media_seek: mode: box play_media: - name: Play media - description: Send the media player the command for playing media. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.PLAY_MEDIA fields: media_content_id: - name: Content ID - description: The ID of the content to play. Platform dependent. required: true example: "https://home-assistant.io/images/cast/splash.png" selector: text: media_content_type: - name: Content type - description: - The type of the content to play. Like image, music, tvshow, video, - episode, channel or playlist. required: true example: "music" selector: text: enqueue: - name: Enqueue - description: If the content should be played now or be added to the queue. + filter: + supported_features: + - media_player.MediaPlayerEntityFeature.MEDIA_ENQUEUE required: false selector: select: options: - - label: "Play now" - value: "play" - - label: "Play next" - value: "next" - - label: "Add to queue" - value: "add" - - label: "Play now and clear queue" - value: "replace" + - "play" + - "next" + - "add" + - "replace" + translation_key: enqueue announce: - name: Announce - description: If the media should be played as an announcement. + filter: + supported_features: + - media_player.MediaPlayerEntityFeature.MEDIA_ANNOUNCE required: false example: "true" selector: boolean: select_source: - name: Select source - description: Send the media player the command to change input source. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.SELECT_SOURCE fields: source: - name: Source - description: Name of the source to switch to. Platform dependent. required: true example: "video1" selector: text: select_sound_mode: - name: Select sound mode - description: Send the media player the command to change sound mode. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.SELECT_SOUND_MODE fields: sound_mode: - name: Sound mode - description: Name of the sound mode to switch to. example: "Music" selector: text: clear_playlist: - name: Clear playlist - description: Send the media player the command to clear players playlist. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.CLEAR_PLAYLIST shuffle_set: - name: Shuffle - description: Set shuffling state. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.SHUFFLE_SET fields: shuffle: - name: Shuffle - description: True/false for enabling/disabling shuffle. required: true selector: boolean: repeat_set: - name: Repeat - description: Set repeat mode target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.REPEAT_SET fields: repeat: - name: Repeat mode - description: Repeat mode to set. required: true selector: select: options: - - label: "Off" - value: "off" - - label: "Repeat all" - value: "all" - - label: "Repeat one" - value: "one" - + - "off" + - "all" + - "one" + translation_key: repeat join: - name: Join - description: - Group players together. Only works on platforms with support for player - groups. target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.GROUPING fields: group_members: - name: Group members - description: The players which will be synced with the target player. required: true example: | - media_player.multiroom_player2 @@ -267,10 +243,8 @@ join: domain: media_player unjoin: - description: - Unjoin the player from a group. Only works on platforms with support for - player groups. - name: Unjoin target: entity: domain: media_player + supported_features: + - media_player.MediaPlayerEntityFeature.GROUPING diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 4c33d1f27ef..bcf594a2675 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -3,20 +3,20 @@ "device_automation": { "condition_type": { "is_buffering": "{entity_name} is buffering", - "is_on": "{entity_name} is on", - "is_off": "{entity_name} is off", + "is_on": "[%key:common::device_automation::condition_type::is_on%]", + "is_off": "[%key:common::device_automation::condition_type::is_off%]", "is_idle": "{entity_name} is idle", "is_paused": "{entity_name} is paused", "is_playing": "{entity_name} is playing" }, "trigger_type": { "buffering": "{entity_name} starts buffering", - "turned_on": "{entity_name} turned on", - "turned_off": "{entity_name} turned off", + "turned_on": "[%key:common::device_automation::trigger_type::turned_on%]", + "turned_off": "[%key:common::device_automation::trigger_type::turned_off%]", "idle": "{entity_name} becomes idle", "paused": "{entity_name} is paused", "playing": "{entity_name} starts playing", - "changed_states": "{entity_name} changed states" + "changed_states": "[%key:common::device_automation::trigger_type::changed_states%]" } }, "entity_component": { @@ -122,7 +122,7 @@ "name": "Repeat", "state": { "all": "All", - "off": "Off", + "off": "[%key:common::state::off%]", "one": "One" } }, @@ -160,10 +160,177 @@ "name": "Receiver" } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Turns on the power of the media player." + }, + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Turns off the power of the media player." + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles a media player on/off." + }, + "volume_up": { + "name": "Turn up volume", + "description": "Turns up the volume." + }, + "volume_down": { + "name": "Turn down volume", + "description": "Turns down the volume." + }, + "volume_mute": { + "name": "Mute/unmute volume", + "description": "Mutes or unmutes the media player.", + "fields": { + "is_volume_muted": { + "name": "Muted", + "description": "Defines whether or not it is muted." + } + } + }, + "volume_set": { + "name": "Set volume", + "description": "Sets the volume level.", + "fields": { + "volume_level": { + "name": "Level", + "description": "The volume. 0 is inaudible, 1 is the maximum volume." + } + } + }, + "media_play_pause": { + "name": "Play/Pause", + "description": "Toggles play/pause." + }, + "media_play": { + "name": "Play", + "description": "Starts playing." + }, + "media_pause": { + "name": "[%key:common::action::pause%]", + "description": "Pauses." + }, + "media_stop": { + "name": "[%key:common::action::stop%]", + "description": "Stops playing." + }, + "media_next_track": { + "name": "Next", + "description": "Selects the next track." + }, + "media_previous_track": { + "name": "Previous", + "description": "Selects the previous track." + }, + "media_seek": { + "name": "Seek", + "description": "Allows you to go to a different part of the media that is currently playing.", + "fields": { + "seek_position": { + "name": "Position", + "description": "Target position in the currently playing media. The format is platform dependent." + } + } + }, + "play_media": { + "name": "Play media", + "description": "Starts playing specified media.", + "fields": { + "media_content_id": { + "name": "Content ID", + "description": "The ID of the content to play. Platform dependent." + }, + "media_content_type": { + "name": "Content type", + "description": "The type of the content to play. Such as image, music, tv show, video, episode, channel, or playlist." + }, + "enqueue": { + "name": "Enqueue", + "description": "If the content should be played now or be added to the queue." + }, + "announce": { + "name": "Announce", + "description": "If the media should be played as an announcement." + } + } + }, + "select_source": { + "name": "Select source", + "description": "Sends the media player the command to change input source.", + "fields": { + "source": { + "name": "Source", + "description": "Name of the source to switch to. Platform dependent." + } + } + }, + "select_sound_mode": { + "name": "Select sound mode", + "description": "Selects a specific sound mode.", + "fields": { + "sound_mode": { + "name": "Sound mode", + "description": "Name of the sound mode to switch to." + } + } + }, + "clear_playlist": { + "name": "Clear playlist", + "description": "Clears the playlist." + }, + "shuffle_set": { + "name": "Shuffle", + "description": "Playback mode that selects the media in randomized order.", + "fields": { + "shuffle": { + "name": "Shuffle", + "description": "Whether or not shuffle mode is enabled." + } + } + }, + "repeat_set": { + "name": "Repeat", + "description": "Playback mode that plays the media in a loop.", + "fields": { + "repeat": { + "name": "Repeat mode", + "description": "Repeat mode to set." + } + } + }, + "join": { + "name": "Join", + "description": "Groups media players together for synchronous playback. Only works on supported multiroom audio systems.", + "fields": { + "group_members": { + "name": "Group members", + "description": "The players which will be synced with the playback specified in `target`." + } + } + }, + "unjoin": { + "name": "Unjoin", + "description": "Removes the player from a group. Only works on platforms which support player groups." + } + }, + "selector": { + "enqueue": { + "options": { + "play": "Play", + "next": "Play next", + "add": "Add to queue", + "replace": "Play now and clear queue" + } + }, + "repeat": { + "options": { + "off": "Off", + "all": "Repeat all", + "one": "Repeat one" + } } } } diff --git a/homeassistant/components/melcloud/services.yaml b/homeassistant/components/melcloud/services.yaml index f470076ee7f..f13cd646388 100644 --- a/homeassistant/components/melcloud/services.yaml +++ b/homeassistant/components/melcloud/services.yaml @@ -1,34 +1,22 @@ set_vane_horizontal: - name: Set vane horizontal - description: Sets horizontal vane position. target: entity: integration: melcloud domain: climate fields: position: - name: Position - description: > - Horizontal vane position. Possible options can be found in the - vane_horizontal_positions state attribute. required: true example: "auto" selector: text: set_vane_vertical: - name: Set vane vertical - description: Sets vertical vane position. target: entity: integration: melcloud domain: climate fields: position: - name: Position - description: > - Vertical vane position. Possible options can be found in the - vane_vertical_positions state attribute. required: true example: "auto" selector: diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index a1bce80d7ad..bef65e28880 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -18,5 +18,27 @@ "abort": { "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed." } + }, + "services": { + "set_vane_horizontal": { + "name": "Set vane horizontal", + "description": "Sets horizontal vane position.", + "fields": { + "position": { + "name": "Position", + "description": "Horizontal vane position. Possible options can be found in the vane_horizontal_positions state attribute.\n." + } + } + }, + "set_vane_vertical": { + "name": "Set vane vertical", + "description": "Sets vertical vane position.", + "fields": { + "position": { + "name": "Position", + "description": "Vertical vane position. Possible options can be found in the vane_vertical_positions state attribute.\n." + } + } + } } } diff --git a/homeassistant/components/melcloud/water_heater.py b/homeassistant/components/melcloud/water_heater.py index 511518279cb..cf4b788480f 100644 --- a/homeassistant/components/melcloud/water_heater.py +++ b/homeassistant/components/melcloud/water_heater.py @@ -44,6 +44,7 @@ class AtwWaterHeater(WaterHeaterEntity): _attr_supported_features = ( WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF | WaterHeaterEntityFeature.OPERATION_MODE ) @@ -72,11 +73,11 @@ class AtwWaterHeater(WaterHeaterEntity): """Return a device description for device registry.""" return self._api.device_info - async def async_turn_on(self) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self._device.set({PROPERTY_POWER: True}) - async def async_turn_off(self) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self._device.set({PROPERTY_POWER: False}) diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py index 79b80a6d7b5..e0f9c7d3bf6 100644 --- a/homeassistant/components/melnor/number.py +++ b/homeassistant/components/melnor/number.py @@ -48,7 +48,7 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneNumberEntityDescription] = [ native_min_value=1, icon="mdi:timer-cog-outline", key="manual_minutes", - name="Manual Duration", + translation_key="manual_minutes", native_unit_of_measurement=UnitOfTime.MINUTES, set_num_fn=lambda valve, value: valve.set_manual_watering_minutes(value), state_fn=lambda valve: valve.manual_watering_minutes, @@ -59,7 +59,7 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneNumberEntityDescription] = [ native_min_value=1, icon="mdi:calendar-refresh-outline", key="frequency_interval_hours", - name="Schedule Interval", + translation_key="frequency_interval_hours", native_unit_of_measurement=UnitOfTime.HOURS, set_num_fn=lambda valve, value: valve.set_frequency_interval_hours(value), state_fn=lambda valve: valve.frequency.interval_hours, @@ -70,7 +70,7 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneNumberEntityDescription] = [ native_min_value=1, icon="mdi:timer-outline", key="frequency_duration_minutes", - name="Schedule Duration", + translation_key="frequency_duration_minutes", native_unit_of_measurement=UnitOfTime.MINUTES, set_num_fn=lambda valve, value: valve.set_frequency_duration_minutes(value), state_fn=lambda valve: valve.frequency.duration_minutes, diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index b4a1d44a291..edb906cc80f 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -87,7 +87,6 @@ DEVICE_ENTITY_DESCRIPTIONS: list[MelnorSensorEntityDescription] = [ device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, key="battery", - name="Battery", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, state_fn=lambda device: device.battery_level, @@ -97,7 +96,7 @@ DEVICE_ENTITY_DESCRIPTIONS: list[MelnorSensorEntityDescription] = [ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, key="rssi", - name="RSSI", + translation_key="rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, state_fn=lambda device: device.rssi, @@ -108,13 +107,13 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneSensorEntityDescription] = [ MelnorZoneSensorEntityDescription( device_class=SensorDeviceClass.TIMESTAMP, key="manual_cycle_end", - name="Manual Cycle End", + translation_key="manual_cycle_end", state_fn=watering_seconds_left, ), MelnorZoneSensorEntityDescription( device_class=SensorDeviceClass.TIMESTAMP, key="next_cycle", - name="Next Cycle", + translation_key="next_cycle", state_fn=next_cycle, ), ] diff --git a/homeassistant/components/melnor/strings.json b/homeassistant/components/melnor/strings.json index 2fefa32b6bc..51ca18b0b3d 100644 --- a/homeassistant/components/melnor/strings.json +++ b/homeassistant/components/melnor/strings.json @@ -10,5 +10,39 @@ "title": "Discovered Melnor Bluetooth valve" } } + }, + "entity": { + "number": { + "manual_minutes": { + "name": "Manual duration" + }, + "frequency_interval_hours": { + "name": "Schedule interval" + }, + "frequency_duration_minutes": { + "name": "Schedule duration" + } + }, + "sensor": { + "rssi": { + "name": "RSSI" + }, + "manual_cycle_end": { + "name": "Manual cycle end" + }, + "next_cycle": { + "name": "Next cycle" + } + }, + "switch": { + "frequency": { + "name": "Schedule" + } + }, + "time": { + "frequency_start_time": { + "name": "Schedule start time" + } + } } } diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index e5f70bc25a0..03bd28faa9d 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -53,7 +53,7 @@ ZONE_ENTITY_DESCRIPTIONS = [ device_class=SwitchDeviceClass.SWITCH, icon="mdi:calendar-sync-outline", key="frequency", - name="Schedule", + translation_key="frequency", on_off_fn=lambda valve, bool: valve.set_frequency_enabled(bool), state_fn=lambda valve: valve.schedule_enabled, ), diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py index 7abdf62e20c..943a7996aeb 100644 --- a/homeassistant/components/melnor/time.py +++ b/homeassistant/components/melnor/time.py @@ -42,7 +42,7 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneTimeEntityDescription] = [ MelnorZoneTimeEntityDescription( entity_category=EntityCategory.CONFIG, key="frequency_start_time", - name="Schedule Start Time", + translation_key="frequency_start_time", set_time_fn=lambda valve, value: valve.set_frequency_start_time(value), state_fn=lambda valve: valve.frequency.start_time, ), diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 32b095230d9..16bfc93f715 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -6,10 +6,9 @@ from datetime import timedelta import logging from random import randrange from types import MappingProxyType -from typing import Any +from typing import Any, Self import metno -from typing_extensions import Self from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 05642c12991..20822dc9973 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -218,7 +218,7 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): def device_info(self) -> DeviceInfo: """Device info.""" return DeviceInfo( - default_name="Forecast", + name="Forecast", entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN,)}, # type: ignore[arg-type] manufacturer="Met.no", diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index cce35731c72..bf0d7214c6e 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -159,7 +159,7 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): def device_info(self): """Device info.""" return DeviceInfo( - default_name="Forecast", + name="Forecast", entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN,)}, manufacturer="Met Éireann", diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 8c27f2970a3..89faf6d80eb 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -137,6 +137,13 @@ SENSOR_TYPES: tuple[MeteoFranceSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, data_path="today_forecast:weather12H:desc", ), + MeteoFranceSensorEntityDescription( + key="humidity", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:water-percent", + data_path="current_forecast:humidity", + ), ) SENSOR_TYPES_RAIN: tuple[MeteoFranceSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/meteo_france/strings.json b/homeassistant/components/meteo_france/strings.json index 3ff8d4308a3..944f2b32fab 100644 --- a/homeassistant/components/meteo_france/strings.json +++ b/homeassistant/components/meteo_france/strings.json @@ -10,7 +10,7 @@ "cities": { "description": "Choose your city from the list", "data": { - "city": "City" + "city": "[%key:component::meteo_france::config::step::user::data::city%]" } } }, diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 7709ba0a638..165cefc9240 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -6,6 +6,7 @@ from meteofrance_api.model.forecast import Forecast from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, + ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_TEMP_LOW, @@ -171,6 +172,7 @@ class MeteoFranceWeather( ATTR_FORECAST_CONDITION: format_condition( forecast["weather"]["desc"] ), + ATTR_FORECAST_HUMIDITY: forecast["humidity"], ATTR_FORECAST_NATIVE_TEMP: forecast["T"]["value"], ATTR_FORECAST_NATIVE_PRECIPITATION: forecast["rain"].get("1h"), ATTR_FORECAST_NATIVE_WIND_SPEED: forecast["wind"]["speed"], @@ -192,6 +194,7 @@ class MeteoFranceWeather( ATTR_FORECAST_CONDITION: format_condition( forecast["weather12H"]["desc"] ), + ATTR_FORECAST_HUMIDITY: forecast["humidity"]["max"], ATTR_FORECAST_NATIVE_TEMP: forecast["T"]["max"], ATTR_FORECAST_NATIVE_TEMP_LOW: forecast["T"]["min"], ATTR_FORECAST_NATIVE_PRECIPITATION: forecast["precipitation"][ diff --git a/homeassistant/components/microsoft_face/services.yaml b/homeassistant/components/microsoft_face/services.yaml index e27e29dfc6f..13078495b43 100644 --- a/homeassistant/components/microsoft_face/services.yaml +++ b/homeassistant/components/microsoft_face/services.yaml @@ -1,93 +1,61 @@ create_group: - name: Create group - description: Create a new person group. fields: name: - name: Name - description: Name of the group. required: true example: family selector: text: create_person: - name: Create person - description: Create a new person in the group. fields: group: - name: Group - description: Name of the group required: true example: family selector: text: name: - name: Name - description: Name of the person required: true example: Hans selector: text: delete_group: - name: Delete group - description: Delete a new person group. fields: name: - name: Name - description: Name of the group. required: true example: family selector: text: delete_person: - name: Delete person - description: Delete a person in the group. fields: group: - name: Group - description: Name of the group. required: true example: family selector: text: name: - name: Name - description: Name of the person. required: true example: Hans selector: text: face_person: - name: Face person - description: Add a new picture to a person. fields: camera_entity: - name: Camera entity - description: Camera to take a picture. required: true example: camera.door selector: text: group: - name: Group - description: Name of the group. required: true example: family selector: text: person: - name: Person - description: Name of the person. required: true example: Hans selector: text: train_group: - name: Train group - description: Train a person group. fields: group: - name: Group - description: Name of the group required: true example: family selector: diff --git a/homeassistant/components/microsoft_face/strings.json b/homeassistant/components/microsoft_face/strings.json new file mode 100644 index 00000000000..4357276a650 --- /dev/null +++ b/homeassistant/components/microsoft_face/strings.json @@ -0,0 +1,80 @@ +{ + "services": { + "create_group": { + "name": "Create group", + "description": "Creates a new person group.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "Name of the group." + } + } + }, + "create_person": { + "name": "Create person", + "description": "Creates a new person in the group.", + "fields": { + "group": { + "name": "Group", + "description": "Name of the group." + }, + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "Name of the person." + } + } + }, + "delete_group": { + "name": "Delete group", + "description": "Deletes a new person group.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "Name of the group." + } + } + }, + "delete_person": { + "name": "Delete person", + "description": "Deletes a person in the group.", + "fields": { + "group": { + "name": "Group", + "description": "Name of the group." + }, + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "[%key:component::microsoft_face::services::create_person::fields::name::description%]" + } + } + }, + "face_person": { + "name": "Face person", + "description": "Adds a new picture to a person.", + "fields": { + "camera_entity": { + "name": "Camera entity", + "description": "Camera to take a picture." + }, + "group": { + "name": "Group", + "description": "Name of the group." + }, + "person": { + "name": "Person", + "description": "[%key:component::microsoft_face::services::create_person::fields::name::description%]" + } + } + }, + "train_group": { + "name": "Train group", + "description": "Trains a person group.", + "fields": { + "group": { + "name": "Group", + "description": "Name of the group." + } + } + } + } +} diff --git a/homeassistant/components/miflora/__init__.py b/homeassistant/components/miflora/__init__.py deleted file mode 100644 index ed1569e1af0..00000000000 --- a/homeassistant/components/miflora/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The miflora component.""" diff --git a/homeassistant/components/miflora/manifest.json b/homeassistant/components/miflora/manifest.json deleted file mode 100644 index 8a6e1843d86..00000000000 --- a/homeassistant/components/miflora/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "miflora", - "name": "Mi Flora", - "codeowners": ["@danielhiversen", "@basnijholt"], - "documentation": "https://www.home-assistant.io/integrations/miflora", - "iot_class": "local_polling", - "requirements": [] -} diff --git a/homeassistant/components/miflora/sensor.py b/homeassistant/components/miflora/sensor.py deleted file mode 100644 index 764e03786f8..00000000000 --- a/homeassistant/components/miflora/sensor.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Support for Xiaomi Mi Flora BLE plant sensor.""" -from __future__ import annotations - -from homeassistant.components.sensor import PLATFORM_SCHEMA_BASE -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -PLATFORM_SCHEMA = PLATFORM_SCHEMA_BASE - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the MiFlora sensor.""" - async_create_issue( - hass, - "miflora", - "replaced", - breaks_in_ha_version="2022.8.0", - is_fixable=False, - severity=IssueSeverity.ERROR, - translation_key="replaced", - learn_more_url="https://www.home-assistant.io/integrations/xiaomi_ble/", - ) diff --git a/homeassistant/components/miflora/strings.json b/homeassistant/components/miflora/strings.json deleted file mode 100644 index 03427e88af9..00000000000 --- a/homeassistant/components/miflora/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "replaced": { - "title": "The Mi Flora integration has been replaced", - "description": "The Mi Flora integration stopped working in Home Assistant 2022.7 and replaced by the Xiaomi BLE integration in the 2022.8 release.\n\nThere is no migration path possible, therefore, you have to add your Mi Flora device using the new integration manually.\n\nYour existing Mi Flora YAML configuration is no longer used by Home Assistant. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/components/mill/services.yaml b/homeassistant/components/mill/services.yaml index ad5cff4a5ff..14e2196eb83 100644 --- a/homeassistant/components/mill/services.yaml +++ b/homeassistant/components/mill/services.yaml @@ -1,33 +1,23 @@ set_room_temperature: - name: Set room temperature - description: Set Mill room temperatures. fields: room_name: - name: Room name - description: Name of room to change. required: true example: "kitchen" selector: text: away_temp: - name: Away temperature - description: Away temp. selector: number: min: 0 max: 100 unit_of_measurement: "°" comfort_temp: - name: Comfort temperature - description: Comfort temp. selector: number: min: 0 max: 100 unit_of_measurement: "°" sleep_temp: - name: Sleep temperature - description: Sleep temp. selector: number: min: 0 diff --git a/homeassistant/components/mill/strings.json b/homeassistant/components/mill/strings.json index 5f4cec1336e..caeea189c0e 100644 --- a/homeassistant/components/mill/strings.json +++ b/homeassistant/components/mill/strings.json @@ -26,5 +26,29 @@ "description": "Local IP address of the device." } } + }, + "services": { + "set_room_temperature": { + "name": "Set room temperature", + "description": "Sets Mill room temperatures.", + "fields": { + "room_name": { + "name": "Room name", + "description": "Name of room to change." + }, + "away_temp": { + "name": "Away temperature", + "description": "Away temp." + }, + "comfort_temp": { + "name": "Comfort temperature", + "description": "Comfort temp." + }, + "sleep_temp": { + "name": "Sleep temperature", + "description": "Sleep temp." + } + } + } } } diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index d0064d07511..cc26a684a8d 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -22,10 +22,13 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ( ConfigType, @@ -254,7 +257,9 @@ class MinMaxSensor(SensorEntity): # Replay current state of source entities for entity_id in self._entity_ids: state = self.hass.states.get(entity_id) - state_event = Event("", {"entity_id": entity_id, "new_state": state}) + state_event: EventType[EventStateChangedData] = EventType( + "", {"entity_id": entity_id, "new_state": state, "old_state": None} + ) self._async_min_max_sensor_state_listener(state_event, update_state=False) self._calc_values() @@ -287,11 +292,11 @@ class MinMaxSensor(SensorEntity): @callback def _async_min_max_sensor_state_listener( - self, event: EventType, update_state: bool = True + self, event: EventType[EventStateChangedData], update_state: bool = True ) -> None: """Handle the sensor state changes.""" - new_state: State | None = event.data.get("new_state") - entity: str = event.data["entity_id"] + new_state = event.data["new_state"] + entity = event.data["entity_id"] if ( new_state is None diff --git a/homeassistant/components/min_max/services.yaml b/homeassistant/components/min_max/services.yaml index cca67d92144..c983a105c93 100644 --- a/homeassistant/components/min_max/services.yaml +++ b/homeassistant/components/min_max/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload all min_max entities. diff --git a/homeassistant/components/min_max/strings.json b/homeassistant/components/min_max/strings.json index c76a6faf2f5..e73fac97bb7 100644 --- a/homeassistant/components/min_max/strings.json +++ b/homeassistant/components/min_max/strings.json @@ -3,11 +3,11 @@ "config": { "step": { "user": { - "title": "Combine the state of several sensors", + "title": "[%key:component::min_max::title%]", "description": "Create a sensor that calculates a min, max, mean, median or sum from a list of input sensors.", "data": { "entity_ids": "Input entities", - "name": "Name", + "name": "[%key:common::config_flow::data::name%]", "round_digits": "Precision", "type": "Statistic characteristic" }, @@ -43,5 +43,11 @@ "sum": "Sum" } } + }, + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads min/max sensors from the YAML-configuration." + } } } diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index dad8ebe7f11..aef6c94767f 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -6,20 +6,16 @@ from datetime import datetime, timedelta import logging from typing import Any -from mcstatus.server import MinecraftServer as MCStatus +from mcstatus.server import JavaServer from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from . import helpers -from .const import DOMAIN, MANUFACTURER, SCAN_INTERVAL, SIGNAL_NAME_PREFIX +from .const import DOMAIN, SCAN_INTERVAL, SIGNAL_NAME_PREFIX PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -69,9 +65,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class MinecraftServer: """Representation of a Minecraft server.""" - # Private constants - _MAX_RETRIES_STATUS = 3 - def __init__( self, hass: HomeAssistant, unique_id: str, config_data: Mapping[str, Any] ) -> None: @@ -88,16 +81,16 @@ class MinecraftServer: self.srv_record_checked = False # 3rd party library instance - self._mc_status = MCStatus(self.host, self.port) + self._server = JavaServer(self.host, self.port) # Data provided by 3rd party library - self.version = None - self.protocol_version = None - self.latency_time = None - self.players_online = None - self.players_max = None + self.version: str | None = None + self.protocol_version: int | None = None + self.latency_time: float | None = None + self.players_online: int | None = None + self.players_max: int | None = None self.players_list: list[str] | None = None - self.motd = None + self.motd: str | None = None # Dispatcher signal name self.signal_name = f"{SIGNAL_NAME_PREFIX}_{self.unique_id}" @@ -133,13 +126,11 @@ class MinecraftServer: # with data extracted out of SRV record. self.host = srv_record[CONF_HOST] self.port = srv_record[CONF_PORT] - self._mc_status = MCStatus(self.host, self.port) + self._server = JavaServer(self.host, self.port) # Ping the server with a status request. try: - await self._hass.async_add_executor_job( - self._mc_status.status, self._MAX_RETRIES_STATUS - ) + await self._server.async_status() self.online = True except OSError as error: _LOGGER.debug( @@ -176,9 +167,7 @@ class MinecraftServer: async def _async_status_request(self) -> None: """Request server status and update properties.""" try: - status_response = await self._hass.async_add_executor_job( - self._mc_status.status, self._MAX_RETRIES_STATUS - ) + status_response = await self._server.async_status() # Got answer to request, update properties. self.version = status_response.version.name @@ -186,7 +175,8 @@ class MinecraftServer: self.players_online = status_response.players.online self.players_max = status_response.players.max self.latency_time = status_response.latency - self.motd = (status_response.description).get("text") + self.motd = status_response.motd.to_plain() + self.players_list = [] if status_response.players.sample is not None: for player in status_response.players.sample: @@ -220,53 +210,3 @@ class MinecraftServer: error, ) self._last_status_request_failed = True - - -class MinecraftServerEntity(Entity): - """Representation of a Minecraft Server base entity.""" - - _attr_has_entity_name = True - _attr_should_poll = False - - def __init__( - self, - server: MinecraftServer, - type_name: str, - icon: str, - device_class: str | None, - ) -> None: - """Initialize base entity.""" - self._server = server - self._attr_name = type_name - self._attr_icon = icon - self._attr_unique_id = f"{self._server.unique_id}-{type_name}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._server.unique_id)}, - manufacturer=MANUFACTURER, - model=f"Minecraft Server ({self._server.version})", - name=self._server.name, - sw_version=self._server.protocol_version, - ) - self._attr_device_class = device_class - self._extra_state_attributes = None - self._disconnect_dispatcher: CALLBACK_TYPE | None = None - - async def async_update(self) -> None: - """Fetch data from the server.""" - raise NotImplementedError() - - async def async_added_to_hass(self) -> None: - """Connect dispatcher to signal from server.""" - self._disconnect_dispatcher = async_dispatcher_connect( - self.hass, self._server.signal_name, self._update_callback - ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher before removal.""" - if self._disconnect_dispatcher: - self._disconnect_dispatcher() - - @callback - def _update_callback(self) -> None: - """Triggers update of properties after receiving signal from server.""" - self.async_schedule_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 0bf4cdab859..5c9cb5f42e1 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -7,8 +7,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MinecraftServer, MinecraftServerEntity +from . import MinecraftServer from .const import DOMAIN, ICON_STATUS, NAME_STATUS +from .entity import MinecraftServerEntity async def async_setup_entry( @@ -29,6 +30,8 @@ async def async_setup_entry( class MinecraftServerStatusBinarySensor(MinecraftServerEntity, BinarySensorEntity): """Representation of a Minecraft Server status binary sensor.""" + _attr_translation_key = "status" + def __init__(self, server: MinecraftServer) -> None: """Initialize status binary sensor.""" super().__init__( diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index 691aea0f75e..b402b7cfff0 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.data_entry_flow import FlowResult from . import MinecraftServer, helpers from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN @@ -18,7 +19,7 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> FlowResult: """Handle the initial step.""" errors = {} @@ -117,7 +118,7 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): # form filled with user_input and eventually with errors otherwise). return self._show_config_form(user_input, errors) - def _show_config_form(self, user_input=None, errors=None): + def _show_config_form(self, user_input=None, errors=None) -> FlowResult: """Show the setup form to the user.""" if user_input is None: user_input = {} diff --git a/homeassistant/components/minecraft_server/entity.py b/homeassistant/components/minecraft_server/entity.py new file mode 100644 index 00000000000..02875cb69f2 --- /dev/null +++ b/homeassistant/components/minecraft_server/entity.py @@ -0,0 +1,57 @@ +"""Base entity for the Minecraft Server integration.""" + +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo, Entity + +from . import MinecraftServer +from .const import DOMAIN, MANUFACTURER + + +class MinecraftServerEntity(Entity): + """Representation of a Minecraft Server base entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__( + self, + server: MinecraftServer, + type_name: str, + icon: str, + device_class: str | None, + ) -> None: + """Initialize base entity.""" + self._server = server + self._attr_icon = icon + self._attr_unique_id = f"{self._server.unique_id}-{type_name}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._server.unique_id)}, + manufacturer=MANUFACTURER, + model=f"Minecraft Server ({self._server.version})", + name=self._server.name, + sw_version=str(self._server.protocol_version), + ) + self._attr_device_class = device_class + self._extra_state_attributes = None + self._disconnect_dispatcher: CALLBACK_TYPE | None = None + + async def async_update(self) -> None: + """Fetch data from the server.""" + raise NotImplementedError() + + async def async_added_to_hass(self) -> None: + """Connect dispatcher to signal from server.""" + self._disconnect_dispatcher = async_dispatcher_connect( + self.hass, self._server.signal_name, self._update_callback + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect dispatcher before removal.""" + if self._disconnect_dispatcher: + self._disconnect_dispatcher() + + @callback + def _update_callback(self) -> None: + """Triggers update of properties after receiving signal from server.""" + self.async_schedule_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index b831e1eae90..27019cb80a8 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], "quality_scale": "silver", - "requirements": ["aiodns==3.0.0", "getmac==0.8.2", "mcstatus==6.0.0"] + "requirements": ["aiodns==3.0.0", "getmac==0.8.2", "mcstatus==11.0.0"] } diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 2499dd8b75b..3a9e4b8f0a0 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -7,7 +7,7 @@ from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MinecraftServer, MinecraftServerEntity +from . import MinecraftServer from .const import ( ATTR_PLAYERS_LIST, DOMAIN, @@ -26,6 +26,7 @@ from .const import ( UNIT_PLAYERS_MAX, UNIT_PLAYERS_ONLINE, ) +from .entity import MinecraftServerEntity async def async_setup_entry( @@ -74,6 +75,8 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): class MinecraftServerVersionSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server version sensor.""" + _attr_translation_key = "version" + def __init__(self, server: MinecraftServer) -> None: """Initialize version sensor.""" super().__init__(server=server, type_name=NAME_VERSION, icon=ICON_VERSION) @@ -86,6 +89,8 @@ class MinecraftServerVersionSensor(MinecraftServerSensorEntity): class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server protocol version sensor.""" + _attr_translation_key = "protocol_version" + def __init__(self, server: MinecraftServer) -> None: """Initialize protocol version sensor.""" super().__init__( @@ -102,6 +107,8 @@ class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity): class MinecraftServerLatencyTimeSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server latency time sensor.""" + _attr_translation_key = "latency" + def __init__(self, server: MinecraftServer) -> None: """Initialize latency time sensor.""" super().__init__( @@ -119,6 +126,8 @@ class MinecraftServerLatencyTimeSensor(MinecraftServerSensorEntity): class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server online players sensor.""" + _attr_translation_key = "players_online" + def __init__(self, server: MinecraftServer) -> None: """Initialize online players sensor.""" super().__init__( @@ -144,6 +153,8 @@ class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity): class MinecraftServerPlayersMaxSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server maximum number of players sensor.""" + _attr_translation_key = "players_max" + def __init__(self, server: MinecraftServer) -> None: """Initialize maximum number of players sensor.""" super().__init__( @@ -161,6 +172,8 @@ class MinecraftServerPlayersMaxSensor(MinecraftServerSensorEntity): class MinecraftServerMOTDSensor(MinecraftServerSensorEntity): """Representation of a Minecraft Server MOTD sensor.""" + _attr_translation_key = "motd" + def __init__(self, server: MinecraftServer) -> None: """Initialize MOTD sensor.""" super().__init__( diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index 9e546a3cdfa..b4d68bc6117 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -18,5 +18,32 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "entity": { + "binary_sensor": { + "status": { + "name": "Status" + } + }, + "sensor": { + "version": { + "name": "Version" + }, + "protocol_version": { + "name": "Protocol version" + }, + "latency": { + "name": "Latency" + }, + "players_online": { + "name": "Players online" + }, + "players_max": { + "name": "Players max" + }, + "motd": { + "name": "World message" + } + } } } diff --git a/homeassistant/components/minio/minio_helper.py b/homeassistant/components/minio/minio_helper.py index a5068e1a47b..7edb11797eb 100644 --- a/homeassistant/components/minio/minio_helper.py +++ b/homeassistant/components/minio/minio_helper.py @@ -8,10 +8,10 @@ from queue import Queue import re import threading import time +from typing import Self from urllib.parse import unquote from minio import Minio -from typing_extensions import Self from urllib3.exceptions import HTTPError _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/minio/services.yaml b/homeassistant/components/minio/services.yaml index 39e430ab165..b40797bc165 100644 --- a/homeassistant/components/minio/services.yaml +++ b/homeassistant/components/minio/services.yaml @@ -1,69 +1,47 @@ get: - name: Get - description: Download file from Minio. fields: bucket: - name: Bucket - description: Bucket to use. required: true example: camera-files selector: text: key: - name: Kay - description: Object key of the file. required: true example: front_camera/2018/01/02/snapshot_12512514.jpg selector: text: file_path: - name: File path - description: File path on local filesystem. required: true example: /data/camera_files/snapshot.jpg selector: text: put: - name: Put - description: Upload file to Minio. fields: bucket: - name: Bucket - description: Bucket to use. required: true example: camera-files selector: text: key: - name: Key - description: Object key of the file. required: true example: front_camera/2018/01/02/snapshot_12512514.jpg selector: text: file_path: - name: File path - description: File path on local filesystem. required: true example: /data/camera_files/snapshot.jpg selector: text: remove: - name: Remove - description: Delete file from Minio. fields: bucket: - name: Bucket - description: Bucket to use. required: true example: camera-files selector: text: key: - name: Key - description: Object key of the file. required: true example: front_camera/2018/01/02/snapshot_12512514.jpg selector: diff --git a/homeassistant/components/minio/strings.json b/homeassistant/components/minio/strings.json new file mode 100644 index 00000000000..75b8375adb1 --- /dev/null +++ b/homeassistant/components/minio/strings.json @@ -0,0 +1,54 @@ +{ + "services": { + "get": { + "name": "Get", + "description": "Downloads file from Minio.", + "fields": { + "bucket": { + "name": "Bucket", + "description": "Bucket to use." + }, + "key": { + "name": "Kay", + "description": "Object key of the file." + }, + "file_path": { + "name": "File path", + "description": "File path on local filesystem." + } + } + }, + "put": { + "name": "Put", + "description": "Uploads file to Minio.", + "fields": { + "bucket": { + "name": "[%key:component::minio::services::get::fields::bucket::name%]", + "description": "[%key:component::minio::services::get::fields::bucket::description%]" + }, + "key": { + "name": "Key", + "description": "[%key:component::minio::services::get::fields::key::description%]" + }, + "file_path": { + "name": "File path", + "description": "[%key:component::minio::services::get::fields::file_path::description%]" + } + } + }, + "remove": { + "name": "Remove", + "description": "Deletes file from Minio.", + "fields": { + "bucket": { + "name": "[%key:component::minio::services::get::fields::bucket::name%]", + "description": "[%key:component::minio::services::get::fields::bucket::description%]" + }, + "key": { + "name": "Key", + "description": "[%key:component::minio::services::get::fields::key::description%]" + } + } + } + } +} diff --git a/homeassistant/components/mitemp_bt/__init__.py b/homeassistant/components/mitemp_bt/__init__.py deleted file mode 100644 index 785956572af..00000000000 --- a/homeassistant/components/mitemp_bt/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The mitemp_bt component.""" diff --git a/homeassistant/components/mitemp_bt/manifest.json b/homeassistant/components/mitemp_bt/manifest.json deleted file mode 100644 index 2709c08ad78..00000000000 --- a/homeassistant/components/mitemp_bt/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "mitemp_bt", - "name": "Xiaomi Mijia BLE Temperature and Humidity Sensor", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/mitemp_bt", - "iot_class": "local_polling", - "requirements": [] -} diff --git a/homeassistant/components/mitemp_bt/sensor.py b/homeassistant/components/mitemp_bt/sensor.py deleted file mode 100644 index a1646bed51c..00000000000 --- a/homeassistant/components/mitemp_bt/sensor.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Support for Xiaomi Mi Temp BLE environmental sensor.""" -from __future__ import annotations - -from homeassistant.components.sensor import PLATFORM_SCHEMA_BASE -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -PLATFORM_SCHEMA = PLATFORM_SCHEMA_BASE - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the MiTempBt sensor.""" - async_create_issue( - hass, - "mitemp_bt", - "replaced", - breaks_in_ha_version="2022.8.0", - is_fixable=False, - severity=IssueSeverity.ERROR, - translation_key="replaced", - learn_more_url="https://www.home-assistant.io/integrations/xiaomi_ble/", - ) diff --git a/homeassistant/components/mitemp_bt/strings.json b/homeassistant/components/mitemp_bt/strings.json deleted file mode 100644 index 1f9f031a3bb..00000000000 --- a/homeassistant/components/mitemp_bt/strings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "issues": { - "replaced": { - "title": "The Xiaomi Mijia BLE Temperature and Humidity Sensor integration has been replaced", - "description": "The Xiaomi Mijia BLE Temperature and Humidity Sensor integration stopped working in Home Assistant 2022.7 and was replaced by the Xiaomi BLE integration in the 2022.8 release.\n\nThere is no migration path possible, therefore, you have to add your Xiaomi Mijia BLE device using the new integration manually.\n\nYour existing Xiaomi Mijia BLE Temperature and Humidity Sensor YAML configuration is no longer used by Home Assistant. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - } -} diff --git a/homeassistant/components/mjpeg/strings.json b/homeassistant/components/mjpeg/strings.json index 73e6a150a09..0e1e71fd82c 100644 --- a/homeassistant/components/mjpeg/strings.json +++ b/homeassistant/components/mjpeg/strings.json @@ -24,10 +24,10 @@ "step": { "init": { "data": { - "mjpeg_url": "MJPEG URL", + "mjpeg_url": "[%key:component::mjpeg::config::step::user::data::mjpeg_url%]", "name": "[%key:common::config_flow::data::name%]", "password": "[%key:common::config_flow::data::password%]", - "still_image_url": "Still Image URL", + "still_image_url": "[%key:component::mjpeg::config::step::user::data::still_image_url%]", "username": "[%key:common::config_flow::data::username%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 90e244aaf06..62417b0873a 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -144,6 +144,16 @@ WEBHOOK_PAYLOAD_SCHEMA = vol.Any( ), ) +SENSOR_SCHEMA_FULL = vol.Schema( + { + vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict, + vol.Optional(ATTR_SENSOR_ICON, default="mdi:cellphone"): vol.Any(None, cv.icon), + vol.Required(ATTR_SENSOR_STATE): vol.Any(None, bool, int, float, str), + vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES), + vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string, + } +) + def validate_schema(schema): """Decorate a webhook function with a schema.""" @@ -636,18 +646,6 @@ async def webhook_update_sensor_states( hass: HomeAssistant, config_entry: ConfigEntry, data: list[dict[str, Any]] ) -> Response: """Handle an update sensor states webhook.""" - sensor_schema_full = vol.Schema( - { - vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict, - vol.Optional(ATTR_SENSOR_ICON, default="mdi:cellphone"): vol.Any( - None, cv.icon - ), - vol.Required(ATTR_SENSOR_STATE): vol.Any(None, bool, int, float, str), - vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES), - vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string, - } - ) - device_name: str = config_entry.data[ATTR_DEVICE_NAME] resp: dict[str, Any] = {} entity_registry = er.async_get(hass) @@ -677,7 +675,7 @@ async def webhook_update_sensor_states( continue try: - sensor = sensor_schema_full(sensor) + sensor = SENSOR_SCHEMA_FULL(sensor) except vol.Invalid as err: err_msg = vol.humanize.humanize_error(sensor, err) _LOGGER.error( diff --git a/homeassistant/components/modbus/services.yaml b/homeassistant/components/modbus/services.yaml index 07acf0a72df..8dafa911ada 100644 --- a/homeassistant/components/modbus/services.yaml +++ b/homeassistant/components/modbus/services.yaml @@ -1,92 +1,62 @@ reload: - name: Reload - description: Reload all modbus entities. write_coil: - name: Write coil - description: Write to a modbus coil. fields: address: - name: Address - description: Address of the register to write to. required: true selector: number: min: 0 max: 65535 state: - name: State - description: State to write. required: true example: "0 or [1,0]" selector: object: slave: - name: Slave - description: Address of the modbus unit/slave. required: false selector: number: min: 1 max: 255 hub: - name: Hub - description: Modbus hub name. example: "hub1" default: "modbus_hub" selector: text: write_register: - name: Write register - description: Write to a modbus holding register. fields: address: - name: Address - description: Address of the holding register to write to. required: true selector: number: min: 0 max: 65535 slave: - name: Slave - description: Address of the modbus unit/slave. required: false selector: number: min: 1 max: 255 value: - name: Value - description: Value (single value or array) to write. required: true example: "0 or [4,0]" selector: object: hub: - name: Hub - description: Modbus hub name. example: "hub1" default: "modbus_hub" selector: text: stop: - name: Stop - description: Stop modbus hub. fields: hub: - name: Hub - description: Modbus hub name. example: "hub1" default: "modbus_hub" selector: text: restart: - name: Restart - description: Restart modbus hub (if running stop then start). fields: hub: - name: Hub - description: Modbus hub name. example: "hub1" default: "modbus_hub" selector: diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json new file mode 100644 index 00000000000..61694074d79 --- /dev/null +++ b/homeassistant/components/modbus/strings.json @@ -0,0 +1,72 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads all modbus entities." + }, + "write_coil": { + "name": "Write coil", + "description": "Writes to a modbus coil.", + "fields": { + "address": { + "name": "Address", + "description": "Address of the register to write to." + }, + "state": { + "name": "State", + "description": "State to write." + }, + "slave": { + "name": "Slave", + "description": "Address of the modbus unit/slave." + }, + "hub": { + "name": "Hub", + "description": "Modbus hub name." + } + } + }, + "write_register": { + "name": "Write register", + "description": "Writes to a modbus holding register.", + "fields": { + "address": { + "name": "[%key:component::modbus::services::write_coil::fields::address::name%]", + "description": "Address of the holding register to write to." + }, + "slave": { + "name": "[%key:component::modbus::services::write_coil::fields::slave::name%]", + "description": "[%key:component::modbus::services::write_coil::fields::slave::description%]" + }, + "value": { + "name": "Value", + "description": "Value (single value or array) to write." + }, + "hub": { + "name": "[%key:component::modbus::services::write_coil::fields::hub::name%]", + "description": "[%key:component::modbus::services::write_coil::fields::hub::description%]" + } + } + }, + "stop": { + "name": "[%key:common::action::stop%]", + "description": "Stops modbus hub.", + "fields": { + "hub": { + "name": "[%key:component::modbus::services::write_coil::fields::hub::name%]", + "description": "[%key:component::modbus::services::write_coil::fields::hub::description%]" + } + } + }, + "restart": { + "name": "[%key:common::action::restart%]", + "description": "Restarts modbus hub (if running stop then start).", + "fields": { + "hub": { + "name": "[%key:component::modbus::services::write_coil::fields::hub::name%]", + "description": "[%key:component::modbus::services::write_coil::fields::hub::description%]" + } + } + } + } +} diff --git a/homeassistant/components/modem_callerid/strings.json b/homeassistant/components/modem_callerid/strings.json index bb6ac1879da..2e18ba3654f 100644 --- a/homeassistant/components/modem_callerid/strings.json +++ b/homeassistant/components/modem_callerid/strings.json @@ -9,7 +9,7 @@ } }, "usb_confirm": { - "description": "This is an integration for landline calls using a CX93001 voice modem. This can retrieve caller ID information with an option to reject an incoming call." + "description": "[%key:component::modem_callerid::config::step::user::description%]" } }, "error": { diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index d00fe793bf8..d7f30ce5c3b 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -121,12 +121,13 @@ class ModernFormsDataUpdateCoordinator(DataUpdateCoordinator[ModernFormsDeviceSt class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator]): """Defines a Modern Forms device entity.""" + _attr_has_entity_name = True + def __init__( self, *, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator, - name: str, icon: str | None = None, enabled_default: bool = True, ) -> None: @@ -135,7 +136,6 @@ class ModernFormsDeviceEntity(CoordinatorEntity[ModernFormsDataUpdateCoordinator self._attr_enabled_default = enabled_default self._entry_id = entry_id self._attr_icon = icon - self._attr_name = name @property def device_info(self) -> DeviceInfo: diff --git a/homeassistant/components/modern_forms/binary_sensor.py b/homeassistant/components/modern_forms/binary_sensor.py index f8e3f8bbcf8..b3361c3f143 100644 --- a/homeassistant/components/modern_forms/binary_sensor.py +++ b/homeassistant/components/modern_forms/binary_sensor.py @@ -40,14 +40,11 @@ class ModernFormsBinarySensor(ModernFormsDeviceEntity, BinarySensorEntity): *, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator, - name: str, icon: str, key: str, ) -> None: """Initialize Modern Forms switch.""" - super().__init__( - entry_id=entry_id, coordinator=coordinator, name=name, icon=icon - ) + super().__init__(entry_id=entry_id, coordinator=coordinator, icon=icon) self._attr_unique_id = f"{coordinator.data.info.mac_address}_{key}" @@ -56,6 +53,7 @@ class ModernFormsLightSleepTimerActive(ModernFormsBinarySensor): """Defines a Modern Forms Light Sleep Timer Active sensor.""" _attr_entity_registry_enabled_default = False + _attr_translation_key = "light_sleep_timer_active" def __init__( self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator @@ -66,7 +64,6 @@ class ModernFormsLightSleepTimerActive(ModernFormsBinarySensor): entry_id=entry_id, icon="mdi:av-timer", key="light_sleep_timer_active", - name=f"{coordinator.data.info.device_name} Light Sleep Timer Active", ) @property @@ -88,6 +85,7 @@ class ModernFormsFanSleepTimerActive(ModernFormsBinarySensor): """Defines a Modern Forms Fan Sleep Timer Active sensor.""" _attr_entity_registry_enabled_default = False + _attr_translation_key = "fan_sleep_timer_active" def __init__( self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator @@ -98,7 +96,6 @@ class ModernFormsFanSleepTimerActive(ModernFormsBinarySensor): entry_id=entry_id, icon="mdi:av-timer", key="fan_sleep_timer_active", - name=f"{coordinator.data.info.device_name} Fan Sleep Timer Active", ) @property diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 8bd8665dc3b..9d5a3c32235 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -73,6 +73,7 @@ class ModernFormsFanEntity(FanEntity, ModernFormsDeviceEntity): SPEED_RANGE = (1, 6) # off is not included _attr_supported_features = FanEntityFeature.DIRECTION | FanEntityFeature.SET_SPEED + _attr_translation_key = "fan" def __init__( self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator @@ -81,7 +82,6 @@ class ModernFormsFanEntity(FanEntity, ModernFormsDeviceEntity): super().__init__( entry_id=entry_id, coordinator=coordinator, - name=f"{coordinator.data.info.device_name} Fan", ) self._attr_unique_id = f"{self.coordinator.data.info.mac_address}" diff --git a/homeassistant/components/modern_forms/light.py b/homeassistant/components/modern_forms/light.py index 55569054ac4..013d6a17d6d 100644 --- a/homeassistant/components/modern_forms/light.py +++ b/homeassistant/components/modern_forms/light.py @@ -81,6 +81,7 @@ class ModernFormsLightEntity(ModernFormsDeviceEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_translation_key = "light" def __init__( self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator @@ -89,7 +90,6 @@ class ModernFormsLightEntity(ModernFormsDeviceEntity, LightEntity): super().__init__( entry_id=entry_id, coordinator=coordinator, - name=f"{coordinator.data.info.device_name} Light", icon=None, ) self._attr_unique_id = f"{self.coordinator.data.info.mac_address}" diff --git a/homeassistant/components/modern_forms/sensor.py b/homeassistant/components/modern_forms/sensor.py index 6d2ef5b6dab..efd659f3ae0 100644 --- a/homeassistant/components/modern_forms/sensor.py +++ b/homeassistant/components/modern_forms/sensor.py @@ -43,21 +43,20 @@ class ModernFormsSensor(ModernFormsDeviceEntity, SensorEntity): *, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator, - name: str, icon: str, key: str, ) -> None: """Initialize Modern Forms switch.""" self._key = key - super().__init__( - entry_id=entry_id, coordinator=coordinator, name=name, icon=icon - ) + super().__init__(entry_id=entry_id, coordinator=coordinator, icon=icon) self._attr_unique_id = f"{self.coordinator.data.info.mac_address}_{self._key}" class ModernFormsLightTimerRemainingTimeSensor(ModernFormsSensor): """Defines the Modern Forms Light Timer remaining time sensor.""" + _attr_translation_key = "light_timer_remaining_time" + def __init__( self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator ) -> None: @@ -67,7 +66,6 @@ class ModernFormsLightTimerRemainingTimeSensor(ModernFormsSensor): entry_id=entry_id, icon="mdi:timer-outline", key="light_timer_remaining_time", - name=f"{coordinator.data.info.device_name} Light Sleep Time", ) self._attr_device_class = SensorDeviceClass.TIMESTAMP @@ -88,6 +86,8 @@ class ModernFormsLightTimerRemainingTimeSensor(ModernFormsSensor): class ModernFormsFanTimerRemainingTimeSensor(ModernFormsSensor): """Defines the Modern Forms Light Timer remaining time sensor.""" + _attr_translation_key = "fan_timer_remaining_time" + def __init__( self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator ) -> None: @@ -97,7 +97,6 @@ class ModernFormsFanTimerRemainingTimeSensor(ModernFormsSensor): entry_id=entry_id, icon="mdi:timer-outline", key="fan_timer_remaining_time", - name=f"{coordinator.data.info.device_name} Fan Sleep Time", ) self._attr_device_class = SensorDeviceClass.TIMESTAMP diff --git a/homeassistant/components/modern_forms/services.yaml b/homeassistant/components/modern_forms/services.yaml index ce3c29f39b5..07150f530be 100644 --- a/homeassistant/components/modern_forms/services.yaml +++ b/homeassistant/components/modern_forms/services.yaml @@ -1,14 +1,10 @@ set_light_sleep_timer: - name: Set light sleep timer - description: Set a sleep timer on a Modern Forms light. target: entity: integration: modern_forms domain: light fields: sleep_time: - name: Sleep Time - description: Number of minutes to set the timer. required: true example: "900" selector: @@ -17,23 +13,17 @@ set_light_sleep_timer: max: 1440 unit_of_measurement: minutes clear_light_sleep_timer: - name: Clear light sleep timer - description: Clear the sleep timer on a Modern Forms light. target: entity: integration: modern_forms domain: light set_fan_sleep_timer: - name: Set fan sleep timer - description: Set a sleep timer on a Modern Forms fan. target: entity: integration: modern_forms domain: fan fields: sleep_time: - name: Sleep Time - description: Number of minutes to set the timer. required: true example: "900" selector: @@ -42,8 +32,6 @@ set_fan_sleep_timer: max: 1440 unit_of_measurement: minutes clear_fan_sleep_timer: - name: Clear fan sleep timer - description: Clear the sleep timer on a Modern Forms fan. target: entity: integration: modern_forms diff --git a/homeassistant/components/modern_forms/strings.json b/homeassistant/components/modern_forms/strings.json index fc30709960b..dd47ef721af 100644 --- a/homeassistant/components/modern_forms/strings.json +++ b/homeassistant/components/modern_forms/strings.json @@ -20,5 +20,71 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "binary_sensor": { + "light_sleep_timer_active": { + "name": "Light sleep timer active" + }, + "fan_sleep_timer_active": { + "name": "Fan sleep timer active" + } + }, + "fan": { + "fan": { + "name": "[%key:component::fan::title%]" + } + }, + "light": { + "light": { + "name": "[%key:component::light::title%]" + } + }, + "sensor": { + "light_timer_remaining_time": { + "name": "Light sleep time" + }, + "fan_timer_remaining_time": { + "name": "Fan sleep time" + } + }, + "switch": { + "away_mode": { + "name": "Away mode" + }, + "adaptive_learning": { + "name": "Adaptive learning" + } + } + }, + "services": { + "set_light_sleep_timer": { + "name": "Set light sleep timer", + "description": "Sets a sleep timer on a Modern Forms light.", + "fields": { + "sleep_time": { + "name": "Sleep time", + "description": "Number of minutes to set the timer." + } + } + }, + "clear_light_sleep_timer": { + "name": "Clear light sleep timer", + "description": "Clears the sleep timer on a Modern Forms light." + }, + "set_fan_sleep_timer": { + "name": "Set fan sleep timer", + "description": "Sets a sleep timer on a Modern Forms fan.", + "fields": { + "sleep_time": { + "name": "[%key:component::modern_forms::services::set_light_sleep_timer::fields::sleep_time::name%]", + "description": "[%key:component::modern_forms::services::set_light_sleep_timer::fields::sleep_time::description%]" + } + } + }, + "clear_fan_sleep_timer": { + "name": "Clear fan sleep timer", + "description": "Clears the sleep timer on a Modern Forms fan." + } } } diff --git a/homeassistant/components/modern_forms/switch.py b/homeassistant/components/modern_forms/switch.py index 90d5d13d649..18d8caccbd6 100644 --- a/homeassistant/components/modern_forms/switch.py +++ b/homeassistant/components/modern_forms/switch.py @@ -39,21 +39,20 @@ class ModernFormsSwitch(ModernFormsDeviceEntity, SwitchEntity): *, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator, - name: str, icon: str, key: str, ) -> None: """Initialize Modern Forms switch.""" self._key = key - super().__init__( - entry_id=entry_id, coordinator=coordinator, name=name, icon=icon - ) + super().__init__(entry_id=entry_id, coordinator=coordinator, icon=icon) self._attr_unique_id = f"{self.coordinator.data.info.mac_address}_{self._key}" class ModernFormsAwaySwitch(ModernFormsSwitch): """Defines a Modern Forms Away mode switch.""" + _attr_translation_key = "away_mode" + def __init__( self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator ) -> None: @@ -63,7 +62,6 @@ class ModernFormsAwaySwitch(ModernFormsSwitch): entry_id=entry_id, icon="mdi:airplane-takeoff", key="away_mode", - name=f"{coordinator.data.info.device_name} Away Mode", ) @property @@ -85,6 +83,8 @@ class ModernFormsAwaySwitch(ModernFormsSwitch): class ModernFormsAdaptiveLearningSwitch(ModernFormsSwitch): """Defines a Modern Forms Adaptive Learning switch.""" + _attr_translation_key = "adaptive_learning" + def __init__( self, entry_id: str, coordinator: ModernFormsDataUpdateCoordinator ) -> None: @@ -94,7 +94,6 @@ class ModernFormsAdaptiveLearningSwitch(ModernFormsSwitch): entry_id=entry_id, icon="mdi:school-outline", key="adaptive_learning", - name=f"{coordinator.data.info.device_name} Adaptive Learning", ) @property diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index ee3ab9817ea..ce3844475c5 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -16,11 +16,14 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from homeassistant.util.unit_conversion import TemperatureConverter from homeassistant.util.unit_system import METRIC_SYSTEM @@ -117,11 +120,13 @@ class MoldIndicator(SensorEntity): """Register callbacks.""" @callback - def mold_indicator_sensors_state_listener(event): + def mold_indicator_sensors_state_listener( + event: EventType[EventStateChangedData], + ) -> None: """Handle for state changes for dependent sensors.""" - new_state = event.data.get("new_state") - old_state = event.data.get("old_state") - entity = event.data.get("entity_id") + new_state = event.data["new_state"] + old_state = event.data["old_state"] + entity = event.data["entity_id"] _LOGGER.debug( "Sensor state change for %s that had old state %s and new state %s", entity, @@ -173,7 +178,9 @@ class MoldIndicator(SensorEntity): EVENT_HOMEASSISTANT_START, mold_indicator_startup ) - def _update_sensor(self, entity, old_state, new_state): + def _update_sensor( + self, entity: str, old_state: State | None, new_state: State | None + ) -> bool: """Update information based on new sensor states.""" _LOGGER.debug("Sensor update for %s", entity) if new_state is None: @@ -194,7 +201,7 @@ class MoldIndicator(SensorEntity): return True @staticmethod - def _update_temp_sensor(state): + def _update_temp_sensor(state: State) -> float | None: """Parse temperature sensor value.""" _LOGGER.debug("Updating temp sensor with value %s", state.state) @@ -235,7 +242,7 @@ class MoldIndicator(SensorEntity): return None @staticmethod - def _update_hum_sensor(state): + def _update_hum_sensor(state: State) -> float | None: """Parse humidity sensor value.""" _LOGGER.debug("Updating humidity sensor with value %s", state.state) diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py index 52e33da54ed..5a61e306991 100644 --- a/homeassistant/components/monoprice/media_player.py +++ b/homeassistant/components/monoprice/media_player.py @@ -125,6 +125,8 @@ class MonopriceZone(MediaPlayerEntity): | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE ) + _attr_has_entity_name = True + _attr_name = None def __init__(self, monoprice, sources, namespace, zone_id): """Initialize new zone.""" @@ -137,12 +139,11 @@ class MonopriceZone(MediaPlayerEntity): self._attr_source_list = sources[2] self._zone_id = zone_id self._attr_unique_id = f"{namespace}_{self._zone_id}" - self._attr_name = f"Zone {self._zone_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer="Monoprice", model="6-Zone Amplifier", - name=self.name, + name=f"Zone {self._zone_id}", ) self._snapshot = None diff --git a/homeassistant/components/monoprice/services.yaml b/homeassistant/components/monoprice/services.yaml index 93275fd2a1d..7f3039509ba 100644 --- a/homeassistant/components/monoprice/services.yaml +++ b/homeassistant/components/monoprice/services.yaml @@ -1,14 +1,10 @@ snapshot: - name: Snapshot - description: Take a snapshot of the media player zone. target: entity: integration: monoprice domain: media_player restore: - name: Restore - description: Restore a snapshot of the media player zone. target: entity: integration: monoprice diff --git a/homeassistant/components/monoprice/strings.json b/homeassistant/components/monoprice/strings.json index 008c182f41b..003531518dc 100644 --- a/homeassistant/components/monoprice/strings.json +++ b/homeassistant/components/monoprice/strings.json @@ -27,14 +27,24 @@ "init": { "title": "Configure sources", "data": { - "source_1": "Name of source #1", - "source_2": "Name of source #2", - "source_3": "Name of source #3", - "source_4": "Name of source #4", - "source_5": "Name of source #5", - "source_6": "Name of source #6" + "source_1": "[%key:component::monoprice::config::step::user::data::source_1%]", + "source_2": "[%key:component::monoprice::config::step::user::data::source_2%]", + "source_3": "[%key:component::monoprice::config::step::user::data::source_3%]", + "source_4": "[%key:component::monoprice::config::step::user::data::source_4%]", + "source_5": "[%key:component::monoprice::config::step::user::data::source_5%]", + "source_6": "[%key:component::monoprice::config::step::user::data::source_6%]" } } } + }, + "services": { + "snapshot": { + "name": "Snapshot", + "description": "Takes a snapshot of the media player zone." + }, + "restore": { + "name": "Restore", + "description": "Restores a snapshot of the media player zone." + } } } diff --git a/homeassistant/components/moon/strings.json b/homeassistant/components/moon/strings.json index 818460bc13d..1210fb6403e 100644 --- a/homeassistant/components/moon/strings.json +++ b/homeassistant/components/moon/strings.json @@ -10,12 +10,6 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, - "issues": { - "removed_yaml": { - "title": "The Moon YAML configuration has been removed", - "description": "Configuring Moon using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - }, "entity": { "sensor": { "phase": { diff --git a/homeassistant/components/motion_blinds/services.yaml b/homeassistant/components/motion_blinds/services.yaml index d37d6bb5be8..7b18979ed0e 100644 --- a/homeassistant/components/motion_blinds/services.yaml +++ b/homeassistant/components/motion_blinds/services.yaml @@ -1,16 +1,12 @@ # Describes the format for available motion blinds services set_absolute_position: - name: Set absolute position - description: "Set the absolute position of the cover." target: entity: integration: motion_blinds domain: cover fields: absolute_position: - name: Absolute position - description: Absolute position to move to. required: true selector: number: @@ -18,16 +14,12 @@ set_absolute_position: max: 100 unit_of_measurement: "%" tilt_position: - name: Tilt position - description: Tilt position to move to. selector: number: min: 0 max: 100 unit_of_measurement: "%" width: - name: Width - description: Specify the width that is covered, only for TDBU Combined entities. selector: number: min: 1 diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json index 47c0867187e..0e0a32bfb24 100644 --- a/homeassistant/components/motion_blinds/strings.json +++ b/homeassistant/components/motion_blinds/strings.json @@ -40,5 +40,25 @@ } } } + }, + "services": { + "set_absolute_position": { + "name": "Set absolute position", + "description": "Sets the absolute position of the cover.", + "fields": { + "absolute_position": { + "name": "Absolute position", + "description": "Absolute position to move to." + }, + "tilt_position": { + "name": "Tilt position", + "description": "Tilt position to move to." + }, + "width": { + "name": "Width", + "description": "Specify the width that is covered, only for TDBU Combined entities." + } + } + } } } diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index c7aa8edc6c9..2876a4d49a1 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -169,10 +169,7 @@ def async_generate_motioneye_webhook( ) -> str | None: """Generate the full local URL for a webhook_id.""" try: - return "{}{}".format( - get_url(hass, allow_cloud=False), - async_generate_path(webhook_id), - ) + return f"{get_url(hass, allow_cloud=False)}{async_generate_path(webhook_id)}" except NoURLAvailableError: _LOGGER.warning( "Unable to get Home Assistant URL. Have you set the internal and/or " @@ -536,6 +533,8 @@ def get_media_url( class MotionEyeEntity(CoordinatorEntity): """Base class for motionEye entities.""" + _attr_has_entity_name = True + def __init__( self, config_entry_id: str, diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index 189296039aa..683308e081c 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -12,7 +12,6 @@ from motioneye_client.const import ( DEFAULT_SURVEILLANCE_USERNAME, KEY_ACTION_SNAPSHOT, KEY_MOTION_DETECTION, - KEY_NAME, KEY_STREAMING_AUTH_MODE, KEY_TEXT_OVERLAY_CAMERA_NAME, KEY_TEXT_OVERLAY_CUSTOM_TEXT, @@ -144,8 +143,6 @@ async def async_setup_entry( class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): """motionEye mjpeg camera.""" - _name: str - def __init__( self, config_entry_id: str, @@ -203,7 +200,7 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): streaming_url = self._client.get_camera_stream_url(camera) return { - CONF_NAME: camera[KEY_NAME], + CONF_NAME: None, CONF_USERNAME: self._surveillance_username if auth is not None else None, CONF_PASSWORD: self._surveillance_password if auth is not None else "", CONF_MJPEG_URL: streaming_url or "", @@ -218,7 +215,6 @@ class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): # Sets the state of the underlying (inherited) MjpegCamera based on the updated # MotionEye camera dictionary. properties = self._get_mjpeg_camera_properties_for_camera(camera) - self._name = properties[CONF_NAME] self._username = properties[CONF_USERNAME] self._password = properties[CONF_PASSWORD] self._mjpeg_url = properties[CONF_MJPEG_URL] diff --git a/homeassistant/components/motioneye/sensor.py b/homeassistant/components/motioneye/sensor.py index c8b7679149c..4d0abb84d46 100644 --- a/homeassistant/components/motioneye/sensor.py +++ b/homeassistant/components/motioneye/sensor.py @@ -6,7 +6,7 @@ from types import MappingProxyType from typing import Any from motioneye_client.client import MotionEyeClient -from motioneye_client.const import KEY_ACTIONS, KEY_NAME +from motioneye_client.const import KEY_ACTIONS from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry @@ -48,6 +48,8 @@ async def async_setup_entry( class MotionEyeActionSensor(MotionEyeEntity, SensorEntity): """motionEye action sensor camera.""" + _attr_translation_key = "actions" + def __init__( self, config_entry_id: str, @@ -69,12 +71,6 @@ class MotionEyeActionSensor(MotionEyeEntity, SensorEntity): ), ) - @property - def name(self) -> str: - """Return the name of the sensor.""" - camera_prepend = f"{self._camera[KEY_NAME]} " if self._camera else "" - return f"{camera_prepend}Actions" - @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" diff --git a/homeassistant/components/motioneye/services.yaml b/homeassistant/components/motioneye/services.yaml index 2970124c000..c5a11db8a6f 100644 --- a/homeassistant/components/motioneye/services.yaml +++ b/homeassistant/components/motioneye/services.yaml @@ -1,6 +1,4 @@ set_text_overlay: - name: Set Text Overlay - description: Sets the text overlay for a camera. target: device: integration: motioneye @@ -8,8 +6,6 @@ set_text_overlay: integration: motioneye fields: left_text: - name: Left Text Overlay - description: Text to display on the left required: false advanced: false example: "timestamp" @@ -22,8 +18,6 @@ set_text_overlay: - "timestamp" - "custom-text" custom_left_text: - name: Left Custom Text - description: Custom text to display on the left required: false advanced: false example: "Hello on the left!" @@ -32,8 +26,6 @@ set_text_overlay: text: multiline: true right_text: - name: Right Text Overlay - description: Text to display on the right required: false advanced: false example: "timestamp" @@ -46,8 +38,6 @@ set_text_overlay: - "timestamp" - "custom-text" custom_right_text: - name: Right Custom Text - description: Custom text to display on the right required: false advanced: false example: "Hello on the right!" @@ -57,8 +47,6 @@ set_text_overlay: multiline: true action: - name: Action - description: Trigger a motionEye action target: device: integration: motioneye @@ -66,8 +54,6 @@ action: integration: motioneye fields: action: - name: Action - description: Action to trigger required: true advanced: false example: "snapshot" @@ -101,8 +87,6 @@ action: - "preset9" snapshot: - name: Snapshot - description: Trigger a motionEye still snapshot target: device: integration: motioneye diff --git a/homeassistant/components/motioneye/strings.json b/homeassistant/components/motioneye/strings.json index f92fa11cd77..ea7901617cb 100644 --- a/homeassistant/components/motioneye/strings.json +++ b/homeassistant/components/motioneye/strings.json @@ -36,5 +36,70 @@ } } } + }, + "entity": { + "sensor": { + "actions": { + "name": "Actions" + } + }, + "switch": { + "motion_detection": { + "name": "Motion detection" + }, + "text_overlay": { + "name": "Text overlay" + }, + "video_streaming": { + "name": "Video streaming" + }, + "still_images": { + "name": "Still images" + }, + "movies": { + "name": "Movies" + }, + "upload_enabled": { + "name": "Upload enabled" + } + } + }, + "services": { + "set_text_overlay": { + "name": "Set text overlay", + "description": "Sets the text overlay for a camera.", + "fields": { + "left_text": { + "name": "Left text overlay", + "description": "Text to display on the left." + }, + "custom_left_text": { + "name": "Left custom text", + "description": "Custom text to display on the left." + }, + "right_text": { + "name": "Right text overlay", + "description": "Text to display on the right." + }, + "custom_right_text": { + "name": "Right custom text", + "description": "Custom text to display on the right." + } + } + }, + "action": { + "name": "Action", + "description": "Triggers a motionEye action.", + "fields": { + "action": { + "name": "Action", + "description": "Action to trigger." + } + } + }, + "snapshot": { + "name": "Snapshot", + "description": "Triggers a motionEye still snapshot." + } } } diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py index 3be1c20981d..069c5edaad7 100644 --- a/homeassistant/components/motioneye/switch.py +++ b/homeassistant/components/motioneye/switch.py @@ -8,7 +8,6 @@ from motioneye_client.client import MotionEyeClient from motioneye_client.const import ( KEY_MOTION_DETECTION, KEY_MOVIES, - KEY_NAME, KEY_STILL_IMAGES, KEY_TEXT_OVERLAY, KEY_UPLOAD_ENABLED, @@ -28,37 +27,37 @@ from .const import CONF_CLIENT, CONF_COORDINATOR, DOMAIN, TYPE_MOTIONEYE_SWITCH_ MOTIONEYE_SWITCHES = [ SwitchEntityDescription( key=KEY_MOTION_DETECTION, - name="Motion Detection", + translation_key="motion_detection", entity_registry_enabled_default=True, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=KEY_TEXT_OVERLAY, - name="Text Overlay", + translation_key="text_overlay", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=KEY_VIDEO_STREAMING, - name="Video Streaming", + translation_key="video_streaming", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=KEY_STILL_IMAGES, - name="Still Images", + translation_key="still_images", entity_registry_enabled_default=True, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=KEY_MOVIES, - name="Movies", + translation_key="movies", entity_registry_enabled_default=True, entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=KEY_UPLOAD_ENABLED, - name="Upload Enabled", + translation_key="upload_enabled", entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), @@ -114,12 +113,6 @@ class MotionEyeSwitch(MotionEyeEntity, SwitchEntity): entity_description, ) - @property - def name(self) -> str: - """Return the name of the switch.""" - camera_prepend = f"{self._camera[KEY_NAME]} " if self._camera else "" - return f"{camera_prepend}{self.entity_description.name}" - @property def is_on(self) -> bool: """Return true if the switch is on.""" diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 3fb6c8d2c48..9ec6447b32c 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -5,7 +5,7 @@ import asyncio from collections.abc import Callable from datetime import datetime import logging -from typing import Any, cast +from typing import Any, TypeVar, cast import jinja2 import voluptuous as vol @@ -25,7 +25,7 @@ from homeassistant.const import ( ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import TemplateError, Unauthorized -from homeassistant.helpers import config_validation as cv, event, template +from homeassistant.helpers import config_validation as cv, event as ev, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms @@ -42,7 +42,7 @@ from .client import ( # noqa: F401 publish, subscribe, ) -from .config_integration import PLATFORM_CONFIG_SCHEMA_BASE +from .config_integration import CONFIG_SCHEMA_BASE from .const import ( # noqa: F401 ATTR_PAYLOAD, ATTR_QOS, @@ -130,25 +130,54 @@ CONFIG_ENTRY_CONFIG_KEYS = [ CONF_WILL_MESSAGE, ] +_T = TypeVar("_T") + +REMOVED_OPTIONS = vol.All( + cv.removed(CONF_BIRTH_MESSAGE), # Removed in HA Core 2023.4 + cv.removed(CONF_BROKER), # Removed in HA Core 2023.4 + cv.removed(CONF_CERTIFICATE), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_ID), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_CERT), # Removed in HA Core 2023.4 + cv.removed(CONF_CLIENT_KEY), # Removed in HA Core 2023.4 + cv.removed(CONF_DISCOVERY), # Removed in HA Core 2022.3 + cv.removed(CONF_DISCOVERY_PREFIX), # Removed in HA Core 2023.4 + cv.removed(CONF_KEEPALIVE), # Removed in HA Core 2023.4 + cv.removed(CONF_PASSWORD), # Removed in HA Core 2023.4 + cv.removed(CONF_PORT), # Removed in HA Core 2023.4 + cv.removed(CONF_PROTOCOL), # Removed in HA Core 2023.4 + cv.removed(CONF_TLS_INSECURE), # Removed in HA Core 2023.4 + cv.removed(CONF_USERNAME), # Removed in HA Core 2023.4 + cv.removed(CONF_WILL_MESSAGE), # Removed in HA Core 2023.4 +) + +# We accept 2 schemes for configuring manual MQTT items +# +# Preferred style: +# +# mqtt: +# - {domain}: +# name: "" +# ... +# - {domain}: +# name: "" +# ... +# ``` +# +# Legacy supported style: +# +# mqtt: +# {domain}: +# - name: "" +# ... +# - name: "" +# ... CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( - cv.removed(CONF_BIRTH_MESSAGE), # Removed in HA Core 2023.4 - cv.removed(CONF_BROKER), # Removed in HA Core 2023.4 - cv.removed(CONF_CERTIFICATE), # Removed in HA Core 2023.4 - cv.removed(CONF_CLIENT_ID), # Removed in HA Core 2023.4 - cv.removed(CONF_CLIENT_CERT), # Removed in HA Core 2023.4 - cv.removed(CONF_CLIENT_KEY), # Removed in HA Core 2023.4 - cv.removed(CONF_DISCOVERY), # Removed in HA Core 2022.3 - cv.removed(CONF_DISCOVERY_PREFIX), # Removed in HA Core 2023.4 - cv.removed(CONF_KEEPALIVE), # Removed in HA Core 2023.4 - cv.removed(CONF_PASSWORD), # Removed in HA Core 2023.4 - cv.removed(CONF_PORT), # Removed in HA Core 2023.4 - cv.removed(CONF_PROTOCOL), # Removed in HA Core 2023.4 - cv.removed(CONF_TLS_INSECURE), # Removed in HA Core 2023.4 - cv.removed(CONF_USERNAME), # Removed in HA Core 2023.4 - cv.removed(CONF_WILL_MESSAGE), # Removed in HA Core 2023.4 - PLATFORM_CONFIG_SCHEMA_BASE, + cv.ensure_list, + cv.remove_falsy, + [REMOVED_OPTIONS], + [CONFIG_SCHEMA_BASE], ) }, extra=vol.ALLOW_EXTRA, @@ -190,7 +219,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Fetch configuration conf = dict(entry.data) hass_config = await conf_util.async_hass_config_yaml(hass) - mqtt_yaml = PLATFORM_CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {})) + mqtt_yaml = CONFIG_SCHEMA(hass_config).get(DOMAIN, []) await async_create_certificate_temp_files(hass, conf) client = MQTT(hass, entry, conf) if DOMAIN in hass.data: @@ -311,7 +340,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unsub() await hass.async_add_executor_job(write_dump) - event.async_call_later(hass, call.data["duration"], finish_dump) + ev.async_call_later(hass, call.data["duration"], finish_dump) hass.services.async_register( DOMAIN, @@ -336,7 +365,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Reload the platforms.""" # Fetch updated manual configured items and validate config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} - mqtt_data.updated_config = config_yaml.get(DOMAIN, {}) + mqtt_data.config = config_yaml.get(DOMAIN, {}) # Reload the modern yaml platforms mqtt_platforms = async_get_platforms(hass, DOMAIN) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index a5360090bb9..cc0f37ea145 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -60,6 +60,7 @@ ABBREVIATIONS = { "ent_pic": "entity_picture", "err_t": "error_topic", "err_tpl": "error_template", + "evt_typ": "event_types", "fanspd_t": "fan_speed_topic", "fanspd_tpl": "fan_speed_template", "fanspd_lst": "fan_speed_list", diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index dbed1c8aa9e..06f91403057 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -89,7 +89,7 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( CONF_COMMAND_TEMPLATE, default=DEFAULT_COMMAND_TEMPLATE ): cv.template, vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string, @@ -136,6 +136,7 @@ async def _async_setup_entity( class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): """Representation of a MQTT alarm status.""" + _default_name = DEFAULT_NAME _entity_id_format = alarm.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_ALARM_ATTRIBUTES_BLOCKED diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 50af9ef8a55..0d4b2c4a7b4 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -61,7 +61,7 @@ PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_OFF_DELAY): cv.positive_int, vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, @@ -97,6 +97,7 @@ async def _async_setup_entity( class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): """Representation a binary sensor that is updated by MQTT.""" + _default_name = DEFAULT_NAME _entity_id_format = binary_sensor.ENTITY_ID_FORMAT _expired: bool | None _expire_after: int | None diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 46ecc16d385..9b3b04a54f5 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -35,7 +35,7 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_PAYLOAD_PRESS, default=DEFAULT_PAYLOAD_PRESS): cv.string, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, } @@ -70,6 +70,7 @@ async def _async_setup_entity( class MqttButton(MqttEntity, ButtonEntity): """Representation of a switch that can be toggled using MQTT.""" + _default_name = DEFAULT_NAME _entity_id_format = button.ENTITY_ID_FORMAT def __init__( diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 75ab25efcfa..166bfdd38cc 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -41,7 +41,7 @@ MQTT_CAMERA_ATTRIBUTES_BLOCKED = frozenset( PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Required(CONF_TOPIC): valid_subscribe_topic, vol.Optional(CONF_IMAGE_ENCODING): "b64", } @@ -80,6 +80,7 @@ async def _async_setup_entity( class MqttCamera(MqttEntity, Camera): """representation of a MQTT camera.""" + _default_name = DEFAULT_NAME _entity_id_format: str = camera.ENTITY_ID_FORMAT _attributes_extra_blocked: frozenset[str] = MQTT_CAMERA_ATTRIBUTES_BLOCKED diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index e8eabe887f2..07fbc0ca8c5 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -36,6 +36,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util @@ -64,6 +65,7 @@ from .const import ( DEFAULT_WILL, DEFAULT_WS_HEADERS, DEFAULT_WS_PATH, + DOMAIN, MQTT_CONNECTED, MQTT_DISCONNECTED, PROTOCOL_5, @@ -93,6 +95,10 @@ SUBSCRIBE_COOLDOWN = 0.1 UNSUBSCRIBE_COOLDOWN = 0.1 TIMEOUT_ACK = 10 +MQTT_ENTRIES_NAMING_BLOG_URL = ( + "https://developers.home-assistant.io/blog/2023-057-21-change-naming-mqtt-entities/" +) + SubscribePayloadType = str | bytes # Only bytes if encoding is None @@ -404,6 +410,7 @@ class MQTT: @callback def ha_started(_: Event) -> None: + self.register_naming_issues() self._ha_started.set() self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, ha_started) @@ -416,6 +423,25 @@ class MQTT: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt) ) + def register_naming_issues(self) -> None: + """Register issues with MQTT entity naming.""" + mqtt_data = get_mqtt_data(self.hass) + for issue_key, items in mqtt_data.issues.items(): + config_list = "\n".join([f"- {item}" for item in items]) + async_create_issue( + self.hass, + DOMAIN, + issue_key, + breaks_in_ha_version="2024.2.0", + is_fixable=False, + translation_key=issue_key, + translation_placeholders={ + "config": config_list, + }, + learn_more_url=MQTT_ENTRIES_NAMING_BLOG_URL, + severity=IssueSeverity.WARNING, + ) + def start( self, mqtt_data: MqttData, diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 40ec754aa44..f45d2852df0 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -64,6 +64,8 @@ from .const import ( CONF_MODE_LIST, CONF_MODE_STATE_TEMPLATE, CONF_MODE_STATE_TOPIC, + CONF_POWER_COMMAND_TEMPLATE, + CONF_POWER_COMMAND_TOPIC, CONF_PRECISION, CONF_QOS, CONF_RETAIN, @@ -109,15 +111,10 @@ CONF_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic" CONF_HUMIDITY_MAX = "max_humidity" CONF_HUMIDITY_MIN = "min_humidity" -# CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE -# are deprecated, support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE -# was already removed or never added support was deprecated with release 2023.2 -# and will be removed with release 2023.8 +# Support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE +# was removed in HA Core 2023.8 CONF_POWER_STATE_TEMPLATE = "power_state_template" CONF_POWER_STATE_TOPIC = "power_state_topic" - -CONF_POWER_COMMAND_TOPIC = "power_command_topic" -CONF_POWER_COMMAND_TEMPLATE = "power_command_template" CONF_PRESET_MODE_STATE_TOPIC = "preset_mode_state_topic" CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic" CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template" @@ -298,7 +295,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( ): cv.ensure_list, vol.Optional(CONF_MODE_STATE_TEMPLATE): cv.template, vol.Optional(CONF_MODE_STATE_TOPIC): valid_subscribe_topic, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string, vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, @@ -352,12 +349,10 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) PLATFORM_SCHEMA_MODERN = vol.All( - # CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE - # are deprecated, support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE - # was already removed or never added support was deprecated with release 2023.2 - # and will be removed with release 2023.8 - cv.deprecated(CONF_POWER_STATE_TEMPLATE), - cv.deprecated(CONF_POWER_STATE_TOPIC), + # Support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE + # was removed in HA Core 2023.8 + cv.removed(CONF_POWER_STATE_TEMPLATE), + cv.removed(CONF_POWER_STATE_TOPIC), _PLATFORM_SCHEMA_BASE, valid_preset_mode_configuration, valid_humidity_range_configuration, @@ -368,11 +363,10 @@ _DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA DISCOVERY_SCHEMA = vol.All( _DISCOVERY_SCHEMA_BASE, - # CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE are deprecated, - # support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE was already removed or never added - # support was deprecated with release 2023.2 and will be removed with release 2023.8 - cv.deprecated(CONF_POWER_STATE_TEMPLATE), - cv.deprecated(CONF_POWER_STATE_TOPIC), + # Support for CONF_POWER_STATE_TOPIC and CONF_POWER_STATE_TEMPLATE + # was removed in HA Core 2023.8 + cv.removed(CONF_POWER_STATE_TEMPLATE), + cv.removed(CONF_POWER_STATE_TOPIC), valid_preset_mode_configuration, valid_humidity_range_configuration, valid_humidity_state_configuration, @@ -602,6 +596,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): """Representation of an MQTT climate device.""" + _default_name = DEFAULT_NAME _entity_id_format = climate.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index ba2e0427ba7..cd4470ef22d 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -22,6 +22,7 @@ from . import ( climate as climate_platform, cover as cover_platform, device_tracker as device_tracker_platform, + event as event_platform, fan as fan_platform, humidifier as humidifier_platform, image as image_platform, @@ -52,7 +53,7 @@ from .const import ( DEFAULT_TLS_PROTOCOL = "auto" -PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( +CONFIG_SCHEMA_BASE = vol.Schema( { Platform.ALARM_CONTROL_PANEL.value: vol.All( cv.ensure_list, @@ -82,6 +83,10 @@ PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [device_tracker_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), + Platform.EVENT.value: vol.All( + cv.ensure_list, + [event_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] + ), Platform.FAN.value: vol.All( cv.ensure_list, [fan_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index d09a2bb8cb6..fcdfeb4bd7d 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -40,6 +40,8 @@ CONF_MODE_COMMAND_TOPIC = "mode_command_topic" CONF_MODE_LIST = "modes" CONF_MODE_STATE_TEMPLATE = "mode_state_template" CONF_MODE_STATE_TOPIC = "mode_state_topic" +CONF_POWER_COMMAND_TOPIC = "power_command_topic" +CONF_POWER_COMMAND_TEMPLATE = "power_command_template" CONF_PRECISION = "precision" CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" @@ -112,6 +114,7 @@ PLATFORMS = [ Platform.CAMERA, Platform.CLIMATE, Platform.DEVICE_TRACKER, + Platform.EVENT, Platform.COVER, Platform.FAN, Platform.HUMIDIFIER, @@ -138,6 +141,7 @@ RELOADABLE_PLATFORMS = [ Platform.CLIMATE, Platform.COVER, Platform.DEVICE_TRACKER, + Platform.EVENT, Platform.FAN, Platform.HUMIDIFIER, Platform.IMAGE, diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 0b435db0b7a..c11cf2dfb85 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -159,7 +159,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), vol.Optional(CONF_GET_POSITION_TOPIC): valid_subscribe_topic, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_PAYLOAD_CLOSE, default=DEFAULT_PAYLOAD_CLOSE): vol.Any( cv.string, None @@ -236,6 +236,7 @@ async def _async_setup_entity( class MqttCover(MqttEntity, CoverEntity): """Representation of a cover that can be controlled using MQTT.""" + _default_name = DEFAULT_NAME _entity_id_format: str = cover.ENTITY_ID_FORMAT _attributes_extra_blocked: frozenset[str] = MQTT_COVER_ATTRIBUTES_BLOCKED diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index a9c4017593c..dd4eca9878a 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -61,7 +61,7 @@ PLATFORM_SCHEMA_MODERN_BASE = MQTT_BASE_SCHEMA.extend( { vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_PAYLOAD_HOME, default=STATE_HOME): cv.string, vol.Optional(CONF_PAYLOAD_NOT_HOME, default=STATE_NOT_HOME): cv.string, vol.Optional(CONF_PAYLOAD_RESET, default=DEFAULT_PAYLOAD_RESET): cv.string, @@ -104,6 +104,7 @@ async def _async_setup_entity( class MqttDeviceTracker(MqttEntity, TrackerEntity): """Representation of a device tracker using MQTT.""" + _default_name = None _entity_id_format = device_tracker.ENTITY_ID_FORMAT _value_template: Callable[..., ReceivePayloadType] diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 70e5ac9e535..8e563a48cdd 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -52,6 +52,7 @@ SUPPORTED_COMPONENTS = [ "cover", "device_automation", "device_tracker", + "event", "fan", "humidifier", "image", diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py new file mode 100644 index 00000000000..5a94ec754c0 --- /dev/null +++ b/homeassistant/components/mqtt/event.py @@ -0,0 +1,221 @@ +"""Support for MQTT events.""" +from __future__ import annotations + +from collections.abc import Callable +import functools +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import event +from homeassistant.components.event import ( + ENTITY_ID_FORMAT, + EventDeviceClass, + EventEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_VALUE_TEMPLATE, +) +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object + +from . import subscription +from .config import MQTT_RO_SCHEMA +from .const import ( + CONF_ENCODING, + CONF_QOS, + CONF_STATE_TOPIC, + PAYLOAD_EMPTY_JSON, + PAYLOAD_NONE, +) +from .debug_info import log_messages +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entry_helper, +) +from .models import ( + MqttValueTemplate, + PayloadSentinel, + ReceiveMessage, + ReceivePayloadType, +) +from .util import get_mqtt_data + +_LOGGER = logging.getLogger(__name__) + +CONF_EVENT_TYPES = "event_types" + +MQTT_EVENT_ATTRIBUTES_BLOCKED = frozenset( + { + event.ATTR_EVENT_TYPE, + event.ATTR_EVENT_TYPES, + } +) + +DEFAULT_NAME = "MQTT Event" +DEFAULT_FORCE_UPDATE = False +DEVICE_CLASS_SCHEMA = vol.All(vol.Lower, vol.Coerce(EventDeviceClass)) + +_PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASS_SCHEMA, + vol.Optional(CONF_NAME): vol.Any(None, cv.string), + vol.Required(CONF_EVENT_TYPES): vol.All(cv.ensure_list, [cv.string]), + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +PLATFORM_SCHEMA_MODERN = vol.All( + _PLATFORM_SCHEMA_BASE, +) + +DISCOVERY_SCHEMA = vol.All( + _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT event through YAML and through MQTT discovery.""" + setup = functools.partial( + _async_setup_entity, hass, async_add_entities, config_entry=config_entry + ) + await async_setup_entry_helper(hass, event.DOMAIN, setup, DISCOVERY_SCHEMA) + + +async def _async_setup_entity( + hass: HomeAssistant, + async_add_entities: AddEntitiesCallback, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None = None, +) -> None: + """Set up MQTT event.""" + async_add_entities([MqttEvent(hass, config, config_entry, discovery_data)]) + + +class MqttEvent(MqttEntity, EventEntity): + """Representation of an event that can be updated using MQTT.""" + + _default_name = DEFAULT_NAME + _entity_id_format = ENTITY_ID_FORMAT + _attributes_extra_blocked = MQTT_EVENT_ATTRIBUTES_BLOCKED + _template: Callable[[ReceivePayloadType, PayloadSentinel], ReceivePayloadType] + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None, + ) -> None: + """Initialize the sensor.""" + MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """(Re)Setup the entity.""" + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_event_types = config[CONF_EVENT_TYPES] + self._template = MqttValueTemplate( + self._config.get(CONF_VALUE_TEMPLATE), entity=self + ).async_render_with_possible_json_value + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + topics: dict[str, dict[str, Any]] = {} + + @callback + @log_messages(self.hass, self.entity_id) + def message_received(msg: ReceiveMessage) -> None: + """Handle new MQTT messages.""" + event_attributes: dict[str, Any] = {} + event_type: str + payload = self._template(msg.payload, PayloadSentinel.DEFAULT) + if ( + not payload + or payload is PayloadSentinel.DEFAULT + or payload == PAYLOAD_NONE + or payload == PAYLOAD_EMPTY_JSON + ): + _LOGGER.debug( + "Ignoring empty payload '%s' after rendering for topic %s", + payload, + msg.topic, + ) + return + try: + event_attributes = json_loads_object(payload) + event_type = str(event_attributes.pop(event.ATTR_EVENT_TYPE)) + _LOGGER.debug( + ( + "JSON event data detected after processing payload '%s' on" + " topic %s, type %s, attributes %s" + ), + payload, + msg.topic, + event_type, + event_attributes, + ) + except KeyError: + _LOGGER.warning( + ( + "`event_type` missing in JSON event payload, " + " '%s' on topic %s" + ), + payload, + msg.topic, + ) + return + except JSON_DECODE_EXCEPTIONS: + _LOGGER.warning( + ( + "No valid JSON event payload detected, " + "value after processing payload" + " '%s' on topic %s" + ), + payload, + msg.topic, + ) + return + try: + self._trigger_event(event_type, event_attributes) + except ValueError: + _LOGGER.warning( + "Invalid event type %s for %s received on topic %s, payload %s", + event_type, + self.entity_id, + msg.topic, + payload, + ) + return + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + + topics["state_topic"] = { + "topic": self._config[CONF_STATE_TOPIC], + "msg_callback": message_received, + "qos": self._config[CONF_QOS], + "encoding": self._config[CONF_ENCODING] or None, + } + + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, self._sub_state, topics + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + await subscription.async_subscribe_topics(self.hass, self._sub_state) diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index f5e92d8ecf9..58189c3cb3e 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -127,7 +127,7 @@ def valid_preset_mode_configuration(config: ConfigType) -> ConfigType: _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_DIRECTION_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_DIRECTION_COMMAND_TEMPLATE): cv.template, @@ -215,6 +215,7 @@ async def _async_setup_entity( class MqttFan(MqttEntity, FanEntity): """A MQTT fan component.""" + _default_name = DEFAULT_NAME _entity_id_format = fan.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_FAN_ATTRIBUTES_BLOCKED diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 392a112bcdb..aebb05c19f7 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -136,7 +136,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_MODE_STATE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template, @@ -207,6 +207,7 @@ async def _async_setup_entity( class MqttHumidifier(MqttEntity, HumidifierEntity): """A MQTT humidifier component.""" + _default_name = DEFAULT_NAME _entity_id_format = humidifier.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index 2764539770d..a21d45369f8 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -61,7 +61,7 @@ def validate_topic_required(config: ConfigType) -> ConfigType: PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( { vol.Optional(CONF_CONTENT_TYPE): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Exclusive(CONF_URL_TOPIC, "image_topic"): valid_subscribe_topic, vol.Exclusive(CONF_IMAGE_TOPIC, "image_topic"): valid_subscribe_topic, vol.Optional(CONF_IMAGE_ENCODING): "b64", @@ -102,6 +102,7 @@ async def _async_setup_entity( class MqttImage(MqttEntity, ImageEntity): """representation of a MQTT image.""" + _default_name = DEFAULT_NAME _entity_id_format: str = image.ENTITY_ID_FORMAT _last_image: bytes | None = None _client: httpx.AsyncClient diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 7f2c2cf5e06..2a726075bb0 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -190,7 +190,7 @@ PLATFORM_SCHEMA_MODERN_BASIC = ( vol.Optional(CONF_HS_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_ON_COMMAND_TYPE, default=DEFAULT_ON_COMMAND_TYPE): vol.In( VALUES_ON_COMMAND_TYPE ), @@ -242,6 +242,7 @@ async def async_setup_entity_basic( class MqttLight(MqttEntity, LightEntity, RestoreEntity): """Representation of a MQTT light.""" + _default_name = DEFAULT_NAME _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED _topic: dict[str, str | None] @@ -482,6 +483,13 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is None: rgb = convert_color(*color) brightness = max(rgb) + if brightness == 0: + _LOGGER.debug( + "Ignoring %s message with zero rgb brightness from '%s'", + color_mode, + msg.topic, + ) + return None self._attr_brightness = brightness # Normalize the color to 100% brightness color = tuple( diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 70992887ca7..8f710eb5ea6 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -132,7 +132,7 @@ _PLATFORM_SCHEMA_BASE = ( vol.Optional(CONF_HS, default=DEFAULT_HS): cv.boolean, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_QOS, default=DEFAULT_QOS): vol.All( vol.Coerce(int), vol.In([0, 1, 2]) ), @@ -180,6 +180,7 @@ async def async_setup_entity_json( class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): """Representation of a MQTT JSON light.""" + _default_name = DEFAULT_NAME _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 063895d738c..98ee7648eeb 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -100,7 +100,7 @@ PLATFORM_SCHEMA_MODERN_TEMPLATE = ( vol.Optional(CONF_GREEN_TEMPLATE): cv.template, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_RED_TEMPLATE): cv.template, vol.Optional(CONF_STATE_TEMPLATE): cv.template, } @@ -128,6 +128,7 @@ async def async_setup_entity_template( class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): """Representation of a MQTT Template light.""" + _default_name = DEFAULT_NAME _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED _optimistic: bool diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 966cbc21105..cb586c06309 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -76,7 +76,7 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_CODE_FORMAT): cv.is_regex, vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_PAYLOAD_LOCK, default=DEFAULT_PAYLOAD_LOCK): cv.string, vol.Optional(CONF_PAYLOAD_UNLOCK, default=DEFAULT_PAYLOAD_UNLOCK): cv.string, vol.Optional(CONF_PAYLOAD_OPEN): cv.string, @@ -126,6 +126,7 @@ async def _async_setup_entity( class MqttLock(MqttEntity, LockEntity): """Representation of a lock that can be toggled using MQTT.""" + _default_name = DEFAULT_NAME _entity_id_format = lock.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LOCK_ATTRIBUTES_BLOCKED diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 34b61d89c48..70156703155 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -28,13 +28,16 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.device_registry import ( + DeviceEntry, + EventDeviceRegistryUpdatedData, +) from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -50,7 +53,13 @@ from homeassistant.helpers.event import ( async_track_device_registry_updated_event, async_track_entity_registry_updated_event, ) -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ( + UNDEFINED, + ConfigType, + DiscoveryInfoType, + EventType, + UndefinedType, +) from homeassistant.util.json import json_loads from . import debug_info, subscription @@ -210,7 +219,7 @@ MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( vol.Optional(CONF_SW_VERSION): cv.string, vol.Optional(CONF_VIA_DEVICE): cv.string, vol.Optional(CONF_SUGGESTED_AREA): cv.string, - vol.Optional(CONF_CONFIGURATION_URL): cv.url, + vol.Optional(CONF_CONFIGURATION_URL): cv.configuration_url, } ), validate_device_has_at_least_one_identifier, @@ -307,16 +316,18 @@ async def async_setup_entry_helper( async def _async_setup_entities() -> None: """Set up MQTT items from configuration.yaml.""" mqtt_data = get_mqtt_data(hass) - if mqtt_data.updated_config: - # The platform has been reloaded - config_yaml = mqtt_data.updated_config - else: - config_yaml = mqtt_data.config or {} - if not config_yaml: + if not (config_yaml := mqtt_data.config): return - if domain not in config_yaml: + setups: list[Coroutine[Any, Any, None]] = [ + async_setup(config) + for config_item in config_yaml + for config_domain, configs in config_item.items() + for config in configs + if config_domain == domain + ] + if not setups: return - await asyncio.gather(*[async_setup(config) for config in config_yaml[domain]]) + await asyncio.gather(*setups) # discover manual configured MQTT items mqtt_data.reload_handlers[domain] = _async_setup_entities @@ -340,7 +351,6 @@ class MqttAttributes(Entity): def __init__(self, config: ConfigType) -> None: """Initialize the JSON attributes mixin.""" - self._attributes: dict[str, Any] | None = None self._attributes_sub_state: dict[str, EntitySubscription] = {} self._attributes_config = config @@ -378,16 +388,14 @@ class MqttAttributes(Entity): if k not in MQTT_ATTRIBUTES_BLOCKED and k not in self._attributes_extra_blocked } - self._attributes = filtered_dict + self._attr_extra_state_attributes = filtered_dict get_mqtt_data(self.hass).state_write_requests.write_state_request( self ) else: _LOGGER.warning("JSON result was not a dictionary") - self._attributes = None except ValueError: _LOGGER.warning("Erroneous JSON: %s", payload) - self._attributes = None self._attributes_sub_state = async_prepare_subscribe_topics( self.hass, @@ -412,11 +420,6 @@ class MqttAttributes(Entity): self.hass, self._attributes_sub_state ) - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - return self._attributes - class MqttAvailability(Entity): """Mixin used for platforms that report availability.""" @@ -617,7 +620,7 @@ async def async_remove_discovery_payload( async def async_clear_discovery_topic_if_entity_removed( hass: HomeAssistant, discovery_data: DiscoveryInfoType, - event: Event, + event: EventType[er.EventEntityRegistryUpdatedData], ) -> None: """Clear the discovery topic if the entity is removed.""" if event.data["action"] == "remove": @@ -720,7 +723,9 @@ class MqttDiscoveryDeviceUpdate(ABC): ) return - async def _async_device_removed(self, event: Event) -> None: + async def _async_device_removed( + self, event: EventType[EventDeviceRegistryUpdatedData] + ) -> None: """Handle the manual removal of a device.""" if self._skip_device_removal or not async_removed_from_device( self.hass, event, cast(str, self._device_id), self._config_entry_id @@ -1005,8 +1010,11 @@ class MqttEntity( ): """Representation of an MQTT entity.""" + _attr_has_entity_name = True _attr_should_poll = False + _default_name: str | None _entity_id_format: str + _issue_key: str | None def __init__( self, @@ -1020,10 +1028,11 @@ class MqttEntity( self._config: ConfigType = config self._attr_unique_id = config.get(CONF_UNIQUE_ID) self._sub_state: dict[str, EntitySubscription] = {} + self._discovery = discovery_data is not None # Load config - self._setup_common_attributes_from_config(self._config) self._setup_from_config(self._config) + self._setup_common_attributes_from_config(self._config) # Initialize entity_id from config self._init_entity_id() @@ -1043,6 +1052,7 @@ class MqttEntity( @final async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" + self.collect_issues() await super().async_added_to_hass() self._prepare_subscribe_topics() await self._subscribe_topics() @@ -1064,8 +1074,8 @@ class MqttEntity( async_handle_schema_error(discovery_payload, err) return self._config = config - self._setup_common_attributes_from_config(self._config) self._setup_from_config(self._config) + self._setup_common_attributes_from_config(self._config) # Prepare MQTT subscriptions self.attributes_prepare_discovery_update(config) @@ -1113,6 +1123,67 @@ class MqttEntity( def config_schema() -> vol.Schema: """Return the config schema.""" + def _set_entity_name(self, config: ConfigType) -> None: + """Help setting the entity name if needed.""" + self._issue_key = None + entity_name: str | None | UndefinedType = config.get(CONF_NAME, UNDEFINED) + # Only set _attr_name if it is needed + if entity_name is not UNDEFINED: + self._attr_name = entity_name + elif not self._default_to_device_class_name(): + # Assign the default name + self._attr_name = self._default_name + if CONF_DEVICE in config: + device_name: str + if CONF_NAME not in config[CONF_DEVICE]: + _LOGGER.info( + "MQTT device information always needs to include a name, got %s, " + "if device information is shared between multiple entities, the device " + "name must be included in each entity's device configuration", + config, + ) + elif (device_name := config[CONF_DEVICE][CONF_NAME]) == entity_name: + self._attr_name = None + self._issue_key = ( + "entity_name_is_device_name_discovery" + if self._discovery + else "entity_name_is_device_name_yaml" + ) + _LOGGER.warning( + "MQTT device name is equal to entity name in your config %s, " + "this is not expected. Please correct your configuration. " + "The entity name will be set to `null`", + config, + ) + elif isinstance(entity_name, str) and entity_name.startswith(device_name): + self._attr_name = ( + new_entity_name := entity_name[len(device_name) :].lstrip() + ) + if device_name[:1].isupper(): + # Ensure a capital if the device name first char is a capital + new_entity_name = new_entity_name[:1].upper() + new_entity_name[1:] + self._issue_key = ( + "entity_name_startswith_device_name_discovery" + if self._discovery + else "entity_name_startswith_device_name_yaml" + ) + _LOGGER.warning( + "MQTT entity name starts with the device name in your config %s, " + "this is not expected. Please correct your configuration. " + "The device name prefix will be stripped off the entity name " + "and becomes '%s'", + config, + new_entity_name, + ) + + def collect_issues(self) -> None: + """Process issues for MQTT entities.""" + if self._issue_key is None: + return + mqtt_data = get_mqtt_data(self.hass) + issues = mqtt_data.issues.setdefault(self._issue_key, set()) + issues.add(self.entity_id) + def _setup_common_attributes_from_config(self, config: ConfigType) -> None: """(Re)Setup the common attributes for the entity.""" self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) @@ -1120,7 +1191,8 @@ class MqttEntity( config.get(CONF_ENABLED_BY_DEFAULT) ) self._attr_icon = config.get(CONF_ICON) - self._attr_name = config.get(CONF_NAME) + # Set the entity name if needed + self._set_entity_name(config) def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" @@ -1158,14 +1230,16 @@ def update_device( @callback def async_removed_from_device( - hass: HomeAssistant, event: Event, mqtt_device_id: str, config_entry_id: str + hass: HomeAssistant, + event: EventType[EventDeviceRegistryUpdatedData], + mqtt_device_id: str, + config_entry_id: str, ) -> bool: """Check if the passed event indicates MQTT was removed from a device.""" - action: str = event.data["action"] - if action not in ("remove", "update"): + if event.data["action"] not in ("remove", "update"): return False - if action == "update": + if event.data["action"] == "update": if "config_entries" not in event.data["changes"]: return False device_registry = dr.async_get(hass) diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index aeae184dc89..9afa3de3f48 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -7,12 +7,12 @@ from collections import deque from collections.abc import Callable, Coroutine from dataclasses import dataclass, field import datetime as dt +from enum import StrEnum import logging from typing import TYPE_CHECKING, Any, TypedDict import attr -from homeassistant.backports.enum import StrEnum from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import template @@ -289,7 +289,7 @@ class MqttData: """Keep the MQTT entry data.""" client: MQTT - config: ConfigType + config: list[ConfigType] debug_info_entities: dict[str, EntityDebugInfo] = field(default_factory=dict) debug_info_triggers: dict[tuple[str, str], TriggerDebugInfo] = field( default_factory=dict @@ -305,6 +305,7 @@ class MqttData: ) discovery_unsubscribe: list[CALLBACK_TYPE] = field(default_factory=list) integration_unsubscribe: dict[str, CALLBACK_TYPE] = field(default_factory=dict) + issues: dict[str, set[str]] = field(default_factory=dict) last_discovery: float = 0.0 reload_dispatchers: list[CALLBACK_TYPE] = field(default_factory=list) reload_handlers: dict[str, Callable[[], Coroutine[Any, Any, None]]] = field( @@ -313,4 +314,3 @@ class MqttData: state_write_requests: EntityTopicState = field(default_factory=EntityTopicState) subscriptions_to_restore: list[Subscription] = field(default_factory=list) tags: dict[str, dict[str, MQTTTagScanner]] = field(default_factory=dict) - updated_config: ConfigType = field(default_factory=dict) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 5986eab1207..971b44b43bf 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -87,7 +87,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): vol.Coerce(float), vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): vol.Coerce(float), vol.Optional(CONF_MODE, default=NumberMode.AUTO): vol.Coerce(NumberMode), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_PAYLOAD_RESET, default=DEFAULT_PAYLOAD_RESET): cv.string, vol.Optional(CONF_STEP, default=DEFAULT_STEP): vol.All( vol.Coerce(float), vol.Range(min=1e-3) @@ -134,6 +134,7 @@ async def _async_setup_entity( class MqttNumber(MqttEntity, RestoreNumber): """representation of an MQTT number.""" + _default_name = DEFAULT_NAME _entity_id_format = number.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_NUMBER_ATTRIBUTES_BLOCKED diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index f716e4fe46f..87c56869d0c 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -9,19 +9,16 @@ import voluptuous as vol from homeassistant.components import scene from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ICON, CONF_NAME, CONF_PAYLOAD_ON, CONF_UNIQUE_ID +from homeassistant.const import CONF_NAME, CONF_PAYLOAD_ON from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .client import async_publish from .config import MQTT_BASE_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN from .mixins import ( - CONF_ENABLED_BY_DEFAULT, - CONF_OBJECT_ID, - MQTT_AVAILABILITY_SCHEMA, + MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper, ) @@ -30,20 +27,16 @@ from .util import valid_publish_topic DEFAULT_NAME = "MQTT Scene" DEFAULT_RETAIN = False +ENTITY_ID_FORMAT = scene.DOMAIN + ".{}" + PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( { vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_ICON): cv.icon, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_PAYLOAD_ON): cv.string, - vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, - vol.Optional(CONF_OBJECT_ID): cv.string, - # CONF_ENABLED_BY_DEFAULT is not added by default because - # we are not using the common schema here - vol.Optional(CONF_ENABLED_BY_DEFAULT, default=True): cv.boolean, } -).extend(MQTT_AVAILABILITY_SCHEMA.schema) +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) @@ -77,6 +70,7 @@ class MqttScene( ): """Representation of a scene that can be activated using MQTT.""" + _default_name = DEFAULT_NAME _entity_id_format = scene.DOMAIN + ".{}" def __init__( @@ -96,7 +90,6 @@ class MqttScene( def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._config = config def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -109,8 +102,7 @@ class MqttScene( This method is a coroutine. """ - await async_publish( - self.hass, + await self.async_publish( self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_ON], self._config[CONF_QOS], diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 26e72af9192..df8cf024bd2 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -54,7 +54,7 @@ MQTT_SELECT_ATTRIBUTES_BLOCKED = frozenset( PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Required(CONF_OPTIONS): cv.ensure_list, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, }, @@ -89,6 +89,7 @@ async def _async_setup_entity( class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): """representation of an MQTT select.""" + _default_name = DEFAULT_NAME _entity_id_format = select.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_SELECT_ATTRIBUTES_BLOCKED _command_template: Callable[[PublishPayloadType], PublishPayloadType] diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index e4b5f61bda0..ae94b0df0ce 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -78,7 +78,7 @@ _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_SUGGESTED_DISPLAY_PRECISION): cv.positive_int, vol.Optional(CONF_STATE_CLASS): vol.Any(STATE_CLASSES_SCHEMA, None), vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.Any(cv.string, None), @@ -126,6 +126,7 @@ async def _async_setup_entity( class MqttSensor(MqttEntity, RestoreSensor): """Representation of a sensor that can be updated using MQTT.""" + _default_name = DEFAULT_NAME _entity_id_format = ENTITY_ID_FORMAT _attr_last_reset: datetime | None = None _attributes_extra_blocked = MQTT_SENSOR_ATTRIBUTES_BLOCKED diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml index 07507035c57..4960cf9fb82 100644 --- a/homeassistant/components/mqtt/services.yaml +++ b/homeassistant/components/mqtt/services.yaml @@ -1,32 +1,22 @@ # Describes the format for available MQTT services publish: - name: Publish - description: Publish a message to an MQTT topic. fields: topic: - name: Topic - description: Topic to publish payload. required: true example: /homeassistant/hello selector: text: payload: - name: Payload - description: Payload to publish. example: This is great selector: text: payload_template: - name: Payload Template - description: Template to render as payload value. Ignored if payload given. advanced: true example: "{{ states('sensor.temperature') }}" selector: object: qos: - name: QoS - description: Quality of Service to use. advanced: true default: 0 selector: @@ -36,27 +26,17 @@ publish: - "1" - "2" retain: - name: Retain - description: If message should have the retain flag set. default: false selector: boolean: dump: - name: Dump - description: - Dump messages on a topic selector to the 'mqtt_dump.txt' file in your - configuration folder. fields: topic: - name: Topic - description: topic to listen to example: "OpenZWave/#" selector: text: duration: - name: Duration - description: how long we should listen for messages in seconds default: 5 selector: number: @@ -65,5 +45,3 @@ dump: unit_of_measurement: "seconds" reload: - name: Reload - description: Reload all MQTT entities from YAML. diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 4134dd97148..328812a6e49 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import copy import functools import logging from typing import Any, cast @@ -80,7 +79,7 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( vol.Optional(CONF_AVAILABLE_TONES): cv.ensure_list, vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_COMMAND_OFF_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_STATE_OFF): cv.string, @@ -139,8 +138,10 @@ async def _async_setup_entity( class MqttSiren(MqttEntity, SirenEntity): """Representation of a siren that can be controlled using MQTT.""" + _default_name = DEFAULT_NAME _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_SIREN_ATTRIBUTES_BLOCKED + _extra_attributes: dict[str, Any] _command_templates: dict[ str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType] | None @@ -158,6 +159,7 @@ class MqttSiren(MqttEntity, SirenEntity): discovery_data: DiscoveryInfoType | None, ) -> None: """Initialize the MQTT siren.""" + self._extra_attributes: dict[str, Any] = {} MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @staticmethod @@ -174,21 +176,21 @@ class MqttSiren(MqttEntity, SirenEntity): state_off: str | None = config.get(CONF_STATE_OFF) self._state_off = state_off if state_off else config[CONF_PAYLOAD_OFF] - self._attr_extra_state_attributes = {} + self._extra_attributes = {} _supported_features = SUPPORTED_BASE if config[CONF_SUPPORT_DURATION]: _supported_features |= SirenEntityFeature.DURATION - self._attr_extra_state_attributes[ATTR_DURATION] = None + self._extra_attributes[ATTR_DURATION] = None if config.get(CONF_AVAILABLE_TONES): _supported_features |= SirenEntityFeature.TONES self._attr_available_tones = config[CONF_AVAILABLE_TONES] - self._attr_extra_state_attributes[ATTR_TONE] = None + self._extra_attributes[ATTR_TONE] = None if config[CONF_SUPPORT_VOLUME_SET]: _supported_features |= SirenEntityFeature.VOLUME_SET - self._attr_extra_state_attributes[ATTR_VOLUME_LEVEL] = None + self._extra_attributes[ATTR_VOLUME_LEVEL] = None self._attr_supported_features = _supported_features self._optimistic = config[CONF_OPTIMISTIC] or CONF_STATE_TOPIC not in config @@ -305,14 +307,19 @@ class MqttSiren(MqttEntity, SirenEntity): return self._optimistic @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" - mqtt_attributes = super().extra_state_attributes - attributes = ( - copy.deepcopy(mqtt_attributes) if mqtt_attributes is not None else {} + extra_attributes = ( + self._attr_extra_state_attributes + if hasattr(self, "_attr_extra_state_attributes") + else {} ) - attributes.update(self._attr_extra_state_attributes) - return attributes + if extra_attributes: + return ( + dict({*self._extra_attributes.items(), *extra_attributes.items()}) + or None + ) + return self._extra_attributes or None async def _async_publish( self, @@ -376,6 +383,6 @@ class MqttSiren(MqttEntity, SirenEntity): """Update the extra siren state attributes.""" for attribute, support in SUPPORTED_ATTRIBUTES.items(): if self._attr_supported_features & support and attribute in data: - self._attr_extra_state_attributes[attribute] = data[ + self._extra_attributes[attribute] = data[ attribute # type: ignore[literal-required] ] diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index b06794c9b32..55677798a08 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,4 +1,30 @@ { + "issues": { + "deprecation_mqtt_legacy_vacuum_yaml": { + "title": "MQTT vacuum entities with legacy schema found in your configuration.yaml", + "description": "MQTT vacuum entities that use the legacy schema are deprecated, please adjust your configuration.yaml and restart Home Assistant to fix this issue." + }, + "deprecation_mqtt_legacy_vacuum_discovery": { + "title": "MQTT vacuum entities with legacy schema added through MQTT discovery", + "description": "MQTT vacuum entities that use the legacy schema are deprecated, please adjust your devices to use the correct schema and restart Home Assistant to fix this issue." + }, + "entity_name_is_device_name_yaml": { + "title": "Manual configured MQTT entities with a name that is equal to the device name", + "description": "Some MQTT entities have an entity name equal to the device name. This is not expected. The entity name is set to `null` as a work-a-round to avoid a duplicate name. Please update your configuration and restart Home Assistant to fix this issue.\n\nList of affected entities:\n\n{config}" + }, + "entity_name_startswith_device_name_yaml": { + "title": "Manual configured MQTT entities with a name that starts with the device name", + "description": "Some MQTT entities have an entity name that starts with the device name. This is not expected. To avoid a duplicate name the device name prefix is stripped of the entity name as a work-a-round. Please update your configuration and restart Home Assistant to fix this issue. \n\nList of affected entities:\n\n{config}" + }, + "entity_name_is_device_name_discovery": { + "title": "Discovered MQTT entities with a name that is equal to the device name", + "description": "Some MQTT entities have an entity name equal to the device name. This is not expected. The entity name is set to `null` as a work-a-round to avoid a duplicate name. Please inform the maintainer of the software application that supplies the affected entities to fix this issue.\n\nList of affected entities:\n\n{config}" + }, + "entity_name_startswith_device_name_discovery": { + "title": "Discovered entities with a name that starts with the device name", + "description": "Some MQTT entities have an entity name that starts with the device name. This is not expected. To avoid a duplicate name the device name prefix is stripped of the entity name as a work-a-round. Please inform the maintainer of the software application that supplies the affected entities to fix this issue. \n\nList of affected entities:\n\n{config}" + } + }, "config": { "step": { "broker": { @@ -60,8 +86,8 @@ "button_quintuple_press": "\"{subtype}\" quintuple clicked" }, "trigger_subtype": { - "turn_on": "Turn on", - "turn_off": "Turn off", + "turn_on": "[%key:common::action::turn_on%]", + "turn_off": "[%key:common::action::turn_off%]", "button_1": "First button", "button_2": "Second button", "button_3": "Third button", @@ -130,10 +156,56 @@ "selector": { "set_ca_cert": { "options": { - "off": "Off", + "off": "[%key:common::state::off%]", "auto": "Auto", "custom": "Custom" } } + }, + "services": { + "publish": { + "name": "Publish", + "description": "Publishes a message to an MQTT topic.", + "fields": { + "topic": { + "name": "Topic", + "description": "Topic to publish to." + }, + "payload": { + "name": "Payload", + "description": "The payload to publish." + }, + "payload_template": { + "name": "Payload template", + "description": "Template to render as a payload value. If a payload is provided, the template is ignored." + }, + "qos": { + "name": "QoS", + "description": "Quality of Service to use. O. At most once. 1: At least once. 2: Exactly once." + }, + "retain": { + "name": "Retain", + "description": "If the message should have the retain flag set. If set, the broker stores the most recent message on a topic." + } + } + }, + "dump": { + "name": "Export", + "description": "Writes all messages on a specific topic into the `mqtt_dump.txt` file in your configuration folder.", + "fields": { + "topic": { + "name": "[%key:component::mqtt::services::publish::fields::topic::name%]", + "description": "Topic to listen to." + }, + "duration": { + "name": "Duration", + "description": "How long we should listen for messages in seconds." + } + } + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads MQTT entities from the YAML-configuration." + } } } diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 7f4f609f265..107b0b1cb10 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -49,7 +49,7 @@ CONF_STATE_OFF = "state_off" PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_STATE_OFF): cv.string, @@ -88,6 +88,7 @@ async def _async_setup_entity( class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): """Representation of a switch that can be toggled using MQTT.""" + _default_name = DEFAULT_NAME _entity_id_format = switch.ENTITY_ID_FORMAT _optimistic: bool diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 02883b5cd85..848950169d8 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -20,6 +20,7 @@ from .discovery import MQTTDiscoveryPayload from .mixins import ( MQTT_ENTITY_DEVICE_INFO_SCHEMA, MqttDiscoveryDeviceUpdate, + async_handle_schema_error, async_setup_entry_helper, send_discovery_done, update_device, @@ -119,7 +120,11 @@ class MQTTTagScanner(MqttDiscoveryDeviceUpdate): async def async_update(self, discovery_data: MQTTDiscoveryPayload) -> None: """Handle MQTT tag discovery updates.""" # Update tag scanner - config: DiscoveryInfoType = PLATFORM_SCHEMA(discovery_data) + try: + config: DiscoveryInfoType = PLATFORM_SCHEMA(discovery_data) + except vol.Invalid as err: + async_handle_schema_error(discovery_data, err) + return self._config = config self._value_template = MqttValueTemplate( config.get(CONF_VALUE_TEMPLATE), diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index 01622c10a6d..13677b7f35b 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -78,7 +78,7 @@ def valid_text_size_configuration(config: ConfigType) -> ConfigType: _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_MAX, default=MAX_LENGTH_STATE_STATE): cv.positive_int, vol.Optional(CONF_MIN, default=0): cv.positive_int, vol.Optional(CONF_MODE, default=text.TextMode.TEXT): vol.In( @@ -125,6 +125,7 @@ class MqttTextEntity(MqttEntity, TextEntity): """Representation of the MQTT text entity.""" _attributes_extra_blocked = MQTT_TEXT_ATTRIBUTES_BLOCKED + _default_name = DEFAULT_NAME _entity_id_format = text.ENTITY_ID_FORMAT _compiled_pattern: re.Pattern[Any] | None diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 930f4d22506..f6db0d3fd64 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -57,7 +57,7 @@ PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( vol.Optional(CONF_ENTITY_PICTURE): cv.string, vol.Optional(CONF_LATEST_VERSION_TEMPLATE): cv.template, vol.Optional(CONF_LATEST_VERSION_TOPIC): valid_subscribe_topic, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_PAYLOAD_INSTALL): cv.string, vol.Optional(CONF_RELEASE_SUMMARY): cv.string, vol.Optional(CONF_RELEASE_URL): cv.string, @@ -107,6 +107,7 @@ async def _async_setup_entity( class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): """Representation of the MQTT update entity.""" + _default_name = DEFAULT_NAME _entity_id_format = update.ENTITY_ID_FORMAT def __init__( diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index 068bc183ec4..3a2586bdfd7 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -1,7 +1,12 @@ """Support for MQTT vacuums.""" + +# The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 +# and will be removed with HA Core 2024.2.0 + from __future__ import annotations import functools +import logging import voluptuous as vol @@ -9,8 +14,10 @@ from homeassistant.components import vacuum from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from ..const import DOMAIN from ..mixins import async_setup_entry_helper from .schema import CONF_SCHEMA, LEGACY, MQTT_VACUUM_SCHEMA, STATE from .schema_legacy import ( @@ -24,9 +31,44 @@ from .schema_state import ( async_setup_entity_state, ) +_LOGGER = logging.getLogger(__name__) + +MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/" + + +# The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 +# and will be removed with HA Core 2024.2.0 +def warn_for_deprecation_legacy_schema( + hass: HomeAssistant, config: ConfigType, discovery_data: DiscoveryInfoType | None +) -> None: + """Warn for deprecation of legacy schema.""" + if config[CONF_SCHEMA] == STATE: + return + + key_suffix = "yaml" if discovery_data is None else "discovery" + translation_key = f"deprecation_mqtt_legacy_vacuum_{key_suffix}" + async_create_issue( + hass, + DOMAIN, + translation_key, + breaks_in_ha_version="2024.2.0", + is_fixable=False, + translation_key=translation_key, + learn_more_url=MQTT_VACUUM_DOCS_URL, + severity=IssueSeverity.WARNING, + ) + _LOGGER.warning( + "Deprecated `legacy` schema detected for MQTT vacuum, expected `state` schema, config found: %s", + config, + ) + def validate_mqtt_vacuum_discovery(config_value: ConfigType) -> ConfigType: """Validate MQTT vacuum schema.""" + + # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 + # and will be removed with HA Core 2024.2.0 + schemas = {LEGACY: DISCOVERY_SCHEMA_LEGACY, STATE: DISCOVERY_SCHEMA_STATE} config: ConfigType = schemas[config_value[CONF_SCHEMA]](config_value) return config @@ -34,6 +76,10 @@ def validate_mqtt_vacuum_discovery(config_value: ConfigType) -> ConfigType: def validate_mqtt_vacuum_modern(config_value: ConfigType) -> ConfigType: """Validate MQTT vacuum modern schema.""" + + # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 + # and will be removed with HA Core 2024.2.0 + schemas = { LEGACY: PLATFORM_SCHEMA_LEGACY_MODERN, STATE: PLATFORM_SCHEMA_STATE_MODERN, @@ -71,6 +117,10 @@ async def _async_setup_entity( discovery_data: DiscoveryInfoType | None = None, ) -> None: """Set up the MQTT vacuum.""" + + # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 + # and will be removed with HA Core 2024.2.0 + warn_for_deprecation_legacy_schema(hass, config, discovery_data) setup_entity = { LEGACY: async_setup_entity_legacy, STATE: async_setup_entity_state, diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index 6cab62cdb5d..516a7772c11 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -1,4 +1,9 @@ -"""Support for Legacy MQTT vacuum.""" +"""Support for Legacy MQTT vacuum. + +The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 +and is will be removed with HA Core 2024.2.0 +""" + from __future__ import annotations from collections.abc import Callable @@ -126,7 +131,7 @@ PLATFORM_SCHEMA_LEGACY_MODERN = ( ), vol.Inclusive(CONF_FAN_SPEED_TEMPLATE, "fan_speed"): cv.template, vol.Inclusive(CONF_FAN_SPEED_TOPIC, "fan_speed"): valid_publish_topic, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional( CONF_PAYLOAD_CLEAN_SPOT, default=DEFAULT_PAYLOAD_CLEAN_SPOT ): cv.string, @@ -210,6 +215,7 @@ async def async_setup_entity_legacy( class MqttVacuum(MqttEntity, VacuumEntity): """Representation of a MQTT-controlled legacy vacuum.""" + _default_name = DEFAULT_NAME _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED @@ -414,9 +420,9 @@ class MqttVacuum(MqttEntity, VacuumEntity): ) async def _async_publish_command(self, feature: VacuumEntityFeature) -> None: - """Check for a missing feature or command topic.""" + """Publish a command.""" - if self._command_topic is None or self.supported_features & feature == 0: + if self._command_topic is None: return await self.async_publish( diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 385d60a3886..5113e19f097 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -64,7 +64,6 @@ DEFAULT_SERVICES = ( VacuumEntityFeature.START | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.STATUS | VacuumEntityFeature.BATTERY | VacuumEntityFeature.CLEAN_SPOT ) @@ -127,7 +126,7 @@ PLATFORM_SCHEMA_STATE_MODERN = ( vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All( cv.ensure_list, [cv.string] ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional( CONF_PAYLOAD_CLEAN_SPOT, default=DEFAULT_PAYLOAD_CLEAN_SPOT ): cv.string, @@ -171,6 +170,7 @@ async def async_setup_entity_state( class MqttStateVacuum(MqttEntity, StateVacuumEntity): """Representation of a MQTT-controlled state vacuum.""" + _default_name = DEFAULT_NAME _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_VACUUM_ATTRIBUTES_BLOCKED @@ -199,7 +199,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" supported_feature_strings: list[str] = config[CONF_SUPPORTED_FEATURES] - self._attr_supported_features = strings_to_services( + self._attr_supported_features = VacuumEntityFeature.STATE | strings_to_services( supported_feature_strings, STRING_TO_SERVICE ) self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] @@ -260,8 +260,8 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): await subscription.async_subscribe_topics(self.hass, self._sub_state) async def _async_publish_command(self, feature: VacuumEntityFeature) -> None: - """Check for a missing feature or command topic.""" - if self._command_topic is None or self.supported_features & feature == 0: + """Publish a command.""" + if self._command_topic is None: return await self.async_publish( diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 0f622d55b84..08b9d36d850 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -51,6 +51,8 @@ from .const import ( CONF_MODE_LIST, CONF_MODE_STATE_TEMPLATE, CONF_MODE_STATE_TOPIC, + CONF_POWER_COMMAND_TEMPLATE, + CONF_POWER_COMMAND_TOPIC, CONF_PRECISION, CONF_RETAIN, CONF_TEMP_COMMAND_TEMPLATE, @@ -91,6 +93,7 @@ VALUE_TEMPLATE_KEYS = ( COMMAND_TEMPLATE_KEYS = { CONF_MODE_COMMAND_TEMPLATE, CONF_TEMP_COMMAND_TEMPLATE, + CONF_POWER_COMMAND_TEMPLATE, } @@ -98,6 +101,7 @@ TOPIC_KEYS = ( CONF_CURRENT_TEMP_TOPIC, CONF_MODE_COMMAND_TOPIC, CONF_MODE_STATE_TOPIC, + CONF_POWER_COMMAND_TOPIC, CONF_TEMP_COMMAND_TOPIC, CONF_TEMP_STATE_TOPIC, ) @@ -123,10 +127,12 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( ): cv.ensure_list, vol.Optional(CONF_MODE_STATE_TEMPLATE): cv.template, vol.Optional(CONF_MODE_STATE_TOPIC): valid_subscribe_topic, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): vol.Any(cv.string, None), vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string, vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, + vol.Optional(CONF_POWER_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_POWER_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_PRECISION): vol.In( [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] ), @@ -180,6 +186,7 @@ async def _async_setup_entity( class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): """Representation of an MQTT water heater device.""" + _default_name = DEFAULT_NAME _entity_id_format = water_heater.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED @@ -265,6 +272,9 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): ): support |= WaterHeaterEntityFeature.OPERATION_MODE + if self._topic[CONF_POWER_COMMAND_TOPIC] is not None: + support |= WaterHeaterEntityFeature.ON_OFF + self._attr_supported_features = support def _prepare_subscribe_topics(self) -> None: @@ -316,3 +326,19 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): if self._optimistic or self._topic[CONF_MODE_STATE_TOPIC] is None: self._attr_current_operation = operation_mode self.async_write_ha_state() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + if CONF_POWER_COMMAND_TOPIC in self._config: + mqtt_payload = self._command_templates[CONF_POWER_COMMAND_TEMPLATE]( + self._config[CONF_PAYLOAD_ON] + ) + await self._publish(CONF_POWER_COMMAND_TOPIC, mqtt_payload) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + if CONF_POWER_COMMAND_TOPIC in self._config: + mqtt_payload = self._command_templates[CONF_POWER_COMMAND_TEMPLATE]( + self._config[CONF_PAYLOAD_OFF] + ) + await self._publish(CONF_POWER_COMMAND_TOPIC, mqtt_payload) diff --git a/homeassistant/components/mullvad/__init__.py b/homeassistant/components/mullvad/__init__.py index 7934220cf91..b8551682f1f 100644 --- a/homeassistant/components/mullvad/__init__.py +++ b/homeassistant/components/mullvad/__init__.py @@ -8,7 +8,7 @@ from mullvad_api import MullvadAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import update_coordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN @@ -23,7 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api = await hass.async_add_executor_job(MullvadAPI) return api.data - coordinator = update_coordinator.DataUpdateCoordinator( + coordinator = DataUpdateCoordinator( hass, logging.getLogger(__name__), name=DOMAIN, diff --git a/homeassistant/components/mullvad/binary_sensor.py b/homeassistant/components/mullvad/binary_sensor.py index f39f05dd430..2ccf754bbbd 100644 --- a/homeassistant/components/mullvad/binary_sensor.py +++ b/homeassistant/components/mullvad/binary_sensor.py @@ -2,21 +2,24 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .const import DOMAIN BINARY_SENSORS = ( - { - CONF_ID: "mullvad_exit_ip", - CONF_NAME: "Mullvad Exit IP", - CONF_DEVICE_CLASS: BinarySensorDeviceClass.CONNECTIVITY, - }, + BinarySensorEntityDescription( + key="mullvad_exit_ip", + name="Mullvad Exit IP", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), ) @@ -29,23 +32,26 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN] async_add_entities( - MullvadBinarySensor(coordinator, sensor, config_entry) - for sensor in BINARY_SENSORS + MullvadBinarySensor(coordinator, entity_description, config_entry) + for entity_description in BINARY_SENSORS ) class MullvadBinarySensor(CoordinatorEntity, BinarySensorEntity): """Represents a Mullvad binary sensor.""" - def __init__(self, coordinator, sensor, config_entry): + def __init__( + self, + coordinator: DataUpdateCoordinator, + entity_description: BinarySensorEntityDescription, + config_entry: ConfigEntry, + ) -> None: """Initialize the Mullvad binary sensor.""" super().__init__(coordinator) - self._sensor = sensor - self._attr_device_class = sensor[CONF_DEVICE_CLASS] - self._attr_name = sensor[CONF_NAME] - self._attr_unique_id = f"{config_entry.entry_id}_{sensor[CONF_ID]}" + self.entity_description = entity_description + self._attr_unique_id = f"{config_entry.entry_id}_{entity_description.key}" @property - def is_on(self): + def is_on(self) -> bool: """Return the state for this binary sensor.""" - return self.coordinator.data[self._sensor[CONF_ID]] + return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/mullvad/config_flow.py b/homeassistant/components/mullvad/config_flow.py index 5b6ef78133f..ad045dbb54c 100644 --- a/homeassistant/components/mullvad/config_flow.py +++ b/homeassistant/components/mullvad/config_flow.py @@ -2,6 +2,7 @@ from mullvad_api import MullvadAPI, MullvadAPIError from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN @@ -11,7 +12,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> FlowResult: """Handle the initial step.""" self._async_abort_entries_match() diff --git a/homeassistant/components/mutesync/binary_sensor.py b/homeassistant/components/mutesync/binary_sensor.py index d225400c7cc..3c9d92094f7 100644 --- a/homeassistant/components/mutesync/binary_sensor.py +++ b/homeassistant/components/mutesync/binary_sensor.py @@ -9,10 +9,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -SENSORS = { - "in_meeting": "In Meeting", - "muted": "Muted", -} +SENSORS = ( + "in_meeting", + "muted", +) async def async_setup_entry( @@ -30,15 +30,13 @@ async def async_setup_entry( class MuteStatus(update_coordinator.CoordinatorEntity, BinarySensorEntity): """Mütesync binary sensors.""" + _attr_has_entity_name = True + def __init__(self, coordinator, sensor_type): """Initialize our sensor.""" super().__init__(coordinator) self._sensor_type = sensor_type - - @property - def name(self): - """Return the name of the sensor.""" - return SENSORS[self._sensor_type] + self._attr_translation_key = sensor_type @property def unique_id(self): diff --git a/homeassistant/components/mutesync/strings.json b/homeassistant/components/mutesync/strings.json index 9b18620acf8..2a3cca666ee 100644 --- a/homeassistant/components/mutesync/strings.json +++ b/homeassistant/components/mutesync/strings.json @@ -12,5 +12,15 @@ "invalid_auth": "Enable authentication in mütesync Preferences > Authentication", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "binary_sensor": { + "in_meeting": { + "name": "In meeting" + }, + "muted": { + "name": "Muted" + } + } } } diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json index 7e0ff2c99d6..30fe5f46d6b 100644 --- a/homeassistant/components/mysensors/strings.json +++ b/homeassistant/components/mysensors/strings.json @@ -29,7 +29,7 @@ "data": { "device": "Serial port", "baud_rate": "baud rate", - "version": "MySensors version", + "version": "[%key:component::mysensors::config::step::gw_tcp::data::version%]", "persistence_file": "Persistence file (leave empty to auto-generate)" } }, @@ -39,8 +39,8 @@ "retain": "MQTT retain", "topic_in_prefix": "Prefix for input topics (topic_in_prefix)", "topic_out_prefix": "Prefix for output topics (topic_out_prefix)", - "version": "MySensors version", - "persistence_file": "Persistence file (leave empty to auto-generate)" + "version": "[%key:component::mysensors::config::step::gw_tcp::data::version%]", + "persistence_file": "[%key:component::mysensors::config::step::gw_serial::data::persistence_file%]" } } }, @@ -67,20 +67,20 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "invalid_subscribe_topic": "Invalid subscribe topic", - "invalid_publish_topic": "Invalid publish topic", - "duplicate_topic": "Topic already in use", - "same_topic": "Subscribe and publish topics are the same", - "invalid_port": "Invalid port number", - "invalid_persistence_file": "Invalid persistence file", - "duplicate_persistence_file": "Persistence file already in use", + "invalid_subscribe_topic": "[%key:component::mysensors::config::error::invalid_subscribe_topic%]", + "invalid_publish_topic": "[%key:component::mysensors::config::error::invalid_publish_topic%]", + "duplicate_topic": "[%key:component::mysensors::config::error::duplicate_topic%]", + "same_topic": "[%key:component::mysensors::config::error::same_topic%]", + "invalid_port": "[%key:component::mysensors::config::error::invalid_port%]", + "invalid_persistence_file": "[%key:component::mysensors::config::error::invalid_persistence_file%]", + "duplicate_persistence_file": "[%key:component::mysensors::config::error::duplicate_persistence_file%]", "invalid_ip": "Invalid IP address", - "invalid_serial": "Invalid serial port", - "invalid_device": "Invalid device", - "invalid_version": "Invalid MySensors version", + "invalid_serial": "[%key:component::mysensors::config::error::invalid_serial%]", + "invalid_device": "[%key:component::mysensors::config::error::invalid_device%]", + "invalid_version": "[%key:component::mysensors::config::error::invalid_version%]", "mqtt_required": "The MQTT integration is not set up", - "not_a_number": "Please enter a number", - "port_out_of_range": "Port number must be at least 1 and at most 65535", + "not_a_number": "[%key:component::mysensors::config::error::not_a_number%]", + "port_out_of_range": "[%key:component::mysensors::config::error::port_out_of_range%]", "unknown": "[%key:common::config_flow::error::unknown%]" } } diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py index 14badde17d2..d32a64dc1e6 100644 --- a/homeassistant/components/mystrom/light.py +++ b/homeassistant/components/mystrom/light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -61,12 +61,17 @@ async def async_setup_platform( """Set up the myStrom light integration.""" async_create_issue( hass, - DOMAIN, - "deprecated_yaml", + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", breaks_in_ha_version="2023.12.0", is_fixable=False, + issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "myStrom", + }, ) hass.async_create_task( hass.config_entries.flow.async_init( @@ -78,6 +83,8 @@ async def async_setup_platform( class MyStromLight(LightEntity): """Representation of the myStrom WiFi bulb.""" + _attr_has_entity_name = True + _attr_name = None _attr_color_mode = ColorMode.HS _attr_supported_color_modes = {ColorMode.HS} _attr_supported_features = LightEntityFeature.EFFECT | LightEntityFeature.FLASH @@ -86,7 +93,6 @@ class MyStromLight(LightEntity): def __init__(self, bulb, name, mac): """Initialize the light.""" self._bulb = bulb - self._attr_name = name self._attr_available = False self._attr_unique_id = mac self._attr_hs_color = 0, 0 diff --git a/homeassistant/components/mystrom/strings.json b/homeassistant/components/mystrom/strings.json index 259501e1486..a485a58f5a6 100644 --- a/homeassistant/components/mystrom/strings.json +++ b/homeassistant/components/mystrom/strings.json @@ -14,11 +14,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The myStrom YAML configuration is being removed", - "description": "Configuring myStrom using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the myStrom YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py index 8e89bb5f151..54c1dc9ad5a 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -48,12 +48,17 @@ async def async_setup_platform( """Set up the myStrom switch/plug integration.""" async_create_issue( hass, - DOMAIN, - "deprecated_yaml", + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", breaks_in_ha_version="2023.12.0", is_fixable=False, + issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "myStrom", + }, ) hass.async_create_task( hass.config_entries.flow.async_init( @@ -65,10 +70,12 @@ async def async_setup_platform( class MyStromSwitch(SwitchEntity): """Representation of a myStrom switch/plug.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, plug, name): """Initialize the myStrom switch/plug.""" self.plug = plug - self._attr_name = name self._attr_unique_id = self.plug.mac self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.plug.mac)}, diff --git a/homeassistant/components/nam/button.py b/homeassistant/components/nam/button.py index a5521596208..a280369e7c8 100644 --- a/homeassistant/components/nam/button.py +++ b/homeassistant/components/nam/button.py @@ -23,7 +23,6 @@ _LOGGER = logging.getLogger(__name__) RESTART_BUTTON: ButtonEntityDescription = ButtonEntityDescription( key="restart", - translation_key="restart", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, ) diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 3f9821a1e34..3c0b8bc9ba4 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -338,7 +338,6 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = ( ), NAMSensorEntityDescription( key=ATTR_SIGNAL_STRENGTH, - translation_key="signal_strength", suggested_display_precision=0, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json index e60855b882c..e443a398984 100644 --- a/homeassistant/components/nam/strings.json +++ b/homeassistant/components/nam/strings.json @@ -39,11 +39,6 @@ } }, "entity": { - "button": { - "restart": { - "name": "[%key:component::button::entity_component::restart::name%]" - } - }, "sensor": { "bme280_humidity": { "name": "BME280 humidity" @@ -153,9 +148,6 @@ "dht22_temperature": { "name": "DHT22 temperature" }, - "signal_strength": { - "name": "[%key:component::sensor::entity_component::signal_strength::name%]" - }, "last_restart": { "name": "Last restart" } diff --git a/homeassistant/components/nanoleaf/button.py b/homeassistant/components/nanoleaf/button.py index 1c6acc516b8..950dc2a591a 100644 --- a/homeassistant/components/nanoleaf/button.py +++ b/homeassistant/components/nanoleaf/button.py @@ -2,7 +2,7 @@ from aionanoleaf import Nanoleaf -from homeassistant.components.button import ButtonEntity +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -27,15 +27,15 @@ async def async_setup_entry( class NanoleafIdentifyButton(NanoleafEntity, ButtonEntity): """Representation of a Nanoleaf identify button.""" + _attr_entity_category = EntityCategory.CONFIG + _attr_device_class = ButtonDeviceClass.IDENTIFY + def __init__( self, nanoleaf: Nanoleaf, coordinator: DataUpdateCoordinator[None] ) -> None: """Initialize the Nanoleaf button.""" super().__init__(nanoleaf, coordinator) self._attr_unique_id = f"{nanoleaf.serial_no}_identify" - self._attr_name = f"Identify {nanoleaf.name}" - self._attr_icon = "mdi:magnify" - self._attr_entity_category = EntityCategory.CONFIG async def async_press(self) -> None: """Identify the Nanoleaf.""" diff --git a/homeassistant/components/nanoleaf/entity.py b/homeassistant/components/nanoleaf/entity.py index 0fb043c4cc4..16fb746049d 100644 --- a/homeassistant/components/nanoleaf/entity.py +++ b/homeassistant/components/nanoleaf/entity.py @@ -14,10 +14,12 @@ from .const import DOMAIN class NanoleafEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): """Representation of a Nanoleaf entity.""" + _attr_has_entity_name = True + def __init__( self, nanoleaf: Nanoleaf, coordinator: DataUpdateCoordinator[None] ) -> None: - """Initialize an Nanoleaf entity.""" + """Initialize a Nanoleaf entity.""" super().__init__(coordinator) self._nanoleaf = nanoleaf self._attr_device_info = DeviceInfo( diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index 20992594cb8..f0425594763 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -46,6 +46,7 @@ class NanoleafLight(NanoleafEntity, LightEntity): _attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} _attr_supported_features = LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION + _attr_name = None def __init__( self, nanoleaf: Nanoleaf, coordinator: DataUpdateCoordinator[None] @@ -53,7 +54,6 @@ class NanoleafLight(NanoleafEntity, LightEntity): """Initialize the Nanoleaf light.""" super().__init__(nanoleaf, coordinator) self._attr_unique_id = nanoleaf.serial_no - self._attr_name = nanoleaf.name self._attr_min_mireds = math.ceil(1000000 / nanoleaf.color_temperature_max) self._attr_max_mireds = kelvin_to_mired(nanoleaf.color_temperature_min) diff --git a/homeassistant/components/neato/services.yaml b/homeassistant/components/neato/services.yaml index cbfff7808ee..5ec782d7bf3 100644 --- a/homeassistant/components/neato/services.yaml +++ b/homeassistant/components/neato/services.yaml @@ -1,14 +1,10 @@ custom_cleaning: - name: Zone Cleaning service - description: Zone Cleaning service call specific to Neato Botvacs. target: entity: integration: neato domain: vacuum fields: mode: - name: Set cleaning mode - description: "Set the cleaning mode: 1 for eco and 2 for turbo. Defaults to turbo if not set." default: 2 selector: number: @@ -16,8 +12,6 @@ custom_cleaning: max: 2 mode: box navigation: - name: Set navigation mode - description: "Set the navigation mode: 1 for normal, 2 for extra care, 3 for deep. Defaults to normal if not set." default: 1 selector: number: @@ -25,8 +19,6 @@ custom_cleaning: max: 3 mode: box category: - name: Use cleaning map - description: "Whether to use a persistent map or not for cleaning (i.e. No go lines): 2 for no map, 4 for map. Default to using map if not set (and fallback to no map if no map is found)." default: 4 selector: number: @@ -35,8 +27,6 @@ custom_cleaning: step: 2 mode: box zone: - name: Name of the zone to clean (Only Botvac D7) - description: Only supported on the Botvac D7. Name of the zone to clean. Defaults to no zone i.e. complete house cleanup. example: "Kitchen" selector: text: diff --git a/homeassistant/components/neato/strings.json b/homeassistant/components/neato/strings.json index 20848ccff08..6136ac94e99 100644 --- a/homeassistant/components/neato/strings.json +++ b/homeassistant/components/neato/strings.json @@ -18,5 +18,29 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } + }, + "services": { + "custom_cleaning": { + "name": "Zone cleaning service", + "description": "Zone cleaning service call specific to Neato Botvacs.", + "fields": { + "mode": { + "name": "Set cleaning mode", + "description": "Set the cleaning mode: 1 for eco and 2 for turbo. Defaults to turbo if not set." + }, + "navigation": { + "name": "Set navigation mode", + "description": "Set the navigation mode: 1 for normal, 2 for extra care, 3 for deep. Defaults to normal if not set." + }, + "category": { + "name": "Use cleaning map", + "description": "Whether to use a persistent map or not for cleaning (i.e. No go lines): 2 for no map, 4 for map. Default to using map if not set (and fallback to no map if no map is found)." + }, + "zone": { + "name": "Name of the zone to clean (Only Botvac D7)", + "description": "Only supported on the Botvac D7. Name of the zone to clean. Defaults to no zone i.e. complete house cleanup." + } + } + } } } diff --git a/homeassistant/components/ness_alarm/services.yaml b/homeassistant/components/ness_alarm/services.yaml index ad320285d5b..b02d5e36805 100644 --- a/homeassistant/components/ness_alarm/services.yaml +++ b/homeassistant/components/ness_alarm/services.yaml @@ -1,31 +1,21 @@ # Describes the format for available ness alarm services aux: - name: Aux - description: Trigger an aux output. fields: output_id: - name: Output ID - description: The aux output you wish to change. required: true selector: number: min: 1 max: 4 state: - name: State - description: The On/Off State. If P14xE 8E is enabled then a value of true will pulse output x for the time specified in P14(x+4)E. default: true selector: boolean: panic: - name: Panic - description: Trigger a panic fields: code: - name: Code - description: The user code to use to trigger the panic. required: true example: 1234 selector: diff --git a/homeassistant/components/ness_alarm/strings.json b/homeassistant/components/ness_alarm/strings.json new file mode 100644 index 00000000000..ec4e39a6128 --- /dev/null +++ b/homeassistant/components/ness_alarm/strings.json @@ -0,0 +1,28 @@ +{ + "services": { + "aux": { + "name": "Aux", + "description": "Trigger an aux output.", + "fields": { + "output_id": { + "name": "Output ID", + "description": "The aux output you wish to change." + }, + "state": { + "name": "State", + "description": "The On/Off State. If P14xE 8E is enabled then a value of true will pulse output x for the time specified in P14(x+4)E." + } + } + }, + "panic": { + "name": "Panic", + "description": "Triggers a panic.", + "fields": { + "code": { + "name": "Code", + "description": "The user code to use to trigger the panic." + } + } + } + } +} diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 092e8ea08d6..e85073061c2 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -46,23 +46,22 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, + issue_registry as ir, ) from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.helpers.typing import ConfigType -from . import api, config_flow +from . import api from .const import ( CONF_PROJECT_ID, CONF_SUBSCRIBER_ID, CONF_SUBSCRIBER_ID_IMPORTED, DATA_DEVICE_MANAGER, - DATA_NEST_CONFIG, DATA_SDM, DATA_SUBSCRIBER, DOMAIN, ) from .events import EVENT_NAME_MAP, NEST_EVENT -from .legacy import async_setup_legacy, async_setup_legacy_entry from .media_source import ( async_get_media_event_store, async_get_media_source_devices, @@ -114,15 +113,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.http.register_view(NestEventMediaView(hass)) hass.http.register_view(NestEventMediaThumbnailView(hass)) - if DOMAIN not in config: - return True # ConfigMode.SDM_APPLICATION_CREDENTIALS - - hass.data[DOMAIN][DATA_NEST_CONFIG] = config[DOMAIN] - - config_mode = config_flow.get_config_mode(hass) - if config_mode == config_flow.ConfigMode.LEGACY: - return await async_setup_legacy(hass, config) - + if DOMAIN in config and CONF_PROJECT_ID not in config[DOMAIN]: + ir.async_create_issue( + hass, + DOMAIN, + "legacy_nest_deprecated", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="legacy_nest_removed", + translation_placeholders={ + "documentation_url": "https://www.home-assistant.io/integrations/nest/", + }, + ) + return False return True @@ -148,7 +152,9 @@ class SignalUpdateCallback: return _LOGGER.debug("Event Update %s", events.keys()) device_registry = dr.async_get(self._hass) - device_entry = device_registry.async_get_device({(DOMAIN, device_id)}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ) if not device_entry: return for api_event_type, image_event in events.items(): @@ -167,9 +173,9 @@ class SignalUpdateCallback: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nest from a config entry with dispatch between old/new flows.""" - config_mode = config_flow.get_config_mode(hass) - if DATA_SDM not in entry.data or config_mode == config_flow.ConfigMode.LEGACY: - return await async_setup_legacy_entry(hass, entry) + if DATA_SDM not in entry.data: + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False if entry.unique_id != entry.data[CONF_PROJECT_ID]: hass.config_entries.async_update_entry( diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 7ae3e0db943..3f8c99d7658 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -1,19 +1,228 @@ -"""Support for Nest cameras that dispatches between API versions.""" +"""Support for Google Nest SDM Cameras.""" +from __future__ import annotations +import asyncio +from collections.abc import Callable +import datetime +import functools +import logging +from pathlib import Path + +from google_nest_sdm.camera_traits import ( + CameraImageTrait, + CameraLiveStreamTrait, + RtspStream, + StreamingProtocol, +) +from google_nest_sdm.device import Device +from google_nest_sdm.device_manager import DeviceManager +from google_nest_sdm.exceptions import ApiException + +from homeassistant.components.camera import Camera, CameraEntityFeature, StreamType +from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util.dt import utcnow -from .camera_sdm import async_setup_sdm_entry -from .const import DATA_SDM -from .legacy.camera import async_setup_legacy_entry +from .const import DATA_DEVICE_MANAGER, DOMAIN +from .device_info import NestDeviceInfo + +_LOGGER = logging.getLogger(__name__) + +PLACEHOLDER = Path(__file__).parent / "placeholder.png" + +# Used to schedule an alarm to refresh the stream before expiration +STREAM_EXPIRATION_BUFFER = datetime.timedelta(seconds=30) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the cameras.""" - if DATA_SDM not in entry.data: - await async_setup_legacy_entry(hass, entry, async_add_entities) - return - await async_setup_sdm_entry(hass, entry, async_add_entities) + + device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ + DATA_DEVICE_MANAGER + ] + entities = [] + for device in device_manager.devices.values(): + if ( + CameraImageTrait.NAME in device.traits + or CameraLiveStreamTrait.NAME in device.traits + ): + entities.append(NestCamera(device)) + async_add_entities(entities) + + +class NestCamera(Camera): + """Devices that support cameras.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, device: Device) -> None: + """Initialize the camera.""" + super().__init__() + self._device = device + self._device_info = NestDeviceInfo(device) + self._stream: RtspStream | None = None + self._create_stream_url_lock = asyncio.Lock() + self._stream_refresh_unsub: Callable[[], None] | None = None + self._attr_is_streaming = CameraLiveStreamTrait.NAME in self._device.traits + self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + # The API "name" field is a unique device identifier. + return f"{self._device.name}-camera" + + @property + def device_info(self) -> DeviceInfo: + """Return device specific attributes.""" + return self._device_info.device_info + + @property + def brand(self) -> str | None: + """Return the camera brand.""" + return self._device_info.device_brand + + @property + def model(self) -> str | None: + """Return the camera model.""" + return self._device_info.device_model + + @property + def supported_features(self) -> CameraEntityFeature: + """Flag supported features.""" + supported_features = CameraEntityFeature(0) + if CameraLiveStreamTrait.NAME in self._device.traits: + supported_features |= CameraEntityFeature.STREAM + return supported_features + + @property + def frontend_stream_type(self) -> StreamType | None: + """Return the type of stream supported by this camera.""" + if CameraLiveStreamTrait.NAME not in self._device.traits: + return None + trait = self._device.traits[CameraLiveStreamTrait.NAME] + if StreamingProtocol.WEB_RTC in trait.supported_protocols: + return StreamType.WEB_RTC + return super().frontend_stream_type + + @property + def available(self) -> bool: + """Return True if entity is available.""" + # Cameras are marked unavailable on stream errors in #54659 however nest + # streams have a high error rate (#60353). Given nest streams are so flaky, + # marking the stream unavailable has other side effects like not showing + # the camera image which sometimes are still able to work. Until the + # streams are fixed, just leave the streams as available. + return True + + async def stream_source(self) -> str | None: + """Return the source of the stream.""" + if not self.supported_features & CameraEntityFeature.STREAM: + return None + if CameraLiveStreamTrait.NAME not in self._device.traits: + return None + trait = self._device.traits[CameraLiveStreamTrait.NAME] + if StreamingProtocol.RTSP not in trait.supported_protocols: + return None + async with self._create_stream_url_lock: + if not self._stream: + _LOGGER.debug("Fetching stream url") + try: + self._stream = await trait.generate_rtsp_stream() + except ApiException as err: + raise HomeAssistantError(f"Nest API error: {err}") from err + self._schedule_stream_refresh() + assert self._stream + if self._stream.expires_at < utcnow(): + _LOGGER.warning("Stream already expired") + return self._stream.rtsp_stream_url + + def _schedule_stream_refresh(self) -> None: + """Schedules an alarm to refresh the stream url before expiration.""" + assert self._stream + _LOGGER.debug("New stream url expires at %s", self._stream.expires_at) + refresh_time = self._stream.expires_at - STREAM_EXPIRATION_BUFFER + # Schedule an alarm to extend the stream + if self._stream_refresh_unsub is not None: + self._stream_refresh_unsub() + + self._stream_refresh_unsub = async_track_point_in_utc_time( + self.hass, + self._handle_stream_refresh, + refresh_time, + ) + + async def _handle_stream_refresh(self, now: datetime.datetime) -> None: + """Alarm that fires to check if the stream should be refreshed.""" + if not self._stream: + return + _LOGGER.debug("Extending stream url") + try: + self._stream = await self._stream.extend_rtsp_stream() + except ApiException as err: + _LOGGER.debug("Failed to extend stream: %s", err) + # Next attempt to catch a url will get a new one + self._stream = None + if self.stream: + await self.stream.stop() + self.stream = None + return + # Update the stream worker with the latest valid url + if self.stream: + self.stream.update_source(self._stream.rtsp_stream_url) + self._schedule_stream_refresh() + + async def async_will_remove_from_hass(self) -> None: + """Invalidates the RTSP token when unloaded.""" + if self._stream: + _LOGGER.debug("Invalidating stream") + try: + await self._stream.stop_rtsp_stream() + except ApiException as err: + _LOGGER.debug( + "Failed to revoke stream token, will rely on ttl: %s", err + ) + if self._stream_refresh_unsub: + self._stream_refresh_unsub() + + async def async_added_to_hass(self) -> None: + """Run when entity is added to register update signal handler.""" + self.async_on_remove( + self._device.add_update_listener(self.async_write_ha_state) + ) + + async def async_camera_image( + self, width: int | None = None, height: int | None = None + ) -> bytes | None: + """Return bytes of camera image.""" + # Use the thumbnail from RTSP stream, or a placeholder if stream is + # not supported (e.g. WebRTC) + stream = await self.async_create_stream() + if stream: + return await stream.async_get_image(width, height) + return await self.hass.async_add_executor_job(self.placeholder_image) + + @classmethod + @functools.cache + def placeholder_image(cls) -> bytes: + """Return placeholder image to use when no stream is available.""" + return PLACEHOLDER.read_bytes() + + async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: + """Return the source of the stream.""" + trait: CameraLiveStreamTrait = self._device.traits[CameraLiveStreamTrait.NAME] + if StreamingProtocol.WEB_RTC not in trait.supported_protocols: + return await super().async_handle_web_rtc_offer(offer_sdp) + try: + stream = await trait.generate_web_rtc_stream(offer_sdp) + except ApiException as err: + raise HomeAssistantError(f"Nest API error: {err}") from err + return stream.answer_sdp diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py deleted file mode 100644 index 3eceb448fa4..00000000000 --- a/homeassistant/components/nest/camera_sdm.py +++ /dev/null @@ -1,228 +0,0 @@ -"""Support for Google Nest SDM Cameras.""" -from __future__ import annotations - -import asyncio -from collections.abc import Callable -import datetime -import functools -import logging -from pathlib import Path - -from google_nest_sdm.camera_traits import ( - CameraImageTrait, - CameraLiveStreamTrait, - RtspStream, - StreamingProtocol, -) -from google_nest_sdm.device import Device -from google_nest_sdm.device_manager import DeviceManager -from google_nest_sdm.exceptions import ApiException - -from homeassistant.components.camera import Camera, CameraEntityFeature, StreamType -from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.util.dt import utcnow - -from .const import DATA_DEVICE_MANAGER, DOMAIN -from .device_info import NestDeviceInfo - -_LOGGER = logging.getLogger(__name__) - -PLACEHOLDER = Path(__file__).parent / "placeholder.png" - -# Used to schedule an alarm to refresh the stream before expiration -STREAM_EXPIRATION_BUFFER = datetime.timedelta(seconds=30) - - -async def async_setup_sdm_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the cameras.""" - - device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ - DATA_DEVICE_MANAGER - ] - entities = [] - for device in device_manager.devices.values(): - if ( - CameraImageTrait.NAME in device.traits - or CameraLiveStreamTrait.NAME in device.traits - ): - entities.append(NestCamera(device)) - async_add_entities(entities) - - -class NestCamera(Camera): - """Devices that support cameras.""" - - _attr_has_entity_name = True - _attr_name = None - - def __init__(self, device: Device) -> None: - """Initialize the camera.""" - super().__init__() - self._device = device - self._device_info = NestDeviceInfo(device) - self._stream: RtspStream | None = None - self._create_stream_url_lock = asyncio.Lock() - self._stream_refresh_unsub: Callable[[], None] | None = None - self._attr_is_streaming = CameraLiveStreamTrait.NAME in self._device.traits - self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - # The API "name" field is a unique device identifier. - return f"{self._device.name}-camera" - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return self._device_info.device_info - - @property - def brand(self) -> str | None: - """Return the camera brand.""" - return self._device_info.device_brand - - @property - def model(self) -> str | None: - """Return the camera model.""" - return self._device_info.device_model - - @property - def supported_features(self) -> CameraEntityFeature: - """Flag supported features.""" - supported_features = CameraEntityFeature(0) - if CameraLiveStreamTrait.NAME in self._device.traits: - supported_features |= CameraEntityFeature.STREAM - return supported_features - - @property - def frontend_stream_type(self) -> StreamType | None: - """Return the type of stream supported by this camera.""" - if CameraLiveStreamTrait.NAME not in self._device.traits: - return None - trait = self._device.traits[CameraLiveStreamTrait.NAME] - if StreamingProtocol.WEB_RTC in trait.supported_protocols: - return StreamType.WEB_RTC - return super().frontend_stream_type - - @property - def available(self) -> bool: - """Return True if entity is available.""" - # Cameras are marked unavailable on stream errors in #54659 however nest - # streams have a high error rate (#60353). Given nest streams are so flaky, - # marking the stream unavailable has other side effects like not showing - # the camera image which sometimes are still able to work. Until the - # streams are fixed, just leave the streams as available. - return True - - async def stream_source(self) -> str | None: - """Return the source of the stream.""" - if not self.supported_features & CameraEntityFeature.STREAM: - return None - if CameraLiveStreamTrait.NAME not in self._device.traits: - return None - trait = self._device.traits[CameraLiveStreamTrait.NAME] - if StreamingProtocol.RTSP not in trait.supported_protocols: - return None - async with self._create_stream_url_lock: - if not self._stream: - _LOGGER.debug("Fetching stream url") - try: - self._stream = await trait.generate_rtsp_stream() - except ApiException as err: - raise HomeAssistantError(f"Nest API error: {err}") from err - self._schedule_stream_refresh() - assert self._stream - if self._stream.expires_at < utcnow(): - _LOGGER.warning("Stream already expired") - return self._stream.rtsp_stream_url - - def _schedule_stream_refresh(self) -> None: - """Schedules an alarm to refresh the stream url before expiration.""" - assert self._stream - _LOGGER.debug("New stream url expires at %s", self._stream.expires_at) - refresh_time = self._stream.expires_at - STREAM_EXPIRATION_BUFFER - # Schedule an alarm to extend the stream - if self._stream_refresh_unsub is not None: - self._stream_refresh_unsub() - - self._stream_refresh_unsub = async_track_point_in_utc_time( - self.hass, - self._handle_stream_refresh, - refresh_time, - ) - - async def _handle_stream_refresh(self, now: datetime.datetime) -> None: - """Alarm that fires to check if the stream should be refreshed.""" - if not self._stream: - return - _LOGGER.debug("Extending stream url") - try: - self._stream = await self._stream.extend_rtsp_stream() - except ApiException as err: - _LOGGER.debug("Failed to extend stream: %s", err) - # Next attempt to catch a url will get a new one - self._stream = None - if self.stream: - await self.stream.stop() - self.stream = None - return - # Update the stream worker with the latest valid url - if self.stream: - self.stream.update_source(self._stream.rtsp_stream_url) - self._schedule_stream_refresh() - - async def async_will_remove_from_hass(self) -> None: - """Invalidates the RTSP token when unloaded.""" - if self._stream: - _LOGGER.debug("Invalidating stream") - try: - await self._stream.stop_rtsp_stream() - except ApiException as err: - _LOGGER.debug( - "Failed to revoke stream token, will rely on ttl: %s", err - ) - if self._stream_refresh_unsub: - self._stream_refresh_unsub() - - async def async_added_to_hass(self) -> None: - """Run when entity is added to register update signal handler.""" - self.async_on_remove( - self._device.add_update_listener(self.async_write_ha_state) - ) - - async def async_camera_image( - self, width: int | None = None, height: int | None = None - ) -> bytes | None: - """Return bytes of camera image.""" - # Use the thumbnail from RTSP stream, or a placeholder if stream is - # not supported (e.g. WebRTC) - stream = await self.async_create_stream() - if stream: - return await stream.async_get_image(width, height) - return await self.hass.async_add_executor_job(self.placeholder_image) - - @classmethod - @functools.cache - def placeholder_image(cls) -> bytes: - """Return placeholder image to use when no stream is available.""" - return PLACEHOLDER.read_bytes() - - async def async_handle_web_rtc_offer(self, offer_sdp: str) -> str | None: - """Return the source of the stream.""" - trait: CameraLiveStreamTrait = self._device.traits[CameraLiveStreamTrait.NAME] - if StreamingProtocol.WEB_RTC not in trait.supported_protocols: - return await super().async_handle_web_rtc_offer(offer_sdp) - try: - stream = await trait.generate_web_rtc_stream(offer_sdp) - except ApiException as err: - raise HomeAssistantError(f"Nest API error: {err}") from err - return stream.answer_sdp diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 372909d00c2..307bd201b4d 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -1,19 +1,357 @@ -"""Support for Nest climate that dispatches between API versions.""" +"""Support for Google Nest SDM climate devices.""" +from __future__ import annotations +from typing import Any, cast + +from google_nest_sdm.device import Device +from google_nest_sdm.device_manager import DeviceManager +from google_nest_sdm.device_traits import FanTrait, TemperatureTrait +from google_nest_sdm.exceptions import ApiException +from google_nest_sdm.thermostat_traits import ( + ThermostatEcoTrait, + ThermostatHeatCoolTrait, + ThermostatHvacTrait, + ThermostatModeTrait, + ThermostatTemperatureSetpointTrait, +) + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + FAN_OFF, + FAN_ON, + PRESET_ECO, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .climate_sdm import async_setup_sdm_entry -from .const import DATA_SDM -from .legacy.climate import async_setup_legacy_entry +from .const import DATA_DEVICE_MANAGER, DOMAIN +from .device_info import NestDeviceInfo + +# Mapping for sdm.devices.traits.ThermostatMode mode field +THERMOSTAT_MODE_MAP: dict[str, HVACMode] = { + "OFF": HVACMode.OFF, + "HEAT": HVACMode.HEAT, + "COOL": HVACMode.COOL, + "HEATCOOL": HVACMode.HEAT_COOL, +} +THERMOSTAT_INV_MODE_MAP = {v: k for k, v in THERMOSTAT_MODE_MAP.items()} + +# Mode for sdm.devices.traits.ThermostatEco +THERMOSTAT_ECO_MODE = "MANUAL_ECO" + +# Mapping for sdm.devices.traits.ThermostatHvac status field +THERMOSTAT_HVAC_STATUS_MAP = { + "OFF": HVACAction.OFF, + "HEATING": HVACAction.HEATING, + "COOLING": HVACAction.COOLING, +} + +THERMOSTAT_RANGE_MODES = [HVACMode.HEAT_COOL, HVACMode.AUTO] + +PRESET_MODE_MAP = { + "MANUAL_ECO": PRESET_ECO, + "OFF": PRESET_NONE, +} +PRESET_INV_MODE_MAP = {v: k for k, v in PRESET_MODE_MAP.items()} + +FAN_MODE_MAP = { + "ON": FAN_ON, + "OFF": FAN_OFF, +} +FAN_INV_MODE_MAP = {v: k for k, v in FAN_MODE_MAP.items()} +FAN_INV_MODES = list(FAN_INV_MODE_MAP) + +MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API +MIN_TEMP = 10 +MAX_TEMP = 32 async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the climate platform.""" - if DATA_SDM not in entry.data: - await async_setup_legacy_entry(hass, entry, async_add_entities) - return - await async_setup_sdm_entry(hass, entry, async_add_entities) + """Set up the client entities.""" + + device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ + DATA_DEVICE_MANAGER + ] + entities = [] + for device in device_manager.devices.values(): + if ThermostatHvacTrait.NAME in device.traits: + entities.append(ThermostatEntity(device)) + async_add_entities(entities) + + +class ThermostatEntity(ClimateEntity): + """A nest thermostat climate entity.""" + + _attr_min_temp = MIN_TEMP + _attr_max_temp = MAX_TEMP + _attr_has_entity_name = True + _attr_should_poll = False + _attr_name = None + + def __init__(self, device: Device) -> None: + """Initialize ThermostatEntity.""" + self._device = device + self._device_info = NestDeviceInfo(device) + + @property + def unique_id(self) -> str | None: + """Return a unique ID.""" + # The API "name" field is a unique device identifier. + return self._device.name + + @property + def device_info(self) -> DeviceInfo: + """Return device specific attributes.""" + return self._device_info.device_info + + @property + def available(self) -> bool: + """Return device availability.""" + return self._device_info.available + + async def async_added_to_hass(self) -> None: + """Run when entity is added to register update signal handler.""" + self._attr_supported_features = self._get_supported_features() + self.async_on_remove( + self._device.add_update_listener(self.async_write_ha_state) + ) + + @property + def temperature_unit(self) -> str: + """Return the unit of temperature measurement for the system.""" + return UnitOfTemperature.CELSIUS + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if TemperatureTrait.NAME not in self._device.traits: + return None + trait: TemperatureTrait = self._device.traits[TemperatureTrait.NAME] + return trait.ambient_temperature_celsius + + @property + def target_temperature(self) -> float | None: + """Return the temperature currently set to be reached.""" + if not (trait := self._target_temperature_trait): + return None + if self.hvac_mode == HVACMode.HEAT: + return trait.heat_celsius + if self.hvac_mode == HVACMode.COOL: + return trait.cool_celsius + return None + + @property + def target_temperature_high(self) -> float | None: + """Return the upper bound target temperature.""" + if self.hvac_mode != HVACMode.HEAT_COOL: + return None + if not (trait := self._target_temperature_trait): + return None + return trait.cool_celsius + + @property + def target_temperature_low(self) -> float | None: + """Return the lower bound target temperature.""" + if self.hvac_mode != HVACMode.HEAT_COOL: + return None + if not (trait := self._target_temperature_trait): + return None + return trait.heat_celsius + + @property + def _target_temperature_trait( + self, + ) -> ThermostatHeatCoolTrait | None: + """Return the correct trait with a target temp depending on mode.""" + if ( + self.preset_mode == PRESET_ECO + and ThermostatEcoTrait.NAME in self._device.traits + ): + return cast( + ThermostatEcoTrait, self._device.traits[ThermostatEcoTrait.NAME] + ) + if ThermostatTemperatureSetpointTrait.NAME in self._device.traits: + return cast( + ThermostatTemperatureSetpointTrait, + self._device.traits[ThermostatTemperatureSetpointTrait.NAME], + ) + return None + + @property + def hvac_mode(self) -> HVACMode: + """Return the current operation (e.g. heat, cool, idle).""" + hvac_mode = HVACMode.OFF + if ThermostatModeTrait.NAME in self._device.traits: + trait = self._device.traits[ThermostatModeTrait.NAME] + if trait.mode in THERMOSTAT_MODE_MAP: + hvac_mode = THERMOSTAT_MODE_MAP[trait.mode] + return hvac_mode + + @property + def hvac_modes(self) -> list[HVACMode]: + """List of available operation modes.""" + supported_modes = [] + for mode in self._get_device_hvac_modes: + if mode in THERMOSTAT_MODE_MAP: + supported_modes.append(THERMOSTAT_MODE_MAP[mode]) + return supported_modes + + @property + def _get_device_hvac_modes(self) -> set[str]: + """Return the set of SDM API hvac modes supported by the device.""" + modes = [] + if ThermostatModeTrait.NAME in self._device.traits: + trait = self._device.traits[ThermostatModeTrait.NAME] + modes.extend(trait.available_modes) + return set(modes) + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action (heating, cooling).""" + trait = self._device.traits[ThermostatHvacTrait.NAME] + if trait.status == "OFF" and self.hvac_mode != HVACMode.OFF: + return HVACAction.IDLE + return THERMOSTAT_HVAC_STATUS_MAP.get(trait.status) + + @property + def preset_mode(self) -> str: + """Return the current active preset.""" + if ThermostatEcoTrait.NAME in self._device.traits: + trait = self._device.traits[ThermostatEcoTrait.NAME] + return PRESET_MODE_MAP.get(trait.mode, PRESET_NONE) + return PRESET_NONE + + @property + def preset_modes(self) -> list[str]: + """Return the available presets.""" + modes = [] + if ThermostatEcoTrait.NAME in self._device.traits: + trait = self._device.traits[ThermostatEcoTrait.NAME] + for mode in trait.available_modes: + if mode in PRESET_MODE_MAP: + modes.append(PRESET_MODE_MAP[mode]) + return modes + + @property + def fan_mode(self) -> str: + """Return the current fan mode.""" + if ( + self.supported_features & ClimateEntityFeature.FAN_MODE + and FanTrait.NAME in self._device.traits + ): + trait = self._device.traits[FanTrait.NAME] + return FAN_MODE_MAP.get(trait.timer_mode, FAN_OFF) + return FAN_OFF + + @property + def fan_modes(self) -> list[str]: + """Return the list of available fan modes.""" + if ( + self.supported_features & ClimateEntityFeature.FAN_MODE + and FanTrait.NAME in self._device.traits + ): + return FAN_INV_MODES + return [] + + def _get_supported_features(self) -> ClimateEntityFeature: + """Compute the bitmap of supported features from the current state.""" + features = ClimateEntityFeature(0) + if HVACMode.HEAT_COOL in self.hvac_modes: + features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + if HVACMode.HEAT in self.hvac_modes or HVACMode.COOL in self.hvac_modes: + features |= ClimateEntityFeature.TARGET_TEMPERATURE + if ThermostatEcoTrait.NAME in self._device.traits: + features |= ClimateEntityFeature.PRESET_MODE + if FanTrait.NAME in self._device.traits: + # Fan trait may be present without actually support fan mode + fan_trait = self._device.traits[FanTrait.NAME] + if fan_trait.timer_mode is not None: + features |= ClimateEntityFeature.FAN_MODE + return features + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode not in self.hvac_modes: + raise ValueError(f"Unsupported hvac_mode '{hvac_mode}'") + api_mode = THERMOSTAT_INV_MODE_MAP[hvac_mode] + trait = self._device.traits[ThermostatModeTrait.NAME] + try: + await trait.set_mode(api_mode) + except ApiException as err: + raise HomeAssistantError( + f"Error setting {self.entity_id} HVAC mode to {hvac_mode}: {err}" + ) from err + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + hvac_mode = self.hvac_mode + if kwargs.get(ATTR_HVAC_MODE) is not None: + hvac_mode = kwargs[ATTR_HVAC_MODE] + await self.async_set_hvac_mode(hvac_mode) + low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) + high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + temp = kwargs.get(ATTR_TEMPERATURE) + if ThermostatTemperatureSetpointTrait.NAME not in self._device.traits: + raise HomeAssistantError( + f"Error setting {self.entity_id} temperature to {kwargs}: " + "Unable to find setpoint trait." + ) + trait = self._device.traits[ThermostatTemperatureSetpointTrait.NAME] + try: + if self.preset_mode == PRESET_ECO or hvac_mode == HVACMode.HEAT_COOL: + if low_temp and high_temp: + await trait.set_range(low_temp, high_temp) + elif hvac_mode == HVACMode.COOL and temp: + await trait.set_cool(temp) + elif hvac_mode == HVACMode.HEAT and temp: + await trait.set_heat(temp) + except ApiException as err: + raise HomeAssistantError( + f"Error setting {self.entity_id} temperature to {kwargs}: {err}" + ) from err + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new target preset mode.""" + if preset_mode not in self.preset_modes: + raise ValueError(f"Unsupported preset_mode '{preset_mode}'") + if self.preset_mode == preset_mode: # API doesn't like duplicate preset modes + return + trait = self._device.traits[ThermostatEcoTrait.NAME] + try: + await trait.set_mode(PRESET_INV_MODE_MAP[preset_mode]) + except ApiException as err: + raise HomeAssistantError( + f"Error setting {self.entity_id} preset mode to {preset_mode}: {err}" + ) from err + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + if fan_mode not in self.fan_modes: + raise ValueError(f"Unsupported fan_mode '{fan_mode}'") + if fan_mode == FAN_ON and self.hvac_mode == HVACMode.OFF: + raise ValueError( + "Cannot turn on fan, please set an HVAC mode (e.g. heat/cool) first" + ) + trait = self._device.traits[FanTrait.NAME] + duration = None + if fan_mode != FAN_OFF: + duration = MAX_FAN_DURATION + try: + await trait.set_timer(FAN_INV_MODE_MAP[fan_mode], duration=duration) + except ApiException as err: + raise HomeAssistantError( + f"Error setting {self.entity_id} fan mode to {fan_mode}: {err}" + ) from err diff --git a/homeassistant/components/nest/climate_sdm.py b/homeassistant/components/nest/climate_sdm.py deleted file mode 100644 index ca975ed055d..00000000000 --- a/homeassistant/components/nest/climate_sdm.py +++ /dev/null @@ -1,357 +0,0 @@ -"""Support for Google Nest SDM climate devices.""" -from __future__ import annotations - -from typing import Any, cast - -from google_nest_sdm.device import Device -from google_nest_sdm.device_manager import DeviceManager -from google_nest_sdm.device_traits import FanTrait, TemperatureTrait -from google_nest_sdm.exceptions import ApiException -from google_nest_sdm.thermostat_traits import ( - ThermostatEcoTrait, - ThermostatHeatCoolTrait, - ThermostatHvacTrait, - ThermostatModeTrait, - ThermostatTemperatureSetpointTrait, -) - -from homeassistant.components.climate import ( - ATTR_HVAC_MODE, - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, - FAN_OFF, - FAN_ON, - PRESET_ECO, - PRESET_NONE, - ClimateEntity, - ClimateEntityFeature, - HVACAction, - HVACMode, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DATA_DEVICE_MANAGER, DOMAIN -from .device_info import NestDeviceInfo - -# Mapping for sdm.devices.traits.ThermostatMode mode field -THERMOSTAT_MODE_MAP: dict[str, HVACMode] = { - "OFF": HVACMode.OFF, - "HEAT": HVACMode.HEAT, - "COOL": HVACMode.COOL, - "HEATCOOL": HVACMode.HEAT_COOL, -} -THERMOSTAT_INV_MODE_MAP = {v: k for k, v in THERMOSTAT_MODE_MAP.items()} - -# Mode for sdm.devices.traits.ThermostatEco -THERMOSTAT_ECO_MODE = "MANUAL_ECO" - -# Mapping for sdm.devices.traits.ThermostatHvac status field -THERMOSTAT_HVAC_STATUS_MAP = { - "OFF": HVACAction.OFF, - "HEATING": HVACAction.HEATING, - "COOLING": HVACAction.COOLING, -} - -THERMOSTAT_RANGE_MODES = [HVACMode.HEAT_COOL, HVACMode.AUTO] - -PRESET_MODE_MAP = { - "MANUAL_ECO": PRESET_ECO, - "OFF": PRESET_NONE, -} -PRESET_INV_MODE_MAP = {v: k for k, v in PRESET_MODE_MAP.items()} - -FAN_MODE_MAP = { - "ON": FAN_ON, - "OFF": FAN_OFF, -} -FAN_INV_MODE_MAP = {v: k for k, v in FAN_MODE_MAP.items()} -FAN_INV_MODES = list(FAN_INV_MODE_MAP) - -MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API -MIN_TEMP = 10 -MAX_TEMP = 32 - - -async def async_setup_sdm_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the client entities.""" - - device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ - DATA_DEVICE_MANAGER - ] - entities = [] - for device in device_manager.devices.values(): - if ThermostatHvacTrait.NAME in device.traits: - entities.append(ThermostatEntity(device)) - async_add_entities(entities) - - -class ThermostatEntity(ClimateEntity): - """A nest thermostat climate entity.""" - - _attr_min_temp = MIN_TEMP - _attr_max_temp = MAX_TEMP - _attr_has_entity_name = True - _attr_should_poll = False - _attr_name = None - - def __init__(self, device: Device) -> None: - """Initialize ThermostatEntity.""" - self._device = device - self._device_info = NestDeviceInfo(device) - - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - # The API "name" field is a unique device identifier. - return self._device.name - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return self._device_info.device_info - - @property - def available(self) -> bool: - """Return device availability.""" - return self._device_info.available - - async def async_added_to_hass(self) -> None: - """Run when entity is added to register update signal handler.""" - self._attr_supported_features = self._get_supported_features() - self.async_on_remove( - self._device.add_update_listener(self.async_write_ha_state) - ) - - @property - def temperature_unit(self) -> str: - """Return the unit of temperature measurement for the system.""" - return UnitOfTemperature.CELSIUS - - @property - def current_temperature(self) -> float | None: - """Return the current temperature.""" - if TemperatureTrait.NAME not in self._device.traits: - return None - trait: TemperatureTrait = self._device.traits[TemperatureTrait.NAME] - return trait.ambient_temperature_celsius - - @property - def target_temperature(self) -> float | None: - """Return the temperature currently set to be reached.""" - if not (trait := self._target_temperature_trait): - return None - if self.hvac_mode == HVACMode.HEAT: - return trait.heat_celsius - if self.hvac_mode == HVACMode.COOL: - return trait.cool_celsius - return None - - @property - def target_temperature_high(self) -> float | None: - """Return the upper bound target temperature.""" - if self.hvac_mode != HVACMode.HEAT_COOL: - return None - if not (trait := self._target_temperature_trait): - return None - return trait.cool_celsius - - @property - def target_temperature_low(self) -> float | None: - """Return the lower bound target temperature.""" - if self.hvac_mode != HVACMode.HEAT_COOL: - return None - if not (trait := self._target_temperature_trait): - return None - return trait.heat_celsius - - @property - def _target_temperature_trait( - self, - ) -> ThermostatHeatCoolTrait | None: - """Return the correct trait with a target temp depending on mode.""" - if ( - self.preset_mode == PRESET_ECO - and ThermostatEcoTrait.NAME in self._device.traits - ): - return cast( - ThermostatEcoTrait, self._device.traits[ThermostatEcoTrait.NAME] - ) - if ThermostatTemperatureSetpointTrait.NAME in self._device.traits: - return cast( - ThermostatTemperatureSetpointTrait, - self._device.traits[ThermostatTemperatureSetpointTrait.NAME], - ) - return None - - @property - def hvac_mode(self) -> HVACMode: - """Return the current operation (e.g. heat, cool, idle).""" - hvac_mode = HVACMode.OFF - if ThermostatModeTrait.NAME in self._device.traits: - trait = self._device.traits[ThermostatModeTrait.NAME] - if trait.mode in THERMOSTAT_MODE_MAP: - hvac_mode = THERMOSTAT_MODE_MAP[trait.mode] - return hvac_mode - - @property - def hvac_modes(self) -> list[HVACMode]: - """List of available operation modes.""" - supported_modes = [] - for mode in self._get_device_hvac_modes: - if mode in THERMOSTAT_MODE_MAP: - supported_modes.append(THERMOSTAT_MODE_MAP[mode]) - return supported_modes - - @property - def _get_device_hvac_modes(self) -> set[str]: - """Return the set of SDM API hvac modes supported by the device.""" - modes = [] - if ThermostatModeTrait.NAME in self._device.traits: - trait = self._device.traits[ThermostatModeTrait.NAME] - modes.extend(trait.available_modes) - return set(modes) - - @property - def hvac_action(self) -> HVACAction | None: - """Return the current HVAC action (heating, cooling).""" - trait = self._device.traits[ThermostatHvacTrait.NAME] - if trait.status == "OFF" and self.hvac_mode != HVACMode.OFF: - return HVACAction.IDLE - return THERMOSTAT_HVAC_STATUS_MAP.get(trait.status) - - @property - def preset_mode(self) -> str: - """Return the current active preset.""" - if ThermostatEcoTrait.NAME in self._device.traits: - trait = self._device.traits[ThermostatEcoTrait.NAME] - return PRESET_MODE_MAP.get(trait.mode, PRESET_NONE) - return PRESET_NONE - - @property - def preset_modes(self) -> list[str]: - """Return the available presets.""" - modes = [] - if ThermostatEcoTrait.NAME in self._device.traits: - trait = self._device.traits[ThermostatEcoTrait.NAME] - for mode in trait.available_modes: - if mode in PRESET_MODE_MAP: - modes.append(PRESET_MODE_MAP[mode]) - return modes - - @property - def fan_mode(self) -> str: - """Return the current fan mode.""" - if ( - self.supported_features & ClimateEntityFeature.FAN_MODE - and FanTrait.NAME in self._device.traits - ): - trait = self._device.traits[FanTrait.NAME] - return FAN_MODE_MAP.get(trait.timer_mode, FAN_OFF) - return FAN_OFF - - @property - def fan_modes(self) -> list[str]: - """Return the list of available fan modes.""" - if ( - self.supported_features & ClimateEntityFeature.FAN_MODE - and FanTrait.NAME in self._device.traits - ): - return FAN_INV_MODES - return [] - - def _get_supported_features(self) -> ClimateEntityFeature: - """Compute the bitmap of supported features from the current state.""" - features = ClimateEntityFeature(0) - if HVACMode.HEAT_COOL in self.hvac_modes: - features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - if HVACMode.HEAT in self.hvac_modes or HVACMode.COOL in self.hvac_modes: - features |= ClimateEntityFeature.TARGET_TEMPERATURE - if ThermostatEcoTrait.NAME in self._device.traits: - features |= ClimateEntityFeature.PRESET_MODE - if FanTrait.NAME in self._device.traits: - # Fan trait may be present without actually support fan mode - fan_trait = self._device.traits[FanTrait.NAME] - if fan_trait.timer_mode is not None: - features |= ClimateEntityFeature.FAN_MODE - return features - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target hvac mode.""" - if hvac_mode not in self.hvac_modes: - raise ValueError(f"Unsupported hvac_mode '{hvac_mode}'") - api_mode = THERMOSTAT_INV_MODE_MAP[hvac_mode] - trait = self._device.traits[ThermostatModeTrait.NAME] - try: - await trait.set_mode(api_mode) - except ApiException as err: - raise HomeAssistantError( - f"Error setting {self.entity_id} HVAC mode to {hvac_mode}: {err}" - ) from err - - async def async_set_temperature(self, **kwargs: Any) -> None: - """Set new target temperature.""" - hvac_mode = self.hvac_mode - if kwargs.get(ATTR_HVAC_MODE) is not None: - hvac_mode = kwargs[ATTR_HVAC_MODE] - await self.async_set_hvac_mode(hvac_mode) - low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) - high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) - temp = kwargs.get(ATTR_TEMPERATURE) - if ThermostatTemperatureSetpointTrait.NAME not in self._device.traits: - raise HomeAssistantError( - f"Error setting {self.entity_id} temperature to {kwargs}: " - "Unable to find setpoint trait." - ) - trait = self._device.traits[ThermostatTemperatureSetpointTrait.NAME] - try: - if self.preset_mode == PRESET_ECO or hvac_mode == HVACMode.HEAT_COOL: - if low_temp and high_temp: - await trait.set_range(low_temp, high_temp) - elif hvac_mode == HVACMode.COOL and temp: - await trait.set_cool(temp) - elif hvac_mode == HVACMode.HEAT and temp: - await trait.set_heat(temp) - except ApiException as err: - raise HomeAssistantError( - f"Error setting {self.entity_id} temperature to {kwargs}: {err}" - ) from err - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set new target preset mode.""" - if preset_mode not in self.preset_modes: - raise ValueError(f"Unsupported preset_mode '{preset_mode}'") - if self.preset_mode == preset_mode: # API doesn't like duplicate preset modes - return - trait = self._device.traits[ThermostatEcoTrait.NAME] - try: - await trait.set_mode(PRESET_INV_MODE_MAP[preset_mode]) - except ApiException as err: - raise HomeAssistantError( - f"Error setting {self.entity_id} preset mode to {preset_mode}: {err}" - ) from err - - async def async_set_fan_mode(self, fan_mode: str) -> None: - """Set new target fan mode.""" - if fan_mode not in self.fan_modes: - raise ValueError(f"Unsupported fan_mode '{fan_mode}'") - if fan_mode == FAN_ON and self.hvac_mode == HVACMode.OFF: - raise ValueError( - "Cannot turn on fan, please set an HVAC mode (e.g. heat/cool) first" - ) - trait = self._device.traits[FanTrait.NAME] - duration = None - if fan_mode != FAN_OFF: - duration = MAX_FAN_DURATION - try: - await trait.set_timer(FAN_INV_MODE_MAP[fan_mode], duration=duration) - except ApiException as err: - raise HomeAssistantError( - f"Error setting {self.entity_id} fan mode to {fan_mode}: {err}" - ) from err diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index d20057f4e28..381cc36449d 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -9,15 +9,10 @@ some overrides to custom steps inserted in the middle of the flow. """ from __future__ import annotations -import asyncio -from collections import OrderedDict from collections.abc import Iterable, Mapping -from enum import Enum import logging -import os from typing import Any -import async_timeout from google_nest_sdm.exceptions import ( ApiException, AuthException, @@ -28,12 +23,9 @@ from google_nest_sdm.structure import InfoTrait, Structure import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util import get_random_string -from homeassistant.util.json import JsonObjectType, load_json_object from . import api from .const import ( @@ -71,69 +63,12 @@ DEVICE_ACCESS_CONSOLE_EDIT_URL = ( _LOGGER = logging.getLogger(__name__) -class ConfigMode(Enum): - """Integration configuration mode.""" - - SDM = 1 # SDM api with configuration.yaml - LEGACY = 2 # "Works with Nest" API - SDM_APPLICATION_CREDENTIALS = 3 # Config entry only - - -def get_config_mode(hass: HomeAssistant) -> ConfigMode: - """Return the integration configuration mode.""" - if DOMAIN not in hass.data or not ( - config := hass.data[DOMAIN].get(DATA_NEST_CONFIG) - ): - return ConfigMode.SDM_APPLICATION_CREDENTIALS - if CONF_PROJECT_ID in config: - return ConfigMode.SDM - return ConfigMode.LEGACY - - def _generate_subscription_id(cloud_project_id: str) -> str: """Create a new subscription id.""" rnd = get_random_string(SUBSCRIPTION_RAND_LENGTH) return SUBSCRIPTION_FORMAT.format(cloud_project_id=cloud_project_id, rnd=rnd) -@callback -def register_flow_implementation( - hass: HomeAssistant, - domain: str, - name: str, - gen_authorize_url: str, - convert_code: str, -) -> None: - """Register a flow implementation for legacy api. - - domain: Domain of the component responsible for the implementation. - name: Name of the component. - gen_authorize_url: Coroutine function to generate the authorize url. - convert_code: Coroutine function to convert a code to an access token. - """ - if DATA_FLOW_IMPL not in hass.data: - hass.data[DATA_FLOW_IMPL] = OrderedDict() - - hass.data[DATA_FLOW_IMPL][domain] = { - "domain": domain, - "name": name, - "gen_authorize_url": gen_authorize_url, - "convert_code": convert_code, - } - - -class NestAuthError(HomeAssistantError): - """Base class for Nest auth errors.""" - - -class CodeInvalid(NestAuthError): - """Raised when invalid authorization code.""" - - -class UnexpectedStateError(HomeAssistantError): - """Raised when the config flow is invoked in a 'should not happen' case.""" - - def generate_config_title(structures: Iterable[Structure]) -> str | None: """Pick a user friendly config title based on the Google Home name(s).""" names: list[str] = [] @@ -160,11 +95,6 @@ class NestFlowHandler( # Possible name to use for config entry based on the Google Home name self._structure_config_title: str | None = None - @property - def config_mode(self) -> ConfigMode: - """Return the configuration type for this flow.""" - return get_config_mode(self.hass) - def _async_reauth_entry(self) -> ConfigEntry | None: """Return existing entry for reauth.""" if self.source != SOURCE_REAUTH or not ( @@ -206,7 +136,6 @@ class NestFlowHandler( async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Complete OAuth setup and finish pubsub or finish.""" _LOGGER.debug("Finishing post-oauth configuration") - assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" self._data.update(data) if self.source == SOURCE_REAUTH: _LOGGER.debug("Skipping Pub/Sub configuration") @@ -215,7 +144,6 @@ class NestFlowHandler( async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" self._data.update(entry_data) return await self.async_step_reauth_confirm() @@ -224,7 +152,6 @@ class NestFlowHandler( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm reauth dialog.""" - assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" if user_input is None: return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() @@ -233,8 +160,6 @@ class NestFlowHandler( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" - if self.config_mode == ConfigMode.LEGACY: - return await self.async_step_init(user_input) self._data[DATA_SDM] = {} if self.source == SOURCE_REAUTH: return await super().async_step_user(user_input) @@ -391,7 +316,6 @@ class NestFlowHandler( async def async_step_finish(self, data: dict[str, Any] | None = None) -> FlowResult: """Create an entry for the SDM flow.""" _LOGGER.debug("Creating/updating configuration entry") - assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" # Update existing config entry when in the reauth flow. if entry := self._async_reauth_entry(): self.hass.config_entries.async_update_entry( @@ -404,114 +328,3 @@ class NestFlowHandler( if self._structure_config_title: title = self._structure_config_title return self.async_create_entry(title=title, data=self._data) - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle a flow start.""" - assert ( - self.config_mode == ConfigMode.LEGACY - ), "Step only supported for legacy API" - - flows = self.hass.data.get(DATA_FLOW_IMPL, {}) - - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - if not flows: - return self.async_abort(reason="missing_configuration") - - if len(flows) == 1: - self.flow_impl = list(flows)[0] - return await self.async_step_link() - - if user_input is not None: - self.flow_impl = user_input["flow_impl"] - return await self.async_step_link() - - return self.async_show_form( - step_id="init", - data_schema=vol.Schema({vol.Required("flow_impl"): vol.In(list(flows))}), - ) - - async def async_step_link( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Attempt to link with the Nest account. - - Route the user to a website to authenticate with Nest. Depending on - implementation type we expect a pin or an external component to - deliver the authentication code. - """ - assert ( - self.config_mode == ConfigMode.LEGACY - ), "Step only supported for legacy API" - - flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl] - - errors = {} - - if user_input is not None: - try: - async with async_timeout.timeout(10): - tokens = await flow["convert_code"](user_input["code"]) - return self._entry_from_tokens( - f"Nest (via {flow['name']})", flow, tokens - ) - - except asyncio.TimeoutError: - errors["code"] = "timeout" - except CodeInvalid: - errors["code"] = "invalid_pin" - except NestAuthError: - errors["code"] = "unknown" - except Exception: # pylint: disable=broad-except - errors["code"] = "internal_error" - _LOGGER.exception("Unexpected error resolving code") - - try: - async with async_timeout.timeout(10): - url = await flow["gen_authorize_url"](self.flow_id) - except asyncio.TimeoutError: - return self.async_abort(reason="authorize_url_timeout") - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected error generating auth url") - return self.async_abort(reason="unknown_authorize_url_generation") - - return self.async_show_form( - step_id="link", - description_placeholders={"url": url}, - data_schema=vol.Schema({vol.Required("code"): str}), - errors=errors, - ) - - async def async_step_import(self, info: dict[str, Any]) -> FlowResult: - """Import existing auth from Nest.""" - assert ( - self.config_mode == ConfigMode.LEGACY - ), "Step only supported for legacy API" - - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - config_path = info["nest_conf_path"] - - if not await self.hass.async_add_executor_job(os.path.isfile, config_path): - self.flow_impl = DOMAIN # type: ignore[assignment] - return await self.async_step_link() - - flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN] - tokens = await self.hass.async_add_executor_job(load_json_object, config_path) - - return self._entry_from_tokens( - "Nest (import from configuration.yaml)", flow, tokens - ) - - @callback - def _entry_from_tokens( - self, title: str, flow: dict[str, Any], tokens: JsonObjectType - ) -> FlowResult: - """Create an entry from tokens.""" - return self.async_create_entry( - title=title, data={"tokens": tokens, "impl_domain": flow["domain"]} - ) diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index e269b76fcc4..891365655de 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -100,6 +100,8 @@ def async_nest_devices_by_device_id(hass: HomeAssistant) -> Mapping[str, Device] device_registry = dr.async_get(hass) devices = {} for nest_device_id, device in async_nest_devices(hass).items(): - if device_entry := device_registry.async_get_device({(DOMAIN, nest_device_id)}): + if device_entry := device_registry.async_get_device( + identifiers={(DOMAIN, nest_device_id)} + ): devices[device_entry.id] = device return devices diff --git a/homeassistant/components/nest/legacy/__init__.py b/homeassistant/components/nest/legacy/__init__.py deleted file mode 100644 index 88d046fb62b..00000000000 --- a/homeassistant/components/nest/legacy/__init__.py +++ /dev/null @@ -1,432 +0,0 @@ -"""Support for Nest devices.""" -# mypy: ignore-errors - -from datetime import datetime, timedelta -import logging -import threading - -from nest import Nest -from nest.nest import APIError, AuthorizationError -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_FILENAME, - CONF_STRUCTURE, - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, - Platform, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import DeviceInfo, Entity - -from . import local_auth -from .const import DATA_NEST, DATA_NEST_CONFIG, DOMAIN, SIGNAL_NEST_UPDATE - -_CONFIGURING = {} -_LOGGER = logging.getLogger(__name__) - -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.CAMERA, - Platform.CLIMATE, - Platform.SENSOR, -] - -# Configuration for the legacy nest API -SERVICE_CANCEL_ETA = "cancel_eta" -SERVICE_SET_ETA = "set_eta" - -NEST_CONFIG_FILE = "nest.conf" - -ATTR_ETA = "eta" -ATTR_ETA_WINDOW = "eta_window" -ATTR_STRUCTURE = "structure" -ATTR_TRIP_ID = "trip_id" - -AWAY_MODE_AWAY = "away" -AWAY_MODE_HOME = "home" - -ATTR_AWAY_MODE = "away_mode" -SERVICE_SET_AWAY_MODE = "set_away_mode" - -# Services for the legacy API - -SET_AWAY_MODE_SCHEMA = vol.Schema( - { - vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]), - vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]), - } -) - -SET_ETA_SCHEMA = vol.Schema( - { - vol.Required(ATTR_ETA): cv.time_period, - vol.Optional(ATTR_TRIP_ID): cv.string, - vol.Optional(ATTR_ETA_WINDOW): cv.time_period, - vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]), - } -) - -CANCEL_ETA_SCHEMA = vol.Schema( - { - vol.Required(ATTR_TRIP_ID): cv.string, - vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]), - } -) - - -def nest_update_event_broker(hass, nest): - """Dispatch SIGNAL_NEST_UPDATE to devices when nest stream API received data. - - Used for the legacy nest API. - - Runs in its own thread. - """ - _LOGGER.debug("Listening for nest.update_event") - - while hass.is_running: - nest.update_event.wait() - - if not hass.is_running: - break - - nest.update_event.clear() - _LOGGER.debug("Dispatching nest data update") - dispatcher_send(hass, SIGNAL_NEST_UPDATE) - - _LOGGER.debug("Stop listening for nest.update_event") - - -async def async_setup_legacy(hass: HomeAssistant, config: dict) -> bool: - """Set up Nest components using the legacy nest API.""" - if DOMAIN not in config: - return True - - ir.async_create_issue( - hass, - DOMAIN, - "legacy_nest_deprecated", - breaks_in_ha_version="2023.8.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="legacy_nest_deprecated", - translation_placeholders={ - "documentation_url": "https://www.home-assistant.io/integrations/nest/", - }, - ) - - conf = config[DOMAIN] - - local_auth.initialize(hass, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET]) - - filename = config.get(CONF_FILENAME, NEST_CONFIG_FILE) - access_token_cache_file = hass.config.path(filename) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={"nest_conf_path": access_token_cache_file}, - ) - ) - - # Store config to be used during entry setup - hass.data[DATA_NEST_CONFIG] = conf - - return True - - -async def async_setup_legacy_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Nest from legacy config entry.""" - - nest = Nest(access_token=entry.data["tokens"]["access_token"]) - - _LOGGER.debug("proceeding with setup") - conf = hass.data.get(DATA_NEST_CONFIG, {}) - hass.data[DATA_NEST] = NestLegacyDevice(hass, conf, nest) - if not await hass.async_add_executor_job(hass.data[DATA_NEST].initialize): - return False - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - def validate_structures(target_structures): - all_structures = [structure.name for structure in nest.structures] - for target in target_structures: - if target not in all_structures: - _LOGGER.info("Invalid structure: %s", target) - - def set_away_mode(service): - """Set the away mode for a Nest structure.""" - if ATTR_STRUCTURE in service.data: - target_structures = service.data[ATTR_STRUCTURE] - validate_structures(target_structures) - else: - target_structures = hass.data[DATA_NEST].local_structure - - for structure in nest.structures: - if structure.name in target_structures: - _LOGGER.info( - "Setting away mode for: %s to: %s", - structure.name, - service.data[ATTR_AWAY_MODE], - ) - structure.away = service.data[ATTR_AWAY_MODE] - - def set_eta(service): - """Set away mode to away and include ETA for a Nest structure.""" - if ATTR_STRUCTURE in service.data: - target_structures = service.data[ATTR_STRUCTURE] - validate_structures(target_structures) - else: - target_structures = hass.data[DATA_NEST].local_structure - - for structure in nest.structures: - if structure.name in target_structures: - if structure.thermostats: - _LOGGER.info( - "Setting away mode for: %s to: %s", - structure.name, - AWAY_MODE_AWAY, - ) - structure.away = AWAY_MODE_AWAY - - now = datetime.utcnow() - trip_id = service.data.get( - ATTR_TRIP_ID, f"trip_{int(now.timestamp())}" - ) - eta_begin = now + service.data[ATTR_ETA] - eta_window = service.data.get(ATTR_ETA_WINDOW, timedelta(minutes=1)) - eta_end = eta_begin + eta_window - _LOGGER.info( - ( - "Setting ETA for trip: %s, " - "ETA window starts at: %s and ends at: %s" - ), - trip_id, - eta_begin, - eta_end, - ) - structure.set_eta(trip_id, eta_begin, eta_end) - else: - _LOGGER.info( - "No thermostats found in structure: %s, unable to set ETA", - structure.name, - ) - - def cancel_eta(service): - """Cancel ETA for a Nest structure.""" - if ATTR_STRUCTURE in service.data: - target_structures = service.data[ATTR_STRUCTURE] - validate_structures(target_structures) - else: - target_structures = hass.data[DATA_NEST].local_structure - - for structure in nest.structures: - if structure.name in target_structures: - if structure.thermostats: - trip_id = service.data[ATTR_TRIP_ID] - _LOGGER.info("Cancelling ETA for trip: %s", trip_id) - structure.cancel_eta(trip_id) - else: - _LOGGER.info( - "No thermostats found in structure: %s, unable to cancel ETA", - structure.name, - ) - - hass.services.async_register( - DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, schema=SET_AWAY_MODE_SCHEMA - ) - - hass.services.async_register( - DOMAIN, SERVICE_SET_ETA, set_eta, schema=SET_ETA_SCHEMA - ) - - hass.services.async_register( - DOMAIN, SERVICE_CANCEL_ETA, cancel_eta, schema=CANCEL_ETA_SCHEMA - ) - - @callback - def start_up(event): - """Start Nest update event listener.""" - threading.Thread( - name="Nest update listener", - target=nest_update_event_broker, - args=(hass, nest), - ).start() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_up) - - @callback - def shut_down(event): - """Stop Nest update event listener.""" - nest.update_event.set() - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shut_down) - ) - - _LOGGER.debug("async_setup_nest is done") - - return True - - -class NestLegacyDevice: - """Structure Nest functions for hass for legacy API.""" - - def __init__(self, hass, conf, nest): - """Init Nest Devices.""" - self.hass = hass - self.nest = nest - self.local_structure = conf.get(CONF_STRUCTURE) - - def initialize(self): - """Initialize Nest.""" - try: - # Do not optimize next statement, it is here for initialize - # persistence Nest API connection. - structure_names = [s.name for s in self.nest.structures] - if self.local_structure is None: - self.local_structure = structure_names - - except (AuthorizationError, APIError, OSError) as err: - _LOGGER.error("Connection error while access Nest web service: %s", err) - return False - return True - - def structures(self): - """Generate a list of structures.""" - try: - for structure in self.nest.structures: - if structure.name not in self.local_structure: - _LOGGER.debug( - "Ignoring structure %s, not in %s", - structure.name, - self.local_structure, - ) - continue - yield structure - - except (AuthorizationError, APIError, OSError) as err: - _LOGGER.error("Connection error while access Nest web service: %s", err) - - def thermostats(self): - """Generate a list of thermostats.""" - return self._devices("thermostats") - - def smoke_co_alarms(self): - """Generate a list of smoke co alarms.""" - return self._devices("smoke_co_alarms") - - def cameras(self): - """Generate a list of cameras.""" - return self._devices("cameras") - - def _devices(self, device_type): - """Generate a list of Nest devices.""" - try: - for structure in self.nest.structures: - if structure.name not in self.local_structure: - _LOGGER.debug( - "Ignoring structure %s, not in %s", - structure.name, - self.local_structure, - ) - continue - - for device in getattr(structure, device_type, []): - try: - # Do not optimize next statement, - # it is here for verify Nest API permission. - device.name_long - except KeyError: - _LOGGER.warning( - ( - "Cannot retrieve device name for [%s]" - ", please check your Nest developer " - "account permission settings" - ), - device.serial, - ) - continue - yield (structure, device) - - except (AuthorizationError, APIError, OSError) as err: - _LOGGER.error("Connection error while access Nest web service: %s", err) - - -class NestSensorDevice(Entity): - """Representation of a Nest sensor.""" - - _attr_should_poll = False - - def __init__(self, structure, device, variable): - """Initialize the sensor.""" - self.structure = structure - self.variable = variable - - if device is not None: - # device specific - self.device = device - self._name = f"{self.device.name_long} {self.variable.replace('_', ' ')}" - else: - # structure only - self.device = structure - self._name = f"{self.structure.name} {self.variable.replace('_', ' ')}" - - self._state = None - self._unit = None - - @property - def name(self): - """Return the name of the nest, if any.""" - return self._name - - @property - def unique_id(self): - """Return unique id based on device serial and variable.""" - return f"{self.device.serial}-{self.variable}" - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - if not hasattr(self.device, "name_long"): - name = self.structure.name - model = "Structure" - else: - name = self.device.name_long - if self.device.is_thermostat: - model = "Thermostat" - elif self.device.is_camera: - model = "Camera" - elif self.device.is_smoke_co_alarm: - model = "Nest Protect" - else: - model = None - - return DeviceInfo( - identifiers={(DOMAIN, self.device.serial)}, - manufacturer="Nest Labs", - model=model, - name=name, - ) - - def update(self): - """Do not use NestSensorDevice directly.""" - raise NotImplementedError - - async def async_added_to_hass(self): - """Register update signal handler.""" - - async def async_update_state(): - """Update sensor state.""" - await self.async_update_ha_state(True) - - self.async_on_remove( - async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, async_update_state) - ) diff --git a/homeassistant/components/nest/legacy/binary_sensor.py b/homeassistant/components/nest/legacy/binary_sensor.py deleted file mode 100644 index 5c412b86dbd..00000000000 --- a/homeassistant/components/nest/legacy/binary_sensor.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Support for Nest Thermostat binary sensors.""" -# mypy: ignore-errors - -from itertools import chain -import logging - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_BINARY_SENSORS, CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import NestSensorDevice -from .const import DATA_NEST, DATA_NEST_CONFIG - -_LOGGER = logging.getLogger(__name__) - -BINARY_TYPES = {"online": BinarySensorDeviceClass.CONNECTIVITY} - -CLIMATE_BINARY_TYPES = { - "fan": None, - "is_using_emergency_heat": "heat", - "is_locked": None, - "has_leaf": None, -} - -CAMERA_BINARY_TYPES = { - "motion_detected": BinarySensorDeviceClass.MOTION, - "sound_detected": BinarySensorDeviceClass.SOUND, - "person_detected": BinarySensorDeviceClass.OCCUPANCY, -} - -STRUCTURE_BINARY_TYPES = {"away": None} - -STRUCTURE_BINARY_STATE_MAP = {"away": {"away": True, "home": False}} - -_BINARY_TYPES_DEPRECATED = [ - "hvac_ac_state", - "hvac_aux_heater_state", - "hvac_heater_state", - "hvac_heat_x2_state", - "hvac_heat_x3_state", - "hvac_alt_heat_state", - "hvac_alt_heat_x2_state", - "hvac_emer_heat_state", -] - -_VALID_BINARY_SENSOR_TYPES = { - **BINARY_TYPES, - **CLIMATE_BINARY_TYPES, - **CAMERA_BINARY_TYPES, - **STRUCTURE_BINARY_TYPES, -} - - -async def async_setup_legacy_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up a Nest binary sensor based on a config entry.""" - nest = hass.data[DATA_NEST] - - discovery_info = hass.data.get(DATA_NEST_CONFIG, {}).get(CONF_BINARY_SENSORS, {}) - - # Add all available binary sensors if no Nest binary sensor config is set - if discovery_info == {}: - conditions = _VALID_BINARY_SENSOR_TYPES - else: - conditions = discovery_info.get(CONF_MONITORED_CONDITIONS, {}) - - for variable in conditions: - if variable in _BINARY_TYPES_DEPRECATED: - wstr = ( - f"{variable} is no a longer supported " - "monitored_conditions. See " - "https://www.home-assistant.io/integrations/binary_sensor.nest/ " - "for valid options." - ) - _LOGGER.error(wstr) - - def get_binary_sensors(): - """Get the Nest binary sensors.""" - sensors = [] - for structure in nest.structures(): - sensors += [ - NestBinarySensor(structure, None, variable) - for variable in conditions - if variable in STRUCTURE_BINARY_TYPES - ] - device_chain = chain(nest.thermostats(), nest.smoke_co_alarms(), nest.cameras()) - for structure, device in device_chain: - sensors += [ - NestBinarySensor(structure, device, variable) - for variable in conditions - if variable in BINARY_TYPES - ] - sensors += [ - NestBinarySensor(structure, device, variable) - for variable in conditions - if variable in CLIMATE_BINARY_TYPES and device.is_thermostat - ] - - if device.is_camera: - sensors += [ - NestBinarySensor(structure, device, variable) - for variable in conditions - if variable in CAMERA_BINARY_TYPES - ] - for activity_zone in device.activity_zones: - sensors += [ - NestActivityZoneSensor(structure, device, activity_zone) - ] - - return sensors - - async_add_entities(await hass.async_add_executor_job(get_binary_sensors), True) - - -class NestBinarySensor(NestSensorDevice, BinarySensorEntity): - """Represents a Nest binary sensor.""" - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the device class of the binary sensor.""" - return _VALID_BINARY_SENSOR_TYPES.get(self.variable) - - def update(self): - """Retrieve latest state.""" - value = getattr(self.device, self.variable) - if self.variable in STRUCTURE_BINARY_TYPES: - self._state = bool(STRUCTURE_BINARY_STATE_MAP[self.variable].get(value)) - else: - self._state = bool(value) - - -class NestActivityZoneSensor(NestBinarySensor): - """Represents a Nest binary sensor for activity in a zone.""" - - def __init__(self, structure, device, zone): - """Initialize the sensor.""" - super().__init__(structure, device, "") - self.zone = zone - self._name = f"{self._name} {self.zone.name} activity" - - @property - def unique_id(self): - """Return unique id based on camera serial and zone id.""" - return f"{self.device.serial}-{self.zone.zone_id}" - - @property - def device_class(self): - """Return the device class of the binary sensor.""" - return BinarySensorDeviceClass.MOTION - - def update(self): - """Retrieve latest state.""" - self._state = self.device.has_ongoing_motion_in_zone(self.zone.zone_id) diff --git a/homeassistant/components/nest/legacy/camera.py b/homeassistant/components/nest/legacy/camera.py deleted file mode 100644 index e74f23aeaf6..00000000000 --- a/homeassistant/components/nest/legacy/camera.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Support for Nest Cameras.""" -# mypy: ignore-errors - -from __future__ import annotations - -from datetime import timedelta -import logging - -import requests - -from homeassistant.components.camera import PLATFORM_SCHEMA, Camera, CameraEntityFeature -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.dt import utcnow - -from .const import DATA_NEST, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -NEST_BRAND = "Nest" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({}) - - -async def async_setup_legacy_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up a Nest sensor based on a config entry.""" - camera_devices = await hass.async_add_executor_job(hass.data[DATA_NEST].cameras) - cameras = [NestCamera(structure, device) for structure, device in camera_devices] - async_add_entities(cameras, True) - - -class NestCamera(Camera): - """Representation of a Nest Camera.""" - - _attr_should_poll = True # Cameras default to False - _attr_supported_features = CameraEntityFeature.ON_OFF - - def __init__(self, structure, device): - """Initialize a Nest Camera.""" - super().__init__() - self.structure = structure - self.device = device - self._location = None - self._name = None - self._online = None - self._is_streaming = None - self._is_video_history_enabled = False - # Default to non-NestAware subscribed, but will be fixed during update - self._time_between_snapshots = timedelta(seconds=30) - self._last_image = None - self._next_snapshot_at = None - - @property - def name(self): - """Return the name of the nest, if any.""" - return self._name - - @property - def unique_id(self): - """Return the serial number.""" - return self.device.device_id - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device.device_id)}, - manufacturer="Nest Labs", - model="Camera", - name=self.device.name_long, - ) - - @property - def is_recording(self): - """Return true if the device is recording.""" - return self._is_streaming - - @property - def brand(self): - """Return the brand of the camera.""" - return NEST_BRAND - - @property - def is_on(self): - """Return true if on.""" - return self._online and self._is_streaming - - def turn_off(self): - """Turn off camera.""" - _LOGGER.debug("Turn off camera %s", self._name) - # Calling Nest API in is_streaming setter. - # device.is_streaming would not immediately change until the process - # finished in Nest Cam. - self.device.is_streaming = False - - def turn_on(self): - """Turn on camera.""" - if not self._online: - _LOGGER.error("Camera %s is offline", self._name) - return - - _LOGGER.debug("Turn on camera %s", self._name) - # Calling Nest API in is_streaming setter. - # device.is_streaming would not immediately change until the process - # finished in Nest Cam. - self.device.is_streaming = True - - def update(self): - """Cache value from Python-nest.""" - self._location = self.device.where - self._name = self.device.name - self._online = self.device.online - self._is_streaming = self.device.is_streaming - self._is_video_history_enabled = self.device.is_video_history_enabled - - if self._is_video_history_enabled: - # NestAware allowed 10/min - self._time_between_snapshots = timedelta(seconds=6) - else: - # Otherwise, 2/min - self._time_between_snapshots = timedelta(seconds=30) - - def _ready_for_snapshot(self, now): - return self._next_snapshot_at is None or now > self._next_snapshot_at - - def camera_image( - self, width: int | None = None, height: int | None = None - ) -> bytes | None: - """Return a still image response from the camera.""" - now = utcnow() - if self._ready_for_snapshot(now): - url = self.device.snapshot_url - - try: - response = requests.get(url, timeout=10) - except requests.exceptions.RequestException as error: - _LOGGER.error("Error getting camera image: %s", error) - return None - - self._next_snapshot_at = now + self._time_between_snapshots - self._last_image = response.content - - return self._last_image diff --git a/homeassistant/components/nest/legacy/climate.py b/homeassistant/components/nest/legacy/climate.py deleted file mode 100644 index 323633e0ee3..00000000000 --- a/homeassistant/components/nest/legacy/climate.py +++ /dev/null @@ -1,339 +0,0 @@ -"""Legacy Works with Nest climate implementation.""" -# mypy: ignore-errors - -import logging - -from nest.nest import APIError -import voluptuous as vol - -from homeassistant.components.climate import ( - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, - FAN_AUTO, - FAN_ON, - PLATFORM_SCHEMA, - PRESET_AWAY, - PRESET_ECO, - PRESET_NONE, - ClimateEntity, - ClimateEntityFeature, - HVACAction, - HVACMode, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, CONF_SCAN_INTERVAL, UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DATA_NEST, DOMAIN, SIGNAL_NEST_UPDATE - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Optional(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=1))} -) - -NEST_MODE_HEAT_COOL = "heat-cool" -NEST_MODE_ECO = "eco" -NEST_MODE_HEAT = "heat" -NEST_MODE_COOL = "cool" -NEST_MODE_OFF = "off" - -MODE_HASS_TO_NEST = { - HVACMode.AUTO: NEST_MODE_HEAT_COOL, - HVACMode.HEAT: NEST_MODE_HEAT, - HVACMode.COOL: NEST_MODE_COOL, - HVACMode.OFF: NEST_MODE_OFF, -} - -MODE_NEST_TO_HASS = {v: k for k, v in MODE_HASS_TO_NEST.items()} - -ACTION_NEST_TO_HASS = { - "off": HVACAction.IDLE, - "heating": HVACAction.HEATING, - "cooling": HVACAction.COOLING, -} - -PRESET_AWAY_AND_ECO = "Away and Eco" - -PRESET_MODES = [PRESET_NONE, PRESET_AWAY, PRESET_ECO, PRESET_AWAY_AND_ECO] - - -async def async_setup_legacy_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the Nest climate device based on a config entry.""" - temp_unit = hass.config.units.temperature_unit - - thermostats = await hass.async_add_executor_job(hass.data[DATA_NEST].thermostats) - - all_devices = [ - NestThermostat(structure, device, temp_unit) - for structure, device in thermostats - ] - - async_add_entities(all_devices, True) - - -class NestThermostat(ClimateEntity): - """Representation of a Nest thermostat.""" - - _attr_should_poll = False - - def __init__(self, structure, device, temp_unit): - """Initialize the thermostat.""" - self._unit = temp_unit - self.structure = structure - self.device = device - self._fan_modes = [FAN_ON, FAN_AUTO] - - # Set the default supported features - self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - ) - - # Not all nest devices support cooling and heating remove unused - self._operation_list = [] - - if self.device.can_heat and self.device.can_cool: - self._operation_list.append(HVACMode.AUTO) - self._attr_supported_features |= ( - ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - ) - - # Add supported nest thermostat features - if self.device.can_heat: - self._operation_list.append(HVACMode.HEAT) - - if self.device.can_cool: - self._operation_list.append(HVACMode.COOL) - - self._operation_list.append(HVACMode.OFF) - - # feature of device - self._has_fan = self.device.has_fan - if self._has_fan: - self._attr_supported_features |= ClimateEntityFeature.FAN_MODE - - # data attributes - self._away = None - self._location = None - self._name = None - self._humidity = None - self._target_temperature = None - self._temperature = None - self._temperature_scale = None - self._mode = None - self._action = None - self._fan = None - self._eco_temperature = None - self._is_locked = None - self._locked_temperature = None - self._min_temperature = None - self._max_temperature = None - - async def async_added_to_hass(self): - """Register update signal handler.""" - - async def async_update_state(): - """Update device state.""" - await self.async_update_ha_state(True) - - self.async_on_remove( - async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, async_update_state) - ) - - @property - def unique_id(self): - """Return unique ID for this device.""" - return self.device.serial - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device.device_id)}, - manufacturer="Nest Labs", - model="Thermostat", - name=self.device.name_long, - sw_version=self.device.software_version, - ) - - @property - def name(self): - """Return the name of the nest, if any.""" - return self._name - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return self._temperature_scale - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._temperature - - @property - def hvac_mode(self) -> HVACMode: - """Return current operation ie. heat, cool, idle.""" - if self._mode == NEST_MODE_ECO: - if self.device.previous_mode in MODE_NEST_TO_HASS: - return MODE_NEST_TO_HASS[self.device.previous_mode] - - # previous_mode not supported so return the first compatible mode - return self._operation_list[0] - - return MODE_NEST_TO_HASS[self._mode] - - @property - def hvac_action(self) -> HVACAction: - """Return the current hvac action.""" - return ACTION_NEST_TO_HASS[self._action] - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - if self._mode not in (NEST_MODE_HEAT_COOL, NEST_MODE_ECO): - return self._target_temperature - return None - - @property - def target_temperature_low(self): - """Return the lower bound temperature we try to reach.""" - if self._mode == NEST_MODE_ECO: - return self._eco_temperature[0] - if self._mode == NEST_MODE_HEAT_COOL: - return self._target_temperature[0] - return None - - @property - def target_temperature_high(self): - """Return the upper bound temperature we try to reach.""" - if self._mode == NEST_MODE_ECO: - return self._eco_temperature[1] - if self._mode == NEST_MODE_HEAT_COOL: - return self._target_temperature[1] - return None - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - - temp = None - target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) - target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) - if self._mode == NEST_MODE_HEAT_COOL: - if target_temp_low is not None and target_temp_high is not None: - temp = (target_temp_low, target_temp_high) - _LOGGER.debug("Nest set_temperature-output-value=%s", temp) - else: - temp = kwargs.get(ATTR_TEMPERATURE) - _LOGGER.debug("Nest set_temperature-output-value=%s", temp) - try: - if temp is not None: - self.device.target = temp - except APIError as api_error: - _LOGGER.error("An error occurred while setting temperature: %s", api_error) - # restore target temperature - self.schedule_update_ha_state(True) - - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set operation mode.""" - self.device.mode = MODE_HASS_TO_NEST[hvac_mode] - - @property - def hvac_modes(self) -> list[HVACMode]: - """List of available operation modes.""" - return self._operation_list - - @property - def preset_mode(self): - """Return current preset mode.""" - if self._away and self._mode == NEST_MODE_ECO: - return PRESET_AWAY_AND_ECO - - if self._away: - return PRESET_AWAY - - if self._mode == NEST_MODE_ECO: - return PRESET_ECO - - return PRESET_NONE - - @property - def preset_modes(self): - """Return preset modes.""" - return PRESET_MODES - - def set_preset_mode(self, preset_mode): - """Set preset mode.""" - if preset_mode == self.preset_mode: - return - - need_away = preset_mode in (PRESET_AWAY, PRESET_AWAY_AND_ECO) - need_eco = preset_mode in (PRESET_ECO, PRESET_AWAY_AND_ECO) - is_away = self._away - is_eco = self._mode == NEST_MODE_ECO - - if is_away != need_away: - self.structure.away = need_away - - if is_eco != need_eco: - if need_eco: - self.device.mode = NEST_MODE_ECO - else: - self.device.mode = self.device.previous_mode - - @property - def fan_mode(self): - """Return whether the fan is on.""" - if self._has_fan: - # Return whether the fan is on - return FAN_ON if self._fan else FAN_AUTO - # No Fan available so disable slider - return None - - @property - def fan_modes(self): - """List of available fan modes.""" - if self._has_fan: - return self._fan_modes - return None - - def set_fan_mode(self, fan_mode): - """Turn fan on/off.""" - if self._has_fan: - self.device.fan = fan_mode.lower() - - @property - def min_temp(self): - """Identify min_temp in Nest API or defaults if not available.""" - return self._min_temperature - - @property - def max_temp(self): - """Identify max_temp in Nest API or defaults if not available.""" - return self._max_temperature - - def update(self): - """Cache value from Python-nest.""" - self._location = self.device.where - self._name = self.device.name - self._humidity = self.device.humidity - self._temperature = self.device.temperature - self._mode = self.device.mode - self._action = self.device.hvac_state - self._target_temperature = self.device.target - self._fan = self.device.fan - self._away = self.structure.away == "away" - self._eco_temperature = self.device.eco_temperature - self._locked_temperature = self.device.locked_temperature - self._min_temperature = self.device.min_temperature - self._max_temperature = self.device.max_temperature - self._is_locked = self.device.is_locked - if self.device.temperature_scale == "C": - self._temperature_scale = UnitOfTemperature.CELSIUS - else: - self._temperature_scale = UnitOfTemperature.FAHRENHEIT diff --git a/homeassistant/components/nest/legacy/const.py b/homeassistant/components/nest/legacy/const.py deleted file mode 100644 index 664606b9edc..00000000000 --- a/homeassistant/components/nest/legacy/const.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Constants used by the legacy Nest component.""" - -DOMAIN = "nest" -DATA_NEST = "nest" -DATA_NEST_CONFIG = "nest_config" -SIGNAL_NEST_UPDATE = "nest_update" diff --git a/homeassistant/components/nest/legacy/local_auth.py b/homeassistant/components/nest/legacy/local_auth.py deleted file mode 100644 index a091469cd81..00000000000 --- a/homeassistant/components/nest/legacy/local_auth.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Local Nest authentication for the legacy api.""" -# mypy: ignore-errors - -import asyncio -from functools import partial -from http import HTTPStatus - -from nest.nest import AUTHORIZE_URL, AuthorizationError, NestAuth - -from homeassistant.core import callback - -from ..config_flow import CodeInvalid, NestAuthError, register_flow_implementation -from .const import DOMAIN - - -@callback -def initialize(hass, client_id, client_secret): - """Initialize a local auth provider.""" - register_flow_implementation( - hass, - DOMAIN, - "configuration.yaml", - partial(generate_auth_url, client_id), - partial(resolve_auth_code, hass, client_id, client_secret), - ) - - -async def generate_auth_url(client_id, flow_id): - """Generate an authorize url.""" - return AUTHORIZE_URL.format(client_id, flow_id) - - -async def resolve_auth_code(hass, client_id, client_secret, code): - """Resolve an authorization code.""" - - result = asyncio.Future() - auth = NestAuth( - client_id=client_id, - client_secret=client_secret, - auth_callback=result.set_result, - ) - auth.pin = code - - try: - await hass.async_add_executor_job(auth.login) - return await result - except AuthorizationError as err: - if err.response.status_code == HTTPStatus.UNAUTHORIZED: - raise CodeInvalid() from err - raise NestAuthError( - f"Unknown error: {err} ({err.response.status_code})" - ) from err diff --git a/homeassistant/components/nest/legacy/sensor.py b/homeassistant/components/nest/legacy/sensor.py deleted file mode 100644 index 3c397f3d1f4..00000000000 --- a/homeassistant/components/nest/legacy/sensor.py +++ /dev/null @@ -1,233 +0,0 @@ -"""Support for Nest Thermostat sensors for the legacy API.""" -# mypy: ignore-errors - -import logging - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorStateClass, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, - CONF_SENSORS, - PERCENTAGE, - STATE_OFF, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import NestSensorDevice -from .const import DATA_NEST, DATA_NEST_CONFIG - -SENSOR_TYPES = ["humidity", "operation_mode", "hvac_state"] - -TEMP_SENSOR_TYPES = ["temperature", "target"] - -PROTECT_SENSOR_TYPES = [ - "co_status", - "smoke_status", - "battery_health", - # color_status: "gray", "green", "yellow", "red" - "color_status", -] - -STRUCTURE_SENSOR_TYPES = ["eta"] - -STATE_HEAT = "heat" -STATE_COOL = "cool" - -# security_state is structure level sensor, but only meaningful when -# Nest Cam exist -STRUCTURE_CAMERA_SENSOR_TYPES = ["security_state"] - -_VALID_SENSOR_TYPES = ( - SENSOR_TYPES - + TEMP_SENSOR_TYPES - + PROTECT_SENSOR_TYPES - + STRUCTURE_SENSOR_TYPES - + STRUCTURE_CAMERA_SENSOR_TYPES -) - -SENSOR_UNITS = {"humidity": PERCENTAGE} - -SENSOR_DEVICE_CLASSES = {"humidity": SensorDeviceClass.HUMIDITY} - -SENSOR_STATE_CLASSES = {"humidity": SensorStateClass.MEASUREMENT} - -VARIABLE_NAME_MAPPING = {"eta": "eta_begin", "operation_mode": "mode"} - -VALUE_MAPPING = { - "hvac_state": {"heating": STATE_HEAT, "cooling": STATE_COOL, "off": STATE_OFF} -} - -SENSOR_TYPES_DEPRECATED = ["last_ip", "local_ip", "last_connection", "battery_level"] - -DEPRECATED_WEATHER_VARS = [ - "weather_humidity", - "weather_temperature", - "weather_condition", - "wind_speed", - "wind_direction", -] - -_SENSOR_TYPES_DEPRECATED = SENSOR_TYPES_DEPRECATED + DEPRECATED_WEATHER_VARS - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_legacy_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up a Nest sensor based on a config entry.""" - nest = hass.data[DATA_NEST] - - discovery_info = hass.data.get(DATA_NEST_CONFIG, {}).get(CONF_SENSORS, {}) - - # Add all available sensors if no Nest sensor config is set - if discovery_info == {}: - conditions = _VALID_SENSOR_TYPES - else: - conditions = discovery_info.get(CONF_MONITORED_CONDITIONS, {}) - - for variable in conditions: - if variable in _SENSOR_TYPES_DEPRECATED: - if variable in DEPRECATED_WEATHER_VARS: - wstr = ( - f"Nest no longer provides weather data like {variable}. See " - "https://www.home-assistant.io/integrations/#weather " - "for a list of other weather integrations to use." - ) - else: - wstr = ( - f"{variable} is no a longer supported " - "monitored_conditions. See " - "https://www.home-assistant.io/integrations/" - "binary_sensor.nest/ for valid options." - ) - _LOGGER.error(wstr) - - def get_sensors(): - """Get the Nest sensors.""" - all_sensors = [] - for structure in nest.structures(): - all_sensors += [ - NestBasicSensor(structure, None, variable) - for variable in conditions - if variable in STRUCTURE_SENSOR_TYPES - ] - - for structure, device in nest.thermostats(): - all_sensors += [ - NestBasicSensor(structure, device, variable) - for variable in conditions - if variable in SENSOR_TYPES - ] - all_sensors += [ - NestTempSensor(structure, device, variable) - for variable in conditions - if variable in TEMP_SENSOR_TYPES - ] - - for structure, device in nest.smoke_co_alarms(): - all_sensors += [ - NestBasicSensor(structure, device, variable) - for variable in conditions - if variable in PROTECT_SENSOR_TYPES - ] - - structures_has_camera = {} - for structure, _ in nest.cameras(): - structures_has_camera[structure] = True - for structure in structures_has_camera: - all_sensors += [ - NestBasicSensor(structure, None, variable) - for variable in conditions - if variable in STRUCTURE_CAMERA_SENSOR_TYPES - ] - - return all_sensors - - async_add_entities(await hass.async_add_executor_job(get_sensors), True) - - -class NestBasicSensor(NestSensorDevice, SensorEntity): - """Representation a basic Nest sensor.""" - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_class(self): - """Return the device class of the sensor.""" - return SENSOR_DEVICE_CLASSES.get(self.variable) - - @property - def state_class(self): - """Return the state class of the sensor.""" - return SENSOR_STATE_CLASSES.get(self.variable) - - def update(self): - """Retrieve latest state.""" - self._unit = SENSOR_UNITS.get(self.variable) - - if self.variable in VARIABLE_NAME_MAPPING: - self._state = getattr(self.device, VARIABLE_NAME_MAPPING[self.variable]) - elif self.variable in VALUE_MAPPING: - state = getattr(self.device, self.variable) - self._state = VALUE_MAPPING[self.variable].get(state, state) - elif self.variable in PROTECT_SENSOR_TYPES and self.variable != "color_status": - # keep backward compatibility - state = getattr(self.device, self.variable) - self._state = state.capitalize() if state is not None else None - else: - self._state = getattr(self.device, self.variable) - - -class NestTempSensor(NestSensorDevice, SensorEntity): - """Representation of a Nest Temperature sensor.""" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit - - @property - def device_class(self): - """Return the device class of the sensor.""" - return SensorDeviceClass.TEMPERATURE - - @property - def state_class(self): - """Return the state class of the sensor.""" - return SensorStateClass.MEASUREMENT - - def update(self): - """Retrieve latest state.""" - if self.device.temperature_scale == "C": - self._unit = UnitOfTemperature.CELSIUS - else: - self._unit = UnitOfTemperature.FAHRENHEIT - - if (temp := getattr(self.device, self.variable)) is None: - self._state = None - - if isinstance(temp, tuple): - low, high = temp - self._state = f"{int(low)}-{int(high)}" - else: - self._state = round(temp, 1) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index dbb30ceb52a..54bc44a09b3 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -20,5 +20,5 @@ "iot_class": "cloud_push", "loggers": ["google_nest_sdm", "nest"], "quality_scale": "platinum", - "requirements": ["python-nest==4.2.0", "google-nest-sdm==2.2.5"] + "requirements": ["google-nest-sdm==2.2.5"] } diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py index d9478a99316..ba2faaeaae5 100644 --- a/homeassistant/components/nest/media_source.py +++ b/homeassistant/components/nest/media_source.py @@ -244,7 +244,7 @@ class NestEventMediaStore(EventMediaStore): devices = {} for device in device_manager.devices.values(): if device_entry := device_registry.async_get_device( - {(DOMAIN, device.name)} + identifiers={(DOMAIN, device.name)} ): devices[device.name] = device_entry.id return devices diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py index a9073aec80d..aa170710eb6 100644 --- a/homeassistant/components/nest/sensor.py +++ b/homeassistant/components/nest/sensor.py @@ -1,20 +1,104 @@ -"""Support for Nest sensors that dispatches between API versions.""" +"""Support for Google Nest SDM sensors.""" +from __future__ import annotations +import logging + +from google_nest_sdm.device import Device +from google_nest_sdm.device_manager import DeviceManager +from google_nest_sdm.device_traits import HumidityTrait, TemperatureTrait + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_SDM -from .legacy.sensor import async_setup_legacy_entry -from .sensor_sdm import async_setup_sdm_entry +from .const import DATA_DEVICE_MANAGER, DOMAIN +from .device_info import NestDeviceInfo + +_LOGGER = logging.getLogger(__name__) + + +DEVICE_TYPE_MAP = { + "sdm.devices.types.CAMERA": "Camera", + "sdm.devices.types.DISPLAY": "Display", + "sdm.devices.types.DOORBELL": "Doorbell", + "sdm.devices.types.THERMOSTAT": "Thermostat", +} async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the sensors.""" - if DATA_SDM not in entry.data: - await async_setup_legacy_entry(hass, entry, async_add_entities) - return - await async_setup_sdm_entry(hass, entry, async_add_entities) + device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ + DATA_DEVICE_MANAGER + ] + entities: list[SensorEntity] = [] + for device in device_manager.devices.values(): + if TemperatureTrait.NAME in device.traits: + entities.append(TemperatureSensor(device)) + if HumidityTrait.NAME in device.traits: + entities.append(HumiditySensor(device)) + async_add_entities(entities) + + +class SensorBase(SensorEntity): + """Representation of a dynamically updated Sensor.""" + + _attr_should_poll = False + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_has_entity_name = True + + def __init__(self, device: Device) -> None: + """Initialize the sensor.""" + self._device = device + self._device_info = NestDeviceInfo(device) + self._attr_unique_id = f"{device.name}-{self.device_class}" + self._attr_device_info = self._device_info.device_info + + @property + def available(self) -> bool: + """Return the device availability.""" + return self._device_info.available + + async def async_added_to_hass(self) -> None: + """Run when entity is added to register update signal handler.""" + self.async_on_remove( + self._device.add_update_listener(self.async_write_ha_state) + ) + + +class TemperatureSensor(SensorBase): + """Representation of a Temperature Sensor.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + @property + def native_value(self) -> float: + """Return the state of the sensor.""" + trait: TemperatureTrait = self._device.traits[TemperatureTrait.NAME] + # Round for display purposes because the API returns 5 decimal places. + # This can be removed if the SDM API issue is fixed, or a frontend + # display fix is added for all integrations. + return float(round(trait.ambient_temperature_celsius, 1)) + + +class HumiditySensor(SensorBase): + """Representation of a Humidity Sensor.""" + + _attr_device_class = SensorDeviceClass.HUMIDITY + _attr_native_unit_of_measurement = PERCENTAGE + + @property + def native_value(self) -> int: + """Return the state of the sensor.""" + trait: HumidityTrait = self._device.traits[HumidityTrait.NAME] + # Cast without loss of precision because the API always returns an integer. + return int(trait.ambient_humidity_percent) diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py deleted file mode 100644 index 8eb607b2056..00000000000 --- a/homeassistant/components/nest/sensor_sdm.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Support for Google Nest SDM sensors.""" -from __future__ import annotations - -import logging - -from google_nest_sdm.device import Device -from google_nest_sdm.device_manager import DeviceManager -from google_nest_sdm.device_traits import HumidityTrait, TemperatureTrait - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorStateClass, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import DATA_DEVICE_MANAGER, DOMAIN -from .device_info import NestDeviceInfo - -_LOGGER = logging.getLogger(__name__) - - -DEVICE_TYPE_MAP = { - "sdm.devices.types.CAMERA": "Camera", - "sdm.devices.types.DISPLAY": "Display", - "sdm.devices.types.DOORBELL": "Doorbell", - "sdm.devices.types.THERMOSTAT": "Thermostat", -} - - -async def async_setup_sdm_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the sensors.""" - - device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ - DATA_DEVICE_MANAGER - ] - entities: list[SensorEntity] = [] - for device in device_manager.devices.values(): - if TemperatureTrait.NAME in device.traits: - entities.append(TemperatureSensor(device)) - if HumidityTrait.NAME in device.traits: - entities.append(HumiditySensor(device)) - async_add_entities(entities) - - -class SensorBase(SensorEntity): - """Representation of a dynamically updated Sensor.""" - - _attr_should_poll = False - _attr_state_class = SensorStateClass.MEASUREMENT - _attr_has_entity_name = True - - def __init__(self, device: Device) -> None: - """Initialize the sensor.""" - self._device = device - self._device_info = NestDeviceInfo(device) - self._attr_unique_id = f"{device.name}-{self.device_class}" - self._attr_device_info = self._device_info.device_info - - @property - def available(self) -> bool: - """Return the device availability.""" - return self._device_info.available - - async def async_added_to_hass(self) -> None: - """Run when entity is added to register update signal handler.""" - self.async_on_remove( - self._device.add_update_listener(self.async_write_ha_state) - ) - - -class TemperatureSensor(SensorBase): - """Representation of a Temperature Sensor.""" - - _attr_device_class = SensorDeviceClass.TEMPERATURE - _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - _attr_translation_key = "temperature" - - @property - def native_value(self) -> float: - """Return the state of the sensor.""" - trait: TemperatureTrait = self._device.traits[TemperatureTrait.NAME] - # Round for display purposes because the API returns 5 decimal places. - # This can be removed if the SDM API issue is fixed, or a frontend - # display fix is added for all integrations. - return float(round(trait.ambient_temperature_celsius, 1)) - - -class HumiditySensor(SensorBase): - """Representation of a Humidity Sensor.""" - - _attr_device_class = SensorDeviceClass.HUMIDITY - _attr_native_unit_of_measurement = PERCENTAGE - _attr_translation_key = "humidity" - - @property - def native_value(self) -> int: - """Return the state of the sensor.""" - trait: HumidityTrait = self._device.traits[HumidityTrait.NAME] - # Cast without loss of precision because the API always returns an integer. - return int(trait.ambient_humidity_percent) diff --git a/homeassistant/components/nest/services.yaml b/homeassistant/components/nest/services.yaml index 24b7290668f..5f68bd6a1f2 100644 --- a/homeassistant/components/nest/services.yaml +++ b/homeassistant/components/nest/services.yaml @@ -1,12 +1,8 @@ # Describes the format for available Nest services set_away_mode: - name: Set away mode - description: Set the away mode for a Nest structure. fields: away_mode: - name: Away mode - description: New mode to set. required: true selector: select: @@ -14,55 +10,37 @@ set_away_mode: - "away" - "home" structure: - name: Structure - description: Name(s) of structure(s) to change. Defaults to all structures if not specified. example: "Apartment" selector: object: set_eta: - name: Set estimated time of arrival - description: Set or update the estimated time of arrival window for a Nest structure. fields: eta: - name: ETA - description: Estimated time of arrival from now. required: true selector: time: eta_window: - name: ETA window - description: Estimated time of arrival window. default: "00:01" selector: time: trip_id: - name: Trip ID - description: Unique ID for the trip. Default is auto-generated using a timestamp. example: "Leave Work" selector: text: structure: - name: Structure - description: Name(s) of structure(s) to change. Defaults to all structures if not specified. example: "Apartment" selector: object: cancel_eta: - name: Cancel ETA - description: Cancel an existing estimated time of arrival window for a Nest structure. fields: trip_id: - name: Trip ID - description: Unique ID for the trip. required: true example: "Leave Work" selector: text: structure: - name: Structure - description: Name(s) of structure(s) to change. Defaults to all structures if not specified. example: "Apartment" selector: object: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 2578437acf4..2c2def6b7a3 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -29,33 +29,15 @@ "title": "Configure Google Cloud", "description": "Visit the [Cloud Console]({url}) to find your Google Cloud Project ID.", "data": { - "cloud_project_id": "Google Cloud Project ID" + "cloud_project_id": "[%key:component::nest::config::step::cloud_project::data::cloud_project_id%]" } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Nest integration needs to re-authenticate your account" - }, - "init": { - "title": "Authentication Provider", - "description": "[%key:common::config_flow::title::oauth2_pick_implementation%]", - "data": { - "flow_impl": "Provider" - } - }, - "link": { - "title": "Link Nest Account", - "description": "To link your Nest account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided PIN code below.", - "data": { - "code": "[%key:common::config_flow::data::pin%]" - } } }, "error": { - "timeout": "Timeout validating code", - "invalid_pin": "Invalid PIN", - "unknown": "[%key:common::config_flow::error::unknown%]", - "internal_error": "Internal error validating code", "bad_project_id": "Please enter a valid Cloud Project ID (check Cloud Console)", "wrong_project_id": "Please enter a valid Cloud Project ID (was same as Device Access Project ID)", "subscriber_error": "Unknown subscriber error, see logs" @@ -82,18 +64,60 @@ } }, "issues": { - "legacy_nest_deprecated": { - "title": "Legacy Works With Nest is being removed", - "description": "Legacy Works With Nest is being removed from Home Assistant.\n\nYou must take action to use the SDM API. Remove all `nest` configuration from `configuration.yaml` and restart Home Assistant, then see the Nest [integration instructions]({documentation_url}) for set up instructions and supported devices." + "legacy_nest_removed": { + "title": "Legacy Works With Nest has been removed", + "description": "Legacy Works With Nest has been removed from Home Assistant, and the API shuts down as of September 2023.\n\nYou must take action to use the SDM API. Remove all `nest` configuration from `configuration.yaml` and restart Home Assistant, then see the Nest [integration instructions]({documentation_url}) for set up instructions and supported devices." } }, - "entity": { - "sensor": { - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" + "services": { + "set_away_mode": { + "name": "Set away mode", + "description": "Sets the away mode for a Nest structure.", + "fields": { + "away_mode": { + "name": "Away mode", + "description": "New mode to set." + }, + "structure": { + "name": "Structure", + "description": "Name(s) of structure(s) to change. Defaults to all structures if not specified." + } + } + }, + "set_eta": { + "name": "Set estimated time of arrival", + "description": "Sets or update the estimated time of arrival window for a Nest structure.", + "fields": { + "eta": { + "name": "ETA", + "description": "Estimated time of arrival from now." + }, + "eta_window": { + "name": "ETA window", + "description": "Estimated time of arrival window." + }, + "trip_id": { + "name": "Trip ID", + "description": "Unique ID for the trip. Default is auto-generated using a timestamp." + }, + "structure": { + "name": "[%key:component::nest::services::set_away_mode::fields::structure::name%]", + "description": "[%key:component::nest::services::set_away_mode::fields::structure::description%]" + } + } + }, + "cancel_eta": { + "name": "Cancel ETA", + "description": "Cancels an existing estimated time of arrival window for a Nest structure.", + "fields": { + "trip_id": { + "name": "[%key:component::nest::services::set_eta::fields::trip_id::name%]", + "description": "Unique ID for the trip." + }, + "structure": { + "name": "[%key:component::nest::services::set_away_mode::fields::structure::name%]", + "description": "[%key:component::nest::services::set_away_mode::fields::structure::description%]" + } } } } diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 01c459acaea..7fab99a6f39 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -200,9 +200,7 @@ class NetatmoCamera(NetatmoBase, Camera): await self._camera.async_update_camera_urls() if self._camera.local_url: - return "{}/live/files/{}/index.m3u8".format( - self._camera.local_url, self._quality - ) + return f"{self._camera.local_url}/live/files/{self._quality}/index.m3u8" return f"{self._camera.vpn_url}/live/files/{self._quality}/index.m3u8" @callback diff --git a/homeassistant/components/netatmo/netatmo_entity_base.py b/homeassistant/components/netatmo/netatmo_entity_base.py index 12798c164f8..ff6783ecaa3 100644 --- a/homeassistant/components/netatmo/netatmo_entity_base.py +++ b/homeassistant/components/netatmo/netatmo_entity_base.py @@ -70,7 +70,7 @@ class NetatmoBase(Entity): await self.data_handler.unsubscribe(signal_name, None) registry = dr.async_get(self.hass) - if device := registry.async_get_device({(DOMAIN, self._id)}): + if device := registry.async_get_device(identifiers={(DOMAIN, self._id)}): self.hass.data[DOMAIN][DATA_DEVICE_IDS][self._id] = device.id self.async_update_callback() diff --git a/homeassistant/components/netatmo/services.yaml b/homeassistant/components/netatmo/services.yaml index e61e893e199..726d6867d2d 100644 --- a/homeassistant/components/netatmo/services.yaml +++ b/homeassistant/components/netatmo/services.yaml @@ -1,15 +1,11 @@ # Describes the format for available Netatmo services set_camera_light: - name: Set camera light mode - description: Sets the light mode for a Netatmo Outdoor camera light. target: entity: integration: netatmo domain: light fields: camera_light_mode: - name: Camera light mode - description: Outdoor camera light mode. required: true selector: select: @@ -19,60 +15,39 @@ set_camera_light: - "auto" set_schedule: - name: Set heating schedule - description: - Set the heating schedule for Netatmo climate device. The schedule name must - match a schedule configured at Netatmo. target: entity: integration: netatmo domain: climate fields: schedule_name: - description: Schedule name example: Standard required: true selector: text: set_persons_home: - name: Set persons at home - description: - Set a list of persons as at home. Person's name must match a name known by - the Netatmo Indoor (Welcome) Camera. target: entity: integration: netatmo domain: camera fields: persons: - description: List of names example: "[Alice, Bob]" required: true selector: object: set_person_away: - name: Set person away - description: - Set a person as away. If no person is set the home will be marked as empty. - Person's name must match a name known by the Netatmo Indoor (Welcome) - Camera. target: entity: integration: netatmo domain: camera fields: person: - description: Person's name. example: Bob selector: text: register_webhook: - name: Register webhook - description: Register the webhook to the Netatmo backend. - unregister_webhook: - name: Unregister webhook - description: Unregister the webhook from the Netatmo backend. diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 5fdf580c6aa..e9125f33016 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -41,19 +41,19 @@ "weather_areas": "Weather areas" }, "description": "Configure public weather sensors.", - "title": "Netatmo public weather sensor" + "title": "[%key:component::netatmo::options::step::public_weather::title%]" } } }, "device_automation": { "trigger_subtype": { - "away": "Away", + "away": "[%key:common::state::not_home%]", "schedule": "Schedule", "hg": "Frost guard" }, "trigger_type": { - "turned_off": "{entity_name} turned off", - "turned_on": "{entity_name} turned on", + "turned_off": "[%key:common::device_automation::trigger_type::turned_off%]", + "turned_on": "[%key:common::device_automation::trigger_type::turned_on%]", "human": "{entity_name} detected a human", "movement": "{entity_name} detected movement", "person": "{entity_name} detected a person", @@ -66,5 +66,55 @@ "cancel_set_point": "{entity_name} has resumed its schedule", "therm_mode": "{entity_name} switched to \"{subtype}\"" } + }, + "services": { + "set_camera_light": { + "name": "Set camera light mode", + "description": "Sets the light mode for a Netatmo Outdoor camera light.", + "fields": { + "camera_light_mode": { + "name": "Camera light mode", + "description": "Outdoor camera light mode." + } + } + }, + "set_schedule": { + "name": "Set heating schedule", + "description": "Sets the heating schedule for Netatmo climate device. The schedule name must match a schedule configured at Netatmo.", + "fields": { + "schedule_name": { + "name": "[%key:component::netatmo::device_automation::trigger_subtype::schedule%]", + "description": "Schedule name." + } + } + }, + "set_persons_home": { + "name": "Set persons at home", + "description": "Sets a list of persons as at home. Person's name must match a name known by the Netatmo Indoor (Welcome) Camera.", + "fields": { + "persons": { + "name": "Persons", + "description": "List of names." + } + } + }, + "set_person_away": { + "name": "Set person away", + "description": "Sets a person as away. If no person is set the home will be marked as empty. Person's name must match a name known by the Netatmo Indoor (Welcome) Camera.", + "fields": { + "person": { + "name": "Person", + "description": "Person's name." + } + } + }, + "register_webhook": { + "name": "Register webhook", + "description": "Registers the webhook to the Netatmo backend." + }, + "unregister_webhook": { + "name": "Unregister webhook", + "description": "Unregisters the webhook from the Netatmo backend." + } } } diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index ef31a887691..522b60749d0 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -62,6 +62,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(update_listener)) + configuration_url = None + if host := entry.data[CONF_HOST]: + configuration_url = f"http://{host}/" + assert entry.unique_id device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -72,7 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: model=router.model, sw_version=router.firmware_version, hw_version=router.hardware_version, - configuration_url=f"http://{entry.data[CONF_HOST]}/", + configuration_url=configuration_url, ) async def async_update_devices() -> bool: diff --git a/homeassistant/components/netgear_lte/services.yaml b/homeassistant/components/netgear_lte/services.yaml index bed9647a1b7..05cf8cc3c97 100644 --- a/homeassistant/components/netgear_lte/services.yaml +++ b/homeassistant/components/netgear_lte/services.yaml @@ -1,34 +1,22 @@ delete_sms: - name: Delete SMS - description: Delete messages from the modem inbox. fields: host: - name: Host - description: The modem that should have a message deleted. example: 192.168.5.1 selector: text: sms_id: - name: SMS ID - description: Integer or list of integers with inbox IDs of messages to delete. required: true example: 7 selector: object: set_option: - name: Set option - description: Set options on the modem. fields: host: - name: Host - description: The modem to set options on. example: 192.168.5.1 selector: text: failover: - name: Failover - description: Failover mode. selector: select: options: @@ -36,8 +24,6 @@ set_option: - "mobile" - "wire" autoconnect: - name: Auto-connect - description: Auto-connect mode. selector: select: options: @@ -46,22 +32,15 @@ set_option: - "never" connect_lte: - name: Connect LTE - description: Ask the modem to establish the LTE connection. fields: host: - name: Host - description: The modem that should connect. example: 192.168.5.1 selector: text: disconnect_lte: - name: Disconnect LTE - description: Ask the modem to close the LTE connection. fields: host: - description: The modem that should disconnect. example: 192.168.5.1 selector: text: diff --git a/homeassistant/components/netgear_lte/strings.json b/homeassistant/components/netgear_lte/strings.json new file mode 100644 index 00000000000..1fd10282991 --- /dev/null +++ b/homeassistant/components/netgear_lte/strings.json @@ -0,0 +1,57 @@ +{ + "services": { + "delete_sms": { + "name": "Delete SMS", + "description": "Deletes messages from the modem inbox.", + "fields": { + "host": { + "name": "[%key:common::config_flow::data::host%]", + "description": "The modem that should have a message deleted." + }, + "sms_id": { + "name": "SMS ID", + "description": "Integer or list of integers with inbox IDs of messages to delete." + } + } + }, + "set_option": { + "name": "Set option", + "description": "Sets options on the modem.", + "fields": { + "host": { + "name": "[%key:common::config_flow::data::host%]", + "description": "The modem to set options on." + }, + "failover": { + "name": "Failover", + "description": "Failover mode." + }, + "autoconnect": { + "name": "Auto-connect", + "description": "Auto-connect mode." + } + } + }, + "connect_lte": { + "name": "Connect LTE", + "description": "Asks the modem to establish the LTE connection.", + "fields": { + "host": { + "name": "[%key:common::config_flow::data::host%]", + "description": "The modem that should connect." + } + } + }, + "disconnect_lte": { + "name": "Disconnect LTE", + "description": "Asks the modem to close the LTE connection.", + "fields": { + "host": { + "name": "[%key:common::config_flow::data::host%]", + "description": "The modem that should disconnect." + } + } + } + }, + "selector": {} +} diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 2e54e773a44..5464a241b7a 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nexia", "iot_class": "cloud_polling", "loggers": ["nexia"], - "requirements": ["nexia==2.0.6"] + "requirements": ["nexia==2.0.7"] } diff --git a/homeassistant/components/nexia/services.yaml b/homeassistant/components/nexia/services.yaml index 0deb5225cd3..ede1f311acf 100644 --- a/homeassistant/components/nexia/services.yaml +++ b/homeassistant/components/nexia/services.yaml @@ -1,14 +1,10 @@ set_aircleaner_mode: - name: Set air cleaner mode - description: "The air cleaner mode." target: entity: integration: nexia domain: climate fields: aircleaner_mode: - name: Air cleaner mode - description: "The air cleaner mode to set." required: true selector: select: @@ -18,16 +14,12 @@ set_aircleaner_mode: - "quick" set_humidify_setpoint: - name: Set humidify set point - description: "The humidification set point." target: entity: integration: nexia domain: climate fields: humidity: - name: Humidify - description: "The humidification setpoint." required: true selector: number: @@ -36,16 +28,12 @@ set_humidify_setpoint: unit_of_measurement: "%" set_hvac_run_mode: - name: Set hvac run mode - description: "The hvac run mode." target: entity: integration: nexia domain: climate fields: run_mode: - name: Run mode - description: "Run the schedule or hold. If not specified, the current run mode will be used." required: false selector: select: @@ -53,8 +41,6 @@ set_hvac_run_mode: - "permanent_hold" - "run_schedule" hvac_mode: - name: Hvac mode - description: "The hvac mode to use for the schedule or hold. If not specified, the current hvac mode will be used." required: false selector: select: diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json index c9bc84243da..f3d343ffda3 100644 --- a/homeassistant/components/nexia/strings.json +++ b/homeassistant/components/nexia/strings.json @@ -17,5 +17,41 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "services": { + "set_aircleaner_mode": { + "name": "Set air cleaner mode", + "description": "The air cleaner mode.", + "fields": { + "aircleaner_mode": { + "name": "Air cleaner mode", + "description": "The air cleaner mode to set." + } + } + }, + "set_humidify_setpoint": { + "name": "Set humidify set point", + "description": "The humidification set point.", + "fields": { + "humidity": { + "name": "Humidify", + "description": "The humidification setpoint." + } + } + }, + "set_hvac_run_mode": { + "name": "Set hvac run mode", + "description": "The HVAC run mode.", + "fields": { + "run_mode": { + "name": "Run mode", + "description": "Run the schedule or hold. If not specified, the current run mode will be used." + }, + "hvac_mode": { + "name": "HVAC mode", + "description": "The HVAC mode to use for the schedule or hold. If not specified, the current HVAC mode will be used." + } + } + } } } diff --git a/homeassistant/components/nextcloud/entity.py b/homeassistant/components/nextcloud/entity.py index ed5882cfe74..4308e573859 100644 --- a/homeassistant/components/nextcloud/entity.py +++ b/homeassistant/components/nextcloud/entity.py @@ -1,9 +1,8 @@ """Base entity for the Nextcloud integration.""" - - from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator @@ -21,7 +20,7 @@ class NextcloudEntity(CoordinatorEntity[NextcloudDataUpdateCoordinator]): """Initialize the Nextcloud sensor.""" super().__init__(coordinator) self.item = item - self._attr_name = item + self._attr_translation_key = slugify(item) self._attr_unique_id = f"{coordinator.url}#{item}" self._attr_device_info = DeviceInfo( name="Nextcloud", diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index bcb530ffd73..6c70421bf93 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -28,5 +28,152 @@ "connection_error": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } + }, + "entity": { + "binary_sensor": { + "nextcloud_system_enable_avatars": { + "name": "Avatars enabled" + }, + "nextcloud_system_enable_previews": { + "name": "Previews enabled" + }, + "nextcloud_system_filelocking_enabled": { + "name": "Filelocking enabled" + }, + "nextcloud_system_debug": { + "name": "Debug enabled" + } + }, + "sensor": { + "nextcloud_system_version": { + "name": "System version" + }, + "nextcloud_system_theme": { + "name": "System theme" + }, + "nextcloud_system_memcache_local": { + "name": "System memcache local" + }, + "nextcloud_system_memcache_distributed": { + "name": "System memcache distributed" + }, + "nextcloud_system_memcache_locking": { + "name": "System memcache locking" + }, + "nextcloud_system_freespace": { + "name": "Free space" + }, + "nextcloud_system_cpuload": { + "name": "CPU Load" + }, + "nextcloud_system_mem_total": { + "name": "Total memory" + }, + "nextcloud_system_mem_free": { + "name": "Free memory" + }, + "nextcloud_system_swap_total": { + "name": "Total swap memory" + }, + "nextcloud_system_swap_free": { + "name": "Free swap memory" + }, + "nextcloud_system_apps_num_installed": { + "name": "Apps installed" + }, + "nextcloud_system_apps_num_updates_available": { + "name": "Updates available" + }, + "nextcloud_system_apps_app_updates_calendar": { + "name": "Calendar updates" + }, + "nextcloud_system_apps_app_updates_contacts": { + "name": "Contact updates" + }, + "nextcloud_system_apps_app_updates_tasks": { + "name": "Task updates" + }, + "nextcloud_system_apps_app_updates_twofactor_totp": { + "name": "Two factor authentication updates" + }, + "nextcloud_storage_num_users": { + "name": "Amount of user" + }, + "nextcloud_storage_num_files": { + "name": "Amount of files" + }, + "nextcloud_storage_num_storages": { + "name": "Amount of storages" + }, + "nextcloud_storage_num_storages_local": { + "name": "Amount of local storages" + }, + "nextcloud_storage_num_storages_home": { + "name": "Amount of storages at home" + }, + "nextcloud_storage_num_storages_other": { + "name": "Amount of other storages" + }, + "nextcloud_shares_num_shares": { + "name": "Amount of shares" + }, + "nextcloud_shares_num_shares_user": { + "name": "Amount of user shares" + }, + "nextcloud_shares_num_shares_groups": { + "name": "Amount of group shares" + }, + "nextcloud_shares_num_shares_link": { + "name": "Amount of link shares" + }, + "nextcloud_shares_num_shares_mail": { + "name": "Amount of mail shares" + }, + "nextcloud_shares_num_shares_room": { + "name": "Amount of room shares" + }, + "nextcloud_shares_num_shares_link_no_password": { + "name": "Amount of passwordless link shares" + }, + "nextcloud_shares_num_fed_shares_sent": { + "name": "Amount of shares sent" + }, + "nextcloud_shares_num_fed_shares_received": { + "name": "Amount of shares received" + }, + "nextcloud_shares_permissions_3_1": { + "name": "Permissions 3.1" + }, + "nextcloud_server_webserver": { + "name": "Webserver" + }, + "nextcloud_server_php_version": { + "name": "PHP version" + }, + "nextcloud_server_php_memory_limit": { + "name": "PHP memory limit" + }, + "nextcloud_server_php_max_execution_time": { + "name": "PHP max execution time" + }, + "nextcloud_server_php_upload_max_filesize": { + "name": "PHP upload maximum filesize" + }, + "nextcloud_database_type": { + "name": "Database type" + }, + "nextcloud_database_version": { + "name": "Database version" + }, + "nextcloud_activeusers_last5minutes": { + "name": "Amount of active users last 5 minutes" + }, + "nextcloud_activeusers_last1hour": { + "name": "Amount of active users last hour" + }, + "nextcloud_activeusers_last24hours": { + "name": "Amount of active users last day" + } + } } } diff --git a/homeassistant/components/nibe_heatpump/strings.json b/homeassistant/components/nibe_heatpump/strings.json index a863b9596b1..6fa421e0855 100644 --- a/homeassistant/components/nibe_heatpump/strings.json +++ b/homeassistant/components/nibe_heatpump/strings.json @@ -22,7 +22,7 @@ "nibegw": { "description": "Before attempting to configure the integration, verify that:\n - The NibeGW unit is connected to a heat pump.\n - The MODBUS40 accessory has been enabled in the heat pump configuration.\n - The pump has not gone into an alarm state about missing MODBUS40 accessory.", "data": { - "model": "Model of Heat Pump", + "model": "[%key:component::nibe_heatpump::config::step::modbus::data::model%]", "ip_address": "Remote address", "remote_read_port": "Remote read port", "remote_write_port": "Remote write port", diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json index 98a088620ea..d1897b53e04 100644 --- a/homeassistant/components/nina/manifest.json +++ b/homeassistant/components/nina/manifest.json @@ -3,9 +3,8 @@ "name": "NINA", "codeowners": ["@DeerMaximum"], "config_flow": true, - "dependencies": [], "documentation": "https://www.home-assistant.io/integrations/nina", "iot_class": "cloud_polling", "loggers": ["pynina"], - "requirements": ["PyNINA==0.3.0"] + "requirements": ["PyNINA==0.3.1"] } diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index 23a1fb8dfa6..e145f5ea8ca 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -29,19 +29,19 @@ "init": { "title": "Options", "data": { - "_a_to_d": "City/county (A-D)", - "_e_to_h": "City/county (E-H)", - "_i_to_l": "City/county (I-L)", - "_m_to_q": "City/county (M-Q)", - "_r_to_u": "City/county (R-U)", - "_v_to_z": "City/county (V-Z)", - "slots": "Maximum warnings per city/county", - "headline_filter": "Blacklist regex to filter warning headlines" + "_a_to_d": "[%key:component::nina::config::step::user::data::_a_to_d%]", + "_e_to_h": "[%key:component::nina::config::step::user::data::_e_to_h%]", + "_i_to_l": "[%key:component::nina::config::step::user::data::_i_to_l%]", + "_m_to_q": "[%key:component::nina::config::step::user::data::_m_to_q%]", + "_r_to_u": "[%key:component::nina::config::step::user::data::_r_to_u%]", + "_v_to_z": "[%key:component::nina::config::step::user::data::_v_to_z%]", + "slots": "[%key:component::nina::config::step::user::data::slots%]", + "headline_filter": "[%key:component::nina::config::step::user::data::headline_filter%]" } } }, "error": { - "no_selection": "Please select at least one city/county", + "no_selection": "[%key:component::nina::config::error::no_selection%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" } diff --git a/homeassistant/components/nissan_leaf/services.yaml b/homeassistant/components/nissan_leaf/services.yaml index 901e70de414..d4948072667 100644 --- a/homeassistant/components/nissan_leaf/services.yaml +++ b/homeassistant/components/nissan_leaf/services.yaml @@ -1,29 +1,16 @@ # Describes the format for available services for nissan_leaf start_charge: - name: Start charge - description: > - Start the vehicle charging. It must be plugged in first! fields: vin: - name: VIN - description: > - The vehicle identification number (VIN) of the vehicle, 17 characters required: true example: WBANXXXXXX1234567 selector: text: update: - name: Update - description: > - Fetch the last state of the vehicle of all your accounts, requesting - an update from of the state from the car if possible. fields: vin: - name: VIN - description: > - The vehicle identification number (VIN) of the vehicle, 17 characters required: true example: WBANXXXXXX1234567 selector: diff --git a/homeassistant/components/nissan_leaf/strings.json b/homeassistant/components/nissan_leaf/strings.json new file mode 100644 index 00000000000..d733e39a0fc --- /dev/null +++ b/homeassistant/components/nissan_leaf/strings.json @@ -0,0 +1,24 @@ +{ + "services": { + "start_charge": { + "name": "Start charge", + "description": "Starts the vehicle charging. It must be plugged in first!\n.", + "fields": { + "vin": { + "name": "VIN", + "description": "The vehicle identification number (VIN) of the vehicle, 17 characters\n." + } + } + }, + "update": { + "name": "Update", + "description": "Fetches the last state of the vehicle of all your accounts, requesting an update from of the state from the car if possible.\n.", + "fields": { + "vin": { + "name": "[%key:component::nissan_leaf::services::start_charge::fields::vin::name%]", + "description": "[%key:component::nissan_leaf::services::start_charge::fields::vin::description%]" + } + } + } + } +} diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index d1661dce0fa..00667c43fdb 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -18,10 +18,7 @@ from homeassistant.components.climate import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_IDENTIFIERS, ATTR_NAME, - ATTR_SUGGESTED_AREA, - ATTR_VIA_DEVICE, PRECISION_TENTHS, UnitOfTemperature, ) @@ -76,6 +73,8 @@ class NoboZone(ClimateEntity): controlled as a unity. """ + _attr_name = None + _attr_has_entity_name = True _attr_max_temp = MAX_TEMPERATURE _attr_min_temp = MIN_TEMPERATURE _attr_precision = PRECISION_TENTHS @@ -90,17 +89,15 @@ class NoboZone(ClimateEntity): self._id = zone_id self._nobo = hub self._attr_unique_id = f"{hub.hub_serial}:{zone_id}" - self._attr_name = None - self._attr_has_entity_name = True self._attr_hvac_mode = HVACMode.AUTO self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.AUTO] self._override_type = override_type - self._attr_device_info: DeviceInfo = { - ATTR_IDENTIFIERS: {(DOMAIN, f"{hub.hub_serial}:{zone_id}")}, - ATTR_NAME: hub.zones[zone_id][ATTR_NAME], - ATTR_VIA_DEVICE: (DOMAIN, hub.hub_info[ATTR_SERIAL]), - ATTR_SUGGESTED_AREA: hub.zones[zone_id][ATTR_NAME], - } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{hub.hub_serial}:{zone_id}")}, + name=hub.zones[zone_id][ATTR_NAME], + via_device=(DOMAIN, hub.hub_info[ATTR_SERIAL]), + suggested_area=hub.zones[zone_id][ATTR_NAME], + ) self._read_state() async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index 3bb1fa373a5..9cc957ec1df 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -10,12 +10,8 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, ATTR_MODEL, ATTR_NAME, - ATTR_SUGGESTED_AREA, - ATTR_VIA_DEVICE, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback @@ -50,6 +46,7 @@ class NoboTemperatureSensor(SensorEntity): _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS _attr_state_class = SensorStateClass.MEASUREMENT _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, serial: str, hub: nobo) -> None: """Initialize the temperature sensor.""" @@ -58,18 +55,18 @@ class NoboTemperatureSensor(SensorEntity): self._nobo = hub component = hub.components[self._id] self._attr_unique_id = component[ATTR_SERIAL] - self._attr_name = "Temperature" - self._attr_has_entity_name = True - self._attr_device_info: DeviceInfo = { - ATTR_IDENTIFIERS: {(DOMAIN, component[ATTR_SERIAL])}, - ATTR_NAME: component[ATTR_NAME], - ATTR_MANUFACTURER: NOBO_MANUFACTURER, - ATTR_MODEL: component[ATTR_MODEL].name, - ATTR_VIA_DEVICE: (DOMAIN, hub.hub_info[ATTR_SERIAL]), - } zone_id = component[ATTR_ZONE_ID] + suggested_area = None if zone_id != "-1": - self._attr_device_info[ATTR_SUGGESTED_AREA] = hub.zones[zone_id][ATTR_NAME] + suggested_area = hub.zones[zone_id][ATTR_NAME] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, component[ATTR_SERIAL])}, + name=component[ATTR_NAME], + manufacturer=NOBO_MANUFACTURER, + model=component[ATTR_MODEL].name, + via_device=(DOMAIN, hub.hub_info[ATTR_SERIAL]), + suggested_area=suggested_area, + ) self._read_state() async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/notify/services.yaml b/homeassistant/components/notify/services.yaml index 9311acf2ba9..8d053e3af58 100644 --- a/homeassistant/components/notify/services.yaml +++ b/homeassistant/components/notify/services.yaml @@ -1,61 +1,37 @@ # Describes the format for available notification services notify: - name: Send a notification - description: Sends a notification message to selected notify platforms. fields: message: - name: Message - description: Message body of the notification. required: true example: The garage door has been open for 10 minutes. selector: text: title: - name: Title - description: Title for your notification. example: "Your Garage Door Friend" selector: text: target: - name: Target - description: - An array of targets to send the notification to. Optional depending on - the platform. example: platform specific selector: object: data: - name: Data - description: - Extended information for notification. Optional depending on the - platform. example: platform specific selector: object: persistent_notification: - name: Send a persistent notification - description: Sends a notification that is visible in the front-end. fields: message: - name: Message - description: Message body of the notification. required: true example: The garage door has been open for 10 minutes. selector: text: title: - name: Title - description: Title for your notification. example: "Your Garage Door Friend" selector: text: data: - name: Data - description: - Extended information for notification. Optional depending on the - platform. example: platform specific selector: object: diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json index 02027a84d8f..cff7b265c37 100644 --- a/homeassistant/components/notify/strings.json +++ b/homeassistant/components/notify/strings.json @@ -1 +1,45 @@ -{ "title": "Notifications" } +{ + "title": "Notifications", + "services": { + "notify": { + "name": "Send a notification", + "description": "Sends a notification message to selected targets.", + "fields": { + "message": { + "name": "Message", + "description": "Message body of the notification." + }, + "title": { + "name": "Title", + "description": "Title for your notification." + }, + "target": { + "name": "Target", + "description": "Some integrations allow you to specify the targets that receive the notification. For more information, refer to the integration documentation." + }, + "data": { + "name": "Data", + "description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation." + } + } + }, + "persistent_notification": { + "name": "Send a persistent notification", + "description": "Sends a notification that is visible in the **Notifications** panel.", + "fields": { + "message": { + "name": "Message", + "description": "Message body of the notification." + }, + "title": { + "name": "Title", + "description": "Title of the notification." + }, + "data": { + "name": "Data", + "description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation.." + } + } + } + } +} diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index ad228f08a4b..258f14056ca 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -339,9 +339,13 @@ class NotionEntity(CoordinatorEntity[DataUpdateCoordinator[NotionData]]): self._bridge_id = sensor.bridge.id device_registry = dr.async_get(self.hass) - this_device = device_registry.async_get_device({(DOMAIN, sensor.hardware_id)}) + this_device = device_registry.async_get_device( + identifiers={(DOMAIN, sensor.hardware_id)} + ) bridge = self.coordinator.data.bridges[self._bridge_id] - bridge_device = device_registry.async_get_device({(DOMAIN, bridge.hardware_id)}) + bridge_device = device_registry.async_get_device( + identifiers={(DOMAIN, bridge.hardware_id)} + ) if not bridge_device or not this_device: return diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index f70af18c3e1..ff58d566a34 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -52,7 +52,6 @@ class NotionBinarySensorDescription( BINARY_SENSOR_DESCRIPTIONS = ( NotionBinarySensorDescription( key=SENSOR_BATTERY, - name="Low battery", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, listener_kind=ListenerKind.BATTERY, @@ -60,28 +59,24 @@ BINARY_SENSOR_DESCRIPTIONS = ( ), NotionBinarySensorDescription( key=SENSOR_DOOR, - name="Door", device_class=BinarySensorDeviceClass.DOOR, listener_kind=ListenerKind.DOOR, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_GARAGE_DOOR, - name="Garage door", device_class=BinarySensorDeviceClass.GARAGE_DOOR, listener_kind=ListenerKind.GARAGE_DOOR, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_LEAK, - name="Leak detector", device_class=BinarySensorDeviceClass.MOISTURE, listener_kind=ListenerKind.LEAK_STATUS, on_state="leak", ), NotionBinarySensorDescription( key=SENSOR_MISSING, - name="Missing", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, listener_kind=ListenerKind.CONNECTED, @@ -89,28 +84,28 @@ BINARY_SENSOR_DESCRIPTIONS = ( ), NotionBinarySensorDescription( key=SENSOR_SAFE, - name="Safe", + translation_key="safe", device_class=BinarySensorDeviceClass.DOOR, listener_kind=ListenerKind.SAFE, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_SLIDING, - name="Sliding door/window", + translation_key="sliding_door_window", device_class=BinarySensorDeviceClass.DOOR, listener_kind=ListenerKind.SLIDING_DOOR_OR_WINDOW, on_state="open", ), NotionBinarySensorDescription( key=SENSOR_SMOKE_CO, - name="Smoke/Carbon monoxide detector", + translation_key="smoke_carbon_monoxide_detector", device_class=BinarySensorDeviceClass.SMOKE, listener_kind=ListenerKind.SMOKE, on_state="alarm", ), NotionBinarySensorDescription( key=SENSOR_WINDOW_HINGED, - name="Hinged window", + translation_key="hinged_window", listener_kind=ListenerKind.HINGED_WINDOW, on_state="open", ), diff --git a/homeassistant/components/notion/const.py b/homeassistant/components/notion/const.py index 5e89767d0e0..0961b7c10c5 100644 --- a/homeassistant/components/notion/const.py +++ b/homeassistant/components/notion/const.py @@ -9,6 +9,7 @@ SENSOR_DOOR = "door" SENSOR_GARAGE_DOOR = "garage_door" SENSOR_LEAK = "leak" SENSOR_MISSING = "missing" +SENSOR_MOLD = "mold" SENSOR_SAFE = "safe" SENSOR_SLIDING = "sliding" SENSOR_SMOKE_CO = "alarm" diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index e6ff3eaab69..4777cc94fbf 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import NotionEntity -from .const import DOMAIN, SENSOR_TEMPERATURE +from .const import DOMAIN, SENSOR_MOLD, SENSOR_TEMPERATURE from .model import NotionEntityDescriptionMixin @@ -25,9 +25,14 @@ class NotionSensorDescription(SensorEntityDescription, NotionEntityDescriptionMi SENSOR_DESCRIPTIONS = ( + NotionSensorDescription( + key=SENSOR_MOLD, + translation_key="mold_risk", + icon="mdi:liquid-spot", + listener_kind=ListenerKind.MOLD, + ), NotionSensorDescription( key=SENSOR_TEMPERATURE, - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, @@ -76,11 +81,11 @@ class NotionSensor(NotionEntity, SensorEntity): @property def native_value(self) -> str | None: - """Return the value reported by the sensor. - - The Notion API only returns a localized string for temperature (e.g. "70°"); we - simply remove the degree symbol: - """ + """Return the value reported by the sensor.""" if not self.listener.status_localized: return None - return self.listener.status_localized.state[:-1] + if self.listener.listener_kind == ListenerKind.TEMPERATURE: + # The Notion API only returns a localized string for temperature (e.g. + # "70°"); we simply remove the degree symbol: + return self.listener.status_localized.state[:-1] + return self.listener.status_localized.state diff --git a/homeassistant/components/notion/strings.json b/homeassistant/components/notion/strings.json index 49721568ff2..24a06d7ee71 100644 --- a/homeassistant/components/notion/strings.json +++ b/homeassistant/components/notion/strings.json @@ -24,5 +24,26 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "safe": { + "name": "Safe" + }, + "sliding_door_window": { + "name": "Sliding door/window" + }, + "smoke_carbon_monoxide_detector": { + "name": "Smoke/Carbon monoxide detector" + }, + "hinged_window": { + "name": "Hinged window" + } + }, + "sensor": { + "mold_risk": { + "name": "Mold risk" + } + } } } diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index aa692153215..b2bc66b60c0 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -75,6 +75,8 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) + _attr_has_entity_name = True + _attr_name = None def __init__(self, coordinator, thermostat, temperature_unit): """Initialize the thermostat.""" @@ -84,11 +86,6 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): self._schedule_mode = None self._target_temperature = None - @property - def name(self): - """Return the name of the thermostat.""" - return self._thermostat.room - @property def temperature_unit(self) -> str: """Return the unit of measurement.""" diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index b0bfe18614e..d237303e7c9 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -30,6 +30,7 @@ from homeassistant.helpers import ( entity_registry as er, issue_registry as ir, ) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -368,13 +369,13 @@ class NukiEntity(CoordinatorEntity[NukiCoordinator], Generic[_NukiDeviceT]): self._nuki_device = nuki_device @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info for Nuki entities.""" - return { - "identifiers": {(DOMAIN, parse_id(self._nuki_device.nuki_id))}, - "name": self._nuki_device.name, - "manufacturer": "Nuki Home Solutions GmbH", - "model": self._nuki_device.device_model_str.capitalize(), - "sw_version": self._nuki_device.firmware_version, - "via_device": (DOMAIN, self.coordinator.bridge_id), - } + return DeviceInfo( + identifiers={(DOMAIN, parse_id(self._nuki_device.nuki_id))}, + name=self._nuki_device.name, + manufacturer="Nuki Home Solutions GmbH", + model=self._nuki_device.device_model_str.capitalize(), + sw_version=self._nuki_device.firmware_version, + via_device=(DOMAIN, self.coordinator.bridge_id), + ) diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 2b3006eeb3b..86c7f8343df 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -36,6 +36,7 @@ class NukiDoorsensorEntity(NukiEntity[NukiDevice], BinarySensorEntity): """Representation of a Nuki Lock Doorsensor.""" _attr_has_entity_name = True + _attr_name = None _attr_device_class = BinarySensorDeviceClass.DOOR @property diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index c4578c7d14d..06cfa065c54 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -29,7 +29,6 @@ class NukiBatterySensor(NukiEntity[NukiDevice], SensorEntity): """Representation of a Nuki Lock Battery sensor.""" _attr_has_entity_name = True - _attr_translation_key = "battery" _attr_native_unit_of_measurement = PERCENTAGE _attr_device_class = SensorDeviceClass.BATTERY _attr_entity_category = EntityCategory.DIAGNOSTIC diff --git a/homeassistant/components/nuki/services.yaml b/homeassistant/components/nuki/services.yaml index c43f081dbf7..2002ab8614a 100644 --- a/homeassistant/components/nuki/services.yaml +++ b/homeassistant/components/nuki/services.yaml @@ -1,28 +1,20 @@ lock_n_go: - name: Lock 'n' go - description: "Nuki Lock 'n' Go" target: entity: integration: nuki domain: lock fields: unlatch: - name: Unlatch - description: Whether to unlatch the lock. default: false selector: boolean: set_continuous_mode: - name: Set Continuous Mode - description: "Enable or disable Continuous Mode on Nuki Opener" target: entity: integration: nuki domain: lock fields: enable: - name: Enable - description: Whether to enable or disable the feature default: false selector: boolean: diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json index f139124e961..19aeae989f4 100644 --- a/homeassistant/components/nuki/strings.json +++ b/homeassistant/components/nuki/strings.json @@ -44,10 +44,27 @@ } } } + } + }, + "services": { + "lock_n_go": { + "name": "Lock 'n' go", + "description": "Nuki Lock 'n' Go.", + "fields": { + "unlatch": { + "name": "Unlatch", + "description": "Whether to unlatch the lock." + } + } }, - "sensor": { - "battery": { - "name": "[%key:component::sensor::entity_component::battery::name%]" + "set_continuous_mode": { + "name": "Set continuous mode", + "description": "Enables or disables continuous mode on Nuki Opener.", + "fields": { + "enable": { + "name": "[%key:common::action::enable%]", + "description": "Whether to enable or disable the feature." + } } } } diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 24c44b901a1..aa3566c5a95 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -8,9 +8,8 @@ from datetime import timedelta import inspect import logging from math import ceil, floor -from typing import Any, final +from typing import Any, Self, final -from typing_extensions import Self import voluptuous as vol from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index b0542aa588a..9248d3f9e57 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -1,11 +1,11 @@ """Provides the constants needed for the component.""" from __future__ import annotations +from enum import StrEnum from typing import Final import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -31,6 +31,7 @@ from homeassistant.const import ( UnitOfSoundPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfTime, UnitOfVolume, UnitOfVolumetricFlux, ) @@ -122,6 +123,12 @@ class NumberDeviceClass(StrEnum): - USCS / imperial: `in`, `ft`, `yd`, `mi` """ + DURATION = "duration" + """Fixed duration. + + Unit of measurement: `d`, `h`, `min`, `s`, `ms` + """ + ENERGY = "energy" """Energy. @@ -209,8 +216,14 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `µg/m³` """ + PH = "ph" + """Potential hydrogen (acidity/alkalinity). + + Unit of measurement: Unitless + """ + PM1 = "pm1" - """Particulate matter <= 0.1 μm. + """Particulate matter <= 1 μm. Unit of measurement: `µg/m³` """ @@ -392,6 +405,13 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.DATA_RATE: set(UnitOfDataRate), NumberDeviceClass.DATA_SIZE: set(UnitOfInformation), NumberDeviceClass.DISTANCE: set(UnitOfLength), + NumberDeviceClass.DURATION: { + UnitOfTime.DAYS, + UnitOfTime.HOURS, + UnitOfTime.MINUTES, + UnitOfTime.SECONDS, + UnitOfTime.MILLISECONDS, + }, NumberDeviceClass.ENERGY: set(UnitOfEnergy), NumberDeviceClass.ENERGY_STORAGE: set(UnitOfEnergy), NumberDeviceClass.FREQUENCY: set(UnitOfFrequency), @@ -408,6 +428,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.NITROGEN_MONOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.OZONE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, + NumberDeviceClass.PH: {None}, NumberDeviceClass.PM1: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, NumberDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, diff --git a/homeassistant/components/number/services.yaml b/homeassistant/components/number/services.yaml index 2014c4c5221..dcbb955d739 100644 --- a/homeassistant/components/number/services.yaml +++ b/homeassistant/components/number/services.yaml @@ -1,15 +1,11 @@ # Describes the format for available Number entity services set_value: - name: Set - description: Set the value of a Number entity. target: entity: domain: number fields: value: - name: Value - description: The target value the entity should be set to. example: 42 selector: text: diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 9af54311129..2d72cdbf203 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -91,6 +91,9 @@ "ozone": { "name": "[%key:component::sensor::entity_component::ozone::name%]" }, + "ph": { + "name": "[%key:component::sensor::entity_component::ph::name%]" + }, "pm1": { "name": "[%key:component::sensor::entity_component::pm1::name%]" }, @@ -155,10 +158,16 @@ "name": "[%key:component::sensor::entity_component::wind_speed::name%]" } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "set_value": { + "name": "Set", + "description": "Sets the value of a number.", + "fields": { + "value": { + "name": "Value", + "description": "The target value to set." + } + } } } } diff --git a/homeassistant/components/nut/diagnostics.py b/homeassistant/components/nut/diagnostics.py index e8c0a0711dc..9ee430a655b 100644 --- a/homeassistant/components/nut/diagnostics.py +++ b/homeassistant/components/nut/diagnostics.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from . import PyNUTData -from .const import DOMAIN, PYNUT_DATA, PYNUT_UNIQUE_ID +from .const import DOMAIN, PYNUT_DATA, PYNUT_UNIQUE_ID, USER_AVAILABLE_COMMANDS TO_REDACT = {CONF_PASSWORD, CONF_USERNAME} @@ -26,7 +26,12 @@ async def async_get_config_entry_diagnostics( # Get information from Nut library nut_data: PyNUTData = hass_data[PYNUT_DATA] - data["nut_data"] = {"ups_list": nut_data.ups_list, "status": nut_data.status} + nut_cmd: set[str] = hass_data[USER_AVAILABLE_COMMANDS] + data["nut_data"] = { + "ups_list": nut_data.ups_list, + "status": nut_data.status, + "commands": nut_cmd, + } # Gather information how this Nut device is represented in Home Assistant device_registry = dr.async_get(hass) diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index ed7d825afff..7f5d01f9897 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["metar", "pynws"], "quality_scale": "platinum", - "requirements": ["pynws==1.4.1"] + "requirements": ["pynws==1.5.0"] } diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 9edf6e61751..e8a35ba66f1 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -8,6 +8,8 @@ from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_NATIVE_DEW_POINT, ATTR_FORECAST_NATIVE_TEMP, ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, @@ -52,16 +54,13 @@ from .const import ( PARALLEL_UPDATES = 0 -def convert_condition( - time: str, weather: tuple[tuple[str, int | None], ...] -) -> tuple[str, int | None]: +def convert_condition(time: str, weather: tuple[tuple[str, int | None], ...]) -> str: """Convert NWS codes to HA condition. Choose first condition in CONDITION_CLASSES that exists in weather code. If no match is found, return first condition from NWS """ conditions: list[str] = [w[0] for w in weather] - prec_probs = [w[1] or 0 for w in weather] # Choose condition with highest priority. cond = next( @@ -75,10 +74,10 @@ def convert_condition( if cond == "clear": if time == "day": - return ATTR_CONDITION_SUNNY, max(prec_probs) + return ATTR_CONDITION_SUNNY if time == "night": - return ATTR_CONDITION_CLEAR_NIGHT, max(prec_probs) - return cond, max(prec_probs) + return ATTR_CONDITION_CLEAR_NIGHT + return cond async def async_setup_entry( @@ -219,8 +218,7 @@ class NWSWeather(WeatherEntity): time = self.observation.get("iconTime") if weather: - cond, _ = convert_condition(time, weather) - return cond + return convert_condition(time, weather) return None @property @@ -256,16 +254,27 @@ class NWSWeather(WeatherEntity): else: data[ATTR_FORECAST_NATIVE_TEMP] = None + data[ATTR_FORECAST_PRECIPITATION_PROBABILITY] = forecast_entry.get( + "probabilityOfPrecipitation" + ) + + if (dewp := forecast_entry.get("dewpoint")) is not None: + data[ATTR_FORECAST_NATIVE_DEW_POINT] = TemperatureConverter.convert( + dewp, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS + ) + else: + data[ATTR_FORECAST_NATIVE_DEW_POINT] = None + + data[ATTR_FORECAST_HUMIDITY] = forecast_entry.get("relativeHumidity") + if self.mode == DAYNIGHT: data[ATTR_FORECAST_DAYTIME] = forecast_entry.get("isDaytime") + time = forecast_entry.get("iconTime") weather = forecast_entry.get("iconWeather") - if time and weather: - cond, precip = convert_condition(time, weather) - else: - cond, precip = None, None - data[ATTR_FORECAST_CONDITION] = cond - data[ATTR_FORECAST_PRECIPITATION_PROBABILITY] = precip + data[ATTR_FORECAST_CONDITION] = ( + convert_condition(time, weather) if time and weather else None + ) data[ATTR_FORECAST_WIND_BEARING] = forecast_entry.get("windBearing") wind_speed = forecast_entry.get("windSpeedAvg") diff --git a/homeassistant/components/nx584/services.yaml b/homeassistant/components/nx584/services.yaml index a5c49f6d6a6..da5c0638a4f 100644 --- a/homeassistant/components/nx584/services.yaml +++ b/homeassistant/components/nx584/services.yaml @@ -1,16 +1,12 @@ # Describes the format for available nx584 services bypass_zone: - name: Bypass zone - description: Bypass a zone. target: entity: integration: nx584 domain: alarm_control_panel fields: zone: - name: Zone - description: The number of the zone to be bypassed. required: true selector: number: @@ -18,16 +14,12 @@ bypass_zone: max: 255 unbypass_zone: - name: Un-bypass zone - description: Un-Bypass a zone. target: entity: integration: nx584 domain: alarm_control_panel fields: zone: - name: Zone - description: The number of the zone to be un-bypassed. required: true selector: number: diff --git a/homeassistant/components/nx584/strings.json b/homeassistant/components/nx584/strings.json new file mode 100644 index 00000000000..b3d03815278 --- /dev/null +++ b/homeassistant/components/nx584/strings.json @@ -0,0 +1,24 @@ +{ + "services": { + "bypass_zone": { + "name": "Bypass zone", + "description": "Bypasses a zone.", + "fields": { + "zone": { + "name": "Zone", + "description": "The number of the zone to be bypassed." + } + } + }, + "unbypass_zone": { + "name": "Un-bypass zone", + "description": "Un-Bypasses a zone.", + "fields": { + "zone": { + "name": "[%key:component::nx584::services::bypass_zone::fields::zone::name%]", + "description": "The number of the zone to be un-bypassed." + } + } + } + } +} diff --git a/homeassistant/components/nzbget/services.yaml b/homeassistant/components/nzbget/services.yaml index 46439b761e1..0131bb6ae3a 100644 --- a/homeassistant/components/nzbget/services.yaml +++ b/homeassistant/components/nzbget/services.yaml @@ -1,20 +1,10 @@ # Describes the format for available nzbget services pause: - name: Pause - description: Pause download queue. - resume: - name: Resume - description: Resume download queue. - set_speed: - name: Set speed - description: Set download speed limit fields: speed: - name: Speed - description: Speed limit. 0 is unlimited. default: 1000 selector: number: diff --git a/homeassistant/components/nzbget/strings.json b/homeassistant/components/nzbget/strings.json index fc7d8508a12..7a3c438d11f 100644 --- a/homeassistant/components/nzbget/strings.json +++ b/homeassistant/components/nzbget/strings.json @@ -31,5 +31,25 @@ } } } + }, + "services": { + "pause": { + "name": "[%key:common::action::pause%]", + "description": "Pauses download queue." + }, + "resume": { + "name": "Resume", + "description": "Resumes download queue." + }, + "set_speed": { + "name": "Set speed", + "description": "Sets download speed limit.", + "fields": { + "speed": { + "name": "Speed", + "description": "Speed limit. 0 is unlimited." + } + } + } } } diff --git a/homeassistant/components/oasa_telematics/manifest.json b/homeassistant/components/oasa_telematics/manifest.json index d50561a33a4..d3dbaad98e3 100644 --- a/homeassistant/components/oasa_telematics/manifest.json +++ b/homeassistant/components/oasa_telematics/manifest.json @@ -2,7 +2,7 @@ "domain": "oasa_telematics", "name": "OASA Telematics", "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/oasa_telematics/", + "documentation": "https://www.home-assistant.io/integrations/oasa_telematics", "iot_class": "cloud_polling", "loggers": ["oasatelematics"], "requirements": ["oasatelematics==0.3"] diff --git a/homeassistant/components/ombi/const.py b/homeassistant/components/ombi/const.py index 3ed67389003..59a57a480c2 100644 --- a/homeassistant/components/ombi/const.py +++ b/homeassistant/components/ombi/const.py @@ -1,8 +1,6 @@ """Support for Ombi.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntityDescription - ATTR_SEASON = "season" CONF_URLBASE = "urlbase" @@ -16,36 +14,3 @@ DEFAULT_URLBASE = "" SERVICE_MOVIE_REQUEST = "submit_movie_request" SERVICE_MUSIC_REQUEST = "submit_music_request" SERVICE_TV_REQUEST = "submit_tv_request" - -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="movies", - name="Movie requests", - icon="mdi:movie", - ), - SensorEntityDescription( - key="tv", - name="TV show requests", - icon="mdi:television-classic", - ), - SensorEntityDescription( - key="music", - name="Music album requests", - icon="mdi:album", - ), - SensorEntityDescription( - key="pending", - name="Pending requests", - icon="mdi:clock-alert-outline", - ), - SensorEntityDescription( - key="approved", - name="Approved requests", - icon="mdi:check", - ), - SensorEntityDescription( - key="available", - name="Available requests", - icon="mdi:download", - ), -) diff --git a/homeassistant/components/ombi/manifest.json b/homeassistant/components/ombi/manifest.json index 91df756dafe..d9da13d2381 100644 --- a/homeassistant/components/ombi/manifest.json +++ b/homeassistant/components/ombi/manifest.json @@ -2,7 +2,7 @@ "domain": "ombi", "name": "Ombi", "codeowners": ["@larssont"], - "documentation": "https://www.home-assistant.io/integrations/ombi/", + "documentation": "https://www.home-assistant.io/integrations/ombi", "iot_class": "local_polling", "requirements": ["pyombi==0.1.10"] } diff --git a/homeassistant/components/ombi/sensor.py b/homeassistant/components/ombi/sensor.py index 1ab4b170e00..f534144d02c 100644 --- a/homeassistant/components/ombi/sensor.py +++ b/homeassistant/components/ombi/sensor.py @@ -11,13 +11,47 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN, SENSOR_TYPES +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=60) +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="movies", + name="Movie requests", + icon="mdi:movie", + ), + SensorEntityDescription( + key="tv", + name="TV show requests", + icon="mdi:television-classic", + ), + SensorEntityDescription( + key="music", + name="Music album requests", + icon="mdi:album", + ), + SensorEntityDescription( + key="pending", + name="Pending requests", + icon="mdi:clock-alert-outline", + ), + SensorEntityDescription( + key="approved", + name="Approved requests", + icon="mdi:check", + ), + SensorEntityDescription( + key="available", + name="Available requests", + icon="mdi:download", + ), +) + + def setup_platform( hass: HomeAssistant, config: ConfigType, diff --git a/homeassistant/components/ombi/services.yaml b/homeassistant/components/ombi/services.yaml index d7e7068e84c..8803d2788bf 100644 --- a/homeassistant/components/ombi/services.yaml +++ b/homeassistant/components/ombi/services.yaml @@ -1,30 +1,20 @@ # Ombi services.yaml entries submit_movie_request: - name: Sumbit movie request - description: Searches for a movie and requests the first result. fields: name: - name: Name - description: Search parameter required: true example: "beverly hills cop" selector: text: submit_tv_request: - name: Submit tv request - description: Searches for a TV show and requests the first result. fields: name: - name: Name - description: Search parameter required: true example: "breaking bad" selector: text: season: - name: Season - description: Which season(s) to request. default: latest selector: select: @@ -34,12 +24,8 @@ submit_tv_request: - "latest" submit_music_request: - name: Submit music request - description: Searches for a music album and requests the first result. fields: name: - name: Name - description: Search parameter required: true example: "nevermind" selector: diff --git a/homeassistant/components/ombi/strings.json b/homeassistant/components/ombi/strings.json new file mode 100644 index 00000000000..2cf18248ab8 --- /dev/null +++ b/homeassistant/components/ombi/strings.json @@ -0,0 +1,38 @@ +{ + "services": { + "submit_movie_request": { + "name": "Sumbit movie request", + "description": "Searches for a movie and requests the first result.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "Search parameter." + } + } + }, + "submit_tv_request": { + "name": "Submit TV request", + "description": "Searches for a TV show and requests the first result.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "[%key:component::ombi::services::submit_movie_request::fields::name::description%]" + }, + "season": { + "name": "Season", + "description": "Which season(s) to request." + } + } + }, + "submit_music_request": { + "name": "Submit music request", + "description": "Searches for a music album and requests the first result.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "[%key:component::ombi::services::submit_movie_request::fields::name::description%]" + } + } + } + } +} diff --git a/homeassistant/components/omnilogic/services.yaml b/homeassistant/components/omnilogic/services.yaml index 94ba0d2982e..c82ea7ebbbf 100644 --- a/homeassistant/components/omnilogic/services.yaml +++ b/homeassistant/components/omnilogic/services.yaml @@ -1,14 +1,10 @@ set_pump_speed: - name: Set pump speed - description: Set the run speed of a variable speed pump. target: entity: integration: omnilogic domain: switch fields: speed: - name: Speed - description: Speed for the VSP between min and max speed. required: true selector: number: diff --git a/homeassistant/components/omnilogic/strings.json b/homeassistant/components/omnilogic/strings.json index 2bbb927fd27..454644be244 100644 --- a/homeassistant/components/omnilogic/strings.json +++ b/homeassistant/components/omnilogic/strings.json @@ -27,5 +27,17 @@ } } } + }, + "services": { + "set_pump_speed": { + "name": "Set pump speed", + "description": "Sets the run speed of a variable speed pump.", + "fields": { + "speed": { + "name": "Speed", + "description": "Speed for the VSP between min and max speed." + } + } + } } } diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 5e226dcead7..8b4cfcb61a4 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -35,48 +35,46 @@ from .const import DOMAIN SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="temperature", - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="orp", - name="Oxydo Reduction Potential", + translation_key="oxydo_reduction_potential", native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, icon="mdi:pool", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="ph", - name="pH", + translation_key="ph", icon="mdi:pool", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="tds", - name="TDS", + translation_key="tds", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, icon="mdi:pool", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="battery", - name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="rssi", - name="RSSI", + translation_key="rssi", icon="mdi:wifi", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="salt", - name="Salt", + translation_key="salt", native_unit_of_measurement="mg/L", icon="mdi:pool", state_class=SensorStateClass.MEASUREMENT, @@ -139,6 +137,8 @@ class OndiloICO( ): """Representation of a Sensor.""" + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinator[list[dict[str, Any]]], @@ -154,7 +154,6 @@ class OndiloICO( pooldata = self._pooldata() self._attr_unique_id = f"{pooldata['ICO']['serial_number']}-{description.key}" self._device_name = pooldata["name"] - self._attr_name = f"{self._device_name} {description.name}" def _pooldata(self): """Get pool data dict.""" diff --git a/homeassistant/components/ondilo_ico/strings.json b/homeassistant/components/ondilo_ico/strings.json index 4e5f2330840..3843670bc50 100644 --- a/homeassistant/components/ondilo_ico/strings.json +++ b/homeassistant/components/ondilo_ico/strings.json @@ -12,5 +12,24 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } + }, + "entity": { + "sensor": { + "oxydo_reduction_potential": { + "name": "Oxydo reduction potential" + }, + "ph": { + "name": "pH" + }, + "tds": { + "name": "TDS" + }, + "rssi": { + "name": "RSSI" + }, + "salt": { + "name": "Salt" + } + } } } diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json index 2a7bd307ff8..f58731a2377 100644 --- a/homeassistant/components/onewire/strings.json +++ b/homeassistant/components/onewire/strings.json @@ -251,7 +251,7 @@ "device_selection": { "data": { "clear_device_options": "Clear all device configurations", - "device_selection": "Select devices to configure" + "device_selection": "[%key:component::onewire::options::error::device_not_selected%]" }, "description": "Select what configuration steps to process", "title": "OneWire Device Options" diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index c1df94f5f83..e0342c5f0d4 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from pprint import pformat from typing import Any from urllib.parse import urlparse @@ -171,7 +172,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): registry = dr.async_get(self.hass) if not ( device := registry.async_get_device( - identifiers=set(), connections={(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) ): return self.async_abort(reason="no_devices_found") @@ -218,7 +219,8 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if not configured: self.devices.append(device) - LOGGER.debug("Discovered ONVIF devices %s", pformat(self.devices)) + if LOGGER.isEnabledFor(logging.DEBUG): + LOGGER.debug("Discovered ONVIF devices %s", pformat(self.devices)) if self.devices: devices = {CONF_MANUAL_INPUT: CONF_MANUAL_INPUT} @@ -274,9 +276,10 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, configure_unique_id: bool = True ) -> tuple[dict[str, str], dict[str, str]]: """Fetch ONVIF device profiles.""" - LOGGER.debug( - "Fetching profiles from ONVIF device %s", pformat(self.onvif_config) - ) + if LOGGER.isEnabledFor(logging.DEBUG): + LOGGER.debug( + "Fetching profiles from ONVIF device %s", pformat(self.onvif_config) + ) device = get_device( self.hass, diff --git a/homeassistant/components/onvif/services.yaml b/homeassistant/components/onvif/services.yaml index 9d753b2fe77..9cf3a1fc4c1 100644 --- a/homeassistant/components/onvif/services.yaml +++ b/homeassistant/components/onvif/services.yaml @@ -1,38 +1,28 @@ ptz: - name: PTZ - description: If your ONVIF camera supports PTZ, you will be able to pan, tilt or zoom your camera. target: entity: integration: onvif domain: camera fields: tilt: - name: Tilt - description: "Tilt direction." selector: select: options: - "DOWN" - "UP" pan: - name: Pan - description: "Pan direction." selector: select: options: - "LEFT" - "RIGHT" zoom: - name: Zoom - description: "Zoom." selector: select: options: - "ZOOM_IN" - "ZOOM_OUT" distance: - name: Distance - description: "Distance coefficient. Sets how much PTZ should be executed in one request." default: 0.1 selector: number: @@ -40,8 +30,6 @@ ptz: max: 1 step: 0.01 speed: - name: Speed - description: "Speed coefficient. Sets how fast PTZ will be executed." default: 0.5 selector: number: @@ -49,8 +37,6 @@ ptz: max: 1 step: 0.01 continuous_duration: - name: Continuous duration - description: "Set ContinuousMove delay in seconds before stopping the move" default: 0.5 selector: number: @@ -58,15 +44,11 @@ ptz: max: 1 step: 0.01 preset: - name: Preset - description: "PTZ preset profile token. Sets the preset profile token which is executed with GotoPreset" example: "1" default: "0" selector: text: move_mode: - name: Move Mode - description: "PTZ moving mode." default: "RelativeMove" selector: select: diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index 8e989f1dfa0..cabab347264 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -67,5 +67,45 @@ "title": "ONVIF Device Options" } } + }, + "services": { + "ptz": { + "name": "PTZ", + "description": "If your ONVIF camera supports PTZ, you will be able to pan, tilt or zoom your camera.", + "fields": { + "tilt": { + "name": "Tilt", + "description": "Tilt direction." + }, + "pan": { + "name": "Pan", + "description": "Pan direction." + }, + "zoom": { + "name": "Zoom", + "description": "Zoom." + }, + "distance": { + "name": "Distance", + "description": "Distance coefficient. Sets how much PTZ should be executed in one request." + }, + "speed": { + "name": "Speed", + "description": "Speed coefficient. Sets how fast PTZ will be executed." + }, + "continuous_duration": { + "name": "Continuous duration", + "description": "Set ContinuousMove delay in seconds before stopping the move." + }, + "preset": { + "name": "Preset", + "description": "PTZ preset profile token. Sets the preset profile token which is executed with GotoPreset." + }, + "move_mode": { + "name": "Move Mode", + "description": "PTZ moving mode." + } + } + } } } diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index c1b569ce9e1..9f4c30d91ba 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -7,13 +7,24 @@ from typing import Literal import openai from openai import error +import voluptuous as vol from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, MATCH_ALL -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import intent, template +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ( + ConfigEntryNotReady, + HomeAssistantError, + TemplateError, +) +from homeassistant.helpers import config_validation as cv, intent, selector, template +from homeassistant.helpers.typing import ConfigType from homeassistant.util import ulid from .const import ( @@ -27,18 +38,61 @@ from .const import ( DEFAULT_PROMPT, DEFAULT_TEMPERATURE, DEFAULT_TOP_P, + DOMAIN, ) _LOGGER = logging.getLogger(__name__) +SERVICE_GENERATE_IMAGE = "generate_image" + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up OpenAI Conversation.""" + + async def render_image(call: ServiceCall) -> ServiceResponse: + """Render an image with dall-e.""" + try: + response = await openai.Image.acreate( + api_key=hass.data[DOMAIN][call.data["config_entry"]], + prompt=call.data["prompt"], + n=1, + size=f'{call.data["size"]}x{call.data["size"]}', + ) + except error.OpenAIError as err: + raise HomeAssistantError(f"Error generating image: {err}") from err + + return response["data"][0] + + hass.services.async_register( + DOMAIN, + SERVICE_GENERATE_IMAGE, + render_image, + schema=vol.Schema( + { + vol.Required("config_entry"): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required("prompt"): cv.string, + vol.Optional("size", default="512"): vol.In(("256", "512", "1024")), + } + ), + supports_response=SupportsResponse.ONLY, + ) + return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up OpenAI Conversation from a config entry.""" - openai.api_key = entry.data[CONF_API_KEY] - try: await hass.async_add_executor_job( - partial(openai.Engine.list, request_timeout=10) + partial( + openai.Engine.list, + api_key=entry.data[CONF_API_KEY], + request_timeout=10, + ) ) except error.AuthenticationError as err: _LOGGER.error("Invalid API key: %s", err) @@ -46,13 +100,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except error.OpenAIError as err: raise ConfigEntryNotReady(err) from err + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data[CONF_API_KEY] + conversation.async_set_agent(hass, entry, OpenAIAgent(hass, entry)) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload OpenAI.""" - openai.api_key = None + hass.data[DOMAIN].pop(entry.entry_id) conversation.async_unset_agent(hass, entry) return True @@ -66,11 +122,6 @@ class OpenAIAgent(conversation.AbstractConversationAgent): self.entry = entry self.history: dict[str, list[dict]] = {} - @property - def attribution(self): - """Return the attribution.""" - return {"name": "Powered by OpenAI", "url": "https://www.openai.com"} - @property def supported_languages(self) -> list[str] | Literal["*"]: """Return a list of supported languages.""" @@ -111,6 +162,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent): try: result = await openai.ChatCompletion.acreate( + api_key=self.entry.data[CONF_API_KEY], model=model, messages=messages, max_tokens=max_tokens, diff --git a/homeassistant/components/openai_conversation/services.yaml b/homeassistant/components/openai_conversation/services.yaml new file mode 100644 index 00000000000..81818fb3e71 --- /dev/null +++ b/homeassistant/components/openai_conversation/services.yaml @@ -0,0 +1,22 @@ +generate_image: + fields: + config_entry: + required: true + selector: + config_entry: + integration: openai_conversation + prompt: + required: true + selector: + text: + multiline: true + size: + required: true + example: "512" + default: "512" + selector: + select: + options: + - "256" + - "512" + - "1024" diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 9583e759bd2..542fe06dd56 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -25,5 +25,26 @@ } } } + }, + "services": { + "generate_image": { + "name": "Generate image", + "description": "Turn a prompt into an image", + "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service" + }, + "prompt": { + "name": "Prompt", + "description": "The text to turn into an image", + "example": "A photo of a dog" + }, + "size": { + "name": "Size", + "description": "The size of the image to generate" + } + } + } } } diff --git a/homeassistant/components/openhome/services.yaml b/homeassistant/components/openhome/services.yaml index 0fa95145287..7ccba4fb497 100644 --- a/homeassistant/components/openhome/services.yaml +++ b/homeassistant/components/openhome/services.yaml @@ -1,16 +1,12 @@ # Describes the format for available openhome services invoke_pin: - name: Invoke PIN - description: Invoke a pin on the specified device. target: entity: integration: openhome domain: media_player fields: pin: - name: PIN - description: Which pin to invoke required: true selector: number: diff --git a/homeassistant/components/openhome/strings.json b/homeassistant/components/openhome/strings.json new file mode 100644 index 00000000000..b13fb997b7f --- /dev/null +++ b/homeassistant/components/openhome/strings.json @@ -0,0 +1,14 @@ +{ + "services": { + "invoke_pin": { + "name": "Invoke PIN", + "description": "Invokes a pin on the specified device.", + "fields": { + "pin": { + "name": "PIN", + "description": "Which pin to invoke." + } + } + } + } +} diff --git a/homeassistant/components/opensky/__init__.py b/homeassistant/components/opensky/__init__.py index da805999d53..197356b2092 100644 --- a/homeassistant/components/opensky/__init__.py +++ b/homeassistant/components/opensky/__init__.py @@ -1 +1,27 @@ """The opensky component.""" +from __future__ import annotations + +from python_opensky import OpenSky + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CLIENT, DOMAIN, PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up opensky from a config entry.""" + + client = OpenSky(session=async_get_clientsession(hass)) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {CLIENT: client} + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload opensky config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py new file mode 100644 index 00000000000..6e3ffb5e2b1 --- /dev/null +++ b/homeassistant/components/opensky/config_flow.py @@ -0,0 +1,77 @@ +"""Config flow for OpenSky integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_RADIUS, +) +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DEFAULT_NAME, DOMAIN +from .sensor import CONF_ALTITUDE, DEFAULT_ALTITUDE + + +class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow handler for OpenSky.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Initialize user input.""" + if user_input is not None: + return self.async_create_entry( + title=DEFAULT_NAME, + data={ + CONF_LATITUDE: user_input[CONF_LATITUDE], + CONF_LONGITUDE: user_input[CONF_LONGITUDE], + }, + options={ + CONF_RADIUS: user_input[CONF_RADIUS], + CONF_ALTITUDE: user_input[CONF_ALTITUDE], + }, + ) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_RADIUS): vol.Coerce(float), + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_ALTITUDE): vol.Coerce(float), + } + ), + { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + CONF_ALTITUDE: DEFAULT_ALTITUDE, + }, + ), + ) + + async def async_step_import(self, import_config: ConfigType) -> FlowResult: + """Import config from yaml.""" + entry_data = { + CONF_LATITUDE: import_config.get(CONF_LATITUDE, self.hass.config.latitude), + CONF_LONGITUDE: import_config.get( + CONF_LONGITUDE, self.hass.config.longitude + ), + } + self._async_abort_entries_match(entry_data) + return self.async_create_entry( + title=import_config.get(CONF_NAME, DEFAULT_NAME), + data=entry_data, + options={ + CONF_RADIUS: import_config[CONF_RADIUS] * 1000, + CONF_ALTITUDE: import_config.get(CONF_ALTITUDE, DEFAULT_ALTITUDE), + }, + ) diff --git a/homeassistant/components/opensky/const.py b/homeassistant/components/opensky/const.py new file mode 100644 index 00000000000..ccea69f8b7f --- /dev/null +++ b/homeassistant/components/opensky/const.py @@ -0,0 +1,19 @@ +"""OpenSky constants.""" +from homeassistant.const import Platform + +PLATFORMS = [Platform.SENSOR] +DEFAULT_NAME = "OpenSky" +DOMAIN = "opensky" +CLIENT = "client" + +CONF_ALTITUDE = "altitude" +ATTR_ICAO24 = "icao24" +ATTR_CALLSIGN = "callsign" +ATTR_ALTITUDE = "altitude" +ATTR_ON_GROUND = "on_ground" +ATTR_SENSOR = "sensor" +ATTR_STATES = "states" +DEFAULT_ALTITUDE = 0 + +EVENT_OPENSKY_ENTRY = f"{DOMAIN}_entry" +EVENT_OPENSKY_EXIT = f"{DOMAIN}_exit" diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index 6c6d3acb30e..f3fb13589bb 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -2,6 +2,7 @@ "domain": "opensky", "name": "OpenSky Network", "codeowners": ["@joostlek"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/opensky", "iot_class": "cloud_polling", "requirements": ["python-opensky==0.0.10"] diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index cdedd0c9620..4ef1070d12d 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -7,6 +7,7 @@ from python_opensky import BoundingBox, OpenSky, StateVector import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -15,47 +16,28 @@ from homeassistant.const import ( CONF_NAME, CONF_RADIUS, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -CONF_ALTITUDE = "altitude" +from .const import ( + ATTR_ALTITUDE, + ATTR_CALLSIGN, + ATTR_ICAO24, + ATTR_SENSOR, + CLIENT, + CONF_ALTITUDE, + DEFAULT_ALTITUDE, + DOMAIN, + EVENT_OPENSKY_ENTRY, + EVENT_OPENSKY_EXIT, +) -ATTR_ICAO24 = "icao24" -ATTR_CALLSIGN = "callsign" -ATTR_ALTITUDE = "altitude" -ATTR_ON_GROUND = "on_ground" -ATTR_SENSOR = "sensor" -ATTR_STATES = "states" - -DOMAIN = "opensky" - -DEFAULT_ALTITUDE = 0 - -EVENT_OPENSKY_ENTRY = f"{DOMAIN}_entry" -EVENT_OPENSKY_EXIT = f"{DOMAIN}_exit" # OpenSky free user has 400 credits, with 4 credits per API call. 100/24 = ~4 requests per hour SCAN_INTERVAL = timedelta(minutes=15) -OPENSKY_API_URL = "https://opensky-network.org/api/states/all" -OPENSKY_API_FIELDS = [ - ATTR_ICAO24, - ATTR_CALLSIGN, - "origin_country", - "time_position", - "time_velocity", - ATTR_LONGITUDE, - ATTR_LATITUDE, - ATTR_ALTITUDE, - ATTR_ON_GROUND, - "velocity", - "heading", - "vertical_rate", - "sensors", -] - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -68,27 +50,57 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Open Sky platform.""" - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - radius = config.get(CONF_RADIUS, 0) - bounding_box = OpenSky.get_bounding_box(latitude, longitude, radius * 1000) - session = async_get_clientsession(hass) - opensky = OpenSky(session=session) - add_entities( + """Set up the OpenSky sensor platform from yaml.""" + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.1.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "OpenSky", + }, + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize the entries.""" + + opensky = hass.data[DOMAIN][entry.entry_id][CLIENT] + bounding_box = OpenSky.get_bounding_box( + entry.data[CONF_LATITUDE], + entry.data[CONF_LONGITUDE], + entry.options[CONF_RADIUS], + ) + async_add_entities( [ OpenSkySensor( - hass, - config.get(CONF_NAME, DOMAIN), + entry.title, opensky, bounding_box, - config[CONF_ALTITUDE], + entry.options.get(CONF_ALTITUDE, DEFAULT_ALTITUDE), + entry.entry_id, ) ], True, @@ -104,20 +116,20 @@ class OpenSkySensor(SensorEntity): def __init__( self, - hass: HomeAssistant, name: str, opensky: OpenSky, bounding_box: BoundingBox, altitude: float, + entry_id: str, ) -> None: """Initialize the sensor.""" self._altitude = altitude self._state = 0 - self._hass = hass self._name = name self._previously_tracked: set[str] = set() self._opensky = opensky self._bounding_box = bounding_box + self._attr_unique_id = f"{entry_id}_opensky" @property def name(self) -> str: @@ -154,7 +166,7 @@ class OpenSkySensor(SensorEntity): ATTR_LATITUDE: latitude, ATTR_ICAO24: icao24, } - self._hass.bus.fire(event, data) + self.hass.bus.fire(event, data) async def async_update(self) -> None: """Update device state.""" diff --git a/homeassistant/components/opensky/strings.json b/homeassistant/components/opensky/strings.json new file mode 100644 index 00000000000..768ffde155f --- /dev/null +++ b/homeassistant/components/opensky/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "user": { + "description": "Fill in the location to track.", + "data": { + "name": "[%key:common::config_flow::data::api_key%]", + "radius": "Radius", + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]", + "altitude": "Altitude" + } + } + } + } +} diff --git a/homeassistant/components/opentherm_gw/services.yaml b/homeassistant/components/opentherm_gw/services.yaml index 77ef501f9d8..d68624e0763 100644 --- a/homeassistant/components/opentherm_gw/services.yaml +++ b/homeassistant/components/opentherm_gw/services.yaml @@ -1,84 +1,49 @@ # Describes the format for available opentherm_gw services reset_gateway: - name: Reset gateway - description: Reset the OpenTherm Gateway. fields: gateway_id: - name: Gateway ID - description: The gateway_id of the OpenTherm Gateway. required: true example: "opentherm_gateway" selector: text: set_central_heating_ovrd: - name: Set central heating override - description: > - Set the central heating override option on the gateway. - When overriding the control setpoint (via a set_control_setpoint service call with a value other than 0), the gateway automatically enables the central heating override to start heating. - This service can then be used to control the central heating override status. - To return control of the central heating to the thermostat, call the set_control_setpoint service with temperature value 0. - You will only need this if you are writing your own software thermostat. fields: gateway_id: - name: Gateway ID - description: The gateway_id of the OpenTherm Gateway. required: true example: "opentherm_gateway" selector: text: ch_override: - name: Central heating override - description: > - The desired boolean value for the central heating override. required: true selector: boolean: set_clock: - name: Set clock - description: Set the clock and day of the week on the connected thermostat. fields: gateway_id: - name: Gateway ID - description: The gateway_id of the OpenTherm Gateway. required: true example: "opentherm_gateway" selector: text: date: - name: Date - description: Optional date from which the day of the week will be extracted. Defaults to today. example: "2018-10-23" selector: text: time: - name: Time - description: Optional time in 24h format which will be provided to the thermostat. Defaults to the current time. example: "19:34" selector: text: set_control_setpoint: - name: Set control set point - description: > - Set the central heating control setpoint override on the gateway. - You will only need this if you are writing your own software thermostat. fields: gateway_id: - name: Gateway ID - description: The gateway_id of the OpenTherm Gateway. required: true example: "opentherm_gateway" selector: text: temperature: - name: Temperature - description: > - The central heating setpoint to set on the gateway. - Values between 0 and 90 are accepted, but not all boilers support this range. - A value of 0 disables the central heating setpoint override. required: true selector: number: @@ -88,49 +53,26 @@ set_control_setpoint: unit_of_measurement: "°" set_hot_water_ovrd: - name: Set hot water override - description: > - Set the domestic hot water enable option on the gateway. fields: gateway_id: - name: Gateway ID - description: The gateway_id of the OpenTherm Gateway. required: true example: "opentherm_gateway" selector: text: dhw_override: - name: Domestic hot water override - description: > - Control the domestic hot water enable option. If the boiler has - been configured to let the room unit control when to keep a - small amount of water preheated, this command can influence - that. - Value should be 0 or 1 to enable the override in off or on - state, or "A" to disable the override. required: true example: "1" selector: text: set_hot_water_setpoint: - name: Set hot water set point - description: > - Set the domestic hot water setpoint on the gateway. fields: gateway_id: - name: Gateway ID - description: The gateway_id of the OpenTherm Gateway. required: true example: "opentherm_gateway" selector: text: temperature: - name: Temperature - description: > - The domestic hot water setpoint to set on the gateway. Not all boilers support this feature. - Values between 0 and 90 are accepted, but not all boilers support this range. - Check the values of the slave_dhw_min_setp and slave_dhw_max_setp sensors to see the supported range on your boiler. selector: number: min: 0 @@ -139,19 +81,13 @@ set_hot_water_setpoint: unit_of_measurement: "°" set_gpio_mode: - name: Set gpio mode - description: Change the function of the GPIO pins of the gateway. fields: gateway_id: - name: Gateway ID - description: The gateway_id of the OpenTherm Gateway. required: true example: "opentherm_gateway" selector: text: id: - name: ID - description: The ID of the GPIO pin. required: true selector: select: @@ -159,10 +95,6 @@ set_gpio_mode: - "A" - "B" mode: - name: Mode - description: > - Mode to set on the GPIO pin. Values 0 through 6 are accepted for both GPIOs, 7 is only accepted for GPIO "B". - See https://www.home-assistant.io/integrations/opentherm_gw/#gpio-modes for an explanation of the values. required: true selector: number: @@ -170,19 +102,13 @@ set_gpio_mode: max: 7 set_led_mode: - name: Set LED mode - description: Change the function of the LEDs of the gateway. fields: gateway_id: - name: Gateway ID - description: The gateway_id of the OpenTherm Gateway. required: true example: "opentherm_gateway" selector: text: id: - name: ID - description: The ID of the LED. required: true selector: select: @@ -194,10 +120,6 @@ set_led_mode: - "E" - "F" mode: - name: Mode - description: > - The function to assign to the LED. - See https://www.home-assistant.io/integrations/opentherm_gw/#led-modes for an explanation of the values. required: true selector: select: @@ -216,23 +138,13 @@ set_led_mode: - "X" set_max_modulation: - name: Set max modulation - description: > - Override the maximum relative modulation level. - You will only need this if you are writing your own software thermostat. fields: gateway_id: - name: Gateway ID - description: The gateway_id of the OpenTherm Gateway. required: true example: "opentherm_gateway" selector: text: level: - name: Level - description: > - The modulation level to provide to the gateway. - Provide a value of -1 to clear the override and forward the value from the thermostat again. required: true selector: number: @@ -240,24 +152,13 @@ set_max_modulation: max: 100 set_outside_temperature: - name: Set outside temperature - description: > - Provide an outside temperature to the thermostat. - If your thermostat is unable to display an outside temperature and does not support OTC (Outside Temperature Correction), this has no effect. fields: gateway_id: - name: Gateway ID - description: The gateway_id of the OpenTherm Gateway. required: true example: "opentherm_gateway" selector: text: temperature: - name: Temperature - description: > - The temperature to provide to the thermostat. - Values between -40.0 and 64.0 will be accepted, but not all thermostats can display the full range. - Any value above 64.0 will clear a previously configured value (suggestion: 99) required: true selector: number: @@ -266,19 +167,13 @@ set_outside_temperature: unit_of_measurement: "°" set_setback_temperature: - name: Set setback temperature - description: Configure the setback temperature to be used with the GPIO away mode function. fields: gateway_id: - name: Gateway ID - description: The gateway_id of the OpenTherm Gateway. required: true example: "opentherm_gateway" selector: text: temperature: - name: Temperature - description: The setback temperature to configure on the gateway. required: true selector: number: diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index a80a059481d..a5b8395b56b 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -27,5 +27,169 @@ } } } + }, + "services": { + "reset_gateway": { + "name": "Reset gateway", + "description": "Resets the OpenTherm Gateway.", + "fields": { + "gateway_id": { + "name": "Gateway ID", + "description": "The gateway_id of the OpenTherm Gateway." + } + } + }, + "set_central_heating_ovrd": { + "name": "Set central heating override", + "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a set_control_setpoint service call with a value other than 0), the gateway automatically enables the central heating override to start heating. This service can then be used to control the central heating override status. To return control of the central heating to the thermostat, call the set_control_setpoint service with temperature value 0. You will only need this if you are writing your own software thermostat.\n.", + "fields": { + "gateway_id": { + "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", + "description": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::description%]" + }, + "ch_override": { + "name": "Central heating override", + "description": "The desired boolean value for the central heating override." + } + } + }, + "set_clock": { + "name": "Set clock", + "description": "Sets the clock and day of the week on the connected thermostat.", + "fields": { + "gateway_id": { + "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", + "description": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::description%]" + }, + "date": { + "name": "Date", + "description": "Optional date from which the day of the week will be extracted. Defaults to today." + }, + "time": { + "name": "Time", + "description": "Optional time in 24h format which will be provided to the thermostat. Defaults to the current time." + } + } + }, + "set_control_setpoint": { + "name": "Set control set point", + "description": "Sets the central heating control setpoint override on the gateway. You will only need this if you are writing your own software thermostat.\n.", + "fields": { + "gateway_id": { + "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", + "description": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::description%]" + }, + "temperature": { + "name": "Temperature", + "description": "The central heating setpoint to set on the gateway. Values between 0 and 90 are accepted, but not all boilers support this range. A value of 0 disables the central heating setpoint override.\n." + } + } + }, + "set_hot_water_ovrd": { + "name": "Set hot water override", + "description": "Sets the domestic hot water enable option on the gateway.", + "fields": { + "gateway_id": { + "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", + "description": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::description%]" + }, + "dhw_override": { + "name": "Domestic hot water override", + "description": "Control the domestic hot water enable option. If the boiler has been configured to let the room unit control when to keep a small amount of water preheated, this command can influence that. Value should be 0 or 1 to enable the override in off or on state, or \"A\" to disable the override.\n." + } + } + }, + "set_hot_water_setpoint": { + "name": "Set hot water set point", + "description": "Sets the domestic hot water setpoint on the gateway.", + "fields": { + "gateway_id": { + "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", + "description": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::description%]" + }, + "temperature": { + "name": "Temperature", + "description": "The domestic hot water setpoint to set on the gateway. Not all boilers support this feature. Values between 0 and 90 are accepted, but not all boilers support this range. Check the values of the slave_dhw_min_setp and slave_dhw_max_setp sensors to see the supported range on your boiler.\n." + } + } + }, + "set_gpio_mode": { + "name": "Set gpio mode", + "description": "Changes the function of the GPIO pins of the gateway.", + "fields": { + "gateway_id": { + "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", + "description": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::description%]" + }, + "id": { + "name": "ID", + "description": "The ID of the GPIO pin." + }, + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "Mode to set on the GPIO pin. Values 0 through 6 are accepted for both GPIOs, 7 is only accepted for GPIO \"B\". See https://www.home-assistant.io/integrations/opentherm_gw/#gpio-modes for an explanation of the values.\n." + } + } + }, + "set_led_mode": { + "name": "Set LED mode", + "description": "Changes the function of the LEDs of the gateway.", + "fields": { + "gateway_id": { + "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", + "description": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::description%]" + }, + "id": { + "name": "ID", + "description": "The ID of the LED." + }, + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "The function to assign to the LED. See https://www.home-assistant.io/integrations/opentherm_gw/#led-modes for an explanation of the values.\n." + } + } + }, + "set_max_modulation": { + "name": "Set max modulation", + "description": "Overrides the maximum relative modulation level. You will only need this if you are writing your own software thermostat.\n.", + "fields": { + "gateway_id": { + "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", + "description": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::description%]" + }, + "level": { + "name": "Level", + "description": "The modulation level to provide to the gateway. Provide a value of -1 to clear the override and forward the value from the thermostat again.\n." + } + } + }, + "set_outside_temperature": { + "name": "Set outside temperature", + "description": "Provides an outside temperature to the thermostat. If your thermostat is unable to display an outside temperature and does not support OTC (Outside Temperature Correction), this has no effect.\n.", + "fields": { + "gateway_id": { + "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", + "description": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::description%]" + }, + "temperature": { + "name": "Temperature", + "description": "The temperature to provide to the thermostat. Values between -40.0 and 64.0 will be accepted, but not all thermostats can display the full range. Any value above 64.0 will clear a previously configured value (suggestion: 99)\n." + } + } + }, + "set_setback_temperature": { + "name": "Set setback temperature", + "description": "Configures the setback temperature to be used with the GPIO away mode function.", + "fields": { + "gateway_id": { + "name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]", + "description": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::description%]" + }, + "temperature": { + "name": "Temperature", + "description": "The setback temperature to configure on the gateway." + } + } + } } } diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index 1e69af66eec..e9f9ee99ff6 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -19,7 +19,7 @@ ATTR_PROTECTION_WINDOW_STARTING_UV = "start_uv" BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW = BinarySensorEntityDescription( key=TYPE_PROTECTION_WINDOW, - name="Protection window", + translation_key="protection_window", icon="mdi:sunglasses", ) diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 44bde8341a0..90eefac594a 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -49,67 +49,67 @@ UV_LEVEL_LOW = "Low" SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=TYPE_CURRENT_OZONE_LEVEL, - name="Current ozone level", + translation_key="current_ozone_level", native_unit_of_measurement="du", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_CURRENT_UV_INDEX, - name="Current UV index", + translation_key="current_uv_index", icon="mdi:weather-sunny", native_unit_of_measurement=UV_INDEX, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_CURRENT_UV_LEVEL, - name="Current UV level", + translation_key="current_uv_level", icon="mdi:weather-sunny", ), SensorEntityDescription( key=TYPE_MAX_UV_INDEX, - name="Max UV index", + translation_key="max_uv_index", icon="mdi:weather-sunny", native_unit_of_measurement=UV_INDEX, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_1, - name="Skin type 1 safe exposure time", + translation_key="skin_type_1_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_2, - name="Skin type 2 safe exposure time", + translation_key="skin_type_2_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_3, - name="Skin type 3 safe exposure time", + translation_key="skin_type_3_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_4, - name="Skin type 4 safe exposure time", + translation_key="skin_type_4_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_5, - name="Skin type 5 safe exposure time", + translation_key="skin_type_5_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_6, - name="Skin type 6 safe exposure time", + translation_key="skin_type_6_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json index 9542cb8b1a7..2534622975c 100644 --- a/homeassistant/components/openuv/strings.json +++ b/homeassistant/components/openuv/strings.json @@ -37,14 +37,43 @@ } } }, - "issues": { - "deprecated_service_multiple_alternate_targets": { - "title": "The {deprecated_service} service is being removed", - "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with one of these entity IDs as the target: `{alternate_targets}`." + "entity": { + "binary_sensor": { + "protection_window": { + "name": "Protection window" + } }, - "deprecated_service_single_alternate_target": { - "title": "The {deprecated_service} service is being removed", - "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with `{alternate_targets}` as the target." + "sensor": { + "current_ozone_level": { + "name": "Current ozone level" + }, + "current_uv_index": { + "name": "Current UV index" + }, + "current_uv_level": { + "name": "Current UV level" + }, + "max_uv_index": { + "name": "Max UV index" + }, + "skin_type_1_safe_exposure_time": { + "name": "Skin type 1 safe exposure time" + }, + "skin_type_2_safe_exposure_time": { + "name": "Skin type 2 safe exposure time" + }, + "skin_type_3_safe_exposure_time": { + "name": "Skin type 3 safe exposure time" + }, + "skin_type_4_safe_exposure_time": { + "name": "Skin type 4 safe exposure time" + }, + "skin_type_5_safe_exposure_time": { + "name": "Skin type 5 safe exposure time" + }, + "skin_type_6_safe_exposure_time": { + "name": "Skin type 6 safe exposure time" + } } } } diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json index 12d5c3e21f6..a29a8952434 100644 --- a/homeassistant/components/openweathermap/strings.json +++ b/homeassistant/components/openweathermap/strings.json @@ -15,7 +15,7 @@ "latitude": "[%key:common::config_flow::data::latitude%]", "longitude": "[%key:common::config_flow::data::longitude%]", "mode": "[%key:common::config_flow::data::mode%]", - "name": "Name" + "name": "[%key:common::config_flow::data::name%]" }, "description": "To generate API key go to https://openweathermap.org/appid" } diff --git a/homeassistant/components/opower/__init__.py b/homeassistant/components/opower/__init__.py new file mode 100644 index 00000000000..f4fca22c9b4 --- /dev/null +++ b/homeassistant/components/opower/__init__.py @@ -0,0 +1,31 @@ +"""The Opower integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import OpowerCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Opower from a config entry.""" + + coordinator = OpowerCoordinator(hass, entry.data) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py new file mode 100644 index 00000000000..fdf007c3b68 --- /dev/null +++ b/homeassistant/components/opower/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for Opower integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from opower import CannotConnect, InvalidAuth, Opower, get_supported_utility_names +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import CONF_UTILITY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names()), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +async def _validate_login( + hass: HomeAssistant, login_data: dict[str, str] +) -> dict[str, str]: + """Validate login data and return any errors.""" + api = Opower( + async_create_clientsession(hass), + login_data[CONF_UTILITY], + login_data[CONF_USERNAME], + login_data[CONF_PASSWORD], + ) + errors: dict[str, str] = {} + try: + await api.async_login() + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + return errors + + +class OpowerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Opower.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize a new OpowerConfigFlow.""" + self.reauth_entry: config_entries.ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_UTILITY: user_input[CONF_UTILITY], + CONF_USERNAME: user_input[CONF_USERNAME], + } + ) + errors = await _validate_login(self.hass, user_input) + if not errors: + return self.async_create_entry( + title=f"{user_input[CONF_UTILITY]} ({user_input[CONF_USERNAME]})", + data=user_input, + ) + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle configuration by re-auth.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + assert self.reauth_entry + errors: dict[str, str] = {} + if user_input is not None: + data = {**self.reauth_entry.data, **user_input} + errors = await _validate_login(self.hass, data) + if not errors: + self.hass.config_entries.async_update_entry( + self.reauth_entry, data=data + ) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): self.reauth_entry.data[CONF_USERNAME], + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/opower/const.py b/homeassistant/components/opower/const.py new file mode 100644 index 00000000000..b996a214a05 --- /dev/null +++ b/homeassistant/components/opower/const.py @@ -0,0 +1,5 @@ +"""Constants for the Opower integration.""" + +DOMAIN = "opower" + +CONF_UTILITY = "utility" diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py new file mode 100644 index 00000000000..c331f45bc49 --- /dev/null +++ b/homeassistant/components/opower/coordinator.py @@ -0,0 +1,220 @@ +"""Coordinator to handle Opower connections.""" +from datetime import datetime, timedelta +import logging +from types import MappingProxyType +from typing import Any, cast + +from opower import ( + Account, + AggregateType, + CostRead, + Forecast, + InvalidAuth, + MeterType, + Opower, +) + +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, + statistics_during_period, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_UTILITY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): + """Handle fetching Opower data, updating sensors and inserting statistics.""" + + def __init__( + self, + hass: HomeAssistant, + entry_data: MappingProxyType[str, Any], + ) -> None: + """Initialize the data handler.""" + super().__init__( + hass, + _LOGGER, + name="Opower", + # Data is updated daily on Opower. + # Refresh every 12h to be at most 12h behind. + update_interval=timedelta(hours=12), + ) + self.api = Opower( + aiohttp_client.async_get_clientsession(hass), + entry_data[CONF_UTILITY], + entry_data[CONF_USERNAME], + entry_data[CONF_PASSWORD], + ) + + async def _async_update_data( + self, + ) -> dict[str, Forecast]: + """Fetch data from API endpoint.""" + try: + # Login expires after a few minutes. + # Given the infrequent updating (every 12h) + # assume previous session has expired and re-login. + await self.api.async_login() + except InvalidAuth as err: + raise ConfigEntryAuthFailed from err + forecasts: list[Forecast] = await self.api.async_get_forecast() + _LOGGER.debug("Updating sensor data with: %s", forecasts) + await self._insert_statistics([forecast.account for forecast in forecasts]) + return {forecast.account.utility_account_id: forecast for forecast in forecasts} + + async def _insert_statistics(self, accounts: list[Account]) -> None: + """Insert Opower statistics.""" + for account in accounts: + id_prefix = "_".join( + ( + self.api.utility.subdomain(), + account.meter_type.name.lower(), + account.utility_account_id, + ) + ) + cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost" + consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption" + _LOGGER.debug( + "Updating Statistics for %s and %s", + cost_statistic_id, + consumption_statistic_id, + ) + + last_stat = await get_instance(self.hass).async_add_executor_job( + get_last_statistics, self.hass, 1, consumption_statistic_id, True, set() + ) + if not last_stat: + _LOGGER.debug("Updating statistic for the first time") + cost_reads = await self._async_get_all_cost_reads(account) + cost_sum = 0.0 + consumption_sum = 0.0 + last_stats_time = None + else: + cost_reads = await self._async_get_recent_cost_reads( + account, last_stat[consumption_statistic_id][0]["start"] + ) + if not cost_reads: + _LOGGER.debug("No recent usage/cost data. Skipping update") + continue + stats = await get_instance(self.hass).async_add_executor_job( + statistics_during_period, + self.hass, + cost_reads[0].start_time, + None, + {cost_statistic_id, consumption_statistic_id}, + "hour" if account.meter_type == MeterType.ELEC else "day", + None, + {"sum"}, + ) + cost_sum = cast(float, stats[cost_statistic_id][0]["sum"]) + consumption_sum = cast(float, stats[consumption_statistic_id][0]["sum"]) + last_stats_time = stats[cost_statistic_id][0]["start"] + + cost_statistics = [] + consumption_statistics = [] + + for cost_read in cost_reads: + start = cost_read.start_time + if last_stats_time is not None and start.timestamp() <= last_stats_time: + continue + cost_sum += cost_read.provided_cost + consumption_sum += cost_read.consumption + + cost_statistics.append( + StatisticData( + start=start, state=cost_read.provided_cost, sum=cost_sum + ) + ) + consumption_statistics.append( + StatisticData( + start=start, state=cost_read.consumption, sum=consumption_sum + ) + ) + + name_prefix = " ".join( + ( + "Opower", + self.api.utility.subdomain(), + account.meter_type.name.lower(), + account.utility_account_id, + ) + ) + cost_metadata = StatisticMetaData( + has_mean=False, + has_sum=True, + name=f"{name_prefix} cost", + source=DOMAIN, + statistic_id=cost_statistic_id, + unit_of_measurement=None, + ) + consumption_metadata = StatisticMetaData( + has_mean=False, + has_sum=True, + name=f"{name_prefix} consumption", + source=DOMAIN, + statistic_id=consumption_statistic_id, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR + if account.meter_type == MeterType.ELEC + else UnitOfVolume.CENTUM_CUBIC_FEET, + ) + + async_add_external_statistics(self.hass, cost_metadata, cost_statistics) + async_add_external_statistics( + self.hass, consumption_metadata, consumption_statistics + ) + + async def _async_get_all_cost_reads(self, account: Account) -> list[CostRead]: + """Get all cost reads since account activation but at different resolutions depending on age. + + - month resolution for all years (since account activation) + - day resolution for past 3 years + - hour resolution for past 2 months, only for electricity, not gas + """ + cost_reads = [] + start = None + end = datetime.now() - timedelta(days=3 * 365) + cost_reads += await self.api.async_get_cost_reads( + account, AggregateType.BILL, start, end + ) + start = end if not cost_reads else cost_reads[-1].end_time + end = ( + datetime.now() - timedelta(days=2 * 30) + if account.meter_type == MeterType.ELEC + else datetime.now() + ) + cost_reads += await self.api.async_get_cost_reads( + account, AggregateType.DAY, start, end + ) + if account.meter_type == MeterType.ELEC: + start = end if not cost_reads else cost_reads[-1].end_time + end = datetime.now() + cost_reads += await self.api.async_get_cost_reads( + account, AggregateType.HOUR, start, end + ) + return cost_reads + + async def _async_get_recent_cost_reads( + self, account: Account, last_stat_time: float + ) -> list[CostRead]: + """Get cost reads within the past 30 days to allow corrections in data from utilities. + + Hourly for electricity, daily for gas. + """ + return await self.api.async_get_cost_reads( + account, + AggregateType.HOUR + if account.meter_type == MeterType.ELEC + else AggregateType.DAY, + datetime.fromtimestamp(last_stat_time) - timedelta(days=30), + datetime.now(), + ) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json new file mode 100644 index 00000000000..c0eb319c10c --- /dev/null +++ b/homeassistant/components/opower/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "opower", + "name": "Opower", + "codeowners": ["@tronikos"], + "config_flow": true, + "dependencies": ["recorder"], + "documentation": "https://www.home-assistant.io/integrations/opower", + "iot_class": "cloud_polling", + "requirements": ["opower==0.0.18"] +} diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py new file mode 100644 index 00000000000..36f88a36e8a --- /dev/null +++ b/homeassistant/components/opower/sensor.py @@ -0,0 +1,235 @@ +"""Support for Opower sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from opower import Forecast, MeterType, UnitOfMeasure + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfEnergy, UnitOfVolume +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OpowerCoordinator + + +@dataclass +class OpowerEntityDescriptionMixin: + """Mixin values for required keys.""" + + value_fn: Callable[[Forecast], str | float] + + +@dataclass +class OpowerEntityDescription(SensorEntityDescription, OpowerEntityDescriptionMixin): + """Class describing Opower sensors entities.""" + + +# suggested_display_precision=0 for all sensors since +# Opower provides 0 decimal points for all these. +# (for the statistics in the energy dashboard Opower does provide decimal points) +ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( + OpowerEntityDescription( + key="elec_usage_to_date", + name="Current bill electric usage to date", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.usage_to_date, + ), + OpowerEntityDescription( + key="elec_forecasted_usage", + name="Current bill electric forecasted usage", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.forecasted_usage, + ), + OpowerEntityDescription( + key="elec_typical_usage", + name="Typical monthly electric usage", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.typical_usage, + ), + OpowerEntityDescription( + key="elec_cost_to_date", + name="Current bill electric cost to date", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="USD", + suggested_unit_of_measurement="USD", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.cost_to_date, + ), + OpowerEntityDescription( + key="elec_forecasted_cost", + name="Current bill electric forecasted cost", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="USD", + suggested_unit_of_measurement="USD", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.forecasted_cost, + ), + OpowerEntityDescription( + key="elec_typical_cost", + name="Typical monthly electric cost", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="USD", + suggested_unit_of_measurement="USD", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.typical_cost, + ), +) +GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( + OpowerEntityDescription( + key="gas_usage_to_date", + name="Current bill gas usage to date", + device_class=SensorDeviceClass.GAS, + native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, + suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.usage_to_date, + ), + OpowerEntityDescription( + key="gas_forecasted_usage", + name="Current bill gas forecasted usage", + device_class=SensorDeviceClass.GAS, + native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, + suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.forecasted_usage, + ), + OpowerEntityDescription( + key="gas_typical_usage", + name="Typical monthly gas usage", + device_class=SensorDeviceClass.GAS, + native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, + suggested_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.typical_usage, + ), + OpowerEntityDescription( + key="gas_cost_to_date", + name="Current bill gas cost to date", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="USD", + suggested_unit_of_measurement="USD", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.cost_to_date, + ), + OpowerEntityDescription( + key="gas_forecasted_cost", + name="Current bill gas forecasted cost", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="USD", + suggested_unit_of_measurement="USD", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.forecasted_cost, + ), + OpowerEntityDescription( + key="gas_typical_cost", + name="Typical monthly gas cost", + device_class=SensorDeviceClass.MONETARY, + native_unit_of_measurement="USD", + suggested_unit_of_measurement="USD", + state_class=SensorStateClass.TOTAL, + suggested_display_precision=0, + value_fn=lambda data: data.typical_cost, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Opower sensor.""" + + coordinator: OpowerCoordinator = hass.data[DOMAIN][entry.entry_id] + entities: list[OpowerSensor] = [] + forecasts = coordinator.data.values() + for forecast in forecasts: + device_id = f"{coordinator.api.utility.subdomain()}_{forecast.account.utility_account_id}" + device = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + name=f"{forecast.account.meter_type.name} account {forecast.account.utility_account_id}", + manufacturer="Opower", + model=coordinator.api.utility.name(), + entry_type=DeviceEntryType.SERVICE, + ) + sensors: tuple[OpowerEntityDescription, ...] = () + if ( + forecast.account.meter_type == MeterType.ELEC + and forecast.unit_of_measure == UnitOfMeasure.KWH + ): + sensors = ELEC_SENSORS + elif ( + forecast.account.meter_type == MeterType.GAS + and forecast.unit_of_measure == UnitOfMeasure.THERM + ): + sensors = GAS_SENSORS + for sensor in sensors: + entities.append( + OpowerSensor( + coordinator, + sensor, + forecast.account.utility_account_id, + device, + device_id, + ) + ) + + async_add_entities(entities) + + +class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity): + """Representation of an Opower sensor.""" + + entity_description: OpowerEntityDescription + + def __init__( + self, + coordinator: OpowerCoordinator, + description: OpowerEntityDescription, + utility_account_id: str, + device: DeviceInfo, + device_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{description.key}" + self._attr_device_info = device + self.utility_account_id = utility_account_id + + @property + def native_value(self) -> StateType: + """Return the state.""" + if self.coordinator.data is not None: + return self.entity_description.value_fn( + self.coordinator.data[self.utility_account_id] + ) + return None diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json new file mode 100644 index 00000000000..037983eb6ff --- /dev/null +++ b/homeassistant/components/opower/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "data": { + "utility": "Utility name", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 8f8810b5f33..8685282acec 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -24,6 +24,9 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Open Thread Border Router component.""" websocket_api.async_setup(hass) + if len(config_entries := hass.config_entries.async_entries(DOMAIN)): + for config_entry in config_entries[1:]: + await hass.config_entries.async_remove(config_entry.entry_id) return True diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 9fa38cedbe8..35772c00a89 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -54,7 +54,7 @@ async def _title(hass: HomeAssistant, discovery_info: HassioServiceInfo) -> str: return f"Home Assistant Yellow ({discovery_info.name})" if device and "SkyConnect" in device: - return "Home Assistant SkyConnect" + return f"Home Assistant SkyConnect ({discovery_info.name})" return discovery_info.name diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 94659df8547..a8a5ae062f7 100644 --- a/homeassistant/components/otbr/manifest.json +++ b/homeassistant/components/otbr/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/otbr", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.2.0"] + "requirements": ["python-otbr-api==2.3.0"] } diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 06bbca3a4ab..3b631057529 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -3,11 +3,14 @@ from typing import cast import python_otbr_api -from python_otbr_api import tlv_parser +from python_otbr_api import PENDING_DATASET_DELAY_TIMER, tlv_parser from python_otbr_api.tlv_parser import MeshcopTLVType import voluptuous as vol from homeassistant.components import websocket_api +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, +) from homeassistant.components.thread import async_add_dataset, async_get_dataset from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -22,6 +25,7 @@ def async_setup(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_info) websocket_api.async_register_command(hass, websocket_create_network) websocket_api.async_register_command(hass, websocket_get_extended_address) + websocket_api.async_register_command(hass, websocket_set_channel) websocket_api.async_register_command(hass, websocket_set_network) @@ -43,7 +47,8 @@ async def websocket_info( data: OTBRData = hass.data[DOMAIN] try: - dataset = await data.get_active_dataset_tlvs() + dataset = await data.get_active_dataset() + dataset_tlvs = await data.get_active_dataset_tlvs() except HomeAssistantError as exc: connection.send_error(msg["id"], "get_dataset_failed", str(exc)) return @@ -52,7 +57,8 @@ async def websocket_info( msg["id"], { "url": data.url, - "active_dataset_tlvs": dataset.hex() if dataset else None, + "active_dataset_tlvs": dataset_tlvs.hex() if dataset_tlvs else None, + "channel": dataset.channel if dataset else None, }, ) @@ -205,3 +211,41 @@ async def websocket_get_extended_address( return connection.send_result(msg["id"], {"extended_address": extended_address.hex()}) + + +@websocket_api.websocket_command( + { + "type": "otbr/set_channel", + vol.Required("channel"): int, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_set_channel( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Set current channel.""" + if DOMAIN not in hass.data: + connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded") + return + + data: OTBRData = hass.data[DOMAIN] + + if is_multiprotocol_url(data.url): + connection.send_error( + msg["id"], + "multiprotocol_enabled", + "Channel change not allowed when in multiprotocol mode", + ) + return + + channel: int = msg["channel"] + delay: float = PENDING_DATASET_DELAY_TIMER / 1000 + + try: + await data.set_channel(channel) + except HomeAssistantError as exc: + connection.send_error(msg["id"], "set_channel_failed", str(exc)) + return + + connection.send_result(msg["id"], {"delay": delay}) diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index b27051b1492..7c9cab5f181 100644 --- a/homeassistant/components/overkiz/coordinator.py +++ b/homeassistant/components/overkiz/coordinator.py @@ -178,7 +178,9 @@ async def on_device_removed( base_device_url = event.device_url.split("#")[0] registry = dr.async_get(coordinator.hass) - if registered_device := registry.async_get_device({(DOMAIN, base_device_url)}): + if registered_device := registry.async_get_device( + identifiers={(DOMAIN, base_device_url)} + ): registry.async_remove_device(registered_device.id) if event.device_url: diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py index 16ea12a5d96..fa531410e33 100644 --- a/homeassistant/components/overkiz/entity.py +++ b/homeassistant/components/overkiz/entity.py @@ -60,9 +60,9 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]): if self.is_sub_device: # Only return the url of the base device, to inherit device name # and model from parent device. - return { - "identifiers": {(DOMAIN, self.executor.base_device_url)}, - } + return DeviceInfo( + identifiers={(DOMAIN, self.executor.base_device_url)}, + ) manufacturer = ( self.executor.select_attribute(OverkizAttribute.CORE_MANUFACTURER) diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 9aca0850b05..c841e3b0e36 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -527,6 +527,6 @@ class OverkizHomeKitSetupCodeSensor(OverkizEntity, SensorEntity): # By default this sensor will be listed at a virtual HomekitStack device, # but it makes more sense to show this at the gateway device # in the entity registry. - return { - "identifiers": {(DOMAIN, self.executor.get_gateway_id())}, - } + return DeviceInfo( + identifiers={(DOMAIN, self.executor.get_gateway_id())}, + ) diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 41405780124..c4daf32499a 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -47,7 +47,7 @@ }, "fan_mode": { "state": { - "away": "Away", + "away": "[%key:common::state::not_home%]", "bypass_boost": "Bypass boost", "home_boost": "Home boost", "kitchen_boost": "Kitchen boost" @@ -59,9 +59,9 @@ "select": { "open_closed_pedestrian": { "state": { - "open": "Open", + "open": "[%key:common::state::open%]", "pedestrian": "Pedestrian", - "closed": "Closed" + "closed": "[%key:common::state::closed%]" } }, "memorized_simple_volume": { @@ -121,8 +121,8 @@ }, "three_way_handle_direction": { "state": { - "closed": "Closed", - "open": "Open", + "closed": "[%key:common::state::closed%]", + "open": "[%key:common::state::open%]", "tilt": "Tilt" } } diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index a1fc632c2fd..ba0beb40cf8 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -59,6 +59,9 @@ async def async_setup_entry( class OwnTracksEntity(TrackerEntity, RestoreEntity): """Represent a tracked device.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, dev_id, data=None): """Set up OwnTracks entity.""" self._dev_id = dev_id @@ -108,11 +111,6 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity): """Return a location name for the current location of the device.""" return self._data.get("location_name") - @property - def name(self): - """Return the name of the device.""" - return self._data.get("host_name") - @property def source_type(self) -> SourceType: """Return the source type, eg gps or router, of the device.""" @@ -121,7 +119,10 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity): @property def device_info(self) -> DeviceInfo: """Return the device info.""" - return DeviceInfo(identifiers={(OT_DOMAIN, self._dev_id)}, name=self.name) + device_info = DeviceInfo(identifiers={(OT_DOMAIN, self._dev_id)}) + if "host_name" in self._data: + device_info["name"] = self._data["host_name"] + return device_info async def async_added_to_hass(self) -> None: """Call when entity about to be added to Home Assistant.""" diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py index f192dd44300..21a878fa187 100644 --- a/homeassistant/components/p1_monitor/sensor.py +++ b/homeassistant/components/p1_monitor/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Literal from homeassistant.components.sensor import ( - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -39,7 +38,7 @@ from .const import ( SENSORS_SMARTMETER: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="gas_consumption", - name="Gas Consumption", + translation_key="gas_consumption", entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.GAS, @@ -47,49 +46,49 @@ SENSORS_SMARTMETER: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="power_consumption", - name="Power Consumption", + translation_key="power_consumption", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="energy_consumption_high", - name="Energy Consumption - High Tariff", + translation_key="energy_consumption_high", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="energy_consumption_low", - name="Energy Consumption - Low Tariff", + translation_key="energy_consumption_low", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="power_production", - name="Power Production", + translation_key="power_production", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="energy_production_high", - name="Energy Production - High Tariff", + translation_key="energy_production_high", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="energy_production_low", - name="Energy Production - Low Tariff", + translation_key="energy_production_low", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="energy_tariff_period", - name="Energy Tariff Period", + translation_key="energy_tariff_period", icon="mdi:calendar-clock", ), ) @@ -97,84 +96,84 @@ SENSORS_SMARTMETER: tuple[SensorEntityDescription, ...] = ( SENSORS_PHASES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="voltage_phase_l1", - name="Voltage Phase L1", + translation_key="voltage_phase_l1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="voltage_phase_l2", - name="Voltage Phase L2", + translation_key="voltage_phase_l2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="voltage_phase_l3", - name="Voltage Phase L3", + translation_key="voltage_phase_l3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="current_phase_l1", - name="Current Phase L1", + translation_key="current_phase_l1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="current_phase_l2", - name="Current Phase L2", + translation_key="current_phase_l2", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="current_phase_l3", - name="Current Phase L3", + translation_key="current_phase_l3", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="power_consumed_phase_l1", - name="Power Consumed Phase L1", + translation_key="power_consumed_phase_l1", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="power_consumed_phase_l2", - name="Power Consumed Phase L2", + translation_key="power_consumed_phase_l2", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="power_consumed_phase_l3", - name="Power Consumed Phase L3", + translation_key="power_consumed_phase_l3", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="power_produced_phase_l1", - name="Power Produced Phase L1", + translation_key="power_produced_phase_l1", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="power_produced_phase_l2", - name="Power Produced Phase L2", + translation_key="power_produced_phase_l2", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="power_produced_phase_l3", - name="Power Produced Phase L3", + translation_key="power_produced_phase_l3", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -184,32 +183,32 @@ SENSORS_PHASES: tuple[SensorEntityDescription, ...] = ( SENSORS_SETTINGS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="gas_consumption_price", - name="Gas Consumption Price", + translation_key="gas_consumption_price", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfVolume.CUBIC_METERS}", ), SensorEntityDescription( key="energy_consumption_price_low", - name="Energy Consumption Price - Low", + translation_key="energy_consumption_price_low", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", ), SensorEntityDescription( key="energy_consumption_price_high", - name="Energy Consumption Price - High", + translation_key="energy_consumption_price_high", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", ), SensorEntityDescription( key="energy_production_price_low", - name="Energy Production Price - Low", + translation_key="energy_production_price_low", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", ), SensorEntityDescription( key="energy_production_price_high", - name="Energy Production Price - High", + translation_key="energy_production_price_high", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", ), @@ -218,21 +217,21 @@ SENSORS_SETTINGS: tuple[SensorEntityDescription, ...] = ( SENSORS_WATERMETER: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="consumption_day", - name="Consumption Day", + translation_key="consumption_day", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfVolume.LITERS, device_class=SensorDeviceClass.WATER, ), SensorEntityDescription( key="consumption_total", - name="Consumption Total", + translation_key="consumption_total", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.WATER, ), SensorEntityDescription( key="pulse_count", - name="Pulse Count", + translation_key="pulse_count", ), ) @@ -248,7 +247,6 @@ async def async_setup_entry( coordinator=coordinator, description=description, name="SmartMeter", - service_key="smartmeter", service=SERVICE_SMARTMETER, ) for description in SENSORS_SMARTMETER @@ -258,7 +256,6 @@ async def async_setup_entry( coordinator=coordinator, description=description, name="Phases", - service_key="phases", service=SERVICE_PHASES, ) for description in SENSORS_PHASES @@ -268,7 +265,6 @@ async def async_setup_entry( coordinator=coordinator, description=description, name="Settings", - service_key="settings", service=SERVICE_SETTINGS, ) for description in SENSORS_SETTINGS @@ -279,7 +275,6 @@ async def async_setup_entry( coordinator=coordinator, description=description, name="WaterMeter", - service_key="watermeter", service=SERVICE_WATERMETER, ) for description in SENSORS_WATERMETER @@ -292,30 +287,28 @@ class P1MonitorSensorEntity( ): """Defines an P1 Monitor sensor.""" + _attr_has_entity_name = True + def __init__( self, *, coordinator: P1MonitorDataUpdateCoordinator, description: SensorEntityDescription, - service_key: Literal["smartmeter", "watermeter", "phases", "settings"], name: str, - service: str, + service: Literal["smartmeter", "watermeter", "phases", "settings"], ) -> None: """Initialize P1 Monitor sensor.""" super().__init__(coordinator=coordinator) - self._service_key = service_key + self._service_key = service - self.entity_id = f"{SENSOR_DOMAIN}.{service}_{description.key}" self.entity_description = description self._attr_unique_id = ( - f"{coordinator.config_entry.entry_id}_{service_key}_{description.key}" + f"{coordinator.config_entry.entry_id}_{service}_{description.key}" ) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={ - (DOMAIN, f"{coordinator.config_entry.entry_id}_{service_key}") - }, + identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{service}")}, configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}", manufacturer="P1 Monitor", name=name, diff --git a/homeassistant/components/p1_monitor/strings.json b/homeassistant/components/p1_monitor/strings.json index 0c745554e9d..781ca109235 100644 --- a/homeassistant/components/p1_monitor/strings.json +++ b/homeassistant/components/p1_monitor/strings.json @@ -14,5 +14,93 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "sensor": { + "gas_consumption": { + "name": "Gas consumption" + }, + "power_consumption": { + "name": "Power consumption" + }, + "energy_consumption_high": { + "name": "Energy consumption - High tariff" + }, + "energy_consumption_low": { + "name": "Energy consumption - Low tariff" + }, + "power_production": { + "name": "Power production" + }, + "energy_production_high": { + "name": "Energy production - High tariff" + }, + "energy_production_low": { + "name": "Energy production - Low tariff" + }, + "energy_tariff_period": { + "name": "Energy tariff period" + }, + "voltage_phase_l1": { + "name": "Voltage phase L1" + }, + "voltage_phase_l2": { + "name": "Voltage phase L2" + }, + "voltage_phase_l3": { + "name": "Voltage phase L3" + }, + "current_phase_l1": { + "name": "Current phase L1" + }, + "current_phase_l2": { + "name": "Current phase L2" + }, + "current_phase_l3": { + "name": "Current phase L3" + }, + "power_consumed_phase_l1": { + "name": "Power consumed phase L1" + }, + "power_consumed_phase_l2": { + "name": "Power consumed phase L2" + }, + "power_consumed_phase_l3": { + "name": "Power consumed phase L3" + }, + "power_produced_phase_l1": { + "name": "Power produced phase L1" + }, + "power_produced_phase_l2": { + "name": "Power produced phase L2" + }, + "power_produced_phase_l3": { + "name": "Power produced phase L3" + }, + "gas_consumption_price": { + "name": "Gas consumption price" + }, + "energy_consumption_price_low": { + "name": "Energy consumption price - Low" + }, + "energy_consumption_price_high": { + "name": "Energy consumption price - High" + }, + "energy_production_price_low": { + "name": "Energy production price - Low" + }, + "energy_production_price_high": { + "name": "Energy production price - High" + }, + "consumption_day": { + "name": "Consumption day" + }, + "consumption_total": { + "name": "Consumption total" + }, + "pulse_count": { + "name": "Pulse count" + } + } } } diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py index 493a738c1ea..4f084d5900a 100644 --- a/homeassistant/components/panel_custom/__init__.py +++ b/homeassistant/components/panel_custom/__init__.py @@ -92,6 +92,8 @@ async def async_register_panel( config: ConfigType | None = None, # If your panel should only be shown to admin users require_admin: bool = False, + # If your panel is used to configure an integration, needs the domain of the integration + config_panel_domain: str | None = None, ) -> None: """Register a new custom panel.""" if js_url is None and module_url is None: @@ -127,6 +129,7 @@ async def async_register_panel( frontend_url_path=frontend_url_path, config=config, require_admin=require_admin, + config_panel_domain=config_panel_domain, ) diff --git a/homeassistant/components/peco_opower/__init__.py b/homeassistant/components/peco_opower/__init__.py new file mode 100644 index 00000000000..a0d26cf7b13 --- /dev/null +++ b/homeassistant/components/peco_opower/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: PECO Energy Company (PECO).""" diff --git a/homeassistant/components/peco_opower/manifest.json b/homeassistant/components/peco_opower/manifest.json new file mode 100644 index 00000000000..e0c58729ce5 --- /dev/null +++ b/homeassistant/components/peco_opower/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "peco_opower", + "name": "PECO Energy Company (PECO)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py new file mode 100644 index 00000000000..a2767cb749b --- /dev/null +++ b/homeassistant/components/pegel_online/__init__.py @@ -0,0 +1,49 @@ +"""The PEGELONLINE component.""" +from __future__ import annotations + +import logging + +from aiopegelonline import PegelOnline + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_STATION, + DOMAIN, +) +from .coordinator import PegelOnlineDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up PEGELONLINE entry.""" + station_uuid = entry.data[CONF_STATION] + + _LOGGER.debug("Setting up station with uuid %s", station_uuid) + + api = PegelOnline(async_get_clientsession(hass)) + station = await api.async_get_station_details(station_uuid) + + coordinator = PegelOnlineDataUpdateCoordinator(hass, entry.title, api, station) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload PEGELONLINE entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/pegel_online/config_flow.py b/homeassistant/components/pegel_online/config_flow.py new file mode 100644 index 00000000000..a72e450e2e5 --- /dev/null +++ b/homeassistant/components/pegel_online/config_flow.py @@ -0,0 +1,134 @@ +"""Config flow for PEGELONLINE.""" +from __future__ import annotations + +from typing import Any + +from aiopegelonline import CONNECT_ERRORS, PegelOnline +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_RADIUS, + UnitOfLength, +) +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + LocationSelector, + NumberSelector, + NumberSelectorConfig, + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_STATION, DEFAULT_RADIUS, DOMAIN + + +class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Init the FlowHandler.""" + super().__init__() + self._data: dict[str, Any] = {} + self._stations: dict[str, str] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + if not user_input: + return self._show_form_user() + + api = PegelOnline(async_get_clientsession(self.hass)) + try: + stations = await api.async_get_nearby_stations( + user_input[CONF_LOCATION][CONF_LATITUDE], + user_input[CONF_LOCATION][CONF_LONGITUDE], + user_input[CONF_RADIUS], + ) + except CONNECT_ERRORS: + return self._show_form_user(user_input, errors={"base": "cannot_connect"}) + + if len(stations) == 0: + return self._show_form_user(user_input, errors={CONF_RADIUS: "no_stations"}) + + for uuid, station in stations.items(): + self._stations[uuid] = f"{station.name} {station.water_name}" + + self._data = user_input + + return await self.async_step_select_station() + + async def async_step_select_station( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the step select_station of a flow initialized by the user.""" + if not user_input: + stations = [ + SelectOptionDict(value=k, label=v) for k, v in self._stations.items() + ] + return self.async_show_form( + step_id="select_station", + description_placeholders={"stations_count": str(len(self._stations))}, + data_schema=vol.Schema( + { + vol.Required(CONF_STATION): SelectSelector( + SelectSelectorConfig( + options=stations, mode=SelectSelectorMode.DROPDOWN + ) + ) + } + ), + ) + + await self.async_set_unique_id(user_input[CONF_STATION]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=self._stations[user_input[CONF_STATION]], + data=user_input, + ) + + def _show_form_user( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, Any] | None = None, + ) -> FlowResult: + if user_input is None: + user_input = {} + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_LOCATION, + default=user_input.get( + CONF_LOCATION, + { + "latitude": self.hass.config.latitude, + "longitude": self.hass.config.longitude, + }, + ), + ): LocationSelector(), + vol.Required( + CONF_RADIUS, default=user_input.get(CONF_RADIUS, DEFAULT_RADIUS) + ): NumberSelector( + NumberSelectorConfig( + min=1, + max=100, + step=1, + unit_of_measurement=UnitOfLength.KILOMETERS, + ), + ), + } + ), + errors=errors, + ) diff --git a/homeassistant/components/pegel_online/const.py b/homeassistant/components/pegel_online/const.py new file mode 100644 index 00000000000..1e6c26a057b --- /dev/null +++ b/homeassistant/components/pegel_online/const.py @@ -0,0 +1,9 @@ +"""Constants for PEGELONLINE.""" +from datetime import timedelta + +DOMAIN = "pegel_online" + +DEFAULT_RADIUS = "25" +CONF_STATION = "station" + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) diff --git a/homeassistant/components/pegel_online/coordinator.py b/homeassistant/components/pegel_online/coordinator.py new file mode 100644 index 00000000000..8fab3ce36ae --- /dev/null +++ b/homeassistant/components/pegel_online/coordinator.py @@ -0,0 +1,40 @@ +"""DataUpdateCoordinator for pegel_online.""" +import logging + +from aiopegelonline import CONNECT_ERRORS, PegelOnline, Station + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import MIN_TIME_BETWEEN_UPDATES +from .model import PegelOnlineData + +_LOGGER = logging.getLogger(__name__) + + +class PegelOnlineDataUpdateCoordinator(DataUpdateCoordinator[PegelOnlineData]): + """DataUpdateCoordinator for the pegel_online integration.""" + + def __init__( + self, hass: HomeAssistant, name: str, api: PegelOnline, station: Station + ) -> None: + """Initialize the PegelOnlineDataUpdateCoordinator.""" + self.api = api + self.station = station + super().__init__( + hass, + _LOGGER, + name=name, + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + + async def _async_update_data(self) -> PegelOnlineData: + """Fetch data from API endpoint.""" + try: + water_level = await self.api.async_get_station_measurement( + self.station.uuid + ) + except CONNECT_ERRORS as err: + raise UpdateFailed(f"Failed to communicate with API: {err}") from err + + return {"water_level": water_level} diff --git a/homeassistant/components/pegel_online/entity.py b/homeassistant/components/pegel_online/entity.py new file mode 100644 index 00000000000..c8a01623c7d --- /dev/null +++ b/homeassistant/components/pegel_online/entity.py @@ -0,0 +1,31 @@ +"""The PEGELONLINE base entity.""" +from __future__ import annotations + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PegelOnlineDataUpdateCoordinator + + +class PegelOnlineEntity(CoordinatorEntity[PegelOnlineDataUpdateCoordinator]): + """Representation of a PEGELONLINE entity.""" + + _attr_has_entity_name = True + _attr_available = True + + def __init__(self, coordinator: PegelOnlineDataUpdateCoordinator) -> None: + """Initialize a PEGELONLINE entity.""" + super().__init__(coordinator) + self.station = coordinator.station + self._attr_extra_state_attributes = {} + + @property + def device_info(self) -> DeviceInfo: + """Return the device information of the entity.""" + return DeviceInfo( + identifiers={(DOMAIN, self.station.uuid)}, + name=f"{self.station.name} {self.station.water_name}", + manufacturer=self.station.agency, + configuration_url=self.station.base_data_url, + ) diff --git a/homeassistant/components/pegel_online/manifest.json b/homeassistant/components/pegel_online/manifest.json new file mode 100644 index 00000000000..a51954496cd --- /dev/null +++ b/homeassistant/components/pegel_online/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "pegel_online", + "name": "PEGELONLINE", + "codeowners": ["@mib1185"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/pegel_online", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["aiopegelonline"], + "requirements": ["aiopegelonline==0.0.5"] +} diff --git a/homeassistant/components/pegel_online/model.py b/homeassistant/components/pegel_online/model.py new file mode 100644 index 00000000000..c8dac75bcf2 --- /dev/null +++ b/homeassistant/components/pegel_online/model.py @@ -0,0 +1,11 @@ +"""Models for PEGELONLINE.""" + +from typing import TypedDict + +from aiopegelonline import CurrentMeasurement + + +class PegelOnlineData(TypedDict): + """TypedDict for PEGELONLINE Coordinator Data.""" + + water_level: CurrentMeasurement diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py new file mode 100644 index 00000000000..14ec0c2d032 --- /dev/null +++ b/homeassistant/components/pegel_online/sensor.py @@ -0,0 +1,89 @@ +"""PEGELONLINE sensor entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import PegelOnlineDataUpdateCoordinator +from .entity import PegelOnlineEntity +from .model import PegelOnlineData + + +@dataclass +class PegelOnlineRequiredKeysMixin: + """Mixin for required keys.""" + + fn_native_unit: Callable[[PegelOnlineData], str] + fn_native_value: Callable[[PegelOnlineData], float] + + +@dataclass +class PegelOnlineSensorEntityDescription( + SensorEntityDescription, PegelOnlineRequiredKeysMixin +): + """PEGELONLINE sensor entity description.""" + + +SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( + PegelOnlineSensorEntityDescription( + key="water_level", + translation_key="water_level", + state_class=SensorStateClass.MEASUREMENT, + fn_native_unit=lambda data: data["water_level"].uom, + fn_native_value=lambda data: data["water_level"].value, + icon="mdi:waves-arrow-up", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the PEGELONLINE sensor.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [PegelOnlineSensor(coordinator, description) for description in SENSORS] + ) + + +class PegelOnlineSensor(PegelOnlineEntity, SensorEntity): + """Representation of a PEGELONLINE sensor.""" + + entity_description: PegelOnlineSensorEntityDescription + + def __init__( + self, + coordinator: PegelOnlineDataUpdateCoordinator, + description: PegelOnlineSensorEntityDescription, + ) -> None: + """Initialize a PEGELONLINE sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{self.station.uuid}_{description.key}" + self._attr_native_unit_of_measurement = self.entity_description.fn_native_unit( + coordinator.data + ) + + if self.station.latitude and self.station.longitude: + self._attr_extra_state_attributes.update( + { + ATTR_LATITUDE: self.station.latitude, + ATTR_LONGITUDE: self.station.longitude, + } + ) + + @property + def native_value(self) -> float: + """Return the state of the device.""" + return self.entity_description.fn_native_value(self.coordinator.data) diff --git a/homeassistant/components/pegel_online/strings.json b/homeassistant/components/pegel_online/strings.json new file mode 100644 index 00000000000..930e349f9c3 --- /dev/null +++ b/homeassistant/components/pegel_online/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "user": { + "description": "Select the area, where you want to search for water measuring stations", + "data": { + "location": "[%key:common::config_flow::data::location%]", + "radius": "Search radius (in km)" + } + }, + "select_station": { + "title": "Select the measuring station to add", + "description": "Found {stations_count} stations in radius", + "data": { + "station": "Station" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_stations": "Could not find any station in range." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "entity": { + "sensor": { + "water_level": { + "name": "Water level" + } + } + } +} diff --git a/homeassistant/components/pepco/__init__.py b/homeassistant/components/pepco/__init__.py new file mode 100644 index 00000000000..2ffcd22ade1 --- /dev/null +++ b/homeassistant/components/pepco/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Potomac Electric Power Company (Pepco).""" diff --git a/homeassistant/components/pepco/manifest.json b/homeassistant/components/pepco/manifest.json new file mode 100644 index 00000000000..97a837399d0 --- /dev/null +++ b/homeassistant/components/pepco/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "pepco", + "name": "Potomac Electric Power Company (Pepco)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 581720c2730..c9e8e3703db 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -3,12 +3,12 @@ from __future__ import annotations from collections.abc import Callable, Mapping from datetime import datetime +from enum import StrEnum import logging from typing import Any, Final, TypedDict import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.components import websocket_api from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv, singleton diff --git a/homeassistant/components/persistent_notification/services.yaml b/homeassistant/components/persistent_notification/services.yaml index 046ea237560..c335d962600 100644 --- a/homeassistant/components/persistent_notification/services.yaml +++ b/homeassistant/components/persistent_notification/services.yaml @@ -1,39 +1,25 @@ create: - name: Create - description: Show a notification in the frontend. fields: message: - name: Message - description: Message body of the notification. required: true example: Please check your configuration.yaml. selector: text: title: - name: Title - description: Optional title for your notification. example: Test notification selector: text: notification_id: - name: Notification ID - description: Target ID of the notification, will replace a notification with the same ID. example: 1234 selector: text: dismiss: - name: Dismiss - description: Remove a notification from the frontend. fields: notification_id: - name: Notification ID - description: Target ID of the notification, which should be removed. required: true example: 1234 selector: text: dismiss_all: - name: Dismiss All - description: Remove all notifications. diff --git a/homeassistant/components/persistent_notification/strings.json b/homeassistant/components/persistent_notification/strings.json new file mode 100644 index 00000000000..5f256233149 --- /dev/null +++ b/homeassistant/components/persistent_notification/strings.json @@ -0,0 +1,36 @@ +{ + "services": { + "create": { + "name": "Create", + "description": "Shows a notification on the **Notifications** panel.", + "fields": { + "message": { + "name": "Message", + "description": "Message body of the notification." + }, + "title": { + "name": "Title", + "description": "Optional title of the notification." + }, + "notification_id": { + "name": "Notification ID", + "description": "ID of the notification. This new notification will overwrite an existing notification with the same ID." + } + } + }, + "dismiss": { + "name": "Dismiss", + "description": "Removes a notification from the **Notifications** panel.", + "fields": { + "notification_id": { + "name": "[%key:component::persistent_notification::services::create::fields::notification_id::name%]", + "description": "ID of the notification to be removed." + } + } + }, + "dismiss_all": { + "name": "Dismiss all", + "description": "Removes all notifications from the **Notifications** panel." + } + } +} diff --git a/homeassistant/components/person/services.yaml b/homeassistant/components/person/services.yaml index 265c6049563..c983a105c93 100644 --- a/homeassistant/components/person/services.yaml +++ b/homeassistant/components/person/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload the person configuration. diff --git a/homeassistant/components/person/strings.json b/homeassistant/components/person/strings.json index 8a8915541d8..27c41df6b4e 100644 --- a/homeassistant/components/person/strings.json +++ b/homeassistant/components/person/strings.json @@ -25,5 +25,11 @@ } } } + }, + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads persons from the YAML-configuration." + } } } diff --git a/homeassistant/components/pge/__init__.py b/homeassistant/components/pge/__init__.py new file mode 100644 index 00000000000..e4402a7a3c2 --- /dev/null +++ b/homeassistant/components/pge/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Pacific Gas & Electric (PG&E).""" diff --git a/homeassistant/components/pge/manifest.json b/homeassistant/components/pge/manifest.json new file mode 100644 index 00000000000..4c1fa71a4b8 --- /dev/null +++ b/homeassistant/components/pge/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "pge", + "name": "Pacific Gas & Electric (PG&E)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 55ac33d198f..6f72f31ae8f 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -7,7 +7,12 @@ from datetime import timedelta import logging from typing import Any -from haphilipsjs import AutenticationFailure, ConnectionFailure, PhilipsTV +from haphilipsjs import ( + AutenticationFailure, + ConnectionFailure, + GeneralFailure, + PhilipsTV, +) from haphilipsjs.typing import SystemType from homeassistant.config_entries import ConfigEntry @@ -21,7 +26,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, DOMAIN @@ -101,6 +107,19 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): ), ) + @property + def device_info(self) -> DeviceInfo: + """Return device info.""" + return DeviceInfo( + identifiers={ + (DOMAIN, self.unique_id), + }, + manufacturer="Philips", + model=self.system.get("model"), + name=self.system["name"], + sw_version=self.system.get("softwareversion"), + ) + @property def system(self) -> SystemType: """Return the system descriptor.""" @@ -173,3 +192,5 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): pass except AutenticationFailure as exception: raise ConfigEntryAuthFailed(str(exception)) from exception + except GeneralFailure as exception: + raise UpdateFailed(str(exception)) from exception diff --git a/homeassistant/components/philips_js/entity.py b/homeassistant/components/philips_js/entity.py new file mode 100644 index 00000000000..c2645974f49 --- /dev/null +++ b/homeassistant/components/philips_js/entity.py @@ -0,0 +1,20 @@ +"""Base Philips js entity.""" +from __future__ import annotations + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import PhilipsTVDataUpdateCoordinator + + +class PhilipsJsEntity(CoordinatorEntity[PhilipsTVDataUpdateCoordinator]): + """Base Philips js entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PhilipsTVDataUpdateCoordinator, + ) -> None: + """Initialize light.""" + super().__init__(coordinator) + self._attr_device_info = coordinator.device_info diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py index 8df88ff923a..75f43039de8 100644 --- a/homeassistant/components/philips_js/light.py +++ b/homeassistant/components/philips_js/light.py @@ -18,13 +18,12 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.color import color_hsv_to_RGB, color_RGB_to_hsv from . import PhilipsTVDataUpdateCoordinator from .const import DOMAIN +from .entity import PhilipsJsEntity EFFECT_PARTITION = ": " EFFECT_MODE = "Mode" @@ -134,12 +133,10 @@ def _average_pixels(data): return 0.0, 0.0, 0.0 -class PhilipsTVLightEntity( - CoordinatorEntity[PhilipsTVDataUpdateCoordinator], LightEntity -): +class PhilipsTVLightEntity(PhilipsJsEntity, LightEntity): """Representation of a Philips TV exposing the JointSpace API.""" - _attr_has_entity_name = True + _attr_translation_key = "ambilight" def __init__( self, @@ -155,18 +152,8 @@ class PhilipsTVLightEntity( self._attr_supported_color_modes = {ColorMode.HS, ColorMode.ONOFF} self._attr_supported_features = LightEntityFeature.EFFECT - self._attr_name = "Ambilight" self._attr_unique_id = coordinator.unique_id self._attr_icon = "mdi:television-ambient-light" - self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, self._attr_unique_id), - }, - manufacturer="Philips", - model=coordinator.system.get("model"), - name=coordinator.system["name"], - sw_version=coordinator.system.get("softwareversion"), - ) self._update_from_coordinator() diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py index bdd55bb2dad..6ee70b03d54 100644 --- a/homeassistant/components/philips_js/media_player.py +++ b/homeassistant/components/philips_js/media_player.py @@ -18,13 +18,12 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.trigger import PluggableAction -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import LOGGER as _LOGGER, PhilipsTVDataUpdateCoordinator from .const import DOMAIN +from .entity import PhilipsJsEntity from .helpers import async_get_turn_on_trigger SUPPORT_PHILIPS_JS = ( @@ -63,13 +62,10 @@ async def async_setup_entry( ) -class PhilipsTVMediaPlayer( - CoordinatorEntity[PhilipsTVDataUpdateCoordinator], MediaPlayerEntity -): +class PhilipsTVMediaPlayer(PhilipsJsEntity, MediaPlayerEntity): """Representation of a Philips TV exposing the JointSpace API.""" _attr_device_class = MediaPlayerDeviceClass.TV - _attr_has_entity_name = True _attr_name = None def __init__( @@ -80,15 +76,6 @@ class PhilipsTVMediaPlayer( self._tv = coordinator.api self._sources: dict[str, str] = {} self._attr_unique_id = coordinator.unique_id - self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, coordinator.unique_id), - }, - manufacturer="Philips", - model=coordinator.system.get("model"), - sw_version=coordinator.system.get("softwareversion"), - name=coordinator.system["name"], - ) self._attr_state = MediaPlayerState.OFF self._turn_on = PluggableAction(self.async_write_ha_state) diff --git a/homeassistant/components/philips_js/remote.py b/homeassistant/components/philips_js/remote.py index 0aa61979eb2..c5b24089809 100644 --- a/homeassistant/components/philips_js/remote.py +++ b/homeassistant/components/philips_js/remote.py @@ -13,13 +13,12 @@ from homeassistant.components.remote import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.trigger import PluggableAction -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import LOGGER, PhilipsTVDataUpdateCoordinator from .const import DOMAIN +from .entity import PhilipsJsEntity from .helpers import async_get_turn_on_trigger @@ -33,10 +32,10 @@ async def async_setup_entry( async_add_entities([PhilipsTVRemote(coordinator)]) -class PhilipsTVRemote(CoordinatorEntity[PhilipsTVDataUpdateCoordinator], RemoteEntity): +class PhilipsTVRemote(PhilipsJsEntity, RemoteEntity): """Device that sends commands.""" - _attr_has_entity_name = True + _attr_translation_key = "remote" def __init__( self, @@ -45,17 +44,7 @@ class PhilipsTVRemote(CoordinatorEntity[PhilipsTVDataUpdateCoordinator], RemoteE """Initialize the Philips TV.""" super().__init__(coordinator) self._tv = coordinator.api - self._attr_name = "Remote" self._attr_unique_id = coordinator.unique_id - self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, coordinator.unique_id), - }, - manufacturer="Philips", - model=coordinator.system.get("model"), - name=coordinator.system["name"], - sw_version=coordinator.system.get("softwareversion"), - ) self._turn_on = PluggableAction(self.async_write_ha_state) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json index 302e1b9accf..a260d42feda 100644 --- a/homeassistant/components/philips_js/strings.json +++ b/homeassistant/components/philips_js/strings.json @@ -39,5 +39,25 @@ "trigger_type": { "turn_on": "Device is requested to turn on" } + }, + "entity": { + "light": { + "ambilight": { + "name": "Ambilight" + } + }, + "remote": { + "remote": { + "name": "[%key:component::remote::title%]" + } + }, + "switch": { + "screen_state": { + "name": "Screen state" + }, + "ambilight_hue": { + "name": "Ambilight + Hue" + } + } } } diff --git a/homeassistant/components/philips_js/switch.py b/homeassistant/components/philips_js/switch.py index b66fd3296d9..29cfa10a230 100644 --- a/homeassistant/components/philips_js/switch.py +++ b/homeassistant/components/philips_js/switch.py @@ -6,12 +6,11 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PhilipsTVDataUpdateCoordinator from .const import DOMAIN +from .entity import PhilipsJsEntity HUE_POWER_OFF = "Off" HUE_POWER_ON = "On" @@ -33,12 +32,10 @@ async def async_setup_entry( async_add_entities([PhilipsTVAmbilightHueSwitch(coordinator)]) -class PhilipsTVScreenSwitch( - CoordinatorEntity[PhilipsTVDataUpdateCoordinator], SwitchEntity -): +class PhilipsTVScreenSwitch(PhilipsJsEntity, SwitchEntity): """A Philips TV screen state switch.""" - _attr_has_entity_name = True + _attr_translation_key = "screen_state" def __init__( self, @@ -48,14 +45,8 @@ class PhilipsTVScreenSwitch( super().__init__(coordinator) - self._attr_name = "Screen state" self._attr_icon = "mdi:television-shimmer" self._attr_unique_id = f"{coordinator.unique_id}_screenstate" - self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, coordinator.unique_id), - } - ) @property def available(self) -> bool: @@ -80,11 +71,11 @@ class PhilipsTVScreenSwitch( await self.coordinator.api.setScreenState("Off") -class PhilipsTVAmbilightHueSwitch( - CoordinatorEntity[PhilipsTVDataUpdateCoordinator], SwitchEntity -): +class PhilipsTVAmbilightHueSwitch(PhilipsJsEntity, SwitchEntity): """A Philips TV Ambi+Hue switch.""" + _attr_translation_key = "ambilight_hue" + def __init__( self, coordinator: PhilipsTVDataUpdateCoordinator, @@ -93,14 +84,8 @@ class PhilipsTVAmbilightHueSwitch( super().__init__(coordinator) - self._attr_name = f"{coordinator.system['name']} Ambilight+Hue" self._attr_icon = "mdi:television-ambient-light" self._attr_unique_id = f"{coordinator.unique_id}_ambi_hue" - self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, coordinator.unique_id), - } - ) @property def available(self) -> bool: diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 7ec1bf40c66..5d1419db8b2 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -8,7 +8,6 @@ from typing import Any from hole import Hole from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) @@ -39,42 +38,6 @@ class PiHoleBinarySensorEntityDescription( BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = ( - PiHoleBinarySensorEntityDescription( - # Deprecated, scheduled to be removed in 2022.6 - key="core_update_available", - name="Core Update Available", - entity_registry_enabled_default=False, - device_class=BinarySensorDeviceClass.UPDATE, - extra_value=lambda api: { - "current_version": api.versions["core_current"], - "latest_version": api.versions["core_latest"], - }, - state_value=lambda api: bool(api.versions["core_update"]), - ), - PiHoleBinarySensorEntityDescription( - # Deprecated, scheduled to be removed in 2022.6 - key="web_update_available", - name="Web Update Available", - entity_registry_enabled_default=False, - device_class=BinarySensorDeviceClass.UPDATE, - extra_value=lambda api: { - "current_version": api.versions["web_current"], - "latest_version": api.versions["web_latest"], - }, - state_value=lambda api: bool(api.versions["web_update"]), - ), - PiHoleBinarySensorEntityDescription( - # Deprecated, scheduled to be removed in 2022.6 - key="ftl_update_available", - name="FTL Update Available", - entity_registry_enabled_default=False, - device_class=BinarySensorDeviceClass.UPDATE, - extra_value=lambda api: { - "current_version": api.versions["FTL_current"], - "latest_version": api.versions["FTL_latest"], - }, - state_value=lambda api: bool(api.versions["FTL_update"]), - ), PiHoleBinarySensorEntityDescription( key="status", translation_key="status", diff --git a/homeassistant/components/pi_hole/services.yaml b/homeassistant/components/pi_hole/services.yaml index 1b5da9f0d4f..9c8d8921b12 100644 --- a/homeassistant/components/pi_hole/services.yaml +++ b/homeassistant/components/pi_hole/services.yaml @@ -1,14 +1,10 @@ disable: - name: Disable - description: Disable configured Pi-hole(s) for an amount of time target: entity: integration: pi_hole domain: switch fields: duration: - name: Duration - description: Time that the Pi-hole should be disabled for required: true example: "00:00:15" selector: diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index eb12811722b..b76b61f1903 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -35,23 +35,61 @@ }, "entity": { "binary_sensor": { - "status": { "name": "Status" } + "status": { + "name": "Status" + } }, "sensor": { - "ads_blocked_today": { "name": "Ads blocked today" }, - "ads_percentage_today": { "name": "Ads percentage blocked today" }, - "clients_ever_seen": { "name": "Seen clients" }, - "dns_queries_today": { "name": "DNS queries today" }, - "domains_being_blocked": { "name": "Domains blocked" }, - "queries_cached": { "name": "DNS queries cached" }, - "queries_forwarded": { "name": "DNS queries forwarded" }, - "unique_clients": { "name": "DNS unique clients" }, - "unique_domains": { "name": "DNS unique domains" } + "ads_blocked_today": { + "name": "Ads blocked today" + }, + "ads_percentage_today": { + "name": "Ads percentage blocked today" + }, + "clients_ever_seen": { + "name": "Seen clients" + }, + "dns_queries_today": { + "name": "DNS queries today" + }, + "domains_being_blocked": { + "name": "Domains blocked" + }, + "queries_cached": { + "name": "DNS queries cached" + }, + "queries_forwarded": { + "name": "DNS queries forwarded" + }, + "unique_clients": { + "name": "DNS unique clients" + }, + "unique_domains": { + "name": "DNS unique domains" + } }, "update": { - "core_update_available": { "name": "Core update available" }, - "ftl_update_available": { "name": "FTL update available" }, - "web_update_available": { "name": "Web update available" } + "core_update_available": { + "name": "Core update available" + }, + "ftl_update_available": { + "name": "FTL update available" + }, + "web_update_available": { + "name": "Web update available" + } + } + }, + "services": { + "disable": { + "name": "[%key:common::action::disable%]", + "description": "Disables configured Pi-hole(s) for an amount of time.", + "fields": { + "duration": { + "name": "Duration", + "description": "Time that the Pi-hole should be disabled for." + } + } } } } diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index 74c37e9d5ce..5e2e507e450 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -271,11 +271,6 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): ) return self.entity_description.value_fn(data_set) - @property - def available(self) -> bool: - """Return True if last update was successful.""" - return self.coordinator.last_update_success - @property def device_info(self) -> DeviceInfo: """Return device info.""" diff --git a/homeassistant/components/picnic/services.yaml b/homeassistant/components/picnic/services.yaml index 9af2cb48291..e7afe71bb31 100644 --- a/homeassistant/components/picnic/services.yaml +++ b/homeassistant/components/picnic/services.yaml @@ -1,34 +1,21 @@ add_product: - name: Add a product to the cart - description: >- - Adds a product to the cart based on a search string or product ID. - The search string and product ID are exclusive. - fields: config_entry_id: - name: Picnic service - description: The product will be added to the selected service. required: true selector: config_entry: integration: picnic product_id: - name: Product ID - description: The product ID of a Picnic product. required: false example: "10510201" selector: text: product_name: - name: Product name - description: Search for a product and add the first result required: false example: "Yoghurt" selector: text: amount: - name: Amount - description: Amount to add of the selected product required: false default: 1 selector: diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index f0e0d93231c..0fd107609d1 100644 --- a/homeassistant/components/picnic/strings.json +++ b/homeassistant/components/picnic/strings.json @@ -71,5 +71,29 @@ "name": "End of next delivery's slot" } } + }, + "services": { + "add_product": { + "name": "Add a product to the cart", + "description": "Adds a product to the cart based on a search string or product ID. The search string and product ID are exclusive.", + "fields": { + "config_entry_id": { + "name": "Picnic service", + "description": "The product will be added to the selected service." + }, + "product_id": { + "name": "Product ID", + "description": "The product ID of a Picnic product." + }, + "product_name": { + "name": "Product name", + "description": "Search for a product and add the first result." + }, + "amount": { + "name": "Amount", + "description": "Amount to add of the selected product." + } + } + } } } diff --git a/homeassistant/components/pilight/services.yaml b/homeassistant/components/pilight/services.yaml index 6dc052043bf..b877ae88b0a 100644 --- a/homeassistant/components/pilight/services.yaml +++ b/homeassistant/components/pilight/services.yaml @@ -1,10 +1,6 @@ send: - name: Send - description: Send RF code to Pilight device fields: protocol: - name: Protocol - description: "Protocol that Pilight recognizes. See https://manual.pilight.org/protocols/index.html for supported protocols and additional parameters that each protocol supports" required: true example: "lirc" selector: diff --git a/homeassistant/components/pilight/strings.json b/homeassistant/components/pilight/strings.json new file mode 100644 index 00000000000..4cd819859a3 --- /dev/null +++ b/homeassistant/components/pilight/strings.json @@ -0,0 +1,14 @@ +{ + "services": { + "send": { + "name": "Send", + "description": "Sends RF code to Pilight device.", + "fields": { + "protocol": { + "name": "Protocol", + "description": "Protocol that Pilight recognizes. See https://manual.pilight.org/protocols/index.html for supported protocols and additional parameters that each protocol supports." + } + } + } + } +} diff --git a/homeassistant/components/ping/services.yaml b/homeassistant/components/ping/services.yaml index 1f7e523e685..c983a105c93 100644 --- a/homeassistant/components/ping/services.yaml +++ b/homeassistant/components/ping/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload all ping entities. diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json new file mode 100644 index 00000000000..5b5c5da46bc --- /dev/null +++ b/homeassistant/components/ping/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads ping sensors from the YAML-configuration." + } + } +} diff --git a/homeassistant/components/pioneer/media_player.py b/homeassistant/components/pioneer/media_player.py index a124362251a..741d2b580e4 100644 --- a/homeassistant/components/pioneer/media_player.py +++ b/homeassistant/components/pioneer/media_player.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -import telnetlib +import telnetlib # pylint: disable=deprecated-module from typing import Final import voluptuous as vol diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index e385156c6d1..28cdece0b02 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -19,13 +19,16 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.util import dt as dt_util from .const import ( @@ -176,15 +179,16 @@ class Plant(Entity): self._brightness_history = DailyHistory(self._conf_check_days) @callback - def _state_changed_event(self, event): + def _state_changed_event(self, event: EventType[EventStateChangedData]) -> None: """Sensor state change event.""" - self.state_changed(event.data.get("entity_id"), event.data.get("new_state")) + self.state_changed(event.data["entity_id"], event.data["new_state"]) @callback - def state_changed(self, entity_id, new_state): + def state_changed(self, entity_id: str, new_state: State | None) -> None: """Update the sensor status.""" if new_state is None: return + value: str | float value = new_state.state _LOGGER.debug("Received callback from %s with value %s", entity_id, value) if value == STATE_UNKNOWN: diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index 10d005d1043..39d41369a4b 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -143,9 +143,9 @@ def process_plex_payload( content = plex_url.path server_id = plex_url.host plex_server = get_plex_server(hass, plex_server_id=server_id) - else: + else: # noqa: PLR5501 # Handle legacy payloads without server_id in URL host position - if plex_url.host == "search": # noqa: PLR5501 + if plex_url.host == "search": content = {} else: content = int(plex_url.host) # type: ignore[arg-type] diff --git a/homeassistant/components/plex/services.yaml b/homeassistant/components/plex/services.yaml index 782a4d17c18..5ed655b7d78 100644 --- a/homeassistant/components/plex/services.yaml +++ b/homeassistant/components/plex/services.yaml @@ -1,21 +1,13 @@ refresh_library: - name: Refresh library - description: Refresh a Plex library to scan for new and updated media. fields: server_name: - name: Server name - description: Name of a Plex server if multiple Plex servers configured. example: "My Plex Server" selector: text: library_name: - name: Library name - description: Name of the Plex library to refresh. required: true example: "TV Shows" selector: text: scan_for_clients: - name: Scan for clients - description: Scan for available clients from the Plex server(s), local network, and plex.tv. diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index f08b6f59862..9cba83653fd 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -56,5 +56,25 @@ } } } + }, + "services": { + "refresh_library": { + "name": "Refresh library", + "description": "Refreshes a Plex library to scan for new and updated media.", + "fields": { + "server_name": { + "name": "Server name", + "description": "Name of a Plex server if multiple Plex servers configured." + }, + "library_name": { + "name": "Library name", + "description": "Name of the Plex library to refresh." + } + } + }, + "scan_for_clients": { + "name": "Scan for clients", + "description": "Scans for available clients from the Plex server(s), local network, and plex.tv." + } } } diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 36626c2324e..d0a65799807 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -39,7 +39,7 @@ async def async_setup_entry( class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): - """Representation of an Plugwise thermostat.""" + """Representation of a Plugwise thermostat.""" _attr_has_entity_name = True _attr_name = None diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 5a3e394b119..25667ea16c6 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from dataclasses import dataclass -from plugwise import Smile +from plugwise import ActuatorData, Smile from homeassistant.components.number import ( NumberDeviceClass, @@ -24,13 +24,13 @@ from .entity import PlugwiseEntity @dataclass class PlugwiseEntityDescriptionMixin: - """Mixin values for Plugwse entities.""" + """Mixin values for Plugwise entities.""" command: Callable[[Smile, str, float], Awaitable[None]] - native_max_value_key: str - native_min_value_key: str - native_step_key: str - native_value_key: str + native_max_value_fn: Callable[[ActuatorData], float] + native_min_value_fn: Callable[[ActuatorData], float] + native_step_fn: Callable[[ActuatorData], float] + native_value_fn: Callable[[ActuatorData], float] @dataclass @@ -47,11 +47,11 @@ NUMBER_TYPES = ( command=lambda api, number, value: api.set_number_setpoint(number, value), device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, - native_max_value_key="upper_bound", - native_min_value_key="lower_bound", - native_step_key="resolution", native_unit_of_measurement=UnitOfTemperature.CELSIUS, - native_value_key="setpoint", + native_max_value_fn=lambda data: data["upper_bound"], + native_min_value_fn=lambda data: data["lower_bound"], + native_step_fn=lambda data: data["resolution"], + native_value_fn=lambda data: data["setpoint"], ), ) @@ -70,7 +70,7 @@ async def async_setup_entry( entities: list[PlugwiseNumberEntity] = [] for device_id, device in coordinator.data.devices.items(): for description in NUMBER_TYPES: - if description.key in device and "setpoint" in device[description.key]: + if (actuator := device.get(description.key)) and "setpoint" in actuator: entities.append( PlugwiseNumberEntity(coordinator, device_id, description) ) @@ -91,40 +91,30 @@ class PlugwiseNumberEntity(PlugwiseEntity, NumberEntity): ) -> None: """Initiate Plugwise Number.""" super().__init__(coordinator, device_id) + self.actuator = self.device[description.key] self.entity_description = description self._attr_unique_id = f"{device_id}-{description.key}" self._attr_mode = NumberMode.BOX @property - def native_step(self) -> float: - """Return the setpoint step value.""" - return max( - self.device[self.entity_description.key][ - self.entity_description.native_step_key - ], - 1, - ) - - @property - def native_value(self) -> float: - """Return the present setpoint value.""" - return self.device[self.entity_description.key][ - self.entity_description.native_value_key - ] + def native_max_value(self) -> float: + """Return the setpoint max. value.""" + return self.entity_description.native_max_value_fn(self.actuator) @property def native_min_value(self) -> float: """Return the setpoint min. value.""" - return self.device[self.entity_description.key][ - self.entity_description.native_min_value_key - ] + return self.entity_description.native_min_value_fn(self.actuator) @property - def native_max_value(self) -> float: - """Return the setpoint max. value.""" - return self.device[self.entity_description.key][ - self.entity_description.native_max_value_key - ] + def native_step(self) -> float: + """Return the setpoint step value.""" + return max(self.entity_description.native_step_fn(self.actuator), 1) + + @property + def native_value(self) -> float: + """Return the present setpoint value.""" + return self.entity_description.native_value_fn(self.actuator) async def async_set_native_value(self, value: float) -> None: """Change to the new setpoint value.""" diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 7a504a0db84..d18226e5af9 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -30,7 +30,7 @@ from .entity import PlugwiseEntity SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="setpoint", - name="Setpoint", + translation_key="setpoint", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -38,7 +38,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="setpoint_high", - name="Cooling setpoint", + translation_key="cooling_setpoint", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -46,7 +46,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="setpoint_low", - name="Heating setpoint", + translation_key="heating_setpoint", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -54,7 +54,6 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="temperature", - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, @@ -62,7 +61,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="intended_boiler_temperature", - name="Intended boiler temperature", + translation_key="intended_boiler_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, @@ -70,7 +69,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="temperature_difference", - name="Temperature difference", + translation_key="temperature_difference", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, @@ -78,14 +77,14 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="outdoor_temperature", - name="Outdoor temperature", + translation_key="outdoor_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="outdoor_air_temperature", - name="Outdoor air temperature", + translation_key="outdoor_air_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, @@ -93,7 +92,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="water_temperature", - name="Water temperature", + translation_key="water_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, @@ -101,7 +100,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="return_temperature", - name="Return temperature", + translation_key="return_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, @@ -109,14 +108,14 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="electricity_consumed", - name="Electricity consumed", + translation_key="electricity_consumed", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="electricity_produced", - name="Electricity produced", + translation_key="electricity_produced", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -124,28 +123,28 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="electricity_consumed_interval", - name="Electricity consumed interval", + translation_key="electricity_consumed_interval", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="electricity_consumed_peak_interval", - name="Electricity consumed peak interval", + translation_key="electricity_consumed_peak_interval", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="electricity_consumed_off_peak_interval", - name="Electricity consumed off peak interval", + translation_key="electricity_consumed_off_peak_interval", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="electricity_produced_interval", - name="Electricity produced interval", + translation_key="electricity_produced_interval", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, @@ -153,133 +152,133 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="electricity_produced_peak_interval", - name="Electricity produced peak interval", + translation_key="electricity_produced_peak_interval", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="electricity_produced_off_peak_interval", - name="Electricity produced off peak interval", + translation_key="electricity_produced_off_peak_interval", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="electricity_consumed_point", - name="Electricity consumed point", + translation_key="electricity_consumed_point", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="electricity_consumed_off_peak_point", - name="Electricity consumed off peak point", + translation_key="electricity_consumed_off_peak_point", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="electricity_consumed_peak_point", - name="Electricity consumed peak point", + translation_key="electricity_consumed_peak_point", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="electricity_consumed_off_peak_cumulative", - name="Electricity consumed off peak cumulative", + translation_key="electricity_consumed_off_peak_cumulative", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="electricity_consumed_peak_cumulative", - name="Electricity consumed peak cumulative", + translation_key="electricity_consumed_peak_cumulative", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="electricity_produced_point", - name="Electricity produced point", + translation_key="electricity_produced_point", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="electricity_produced_off_peak_point", - name="Electricity produced off peak point", + translation_key="electricity_produced_off_peak_point", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="electricity_produced_peak_point", - name="Electricity produced peak point", + translation_key="electricity_produced_peak_point", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="electricity_produced_off_peak_cumulative", - name="Electricity produced off peak cumulative", + translation_key="electricity_produced_off_peak_cumulative", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="electricity_produced_peak_cumulative", - name="Electricity produced peak cumulative", + translation_key="electricity_produced_peak_cumulative", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="electricity_phase_one_consumed", - name="Electricity phase one consumed", + translation_key="electricity_phase_one_consumed", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="electricity_phase_two_consumed", - name="Electricity phase two consumed", + translation_key="electricity_phase_two_consumed", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="electricity_phase_three_consumed", - name="Electricity phase three consumed", + translation_key="electricity_phase_three_consumed", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="electricity_phase_one_produced", - name="Electricity phase one produced", + translation_key="electricity_phase_one_produced", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="electricity_phase_two_produced", - name="Electricity phase two produced", + translation_key="electricity_phase_two_produced", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="electricity_phase_three_produced", - name="Electricity phase three produced", + translation_key="electricity_phase_three_produced", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="voltage_phase_one", - name="Voltage phase one", + translation_key="voltage_phase_one", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, @@ -287,7 +286,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="voltage_phase_two", - name="Voltage phase two", + translation_key="voltage_phase_two", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, @@ -295,7 +294,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="voltage_phase_three", - name="Voltage phase three", + translation_key="voltage_phase_three", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, @@ -303,35 +302,34 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="gas_consumed_interval", - name="Gas consumed interval", + translation_key="gas_consumed_interval", icon="mdi:meter-gas", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="gas_consumed_cumulative", - name="Gas consumed cumulative", + translation_key="gas_consumed_cumulative", native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, device_class=SensorDeviceClass.GAS, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="net_electricity_point", - name="Net electricity point", + translation_key="net_electricity_point", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="net_electricity_cumulative", - name="Net electricity cumulative", + translation_key="net_electricity_cumulative", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="battery", - name="Battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, @@ -339,7 +337,6 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="illuminance", - name="Illuminance", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, @@ -347,7 +344,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="modulation_level", - name="Modulation level", + translation_key="modulation_level", icon="mdi:percent", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, @@ -355,7 +352,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="valve_position", - name="Valve position", + translation_key="valve_position", icon="mdi:valve", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, @@ -363,7 +360,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="water_pressure", - name="Water pressure", + translation_key="water_pressure", native_unit_of_measurement=UnitOfPressure.BAR, device_class=SensorDeviceClass.PRESSURE, entity_category=EntityCategory.DIAGNOSTIC, @@ -371,14 +368,13 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="humidity", - name="Relative humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="dhw_temperature", - name="DHW temperature", + translation_key="dhw_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, @@ -386,7 +382,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="domestic_hot_water_setpoint", - name="DHW setpoint", + translation_key="domestic_hot_water_setpoint", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, @@ -394,7 +390,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="maximum_boiler_temperature", - name="Maximum boiler temperature", + translation_key="maximum_boiler_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index afc921f1101..e1b5b5c4053 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -102,6 +102,146 @@ "name": "Thermostat schedule" } }, + "sensor": { + "setpoint": { + "name": "Setpoint" + }, + "cooling_setpoint": { + "name": "Cooling setpoint" + }, + "heating_setpoint": { + "name": "Heating setpoint" + }, + "intended_boiler_temperature": { + "name": "Intended boiler temperature" + }, + "temperature_difference": { + "name": "Temperature difference" + }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "outdoor_air_temperature": { + "name": "Outdoor air temperature" + }, + "water_temperature": { + "name": "Water temperature" + }, + "return_temperature": { + "name": "Return temperature" + }, + "electricity_consumed": { + "name": "Electricity consumed" + }, + "electricity_produced": { + "name": "Electricity produced" + }, + "electricity_consumed_interval": { + "name": "Electricity consumed interval" + }, + "electricity_consumed_peak_interval": { + "name": "Electricity consumed peak interval" + }, + "electricity_consumed_off_peak_interval": { + "name": "Electricity consumed off peak interval" + }, + "electricity_produced_interval": { + "name": "Electricity produced interval" + }, + "electricity_produced_peak_interval": { + "name": "Electricity produced peak interval" + }, + "electricity_produced_off_peak_interval": { + "name": "Electricity produced off peak interval" + }, + "electricity_consumed_point": { + "name": "Electricity consumed point" + }, + "electricity_consumed_off_peak_point": { + "name": "Electricity consumed off peak point" + }, + "electricity_consumed_peak_point": { + "name": "Electricity consumed peak point" + }, + "electricity_consumed_off_peak_cumulative": { + "name": "Electricity consumed off peak cumulative" + }, + "electricity_consumed_peak_cumulative": { + "name": "Electricity consumed peak cumulative" + }, + "electricity_produced_point": { + "name": "Electricity produced point" + }, + "electricity_produced_off_peak_point": { + "name": "Electricity produced off peak point" + }, + "electricity_produced_peak_point": { + "name": "Electricity produced peak point" + }, + "electricity_produced_off_peak_cumulative": { + "name": "Electricity produced off peak cumulative" + }, + "electricity_produced_peak_cumulative": { + "name": "Electricity produced peak cumulative" + }, + "electricity_phase_one_consumed": { + "name": "Electricity phase one consumed" + }, + "electricity_phase_two_consumed": { + "name": "Electricity phase two consumed" + }, + "electricity_phase_three_consumed": { + "name": "Electricity phase three consumed" + }, + "electricity_phase_one_produced": { + "name": "Electricity phase one produced" + }, + "electricity_phase_two_produced": { + "name": "Electricity phase two produced" + }, + "electricity_phase_three_produced": { + "name": "Electricity phase three produced" + }, + "voltage_phase_one": { + "name": "Voltage phase one" + }, + "voltage_phase_two": { + "name": "Voltage phase two" + }, + "voltage_phase_three": { + "name": "Voltage phase three" + }, + "gas_consumed_interval": { + "name": "Gas consumed interval" + }, + "gas_consumed_cumulative": { + "name": "Gas consumed cumulative" + }, + "net_electricity_point": { + "name": "Net electricity point" + }, + "net_electricity_cumulative": { + "name": "Net electricity cumulative" + }, + "modulation_level": { + "name": "Modulation level" + }, + "valve_position": { + "name": "Valve position" + }, + "water_pressure": { + "name": "Water pressure" + }, + "dhw_temperature": { + "name": "DHW temperature" + }, + "domestic_hot_water_setpoint": { + "name": "DHW setpoint" + }, + "maximum_boiler_temperature": { + "name": "Maximum boiler temperature" + } + }, "switch": { "cooling_ena_switch": { "name": "[%key:component::climate::entity_component::_::state_attributes::hvac_action::state::cooling%]" diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py index 9f26200e9ae..ac0dd0c919c 100644 --- a/homeassistant/components/plum_lightpad/light.py +++ b/homeassistant/components/plum_lightpad/light.py @@ -66,6 +66,8 @@ class PlumLight(LightEntity): """Representation of a Plum Lightpad dimmer.""" _attr_should_poll = False + _attr_has_entity_name = True + _attr_name = None def __init__(self, load): """Initialize the light.""" @@ -86,11 +88,6 @@ class PlumLight(LightEntity): """Combine logical load ID with .light to guarantee it is unique.""" return f"{self._load.llid}.light" - @property - def name(self): - """Return the name of the switch if any.""" - return self._load.name - @property def device_info(self) -> DeviceInfo: """Return the device info.""" @@ -98,7 +95,7 @@ class PlumLight(LightEntity): identifiers={(DOMAIN, self.unique_id)}, manufacturer="Plum", model="Dimmer", - name=self.name, + name=self._load.name, ) @property diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 6600a8240a0..627736f605d 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -97,9 +97,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: token_saver=token_saver, ) try: - # pylint: disable-next=fixme - # TODO Remove authlib constraint when refactoring this code - await session.ensure_active_token() + # the call to user() implicitly calls ensure_active_token() in authlib + await session.user() except ConnectTimeout as err: _LOGGER.debug("Connection Timeout") raise ConfigEntryNotReady from err diff --git a/homeassistant/components/poolsense/binary_sensor.py b/homeassistant/components/poolsense/binary_sensor.py index 2a350816685..e206521c3d9 100644 --- a/homeassistant/components/poolsense/binary_sensor.py +++ b/homeassistant/components/poolsense/binary_sensor.py @@ -17,12 +17,12 @@ from .const import DOMAIN BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="pH Status", - name="pH Status", + translation_key="ph_status", device_class=BinarySensorDeviceClass.PROBLEM, ), BinarySensorEntityDescription( key="Chlorine Status", - name="Chlorine Status", + translation_key="chlorine_status", device_class=BinarySensorDeviceClass.PROBLEM, ), ) diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index f8f91620321..fe3535b378f 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -22,55 +22,53 @@ from .const import DOMAIN SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="Chlorine", + translation_key="chlorine", native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, icon="mdi:pool", - name="Chlorine", ), SensorEntityDescription( key="pH", + translation_key="ph", icon="mdi:pool", - name="pH", ), SensorEntityDescription( key="Battery", native_unit_of_measurement=PERCENTAGE, - name="Battery", device_class=SensorDeviceClass.BATTERY, ), SensorEntityDescription( key="Water Temp", native_unit_of_measurement=UnitOfTemperature.CELSIUS, icon="mdi:coolant-temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="Last Seen", + translation_key="last_seen", icon="mdi:clock", - name="Last Seen", device_class=SensorDeviceClass.TIMESTAMP, ), SensorEntityDescription( key="Chlorine High", + translation_key="chlorine_high", native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, icon="mdi:pool", - name="Chlorine High", ), SensorEntityDescription( key="Chlorine Low", + translation_key="chlorine_low", native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, icon="mdi:pool", - name="Chlorine Low", ), SensorEntityDescription( key="pH High", + translation_key="ph_high", icon="mdi:pool", - name="pH High", ), SensorEntityDescription( key="pH Low", + translation_key="ph_low", icon="mdi:pool", - name="pH Low", ), ) diff --git a/homeassistant/components/poolsense/strings.json b/homeassistant/components/poolsense/strings.json index 2ddf3ee77e8..9ec67e223a1 100644 --- a/homeassistant/components/poolsense/strings.json +++ b/homeassistant/components/poolsense/strings.json @@ -14,5 +14,38 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "binary_sensor": { + "ph_status": { + "name": "pH status" + }, + "chlorine_status": { + "name": "Chlorine status" + } + }, + "sensor": { + "chlorine": { + "name": "Chlorine" + }, + "ph": { + "name": "pH" + }, + "last_seen": { + "name": "Last seen" + }, + "chlorine_high": { + "name": "Chlorine high" + }, + "chlorine_low": { + "name": "Chlorine low" + }, + "ph_high": { + "name": "pH high" + }, + "ph_low": { + "name": "pH low" + } + } } } diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index ba5f25a1c02..8c5c206ae9f 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -45,7 +45,6 @@ _KNOWN_LRU_CLASSES = ( "StatesMetaManager", "StateAttributesManager", "StatisticsMetaManager", - "DomainData", "IntegrationMatcher", ) diff --git a/homeassistant/components/profiler/services.yaml b/homeassistant/components/profiler/services.yaml index 3bd6d7636ac..311325fa404 100644 --- a/homeassistant/components/profiler/services.yaml +++ b/homeassistant/components/profiler/services.yaml @@ -1,10 +1,6 @@ start: - name: Start - description: Start the Profiler fields: seconds: - name: Seconds - description: The number of seconds to run the profiler. default: 60.0 selector: number: @@ -12,12 +8,8 @@ start: max: 3600 unit_of_measurement: seconds memory: - name: Memory - description: Start the Memory Profiler fields: seconds: - name: Seconds - description: The number of seconds to run the memory profiler. default: 60.0 selector: number: @@ -25,12 +17,8 @@ memory: max: 3600 unit_of_measurement: seconds start_log_objects: - name: Start logging objects - description: Start logging growth of objects in memory fields: scan_interval: - name: Scan interval - description: The number of seconds between logging objects. default: 30.0 selector: number: @@ -38,26 +26,16 @@ start_log_objects: max: 3600 unit_of_measurement: seconds stop_log_objects: - name: Stop logging objects - description: Stop logging growth of objects in memory. dump_log_objects: - name: Dump log objects - description: Dump the repr of all matching objects to the log. fields: type: - name: Type - description: The type of objects to dump to the log. required: true example: State selector: text: start_log_object_sources: - name: Start logging object sources - description: Start logging sources of new objects in memory fields: scan_interval: - name: Scan interval - description: The number of seconds between logging objects. default: 30.0 selector: number: @@ -65,8 +43,6 @@ start_log_object_sources: max: 3600 unit_of_measurement: seconds max_objects: - name: Maximum objects - description: The maximum number of objects to log. default: 5 selector: number: @@ -74,14 +50,6 @@ start_log_object_sources: max: 30 unit_of_measurement: objects stop_log_object_sources: - name: Stop logging object sources - description: Stop logging sources of new objects in memory. lru_stats: - name: Log LRU stats - description: Log the stats of all lru caches. log_thread_frames: - name: Log thread frames - description: Log the current frames for all threads. log_event_loop_scheduled: - name: Log event loop scheduled - description: Log what is scheduled in the event loop. diff --git a/homeassistant/components/profiler/strings.json b/homeassistant/components/profiler/strings.json index 394c46563cd..a14324a9082 100644 --- a/homeassistant/components/profiler/strings.json +++ b/homeassistant/components/profiler/strings.json @@ -8,5 +8,81 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "services": { + "start": { + "name": "[%key:common::action::start%]", + "description": "Starts the Profiler.", + "fields": { + "seconds": { + "name": "Seconds", + "description": "The number of seconds to run the profiler." + } + } + }, + "memory": { + "name": "Memory", + "description": "Starts the Memory Profiler.", + "fields": { + "seconds": { + "name": "Seconds", + "description": "The number of seconds to run the memory profiler." + } + } + }, + "start_log_objects": { + "name": "Start logging objects", + "description": "Starts logging growth of objects in memory.", + "fields": { + "scan_interval": { + "name": "Scan interval", + "description": "The number of seconds between logging objects." + } + } + }, + "stop_log_objects": { + "name": "Stop logging objects", + "description": "Stops logging growth of objects in memory." + }, + "dump_log_objects": { + "name": "Dump log objects", + "description": "Dumps the repr of all matching objects to the log.", + "fields": { + "type": { + "name": "Type", + "description": "The type of objects to dump to the log." + } + } + }, + "start_log_object_sources": { + "name": "Start logging object sources", + "description": "Starts logging sources of new objects in memory.", + "fields": { + "scan_interval": { + "name": "Scan interval", + "description": "[%key:component::profiler::services::start_log_objects::fields::scan_interval::description%]" + }, + "max_objects": { + "name": "Maximum objects", + "description": "The maximum number of objects to log." + } + } + }, + "stop_log_object_sources": { + "name": "Stop logging object sources", + "description": "Stops logging sources of new objects in memory." + }, + "lru_stats": { + "name": "Log LRU stats", + "description": "Logs the stats of all lru caches." + }, + "log_thread_frames": { + "name": "Log thread frames", + "description": "Logs the current frames for all threads." + }, + "log_event_loop_scheduled": { + "name": "Log event loop scheduled", + "description": "Logs what is scheduled in the event loop." + } } } diff --git a/homeassistant/components/prosegur/services.yaml b/homeassistant/components/prosegur/services.yaml index 0db63cb7adf..e02eb2e60e5 100644 --- a/homeassistant/components/prosegur/services.yaml +++ b/homeassistant/components/prosegur/services.yaml @@ -1,6 +1,4 @@ request_image: - name: Request Camera image - description: Request a new image from a Prosegur Camera target: entity: domain: camera diff --git a/homeassistant/components/prosegur/strings.json b/homeassistant/components/prosegur/strings.json index a6c7fcc4a76..9b9ac45fc85 100644 --- a/homeassistant/components/prosegur/strings.json +++ b/homeassistant/components/prosegur/strings.json @@ -30,5 +30,11 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "services": { + "request_image": { + "name": "Request camera image", + "description": "Requests a new image from a Prosegur camera." + } } } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 88a2a6c9b0f..b38bc93567d 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,5 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["Pillow==9.5.0"] + "requirements": ["Pillow==10.0.0"] } diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json index 53f5f0153fe..aa992b4874f 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -20,8 +20,8 @@ "printer_state": { "state": { "cancelling": "Cancelling", - "idle": "Idle", - "paused": "Paused", + "idle": "[%key:common::state::idle%]", + "paused": "[%key:common::state::paused%]", "pausing": "Pausing", "printing": "Printing" } diff --git a/homeassistant/components/ps4/services.yaml b/homeassistant/components/ps4/services.yaml index f1f20506edb..0a93f87a249 100644 --- a/homeassistant/components/ps4/services.yaml +++ b/homeassistant/components/ps4/services.yaml @@ -1,18 +1,12 @@ send_command: - name: Send command - description: Emulate button press for PlayStation 4. fields: entity_id: - name: Entity - description: Name of entity to send command. required: true selector: entity: integration: ps4 domain: media_player command: - name: Command - description: Button to press. required: true selector: select: diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json index 9518af77dbc..644b2d61216 100644 --- a/homeassistant/components/ps4/strings.json +++ b/homeassistant/components/ps4/strings.json @@ -38,5 +38,21 @@ "port_987_bind_error": "Could not bind to port 987. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info.", "port_997_bind_error": "Could not bind to port 997. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info." } + }, + "services": { + "send_command": { + "name": "Send command", + "description": "Emulates button press for PlayStation 4.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name of entity to send command." + }, + "command": { + "name": "Command", + "description": "Button to press." + } + } + } } } diff --git a/homeassistant/components/pse/__init__.py b/homeassistant/components/pse/__init__.py new file mode 100644 index 00000000000..5af296c9bef --- /dev/null +++ b/homeassistant/components/pse/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Puget Sound Energy (PSE).""" diff --git a/homeassistant/components/pse/manifest.json b/homeassistant/components/pse/manifest.json new file mode 100644 index 00000000000..5df86ac39a2 --- /dev/null +++ b/homeassistant/components/pse/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "pse", + "name": "Puget Sound Energy (PSE)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py index 7d584c7c1a8..9f67665d66c 100644 --- a/homeassistant/components/pure_energie/sensor.py +++ b/homeassistant/components/pure_energie/sensor.py @@ -39,7 +39,7 @@ class PureEnergieSensorEntityDescription( SENSORS: tuple[PureEnergieSensorEntityDescription, ...] = ( PureEnergieSensorEntityDescription( key="power_flow", - name="Power Flow", + translation_key="power_flow", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -47,7 +47,7 @@ SENSORS: tuple[PureEnergieSensorEntityDescription, ...] = ( ), PureEnergieSensorEntityDescription( key="energy_consumption_total", - name="Energy Consumption", + translation_key="energy_consumption_total", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -55,7 +55,7 @@ SENSORS: tuple[PureEnergieSensorEntityDescription, ...] = ( ), PureEnergieSensorEntityDescription( key="energy_production_total", - name="Energy Production", + translation_key="energy_production_total", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -83,6 +83,7 @@ class PureEnergieSensorEntity( ): """Defines an Pure Energie sensor.""" + _attr_has_entity_name = True entity_description: PureEnergieSensorEntityDescription def __init__( diff --git a/homeassistant/components/pure_energie/strings.json b/homeassistant/components/pure_energie/strings.json index a76b4a001e6..3545f62d667 100644 --- a/homeassistant/components/pure_energie/strings.json +++ b/homeassistant/components/pure_energie/strings.json @@ -22,5 +22,18 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "sensor": { + "power_flow": { + "name": "Power flow" + }, + "energy_consumption_total": { + "name": "Energy consumption" + }, + "energy_production_total": { + "name": "Energy production" + } + } } } diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index c90f4c9031c..f5c4090dc87 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -1,6 +1,9 @@ """The PurpleAir integration.""" from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from aiopurpleair.models.sensors import SensorModel from homeassistant.config_entries import ConfigEntry @@ -9,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import CONF_SHOW_ON_MAP, DOMAIN from .coordinator import PurpleAirDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -60,16 +63,30 @@ class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]): self._attr_device_info = DeviceInfo( configuration_url=self.coordinator.async_get_map_url(sensor_index), hw_version=self.sensor_data.hardware, - identifiers={(DOMAIN, str(self._sensor_index))}, + identifiers={(DOMAIN, str(sensor_index))}, manufacturer="PurpleAir, Inc.", model=self.sensor_data.model, name=self.sensor_data.name, sw_version=self.sensor_data.firmware_version, ) - self._attr_extra_state_attributes = { - ATTR_LATITUDE: self.sensor_data.latitude, - ATTR_LONGITUDE: self.sensor_data.longitude, - } + self._entry = entry + + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return entity specific state attributes.""" + attrs = {} + + # Displaying the geography on the map relies upon putting the latitude/longitude + # in the entity attributes with "latitude" and "longitude" as the keys. + # Conversely, we can hide the location on the map by using other keys, like + # "lati" and "long": + if self._entry.options.get(CONF_SHOW_ON_MAP): + attrs[ATTR_LATITUDE] = self.sensor_data.latitude + attrs[ATTR_LONGITUDE] = self.sensor_data.longitude + else: + attrs["lati"] = self.sensor_data.latitude + attrs["long"] = self.sensor_data.longitude + return attrs @property def sensor_data(self) -> SensorModel: diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 604bcb28c0e..3daa6f96fdf 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -15,7 +15,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import ( aiohttp_client, @@ -23,15 +23,19 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, SelectSelectorConfig, SelectSelectorMode, ) +from homeassistant.helpers.typing import EventType -from .const import CONF_SENSOR_INDICES, DOMAIN, LOGGER +from .const import CONF_SENSOR_INDICES, CONF_SHOW_ON_MAP, DOMAIN, LOGGER CONF_DISTANCE = "distance" CONF_NEARBY_SENSOR_OPTIONS = "nearby_sensor_options" @@ -318,6 +322,22 @@ class PurpleAirOptionsFlowHandler(config_entries.OptionsFlow): self._flow_data: dict[str, Any] = {} self.config_entry = config_entry + @property + def settings_schema(self) -> vol.Schema: + """Return the settings schema.""" + return vol.Schema( + { + vol.Optional( + CONF_SHOW_ON_MAP, + description={ + "suggested_value": self.config_entry.options.get( + CONF_SHOW_ON_MAP + ) + }, + ): bool + } + ) + async def async_step_add_sensor( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -352,7 +372,7 @@ class PurpleAirOptionsFlowHandler(config_entries.OptionsFlow): async def async_step_choose_sensor( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Handle the selection of a sensor.""" + """Choose a sensor.""" if user_input is None: options = self._flow_data.pop(CONF_NEARBY_SENSOR_OPTIONS) return self.async_show_form( @@ -375,13 +395,13 @@ class PurpleAirOptionsFlowHandler(config_entries.OptionsFlow): """Manage the options.""" return self.async_show_menu( step_id="init", - menu_options=["add_sensor", "remove_sensor"], + menu_options=["add_sensor", "remove_sensor", "settings"], ) async def async_step_remove_sensor( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Add a sensor.""" + """Remove a sensor.""" if user_input is None: return self.async_show_form( step_id="remove_sensor", @@ -404,7 +424,9 @@ class PurpleAirOptionsFlowHandler(config_entries.OptionsFlow): device_entities_removed_event = asyncio.Event() @callback - def async_device_entity_state_changed(_: Event) -> None: + def async_device_entity_state_changed( + _: EventType[EventStateChangedData], + ) -> None: """Listen and respond when all device entities are removed.""" if all( self.hass.states.get(entity_entry.entity_id) is None @@ -437,3 +459,15 @@ class PurpleAirOptionsFlowHandler(config_entries.OptionsFlow): options[CONF_SENSOR_INDICES].remove(removed_sensor_index) return self.async_create_entry(data=options) + + async def async_step_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage settings.""" + if user_input is None: + return self.async_show_form( + step_id="settings", data_schema=self.settings_schema + ) + + options = deepcopy({**self.config_entry.options}) + return self.async_create_entry(data=options | user_input) diff --git a/homeassistant/components/purpleair/const.py b/homeassistant/components/purpleair/const.py index 60f51a9e7dd..e3ea7807a21 100644 --- a/homeassistant/components/purpleair/const.py +++ b/homeassistant/components/purpleair/const.py @@ -7,3 +7,4 @@ LOGGER = logging.getLogger(__package__) CONF_READ_KEY = "read_key" CONF_SENSOR_INDICES = "sensor_indices" +CONF_SHOW_ON_MAP = "show_on_map" diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py index 23370f8a20c..fffceffa343 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -50,7 +50,6 @@ class PurpleAirSensorEntityDescription( SENSOR_DESCRIPTIONS = [ PurpleAirSensorEntityDescription( key="humidity", - name="Humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -58,7 +57,7 @@ SENSOR_DESCRIPTIONS = [ ), PurpleAirSensorEntityDescription( key="pm0.3_count_concentration", - name="PM0.3 count concentration", + translation_key="pm0_3_count_concentration", entity_registry_enabled_default=False, icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, @@ -67,7 +66,7 @@ SENSOR_DESCRIPTIONS = [ ), PurpleAirSensorEntityDescription( key="pm0.5_count_concentration", - name="PM0.5 count concentration", + translation_key="pm0_5_count_concentration", entity_registry_enabled_default=False, icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, @@ -76,7 +75,7 @@ SENSOR_DESCRIPTIONS = [ ), PurpleAirSensorEntityDescription( key="pm1.0_count_concentration", - name="PM1.0 count concentration", + translation_key="pm1_0_count_concentration", entity_registry_enabled_default=False, icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, @@ -85,7 +84,6 @@ SENSOR_DESCRIPTIONS = [ ), PurpleAirSensorEntityDescription( key="pm1.0_mass_concentration", - name="PM1.0 mass concentration", device_class=SensorDeviceClass.PM1, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -93,7 +91,7 @@ SENSOR_DESCRIPTIONS = [ ), PurpleAirSensorEntityDescription( key="pm10.0_count_concentration", - name="PM10.0 count concentration", + translation_key="pm10_0_count_concentration", entity_registry_enabled_default=False, icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, @@ -102,7 +100,6 @@ SENSOR_DESCRIPTIONS = [ ), PurpleAirSensorEntityDescription( key="pm10.0_mass_concentration", - name="PM10.0 mass concentration", device_class=SensorDeviceClass.PM10, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -110,7 +107,7 @@ SENSOR_DESCRIPTIONS = [ ), PurpleAirSensorEntityDescription( key="pm2.5_count_concentration", - name="PM2.5 count concentration", + translation_key="pm2_5_count_concentration", entity_registry_enabled_default=False, icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, @@ -119,7 +116,6 @@ SENSOR_DESCRIPTIONS = [ ), PurpleAirSensorEntityDescription( key="pm2.5_mass_concentration", - name="PM2.5 mass concentration", device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -127,7 +123,7 @@ SENSOR_DESCRIPTIONS = [ ), PurpleAirSensorEntityDescription( key="pm5.0_count_concentration", - name="PM5.0 count concentration", + translation_key="pm5_0_count_concentration", entity_registry_enabled_default=False, icon="mdi:blur", native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, @@ -136,7 +132,6 @@ SENSOR_DESCRIPTIONS = [ ), PurpleAirSensorEntityDescription( key="pressure", - name="Pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, @@ -144,7 +139,7 @@ SENSOR_DESCRIPTIONS = [ ), PurpleAirSensorEntityDescription( key="rssi", - name="RSSI", + translation_key="rssi", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -154,7 +149,6 @@ SENSOR_DESCRIPTIONS = [ ), PurpleAirSensorEntityDescription( key="temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, state_class=SensorStateClass.MEASUREMENT, @@ -162,7 +156,7 @@ SENSOR_DESCRIPTIONS = [ ), PurpleAirSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="uptime", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, device_class=SensorDeviceClass.DURATION, @@ -171,8 +165,9 @@ SENSOR_DESCRIPTIONS = [ value_fn=lambda sensor: sensor.uptime, ), PurpleAirSensorEntityDescription( + # This sensor is an air quality index for VOCs. More info at https://github.com/home-assistant/core/pull/84896 key="voc", - name="VOC", + translation_key="voc_aqi", device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda sensor: sensor.voc, diff --git a/homeassistant/components/purpleair/strings.json b/homeassistant/components/purpleair/strings.json index 3d18fef3906..ff505010713 100644 --- a/homeassistant/components/purpleair/strings.json +++ b/homeassistant/components/purpleair/strings.json @@ -79,7 +79,8 @@ "init": { "menu_options": { "add_sensor": "Add sensor", - "remove_sensor": "Remove sensor" + "remove_sensor": "Remove sensor", + "settings": "Settings" } }, "remove_sensor": { @@ -90,6 +91,12 @@ "data_description": { "sensor_device_id": "The sensor to remove" } + }, + "settings": { + "title": "[%key:component::purpleair::options::step::init::menu_options::settings%]", + "data": { + "show_on_map": "Show configured sensor locations on the map" + } } }, "error": { @@ -100,5 +107,36 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "pm0_3_count_concentration": { + "name": "PM0.3 count concentration" + }, + "pm0_5_count_concentration": { + "name": "PM0.5 count concentration" + }, + "pm1_0_count_concentration": { + "name": "PM1.0 count concentration" + }, + "pm10_0_count_concentration": { + "name": "PM10.0 count concentration" + }, + "pm2_5_count_concentration": { + "name": "PM2.5 count concentration" + }, + "pm5_0_count_concentration": { + "name": "PM5.0 count concentration" + }, + "rssi": { + "name": "RSSI" + }, + "uptime": { + "name": "Uptime" + }, + "voc_aqi": { + "name": "Volatile organic compounds air quality index" + } + } } } diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index b61469f6b2a..84d2998e992 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -16,50 +16,50 @@ from .const import DATA_UPDATED, DOMAIN SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="application_name", - name="Application name", + translation_key="application_name", entity_registry_enabled_default=False, ), SensorEntityDescription( key="body", - name="Body", + translation_key="body", ), SensorEntityDescription( key="notification_id", - name="Notification ID", + translation_key="notification_id", entity_registry_enabled_default=False, ), SensorEntityDescription( key="notification_tag", - name="Notification tag", + translation_key="notification_tag", entity_registry_enabled_default=False, ), SensorEntityDescription( key="package_name", - name="Package name", + translation_key="package_name", entity_registry_enabled_default=False, ), SensorEntityDescription( key="receiver_email", - name="Receiver email", + translation_key="receiver_email", entity_registry_enabled_default=False, ), SensorEntityDescription( key="sender_email", - name="Sender email", + translation_key="sender_email", entity_registry_enabled_default=False, ), SensorEntityDescription( key="source_device_iden", - name="Sender device ID", + translation_key="source_device_identifier", entity_registry_enabled_default=False, ), SensorEntityDescription( key="title", - name="Title", + translation_key="title", ), SensorEntityDescription( key="type", - name="Type", + translation_key="type", entity_registry_enabled_default=False, ), ) diff --git a/homeassistant/components/pushbullet/strings.json b/homeassistant/components/pushbullet/strings.json index a6571ae7bf0..94d4202ea8c 100644 --- a/homeassistant/components/pushbullet/strings.json +++ b/homeassistant/components/pushbullet/strings.json @@ -15,5 +15,39 @@ } } } + }, + "entity": { + "sensor": { + "application_name": { + "name": "Application name" + }, + "body": { + "name": "Body" + }, + "notification_id": { + "name": "Notification ID" + }, + "notification_tag": { + "name": "Notification tag" + }, + "package_name": { + "name": "Package name" + }, + "receiver_email": { + "name": "Receiver email" + }, + "sender_email": { + "name": "Sender email" + }, + "source_device_identifier": { + "name": "Sender device ID" + }, + "title": { + "name": "Title" + }, + "type": { + "name": "Type" + } + } } } diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index 700757c6d58..b681678b098 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -45,7 +45,7 @@ class PVOutputSensorEntityDescription( SENSORS: tuple[PVOutputSensorEntityDescription, ...] = ( PVOutputSensorEntityDescription( key="energy_consumption", - name="Energy consumed", + translation_key="energy_consumption", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -53,7 +53,7 @@ SENSORS: tuple[PVOutputSensorEntityDescription, ...] = ( ), PVOutputSensorEntityDescription( key="energy_generation", - name="Energy generated", + translation_key="energy_generation", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -61,7 +61,7 @@ SENSORS: tuple[PVOutputSensorEntityDescription, ...] = ( ), PVOutputSensorEntityDescription( key="normalized_output", - name="Efficiency", + translation_key="efficiency", native_unit_of_measurement=( f"{UnitOfEnergy.KILO_WATT_HOUR}/{UnitOfPower.KILO_WATT}" ), @@ -70,7 +70,7 @@ SENSORS: tuple[PVOutputSensorEntityDescription, ...] = ( ), PVOutputSensorEntityDescription( key="power_consumption", - name="Power consumed", + translation_key="power_consumption", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -78,7 +78,7 @@ SENSORS: tuple[PVOutputSensorEntityDescription, ...] = ( ), PVOutputSensorEntityDescription( key="power_generation", - name="Power generated", + translation_key="power_generation", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -86,7 +86,6 @@ SENSORS: tuple[PVOutputSensorEntityDescription, ...] = ( ), PVOutputSensorEntityDescription( key="temperature", - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -94,7 +93,6 @@ SENSORS: tuple[PVOutputSensorEntityDescription, ...] = ( ), PVOutputSensorEntityDescription( key="voltage", - name="Voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/pvoutput/strings.json b/homeassistant/components/pvoutput/strings.json index 12f30b773d5..06d98971053 100644 --- a/homeassistant/components/pvoutput/strings.json +++ b/homeassistant/components/pvoutput/strings.json @@ -23,5 +23,24 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "energy_consumption": { + "name": "Energy consumed" + }, + "energy_generation": { + "name": "Energy generated" + }, + "efficiency": { + "name": "Efficiency" + }, + "power_consumption": { + "name": "Power consumed" + }, + "power_generation": { + "name": "Power generated" + } + } } } diff --git a/homeassistant/components/python_script/services.yaml b/homeassistant/components/python_script/services.yaml index e9f860f1a62..613c6cbc9e2 100644 --- a/homeassistant/components/python_script/services.yaml +++ b/homeassistant/components/python_script/services.yaml @@ -1,5 +1,3 @@ # Describes the format for available python_script services reload: - name: Reload - description: Reload all available python_scripts diff --git a/homeassistant/components/python_script/strings.json b/homeassistant/components/python_script/strings.json new file mode 100644 index 00000000000..ccf1b33c767 --- /dev/null +++ b/homeassistant/components/python_script/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads all available Python scripts." + } + } +} diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 15a634cf7a9..0d5dc160a11 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -23,7 +23,7 @@ from homeassistant.const import ( STATE_IDLE, UnitOfDataRate, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -84,12 +84,17 @@ async def async_setup_platform( ) ir.async_create_issue( hass, - DOMAIN, - "deprecated_yaml", + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", breaks_in_ha_version="2023.11.0", is_fixable=False, + issue_domain=DOMAIN, severity=ir.IssueSeverity.WARNING, translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "qBittorrent", + }, ) diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 24d1885a917..66c9430911e 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -17,11 +17,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The qBittorrent YAML configuration is being removed", - "description": "Configuring qBittorrent using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the qBittorrent YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 6d214b63e2e..febd4b61ebb 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -28,7 +28,7 @@ from homeassistant.const import ( UnitOfInformation, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo @@ -95,26 +95,31 @@ _CPU_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, icon="mdi:chip", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), ) _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="memory_free", name="Memory Available", - native_unit_of_measurement=UnitOfInformation.GIBIBYTES, + native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, ), SensorEntityDescription( key="memory_used", name="Memory Used", - native_unit_of_measurement=UnitOfInformation.GIBIBYTES, + native_unit_of_measurement=UnitOfInformation.MEBIBYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:memory", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, ), SensorEntityDescription( key="memory_percent_used", @@ -122,6 +127,7 @@ _MEMORY_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), ) _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( @@ -133,20 +139,24 @@ _NETWORK_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="network_tx", name="Network Up", - native_unit_of_measurement=UnitOfDataRate.MEBIBYTES_PER_SECOND, + native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, ), SensorEntityDescription( key="network_rx", name="Network Down", - native_unit_of_measurement=UnitOfDataRate.MEBIBYTES_PER_SECOND, + native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, ), ) _DRIVE_MON_COND: tuple[SensorEntityDescription, ...] = ( @@ -170,20 +180,24 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="volume_size_used", name="Used Space", - native_unit_of_measurement=UnitOfInformation.GIBIBYTES, + native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, ), SensorEntityDescription( key="volume_size_free", name="Free Space", - native_unit_of_measurement=UnitOfInformation.GIBIBYTES, + native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:chart-pie", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES, ), SensorEntityDescription( key="volume_percentage_used", @@ -191,6 +205,7 @@ _VOLUME_MON_COND: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, icon="mdi:chart-pie", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), ) @@ -235,12 +250,17 @@ async def async_setup_platform( async_create_issue( hass, - DOMAIN, - "deprecated_yaml", + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", breaks_in_ha_version="2023.12.0", is_fixable=False, + issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "QNAP", + }, ) hass.async_create_task( @@ -311,16 +331,6 @@ async def async_setup_entry( async_add_entities(sensors) -def round_nicely(number): - """Round a number based on its size (so it looks nice).""" - if number < 10: - return round(number, 2) - if number < 100: - return round(number, 1) - - return round(number) - - class QNAPSensor(CoordinatorEntity[QnapCoordinator], SensorEntity): """Base class for a QNAP sensor.""" @@ -373,25 +383,25 @@ class QNAPMemorySensor(QNAPSensor): @property def native_value(self): """Return the state of the sensor.""" - free = float(self.coordinator.data["system_stats"]["memory"]["free"]) / 1024 + free = float(self.coordinator.data["system_stats"]["memory"]["free"]) if self.entity_description.key == "memory_free": - return round_nicely(free) + return free - total = float(self.coordinator.data["system_stats"]["memory"]["total"]) / 1024 + total = float(self.coordinator.data["system_stats"]["memory"]["total"]) used = total - free if self.entity_description.key == "memory_used": - return round_nicely(used) + return used if self.entity_description.key == "memory_percent_used": - return round(used / total * 100) + return used / total * 100 @property def extra_state_attributes(self): """Return the state attributes.""" if self.coordinator.data: data = self.coordinator.data["system_stats"]["memory"] - size = round_nicely(float(data["total"]) / 1024) + size = round(float(data["total"]) / 1024, 2) return {ATTR_MEMORY_SIZE: f"{size} {UnitOfInformation.GIBIBYTES}"} @@ -407,10 +417,10 @@ class QNAPNetworkSensor(QNAPSensor): data = self.coordinator.data["bandwidth"][self.monitor_device] if self.entity_description.key == "network_tx": - return round_nicely(data["tx"] / 1024 / 1024) + return data["tx"] if self.entity_description.key == "network_rx": - return round_nicely(data["rx"] / 1024 / 1024) + return data["rx"] @property def extra_state_attributes(self): @@ -422,8 +432,6 @@ class QNAPNetworkSensor(QNAPSensor): ATTR_MASK: data["mask"], ATTR_MAC: data["mac"], ATTR_MAX_SPEED: data["max_speed"], - ATTR_PACKETS_TX: data["tx_packets"], - ATTR_PACKETS_RX: data["rx_packets"], ATTR_PACKETS_ERR: data["err_packets"], } @@ -502,18 +510,18 @@ class QNAPVolumeSensor(QNAPSensor): """Return the state of the sensor.""" data = self.coordinator.data["volumes"][self.monitor_device] - free_gb = int(data["free_size"]) / 1024 / 1024 / 1024 + free_gb = int(data["free_size"]) if self.entity_description.key == "volume_size_free": - return round_nicely(free_gb) + return free_gb - total_gb = int(data["total_size"]) / 1024 / 1024 / 1024 + total_gb = int(data["total_size"]) used_gb = total_gb - free_gb if self.entity_description.key == "volume_size_used": - return round_nicely(used_gb) + return used_gb if self.entity_description.key == "volume_percentage_used": - return round(used_gb / total_gb * 100) + return used_gb / total_gb * 100 @property def extra_state_attributes(self): @@ -523,5 +531,5 @@ class QNAPVolumeSensor(QNAPSensor): total_gb = int(data["total_size"]) / 1024 / 1024 / 1024 return { - ATTR_VOLUME_SIZE: f"{round_nicely(total_gb)} {UnitOfInformation.GIBIBYTES}" + ATTR_VOLUME_SIZE: f"{round(total_gb, 1)} {UnitOfInformation.GIBIBYTES}" } diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index 36946b81c0c..64b3f22293a 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -6,9 +6,9 @@ "description": "This qnap sensor allows getting various statistics from your QNAP NAS.", "data": { "host": "Hostname", - "username": "Username", - "password": "Password", - "port": "Port", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", "ssl": "Enable SSL", "verify_ssl": "Verify SSL" } @@ -19,11 +19,5 @@ "invalid_auth": "Bad authentication", "unknown": "Unknown error" } - }, - "issues": { - "deprecated_yaml": { - "title": "The QNAP YAML configuration is being removed", - "description": "Configuring QNAP using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the QNAP YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/qnap_qsw/entity.py b/homeassistant/components/qnap_qsw/entity.py index 288c184984d..38e45457462 100644 --- a/homeassistant/components/qnap_qsw/entity.py +++ b/homeassistant/components/qnap_qsw/entity.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from enum import StrEnum from typing import Any from aioqsw.const import ( @@ -14,7 +15,6 @@ from aioqsw.const import ( QSD_SYSTEM_BOARD, ) -from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import callback diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index a19760ad989..2176aa0c91e 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], - "requirements": ["Pillow==9.5.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==10.0.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/qvr_pro/services.yaml b/homeassistant/components/qvr_pro/services.yaml index edb879c784a..0dad311f899 100644 --- a/homeassistant/components/qvr_pro/services.yaml +++ b/homeassistant/components/qvr_pro/services.yaml @@ -1,22 +1,14 @@ start_record: - name: Start record - description: Start QVR Pro recording on specified channel. fields: guid: - name: GUID - description: GUID of the channel to start recording. required: true example: "245EBE933C0A597EBE865C0A245E0002" selector: text: stop_record: - name: Stop record - description: Stop QVR Pro recording on specified channel. fields: guid: - name: GUID - description: GUID of the channel to stop recording. required: true example: "245EBE933C0A597EBE865C0A245E0002" selector: diff --git a/homeassistant/components/qvr_pro/strings.json b/homeassistant/components/qvr_pro/strings.json new file mode 100644 index 00000000000..de61d38ffea --- /dev/null +++ b/homeassistant/components/qvr_pro/strings.json @@ -0,0 +1,24 @@ +{ + "services": { + "start_record": { + "name": "Start record", + "description": "Starts QVR Pro recording on specified channel.", + "fields": { + "guid": { + "name": "GUID", + "description": "GUID of the channel to start recording." + } + } + }, + "stop_record": { + "name": "Stop record", + "description": "Stops QVR Pro recording on specified channel.", + "fields": { + "guid": { + "name": "[%key:component::qvr_pro::services::start_record::fields::guid::name%]", + "description": "GUID of the channel to stop recording." + } + } + } + } +} diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index 294931b7538..f1c515d37f7 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -109,10 +109,7 @@ class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): @callback def _async_handle_update(self, *args, **kwargs) -> None: """Handle an update to the state of this sensor.""" - if ( - args[0][0][KEY_SUBTYPE] == SUBTYPE_ONLINE - or args[0][0][KEY_SUBTYPE] == SUBTYPE_COLD_REBOOT - ): + if args[0][0][KEY_SUBTYPE] in (SUBTYPE_ONLINE, SUBTYPE_COLD_REBOOT): self._state = True elif args[0][0][KEY_SUBTYPE] == SUBTYPE_OFFLINE: self._state = False diff --git a/homeassistant/components/rachio/services.yaml b/homeassistant/components/rachio/services.yaml index 67463a22172..6a6a8bf5cf6 100644 --- a/homeassistant/components/rachio/services.yaml +++ b/homeassistant/components/rachio/services.yaml @@ -1,14 +1,10 @@ set_zone_moisture_percent: - name: Set zone moisture percent - description: Set the moisture percentage of a zone or list of zones. target: entity: integration: rachio domain: switch fields: percent: - name: Percent - description: Set the desired zone moisture percentage. required: true selector: number: @@ -16,33 +12,23 @@ set_zone_moisture_percent: max: 100 unit_of_measurement: "%" start_multiple_zone_schedule: - name: Start multiple zones - description: Create a custom schedule of zones and runtimes. Note that all zones should be on the same controller to avoid issues. target: entity: integration: rachio domain: switch fields: duration: - name: Duration - description: Number of minutes to run the zone(s). If only 1 duration is given, that time will be used for all zones. If given a list of durations, the durations will apply to the respective zones listed above. example: 15, 20 required: true selector: object: pause_watering: - name: Pause watering - description: Pause any currently running zones or schedules. fields: devices: - name: Devices - description: Name of controllers to pause. Defaults to all controllers on the account if not provided. example: "Main House" selector: text: duration: - name: Duration - description: The time to pause running schedules. default: 60 selector: number: @@ -50,22 +36,14 @@ pause_watering: max: 60 unit_of_measurement: "minutes" resume_watering: - name: Resume watering - description: Resume any paused zone runs or schedules. fields: devices: - name: Devices - description: Name of controllers to resume. Defaults to all controllers on the account if not provided. example: "Main House" selector: text: stop_watering: - name: Stop watering - description: Stop any currently running zones or schedules. fields: devices: - name: Devices - description: Name of controllers to stop. Defaults to all controllers on the account if not provided. example: "Main House" selector: text: diff --git a/homeassistant/components/rachio/strings.json b/homeassistant/components/rachio/strings.json index 697b0bce2db..2132cab8682 100644 --- a/homeassistant/components/rachio/strings.json +++ b/homeassistant/components/rachio/strings.json @@ -26,5 +26,61 @@ } } } + }, + "services": { + "set_zone_moisture_percent": { + "name": "Set zone moisture percent", + "description": "Sets the moisture percentage of a zone or list of zones.", + "fields": { + "percent": { + "name": "Percent", + "description": "Set the desired zone moisture percentage." + } + } + }, + "start_multiple_zone_schedule": { + "name": "Start multiple zones", + "description": "Creates a custom schedule of zones and runtimes. Note that all zones should be on the same controller to avoid issues.", + "fields": { + "duration": { + "name": "Duration", + "description": "Number of minutes to run the zone(s). If only 1 duration is given, that time will be used for all zones. If given a list of durations, the durations will apply to the respective zones listed above." + } + } + }, + "pause_watering": { + "name": "Pause watering", + "description": "Pause any currently running zones or schedules.", + "fields": { + "devices": { + "name": "Devices", + "description": "Name of controllers to pause. Defaults to all controllers on the account if not provided." + }, + "duration": { + "name": "Duration", + "description": "The time to pause running schedules." + } + } + }, + "resume_watering": { + "name": "Resume watering", + "description": "Resume any paused zone runs or schedules.", + "fields": { + "devices": { + "name": "[%key:component::rachio::services::pause_watering::fields::devices::name%]", + "description": "Name of controllers to resume. Defaults to all controllers on the account if not provided." + } + } + }, + "stop_watering": { + "name": "Stop watering", + "description": "Stop any currently running zones or schedules.", + "fields": { + "devices": { + "name": "[%key:component::rachio::services::pause_watering::fields::devices::name%]", + "description": "Name of controllers to stop. Defaults to all controllers on the account if not provided." + } + } + } } } diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index 5537a18725c..c318d662028 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from datetime import timedelta -from typing import Generic, TypeVar, cast +from typing import Generic, TypeVar from aiopyarr import Health, RadarrMovie, RootFolder, SystemStatus, exceptions from aiopyarr.models.host_configuration import PyArrHostConfiguration @@ -71,7 +71,10 @@ class DiskSpaceDataUpdateCoordinator(RadarrDataUpdateCoordinator[list[RootFolder async def _fetch_data(self) -> list[RootFolder]: """Fetch the data.""" - return cast(list[RootFolder], await self.api_client.async_get_root_folders()) + root_folders = await self.api_client.async_get_root_folders() + if isinstance(root_folders, RootFolder): + root_folders = [root_folders] + return root_folders class HealthDataUpdateCoordinator(RadarrDataUpdateCoordinator[list[Health]]): @@ -87,4 +90,7 @@ class MoviesDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]): async def _fetch_data(self) -> int: """Fetch the movies data.""" - return len(cast(list[RadarrMovie], await self.api_client.async_get_movies())) + movies = await self.api_client.async_get_movies() + if isinstance(movies, RadarrMovie): + return 1 + return len(movies) diff --git a/homeassistant/components/radio_browser/manifest.json b/homeassistant/components/radio_browser/manifest.json index 3d2ba299628..035c4bdda45 100644 --- a/homeassistant/components/radio_browser/manifest.json +++ b/homeassistant/components/radio_browser/manifest.json @@ -3,7 +3,7 @@ "name": "Radio Browser", "codeowners": ["@frenck"], "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/radio", + "documentation": "https://www.home-assistant.io/integrations/radio_browser", "integration_type": "service", "iot_class": "cloud_polling", "requirements": ["radios==0.1.1"] diff --git a/homeassistant/components/radio_browser/media_source.py b/homeassistant/components/radio_browser/media_source.py index e49f670d371..dffbdc42dbe 100644 --- a/homeassistant/components/radio_browser/media_source.py +++ b/homeassistant/components/radio_browser/media_source.py @@ -28,7 +28,7 @@ CODEC_TO_MIMETYPE = { async def async_get_media_source(hass: HomeAssistant) -> RadioMediaSource: """Set up Radio Browser media source.""" - # Radio browser support only a single config entry + # Radio browser supports only a single config entry entry = hass.config_entries.async_entries(DOMAIN)[0] return RadioMediaSource(hass, entry) @@ -40,7 +40,7 @@ class RadioMediaSource(MediaSource): name = "Radio Browser" def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize CameraMediaSource.""" + """Initialize RadioMediaSource.""" super().__init__(DOMAIN) self.hass = hass self.entry = entry diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index 2c71eac0193..f5ea14e8f4e 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -105,11 +105,11 @@ class RadioThermostat(RadioThermostatEntity, ClimateEntity): _attr_hvac_modes = OPERATION_LIST _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT _attr_precision = PRECISION_HALVES + _attr_name = None def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: """Initialize the thermostat.""" super().__init__(coordinator) - self._attr_name = self.init_data.name self._attr_unique_id = self.init_data.mac self._attr_fan_modes = CT30_FAN_OPERATION_LIST self._attr_supported_features = ( diff --git a/homeassistant/components/radiotherm/entity.py b/homeassistant/components/radiotherm/entity.py index 203d17a5dc2..7eb14548ada 100644 --- a/homeassistant/components/radiotherm/entity.py +++ b/homeassistant/components/radiotherm/entity.py @@ -14,6 +14,8 @@ from .data import RadioThermUpdate class RadioThermostatEntity(CoordinatorEntity[RadioThermUpdateCoordinator]): """Base class for radiotherm entities.""" + _attr_has_entity_name = True + def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: """Initialize the entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json index 21f53d72bfa..693811f59ab 100644 --- a/homeassistant/components/radiotherm/strings.json +++ b/homeassistant/components/radiotherm/strings.json @@ -27,5 +27,12 @@ } } } + }, + "entity": { + "switch": { + "hold": { + "name": "Hold" + } + } } } diff --git a/homeassistant/components/radiotherm/switch.py b/homeassistant/components/radiotherm/switch.py index 2cf0602a3fa..3b71baffec6 100644 --- a/homeassistant/components/radiotherm/switch.py +++ b/homeassistant/components/radiotherm/switch.py @@ -28,10 +28,11 @@ async def async_setup_entry( class RadioThermHoldSwitch(RadioThermostatEntity, SwitchEntity): """Provides radiotherm hold switch support.""" + _attr_translation_key = "hold" + def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: """Initialize the hold mode switch.""" super().__init__(coordinator) - self._attr_name = f"{coordinator.init_data.name} Hold" self._attr_unique_id = f"{coordinator.init_data.mac}_hold" @property diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index ee5be0e4617..139a17f5181 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) RAIN_SENSOR_ENTITY_DESCRIPTION = BinarySensorEntityDescription( key="rainsensor", - name="Rainsensor", + translation_key="rainsensor", icon="mdi:water", ) @@ -38,6 +38,8 @@ async def async_setup_entry( class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], BinarySensorEntity): """A sensor implementation for Rain Bird device.""" + _attr_has_entity_name = True + def __init__( self, coordinator: RainbirdUpdateCoordinator, diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index e1b52c6ff7d..d76ac78f7e9 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -72,7 +72,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): def device_info(self) -> DeviceInfo: """Return information about the device.""" return DeviceInfo( - default_name=f"{MANUFACTURER} Controller", + name=f"{MANUFACTURER} Controller", identifiers={(DOMAIN, self._serial_number)}, manufacturer=MANUFACTURER, model=self._model_info.model_name, diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index ac1ea961870..febb960d652 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -32,14 +32,14 @@ async def async_setup_entry( class RainDelayNumber(CoordinatorEntity[RainbirdUpdateCoordinator], NumberEntity): - """A number implemnetaiton for the rain delay.""" + """A number implementation for the rain delay.""" _attr_native_min_value = 0 _attr_native_max_value = 14 _attr_native_step = 1 _attr_native_unit_of_measurement = UnitOfTime.DAYS _attr_icon = "mdi:water-off" - _attr_name = "Rain delay" + _attr_translation_key = "rain_delay" _attr_has_entity_name = True def __init__( diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index de74943baf9..f5cf2390095 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -1,4 +1,4 @@ -"""Support for Rain Bird Irrigation system LNK WiFi Module.""" +"""Support for Rain Bird Irrigation system LNK Wi-Fi Module.""" from __future__ import annotations import logging @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) RAIN_DELAY_ENTITY_DESCRIPTION = SensorEntityDescription( key="raindelay", - name="Raindelay", + translation_key="raindelay", icon="mdi:water-off", ) @@ -42,6 +42,8 @@ async def async_setup_entry( class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], SensorEntity): """A sensor implementation for Rain Bird device.""" + _attr_has_entity_name = True + def __init__( self, coordinator: RainbirdUpdateCoordinator, diff --git a/homeassistant/components/rainbird/services.yaml b/homeassistant/components/rainbird/services.yaml index 34f89ec279b..11226966b0a 100644 --- a/homeassistant/components/rainbird/services.yaml +++ b/homeassistant/components/rainbird/services.yaml @@ -1,14 +1,10 @@ start_irrigation: - name: Start irrigation - description: Start the irrigation target: entity: integration: rainbird domain: switch fields: duration: - name: Duration - description: Duration for this sprinkler to be turned on required: true selector: number: @@ -16,19 +12,13 @@ start_irrigation: max: 1440 unit_of_measurement: "minutes" set_rain_delay: - name: Set rain delay - description: Set how long automatic irrigation is turned off. fields: config_entry_id: - name: Rainbird Controller Configuration Entry - description: The setting will be adjusted on the specified controller required: true selector: config_entry: integration: rainbird duration: - name: Duration - description: Duration for this system to be turned off. required: true selector: number: diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index 3b5ae332dbd..6046189ddc4 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -21,11 +21,54 @@ "options": { "step": { "init": { - "title": "Configure Rain Bird", + "title": "[%key:component::rainbird::config::step::user::title%]", "data": { "duration": "Default irrigation time in minutes" } } } + }, + "entity": { + "binary_sensor": { + "rainsensor": { + "name": "Rainsensor" + } + }, + "number": { + "rain_delay": { + "name": "Rain delay" + } + }, + "sensor": { + "raindelay": { + "name": "Raindelay" + } + } + }, + "services": { + "start_irrigation": { + "name": "Start irrigation", + "description": "Starts the irrigation.", + "fields": { + "duration": { + "name": "Duration", + "description": "Duration for this sprinkler to be turned on." + } + } + }, + "set_rain_delay": { + "name": "Set rain delay", + "description": "Sets how long automatic irrigation is turned off.", + "fields": { + "config_entry_id": { + "name": "Rainbird Controller Configuration Entry", + "description": "The setting will be adjusted on the specified controller." + }, + "duration": { + "name": "Duration", + "description": "Duration for this system to be turned off." + } + } + } } } diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index e915c52c9dc..3e2a3115e29 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -1,4 +1,4 @@ -"""Support for Rain Bird Irrigation system LNK WiFi Module.""" +"""Support for Rain Bird Irrigation system LNK Wi-Fi Module.""" from __future__ import annotations import logging @@ -73,7 +73,7 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) self._duration_minutes = duration_minutes self._attr_unique_id = f"{coordinator.serial_number}-{zone}" self._attr_device_info = DeviceInfo( - default_name=f"{MANUFACTURER} Sprinkler {zone}", + name=f"{MANUFACTURER} Sprinkler {zone}", identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer=MANUFACTURER, via_device=(DOMAIN, coordinator.serial_number), diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 0680aa7455d..a7fd27a051f 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -21,22 +21,21 @@ from .data import EagleDataCoordinator SENSORS = ( SensorEntityDescription( key="zigbee:InstantaneousDemand", - # We can drop the "Eagle-200" part of the name in HA 2021.12 - name="Eagle-200 Meter Power Demand", + translation_key="power_demand", native_unit_of_measurement=UnitOfPower.KILO_WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="zigbee:CurrentSummationDelivered", - name="Eagle-200 Total Meter Energy Delivered", + translation_key="total_energy_delivered", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="zigbee:CurrentSummationReceived", - name="Eagle-200 Total Meter Energy Received", + translation_key="total_energy_received", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -57,7 +56,7 @@ async def async_setup_entry( coordinator, SensorEntityDescription( key="zigbee:Price", - name="Meter Price", + translation_key="meter_price", native_unit_of_measurement=f"{coordinator.data['zigbee:PriceCurrency']}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, ), @@ -70,6 +69,8 @@ async def async_setup_entry( class EagleSensor(CoordinatorEntity[EagleDataCoordinator], SensorEntity): """Implementation of the Rainforest Eagle sensor.""" + _attr_has_entity_name = True + def __init__(self, coordinator, entity_description): """Initialize the sensor.""" super().__init__(coordinator) diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index b32f38302f4..58c7f6bd795 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -17,5 +17,21 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "power_demand": { + "name": "Meter power demand" + }, + "total_energy_delivered": { + "name": "Total meter energy delivered" + }, + "total_energy_received": { + "name": "Total meter energy received" + }, + "meter_price": { + "name": "Meter price" + } + } } } diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 33650cfc2fe..7f93db67c4c 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -44,14 +44,14 @@ class RainMachineBinarySensorDescription( BINARY_SENSOR_DESCRIPTIONS = ( RainMachineBinarySensorDescription( key=TYPE_FLOW_SENSOR, - name="Flow sensor", + translation_key=TYPE_FLOW_SENSOR, icon="mdi:water-pump", api_category=DATA_PROVISION_SETTINGS, data_key="useFlowSensor", ), RainMachineBinarySensorDescription( key=TYPE_FREEZE, - name="Freeze restrictions", + translation_key=TYPE_FREEZE, icon="mdi:cancel", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_CURRENT, @@ -59,7 +59,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( ), RainMachineBinarySensorDescription( key=TYPE_HOURLY, - name="Hourly restrictions", + translation_key=TYPE_HOURLY, icon="mdi:cancel", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_CURRENT, @@ -67,7 +67,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( ), RainMachineBinarySensorDescription( key=TYPE_MONTH, - name="Month restrictions", + translation_key=TYPE_MONTH, icon="mdi:cancel", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_CURRENT, @@ -75,7 +75,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( ), RainMachineBinarySensorDescription( key=TYPE_RAINDELAY, - name="Rain delay restrictions", + translation_key=TYPE_RAINDELAY, icon="mdi:cancel", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_CURRENT, @@ -83,7 +83,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( ), RainMachineBinarySensorDescription( key=TYPE_RAINSENSOR, - name="Rain sensor restrictions", + translation_key=TYPE_RAINSENSOR, icon="mdi:cancel", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -92,7 +92,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( ), RainMachineBinarySensorDescription( key=TYPE_WEEKDAY, - name="Weekday restrictions", + translation_key=TYPE_WEEKDAY, icon="mdi:cancel", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_CURRENT, diff --git a/homeassistant/components/rainmachine/button.py b/homeassistant/components/rainmachine/button.py index d4ed17c72e9..82829094957 100644 --- a/homeassistant/components/rainmachine/button.py +++ b/homeassistant/components/rainmachine/button.py @@ -51,7 +51,6 @@ async def _async_reboot(controller: Controller) -> None: BUTTON_DESCRIPTIONS = ( RainMachineButtonDescription( key=BUTTON_KIND_REBOOT, - name="Reboot", api_category=DATA_PROVISION_SETTINGS, push_action=_async_reboot, ), diff --git a/homeassistant/components/rainmachine/select.py b/homeassistant/components/rainmachine/select.py index f482deb4ef4..2a5bc93f601 100644 --- a/homeassistant/components/rainmachine/select.py +++ b/homeassistant/components/rainmachine/select.py @@ -59,7 +59,7 @@ TYPE_FREEZE_PROTECTION_TEMPERATURE = "freeze_protection_temperature" SELECT_DESCRIPTIONS = ( FreezeProtectionSelectDescription( key=TYPE_FREEZE_PROTECTION_TEMPERATURE, - name="Freeze protection temperature", + translation_key=TYPE_FREEZE_PROTECTION_TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, api_category=DATA_RESTRICTIONS_UNIVERSAL, diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 22943d73fcb..6333dcc82f4 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -69,7 +69,7 @@ class RainMachineSensorCompletionTimerDescription( SENSOR_DESCRIPTIONS = ( RainMachineSensorDataDescription( key=TYPE_FLOW_SENSOR_CLICK_M3, - name="Flow sensor clicks per cubic meter", + translation_key=TYPE_FLOW_SENSOR_CLICK_M3, icon="mdi:water-pump", native_unit_of_measurement=f"clicks/{UnitOfVolume.CUBIC_METERS}", entity_category=EntityCategory.DIAGNOSTIC, @@ -80,7 +80,7 @@ SENSOR_DESCRIPTIONS = ( ), RainMachineSensorDataDescription( key=TYPE_FLOW_SENSOR_CONSUMED_LITERS, - name="Flow sensor consumed liters", + translation_key=TYPE_FLOW_SENSOR_CONSUMED_LITERS, icon="mdi:water-pump", device_class=SensorDeviceClass.WATER, entity_category=EntityCategory.DIAGNOSTIC, @@ -92,7 +92,7 @@ SENSOR_DESCRIPTIONS = ( ), RainMachineSensorDataDescription( key=TYPE_FLOW_SENSOR_LEAK_CLICKS, - name="Flow sensor leak clicks", + translation_key=TYPE_FLOW_SENSOR_LEAK_CLICKS, icon="mdi:pipe-leak", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement="clicks", @@ -103,7 +103,7 @@ SENSOR_DESCRIPTIONS = ( ), RainMachineSensorDataDescription( key=TYPE_FLOW_SENSOR_LEAK_VOLUME, - name="Flow sensor leak volume", + translation_key=TYPE_FLOW_SENSOR_LEAK_VOLUME, icon="mdi:pipe-leak", device_class=SensorDeviceClass.WATER, entity_category=EntityCategory.DIAGNOSTIC, @@ -115,7 +115,7 @@ SENSOR_DESCRIPTIONS = ( ), RainMachineSensorDataDescription( key=TYPE_FLOW_SENSOR_START_INDEX, - name="Flow sensor start index", + translation_key=TYPE_FLOW_SENSOR_START_INDEX, icon="mdi:water-pump", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement="index", @@ -125,7 +125,7 @@ SENSOR_DESCRIPTIONS = ( ), RainMachineSensorDataDescription( key=TYPE_FLOW_SENSOR_WATERING_CLICKS, - name="Flow sensor clicks", + translation_key=TYPE_FLOW_SENSOR_WATERING_CLICKS, icon="mdi:water-pump", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement="clicks", @@ -136,7 +136,7 @@ SENSOR_DESCRIPTIONS = ( ), RainMachineSensorDataDescription( key=TYPE_LAST_LEAK_DETECTED, - name="Last leak detected", + translation_key=TYPE_LAST_LEAK_DETECTED, icon="mdi:pipe-leak", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -147,7 +147,7 @@ SENSOR_DESCRIPTIONS = ( ), RainMachineSensorDataDescription( key=TYPE_RAIN_SENSOR_RAIN_START, - name="Rain sensor rain start", + translation_key=TYPE_RAIN_SENSOR_RAIN_START, icon="mdi:weather-pouring", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/rainmachine/services.yaml b/homeassistant/components/rainmachine/services.yaml index 9aa2bb7f50a..2f799afd028 100644 --- a/homeassistant/components/rainmachine/services.yaml +++ b/homeassistant/components/rainmachine/services.yaml @@ -1,18 +1,12 @@ # Describes the format for available RainMachine services pause_watering: - name: Pause All Watering - description: Pause all watering activities for a number of seconds fields: device_id: - name: Controller - description: The controller whose watering activities should be paused required: true selector: device: integration: rainmachine seconds: - name: Duration - description: The amount of time (in seconds) to pause watering required: true selector: number: @@ -20,41 +14,29 @@ pause_watering: max: 43200 unit_of_measurement: seconds restrict_watering: - name: Restrict All Watering - description: Restrict all watering activities from starting for a time period fields: device_id: - name: Controller - description: The controller whose watering activities should be restricted required: true selector: device: integration: rainmachine duration: - name: Duration - description: The time period to restrict watering activities from starting required: true default: "01:00:00" selector: text: start_program: - name: Start Program - description: Start a program target: entity: integration: rainmachine domain: switch start_zone: - name: Start Zone - description: Start a zone target: entity: integration: rainmachine domain: switch fields: zone_run_time: - name: Run Time - description: The amount of time (in seconds) to run the zone default: 600 selector: number: @@ -62,55 +44,37 @@ start_zone: max: 86400 mode: box stop_all: - name: Stop All Watering - description: Stop all watering activities fields: device_id: - name: Controller - description: The controller whose watering activities should be stopped required: true selector: device: integration: rainmachine stop_program: - name: Stop Program - description: Stop a program target: entity: integration: rainmachine domain: switch stop_zone: - name: Stop Zone - description: Stop a zone target: entity: integration: rainmachine domain: switch unpause_watering: - name: Unpause All Watering - description: Unpause all paused watering activities fields: device_id: - name: Controller - description: The controller whose watering activities should be unpaused required: true selector: device: integration: rainmachine push_flow_meter_data: - name: Push Flow Meter Data - description: Push Flow Meter data to the RainMachine device. fields: device_id: - name: Controller - description: The controller to send flow meter data to required: true selector: device: integration: rainmachine value: - name: Value - description: The flow meter value to send required: true selector: number: @@ -119,8 +83,6 @@ push_flow_meter_data: step: 0.1 mode: box unit_of_measurement: - name: Unit of Measurement - description: The flow meter units to send selector: select: options: @@ -129,30 +91,16 @@ push_flow_meter_data: - "litre" - "m3" push_weather_data: - name: Push Weather Data - description: >- - Push Weather Data from Home Assistant to the RainMachine device. - - Local Weather Push service should be enabled from Settings > Weather > Developer tab for RainMachine to consider the values being sent. - Units must be sent in metric; no conversions are performed by the integraion. - - See details of RainMachine API Here: https://rainmachine.docs.apiary.io/#reference/weather-services/parserdata/post fields: device_id: - name: Controller - description: The controller for the weather data to be pushed. required: true selector: device: integration: rainmachine timestamp: - name: Timestamp - description: UNIX Timestamp for the Weather Data. If omitted, the RainMachine device's local time at the time of the call is used. selector: text: mintemp: - name: Min Temp - description: Minimum Temperature (°C). selector: number: min: -40 @@ -160,8 +108,6 @@ push_weather_data: step: 0.1 unit_of_measurement: "°C" maxtemp: - name: Max Temp - description: Maximum Temperature (°C). selector: number: min: -40 @@ -169,8 +115,6 @@ push_weather_data: step: 0.1 unit_of_measurement: "°C" temperature: - name: Temperature - description: Current Temperature (°C). selector: number: min: -40 @@ -178,16 +122,12 @@ push_weather_data: step: 0.1 unit_of_measurement: "°C" wind: - name: Wind Speed - description: Wind Speed (m/s) selector: number: min: 0 max: 65 unit_of_measurement: "m/s" solarrad: - name: Solar Radiation - description: Solar Radiation (MJ/m²/h) selector: number: min: 0 @@ -195,67 +135,45 @@ push_weather_data: step: 0.1 unit_of_measurement: "MJ/m²/h" et: - name: Evapotranspiration - description: Evapotranspiration (mm) selector: number: min: 0 max: 1000 unit_of_measurement: "mm" qpf: - name: Quantitative Precipitation Forecast - description: >- - Quantitative Precipitation Forecast (mm), or QPF. Note: QPF values shouldn't - be send as cumulative values but the measured/forecasted values for each hour or day. - The RainMachine Mixer will sum all QPF values in the current day to have the day total QPF. selector: number: min: 0 max: 1000 unit_of_measurement: "mm" rain: - name: Measured Rainfall - description: >- - Measured Rainfail (mm). Note: RAIN values shouldn't be send as cumulative values but the - measured/forecasted values for each hour or day. The RainMachine Mixer will sum all RAIN values - in the current day to have the day total RAIN. selector: number: min: 0 max: 1000 unit_of_measurement: "mm" minrh: - name: Min Relative Humidity - description: Min Relative Humidity (%RH) selector: number: min: 0 max: 100 unit_of_measurement: "%" maxrh: - name: Max Relative Humidity - description: Max Relative Humidity (%RH) selector: number: min: 0 max: 100 unit_of_measurement: "%" condition: - name: Weather Condition Code - description: Current weather condition code (WNUM). selector: text: pressure: - name: Barametric Pressure - description: Barametric Pressure (kPa) selector: number: min: 60 max: 110 unit_of_measurement: "kPa" dewpoint: - name: Dew Point - description: Dew Point (°C). selector: number: min: -40 @@ -263,12 +181,8 @@ push_weather_data: step: 0.1 unit_of_measurement: "°C" unrestrict_watering: - name: Unrestrict All Watering - description: Unrestrict all watering activities fields: device_id: - name: Controller - description: The controller whose watering activities should be unrestricted required: true selector: device: diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index 9991fd31e03..fc48ebce4eb 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -28,5 +28,240 @@ } } } + }, + "entity": { + "binary_sensor": { + "flow_sensor": { + "name": "Flow sensor" + }, + "freeze": { + "name": "Freeze restrictions" + }, + "hourly": { + "name": "Hourly restrictions" + }, + "month": { + "name": "Month restrictions" + }, + "raindelay": { + "name": "Rain delay restrictions" + }, + "rainsensor": { + "name": "Rain sensor restrictions" + }, + "weekday": { + "name": "Weekday restrictions" + } + }, + "select": { + "freeze_protection_temperature": { + "name": "Freeze protection temperature" + } + }, + "sensor": { + "flow_sensor_clicks_cubic_meter": { + "name": "Flow sensor clicks per cubic meter" + }, + "flow_sensor_consumed_liters": { + "name": "Flow sensor consumed liters" + }, + "flow_sensor_leak_clicks": { + "name": "Flow sensor leak clicks" + }, + "flow_sensor_leak_volume": { + "name": "Flow sensor leak volume" + }, + "flow_sensor_start_index": { + "name": "Flow sensor start index" + }, + "flow_sensor_watering_clicks": { + "name": "Flow sensor clicks" + }, + "last_leak_detected": { + "name": "Last leak detected" + }, + "rain_sensor_rain_start": { + "name": "Rain sensor rain start" + } + }, + "switch": { + "freeze_protect_enabled": { + "name": "Freeze protection" + }, + "hot_days_extra_watering": { + "name": "Extra water on hot days" + } + }, + "update": { + "firmware": { + "name": "Firmware" + } + } + }, + "services": { + "pause_watering": { + "name": "Pause all watering", + "description": "Pauses all watering activities for a number of seconds.", + "fields": { + "device_id": { + "name": "Controller", + "description": "The controller whose watering activities should be paused." + }, + "seconds": { + "name": "Duration", + "description": "The amount of time (in seconds) to pause watering." + } + } + }, + "restrict_watering": { + "name": "Restrict all watering", + "description": "Restricts all watering activities from starting for a time period.", + "fields": { + "device_id": { + "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", + "description": "The controller whose watering activities should be restricted." + }, + "duration": { + "name": "Duration", + "description": "The time period to restrict watering activities from starting." + } + } + }, + "start_program": { + "name": "Start program", + "description": "Starts a program." + }, + "start_zone": { + "name": "Start zone", + "description": "Starts a zone.", + "fields": { + "zone_run_time": { + "name": "Run time", + "description": "The amount of time (in seconds) to run the zone." + } + } + }, + "stop_all": { + "name": "Stop all watering", + "description": "Stops all watering activities.", + "fields": { + "device_id": { + "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", + "description": "The controller whose watering activities should be stopped." + } + } + }, + "stop_program": { + "name": "Stop program", + "description": "Stops a program." + }, + "stop_zone": { + "name": "Stop zone", + "description": "Stops a zone." + }, + "unpause_watering": { + "name": "Unpause all watering", + "description": "Unpauses all paused watering activities.", + "fields": { + "device_id": { + "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", + "description": "The controller whose watering activities should be unpaused." + } + } + }, + "push_flow_meter_data": { + "name": "Push flow meter data", + "description": "Push flow meter data to the RainMachine device.", + "fields": { + "device_id": { + "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", + "description": "The controller to send flow meter data to." + }, + "value": { + "name": "Value", + "description": "The flow meter value to send." + }, + "unit_of_measurement": { + "name": "Unit of measurement", + "description": "The flow meter units to send." + } + } + }, + "push_weather_data": { + "name": "Push weather data", + "description": "Push weather data from Home Assistant to the RainMachine device.\nLocal Weather Push service should be enabled from Settings > Weather > Developer tab for RainMachine to consider the values being sent. Units must be sent in metric; no conversions are performed by the integraion.\nSee details of RainMachine API Here: https://rainmachine.docs.apiary.io/#reference/weather-services/parserdata/post.", + "fields": { + "device_id": { + "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", + "description": "The controller for the weather data to be pushed." + }, + "timestamp": { + "name": "Timestamp", + "description": "UNIX Timestamp for the weather data. If omitted, the RainMachine device's local time at the time of the call is used." + }, + "mintemp": { + "name": "Min temp", + "description": "Minimum temperature (°C)." + }, + "maxtemp": { + "name": "Max temp", + "description": "Maximum temperature (°C)." + }, + "temperature": { + "name": "Temperature", + "description": "Current temperature (°C)." + }, + "wind": { + "name": "Wind speed", + "description": "Wind speed (m/s)." + }, + "solarrad": { + "name": "Solar radiation", + "description": "Solar radiation (MJ/m²/h)." + }, + "et": { + "name": "Evapotranspiration", + "description": "Evapotranspiration (mm)." + }, + "qpf": { + "name": "Quantitative Precipitation Forecast", + "description": "Quantitative Precipitation Forecast (mm), or QPF. Note: QPF values shouldn't be send as cumulative values but the measured/forecasted values for each hour or day. The RainMachine Mixer will sum all QPF values in the current day to have the day total QPF." + }, + "rain": { + "name": "Measured rainfall", + "description": "Measured rainfail (mm). Note: RAIN values shouldn't be send as cumulative values but the measured/forecasted values for each hour or day. The RainMachine Mixer will sum all RAIN values in the current day to have the day total RAIN." + }, + "minrh": { + "name": "Min relative humidity", + "description": "Min relative humidity (%RH)." + }, + "maxrh": { + "name": "Max relative humidity", + "description": "Max relative humidity (%RH)." + }, + "condition": { + "name": "Weather condition code", + "description": "Current weather condition code (WNUM)." + }, + "pressure": { + "name": "Barametric pressure", + "description": "Barametric pressure (kPa)." + }, + "dewpoint": { + "name": "Dew point", + "description": "Dew point (°C)." + } + } + }, + "unrestrict_watering": { + "name": "Unrestrict all watering", + "description": "Unrestrict all watering activities.", + "fields": { + "device_id": { + "name": "[%key:component::rainmachine::services::pause_watering::fields::device_id::name%]", + "description": "The controller whose watering activities should be unrestricted." + } + } + } } } diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 60db5085951..e6ed92d04dc 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -161,14 +161,14 @@ TYPE_RESTRICTIONS_HOT_DAYS_EXTRA_WATERING = "hot_days_extra_watering" RESTRICTIONS_SWITCH_DESCRIPTIONS = ( RainMachineRestrictionSwitchDescription( key=TYPE_RESTRICTIONS_FREEZE_PROTECT_ENABLED, - name="Freeze protection", + translation_key=TYPE_RESTRICTIONS_FREEZE_PROTECT_ENABLED, icon="mdi:snowflake-alert", api_category=DATA_RESTRICTIONS_UNIVERSAL, data_key="freezeProtectEnabled", ), RainMachineRestrictionSwitchDescription( key=TYPE_RESTRICTIONS_HOT_DAYS_EXTRA_WATERING, - name="Extra water on hot days", + translation_key=TYPE_RESTRICTIONS_HOT_DAYS_EXTRA_WATERING, icon="mdi:heat-wave", api_category=DATA_RESTRICTIONS_UNIVERSAL, data_key="hotDaysExtraWatering", diff --git a/homeassistant/components/rainmachine/update.py b/homeassistant/components/rainmachine/update.py index f603cf0ccd7..372319ba9a0 100644 --- a/homeassistant/components/rainmachine/update.py +++ b/homeassistant/components/rainmachine/update.py @@ -44,7 +44,7 @@ UPDATE_STATE_MAP = { UPDATE_DESCRIPTION = RainMachineEntityDescription( key="update", - name="Firmware", + translation_key="firmware", api_category=DATA_MACHINE_FIRMWARE_UPDATE_STATUS, ) @@ -52,7 +52,7 @@ UPDATE_DESCRIPTION = RainMachineEntityDescription( async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up WLED update based on a config entry.""" + """Set up Rainmachine update based on a config entry.""" data: RainMachineData = hass.data[DOMAIN][entry.entry_id] async_add_entities([RainMachineUpdateEntity(entry, data, UPDATE_DESCRIPTION)]) diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py index d4131fdb022..61ef1be500a 100644 --- a/homeassistant/components/rainmachine/util.py +++ b/homeassistant/components/rainmachine/util.py @@ -4,9 +4,9 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Iterable from dataclasses import dataclass from datetime import timedelta +from enum import StrEnum from typing import Any -from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index 13a04515143..9d895f35eb7 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -41,13 +41,13 @@ class RDWBinarySensorEntityDescription( BINARY_SENSORS: tuple[RDWBinarySensorEntityDescription, ...] = ( RDWBinarySensorEntityDescription( key="liability_insured", - name="Liability insured", + translation_key="liability_insured", icon="mdi:shield-car", is_on_fn=lambda vehicle: vehicle.liability_insured, ), RDWBinarySensorEntityDescription( key="pending_recall", - name="Pending recall", + translation_key="pending_recall", device_class=BinarySensorDeviceClass.PROBLEM, is_on_fn=lambda vehicle: vehicle.pending_recall, ), diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index 0b5640fe3a4..5df34652f2b 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -1,7 +1,7 @@ { "domain": "rdw", "name": "RDW", - "codeowners": ["@frenck"], + "codeowners": ["@frenck", "@joostlek"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rdw", "integration_type": "service", diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index e262665dd63..2c324ca7093 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -42,13 +42,13 @@ class RDWSensorEntityDescription( SENSORS: tuple[RDWSensorEntityDescription, ...] = ( RDWSensorEntityDescription( key="apk_expiration", - name="APK expiration", + translation_key="apk_expiration", device_class=SensorDeviceClass.DATE, value_fn=lambda vehicle: vehicle.apk_expiration, ), RDWSensorEntityDescription( key="ascription_date", - name="Ascription date", + translation_key="ascription_date", device_class=SensorDeviceClass.DATE, value_fn=lambda vehicle: vehicle.ascription_date, ), diff --git a/homeassistant/components/rdw/strings.json b/homeassistant/components/rdw/strings.json index 840802a12b7..cf24ec5115c 100644 --- a/homeassistant/components/rdw/strings.json +++ b/homeassistant/components/rdw/strings.json @@ -14,5 +14,23 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown_license_plate": "Unknown license plate" } + }, + "entity": { + "binary_sensor": { + "liability_insured": { + "name": "Liability insured" + }, + "pending_recall": { + "name": "Pending recall" + } + }, + "sensor": { + "apk_expiration": { + "name": "APK expiration" + }, + "ascription_date": { + "name": "Ascription date" + } + } } } diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 4883734f47e..5989fb1cfe3 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -28,11 +28,11 @@ SENSOR_TYPE_NEXT_PICKUP = "next_pickup" SENSOR_DESCRIPTIONS = ( SensorEntityDescription( key=SENSOR_TYPE_CURRENT_PICKUP, - name="Current pickup", + translation_key=SENSOR_TYPE_CURRENT_PICKUP, ), SensorEntityDescription( key=SENSOR_TYPE_NEXT_PICKUP, - name="Next pickup", + translation_key=SENSOR_TYPE_NEXT_PICKUP, ), ) diff --git a/homeassistant/components/recollect_waste/strings.json b/homeassistant/components/recollect_waste/strings.json index a350b9880fc..20aa5982f0d 100644 --- a/homeassistant/components/recollect_waste/strings.json +++ b/homeassistant/components/recollect_waste/strings.json @@ -24,5 +24,15 @@ } } } + }, + "entity": { + "sensor": { + "current_pickup": { + "name": "Current pickup" + }, + "next_pickup": { + "name": "Next pickup" + } + } } } diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index ec5c5c984b5..fc7683db901 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -1,6 +1,7 @@ """Recorder constants.""" -from homeassistant.backports.enum import StrEnum +from enum import StrEnum + from homeassistant.const import ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES from homeassistant.helpers.json import ( # noqa: F401 pylint: disable=unused-import JSON_DUMP, diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 5023393dc5e..d4a026cfefc 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -553,10 +553,10 @@ class Recorder(threading.Thread): If the number of entities has increased, increase the size of the LRU cache to avoid thrashing. """ - new_size = self.hass.states.async_entity_ids_count() * 2 - self.state_attributes_manager.adjust_lru_size(new_size) - self.states_meta_manager.adjust_lru_size(new_size) - self.statistics_meta_manager.adjust_lru_size(new_size) + if new_size := self.hass.states.async_entity_ids_count() * 2: + self.state_attributes_manager.adjust_lru_size(new_size) + self.states_meta_manager.adjust_lru_size(new_size) + self.statistics_meta_manager.adjust_lru_size(new_size) @callback def async_periodic_statistics(self) -> None: diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 0743864aaf7..c99aadb8caa 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -5,7 +5,7 @@ from collections.abc import Callable from datetime import datetime, timedelta import logging import time -from typing import Any, cast +from typing import Any, Self, cast import ciso8601 from fnv_hash_fast import fnv1a_32 @@ -33,7 +33,6 @@ from sqlalchemy.engine.interfaces import Dialect from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import DeclarativeBase, Mapped, aliased, mapped_column, relationship from sqlalchemy.types import TypeDecorator -from typing_extensions import Self from homeassistant.const import ( MAX_LENGTH_EVENT_EVENT_TYPE, diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 2e868542457..6f919ee50da 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -8,7 +8,7 @@ "quality_scale": "internal", "requirements": [ "SQLAlchemy==2.0.15", - "fnv-hash-fast==0.3.1", + "fnv-hash-fast==0.4.0", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 33d8c7b5e67..8fe1d0482e9 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -423,13 +423,7 @@ def _add_columns( with session_scope(session=session_maker()) as session: try: connection = session.connection() - connection.execute( - text( - "ALTER TABLE {table} {column_def}".format( - table=table_name, column_def=column_def - ) - ) - ) + connection.execute(text(f"ALTER TABLE {table_name} {column_def}")) except (InternalError, OperationalError, ProgrammingError) as err: raise_if_exception_missing_str(err, ["already exists", "duplicate"]) _LOGGER.warning( @@ -498,13 +492,7 @@ def _modify_columns( with session_scope(session=session_maker()) as session: try: connection = session.connection() - connection.execute( - text( - "ALTER TABLE {table} {column_def}".format( - table=table_name, column_def=column_def - ) - ) - ) + connection.execute(text(f"ALTER TABLE {table_name} {column_def}")) except (InternalError, OperationalError): _LOGGER.exception( "Could not modify column %s in table %s", column_def, table_name diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml index f099cede9f2..b74dcc2a494 100644 --- a/homeassistant/components/recorder/services.yaml +++ b/homeassistant/components/recorder/services.yaml @@ -1,12 +1,8 @@ # Describes the format for available recorder services purge: - name: Purge - description: Start purge task - to clean up old data from your database. fields: keep_days: - name: Days to keep - description: Number of history days to keep in database after purge. selector: number: min: 0 @@ -14,28 +10,20 @@ purge: unit_of_measurement: days repack: - name: Repack - description: Attempt to save disk space by rewriting the entire database file. default: false selector: boolean: apply_filter: - name: Apply filter - description: Apply entity_id and event_type filter in addition to time based purge. default: false selector: boolean: purge_entities: - name: Purge Entities - description: Start purge task to remove specific entities from your database. target: entity: {} fields: domains: - name: Domains to remove - description: List the domains that need to be removed from the recorder database. example: "sun" required: false default: [] @@ -43,8 +31,6 @@ purge_entities: object: entity_globs: - name: Entity Globs to remove - description: List the glob patterns to select entities for removal from the recorder database. example: "domain*.object_id*" required: false default: [] @@ -52,8 +38,6 @@ purge_entities: object: keep_days: - name: Days to keep - description: Number of history days to keep in database of matching rows. The default of 0 days will remove all matching rows. default: 0 selector: number: @@ -62,9 +46,4 @@ purge_entities: unit_of_measurement: days disable: - name: Disable - description: Stop the recording of events and state changes - enable: - name: Enable - description: Start the recording of events and state changes diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index 7af67f10e25..24f0d806edd 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -13,5 +13,51 @@ "title": "Update MariaDB to {min_version} or later resolve a significant performance issue", "description": "Older versions of MariaDB suffer from a significant performance regression when retrieving history data or purging the database. Update to MariaDB version {min_version} or later and restart Home Assistant. If you are using the MariaDB core add-on, make sure to update it to the latest version." } + }, + "services": { + "purge": { + "name": "Purge", + "description": "Starts purge task - to clean up old data from your database.", + "fields": { + "keep_days": { + "name": "Days to keep", + "description": "Number of days to keep the data in the database. Starting today, counting backward. A value of `7` means that everything older than a week will be purged." + }, + "repack": { + "name": "Repack", + "description": "Attempt to save disk space by rewriting the entire database file." + }, + "apply_filter": { + "name": "Apply filter", + "description": "Applys `entity_id` and `event_type` filters in addition to time-based purge." + } + } + }, + "purge_entities": { + "name": "Purge entities", + "description": "Starts a purge task to remove the data related to specific entities from your database.", + "fields": { + "domains": { + "name": "Domains to remove", + "description": "List of domains for which the data needs to be removed from the recorder database." + }, + "entity_globs": { + "name": "Entity globs to remove", + "description": "List of glob patterns used to select the entities for which the data is to be removed from the recorder database." + }, + "keep_days": { + "name": "[%key:component::recorder::services::purge::fields::keep_days::name%]", + "description": "Number of days to keep the data for rows matching the filter. Starting today, counting backward. A value of `7` means that everything older than a week will be purged. The default of 0 days will remove all matching rows immediately." + } + } + }, + "disable": { + "name": "[%key:common::action::disable%]", + "description": "Stops the recording of events and state changes." + }, + "enable": { + "name": "[%key:common::action::enable%]", + "description": "Starts the recording of events and state changes." + } } } diff --git a/homeassistant/components/remember_the_milk/services.yaml b/homeassistant/components/remember_the_milk/services.yaml index 1458075fbd5..5e94b2bf7d4 100644 --- a/homeassistant/components/remember_the_milk/services.yaml +++ b/homeassistant/components/remember_the_milk/services.yaml @@ -1,33 +1,20 @@ # Describes the format for available Remember The Milk services create_task: - name: Create task - description: >- - Create (or update) a new task in your Remember The Milk account. If you want to update a task - later on, you have to set an "id" when creating the task. - Note: Updating a tasks does not support the smart syntax. fields: name: - name: Name - description: name of the new task, you can use the smart syntax here required: true example: "do this ^today #from_hass" selector: text: id: - name: ID - description: Identifier for the task you're creating, can be used to update or complete the task later on example: myid selector: text: complete_task: - name: Complete task - description: Complete a tasks that was privously created. fields: id: - name: ID - description: identifier that was defined when creating the task required: true example: myid selector: diff --git a/homeassistant/components/remember_the_milk/strings.json b/homeassistant/components/remember_the_milk/strings.json new file mode 100644 index 00000000000..5590691e245 --- /dev/null +++ b/homeassistant/components/remember_the_milk/strings.json @@ -0,0 +1,28 @@ +{ + "services": { + "create_task": { + "name": "Create task", + "description": "Creates (or update) a new task in your Remember The Milk account. If you want to update a task later on, you have to set an \"id\" when creating the task. Note: Updating a tasks does not support the smart syntax.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "Name of the new task, you can use the smart syntax here." + }, + "id": { + "name": "ID", + "description": "Identifier for the task you're creating, can be used to update or complete the task later on." + } + } + }, + "complete_task": { + "name": "Complete task", + "description": "Completes a tasks that was privously created.", + "fields": { + "id": { + "name": "ID", + "description": "Identifier that was defined when creating the task." + } + } + } + } +} diff --git a/homeassistant/components/remote/services.yaml b/homeassistant/components/remote/services.yaml index bdeef15971e..0d8ef63bfc3 100644 --- a/homeassistant/components/remote/services.yaml +++ b/homeassistant/components/remote/services.yaml @@ -1,64 +1,49 @@ # Describes the format for available remote services turn_on: - name: Turn On - description: Sends the Power On Command. target: entity: domain: remote fields: activity: - name: Activity - description: Activity ID or Activity Name to start. example: "BedroomTV" + filter: + supported_features: + - remote.RemoteEntityFeature.ACTIVITY selector: text: toggle: - name: Toggle - description: Toggles a device. target: entity: domain: remote turn_off: - name: Turn Off - description: Sends the Power Off Command. target: entity: domain: remote send_command: - name: Send Command - description: Sends a command or a list of commands to a device. target: entity: domain: remote fields: device: - name: Device - description: Device ID to send command to. example: "32756745" selector: text: command: - name: Command - description: A single command or a list of commands to send. required: true example: "Play" selector: object: num_repeats: - name: Repeats - description: The number of times you want to repeat the command(s). default: 1 selector: number: min: 0 max: 255 delay_secs: - name: Delay Seconds - description: The time you want to wait in between repeated commands. default: 0.4 selector: number: @@ -67,8 +52,6 @@ send_command: step: 0.1 unit_of_measurement: seconds hold_secs: - name: Hold Seconds - description: The time you want to have it held before the release is send. default: 0 selector: number: @@ -78,27 +61,19 @@ send_command: unit_of_measurement: seconds learn_command: - name: Learn Command - description: Learns a command or a list of commands from a device. target: entity: domain: remote fields: device: - name: Device - description: Device ID to learn command from. example: "television" selector: text: command: - name: Command - description: A single command or a list of commands to learn. example: "Turn on" selector: object: command_type: - name: Command Type - description: The type of command to be learned. default: "ir" selector: select: @@ -106,13 +81,9 @@ learn_command: - "ir" - "rf" alternative: - name: Alternative - description: If code must be stored as alternative (useful for discrete remotes). selector: boolean: timeout: - name: Timeout - description: Timeout for the command to be learned. selector: number: min: 0 @@ -121,21 +92,15 @@ learn_command: unit_of_measurement: seconds delete_command: - name: Delete Command - description: Deletes a command or a list of commands from the database. target: entity: domain: remote fields: device: - name: Device - description: Name of the device from which commands will be deleted. example: "television" selector: text: command: - name: Command - description: A single command or a list of commands to delete. required: true example: "Mute" selector: diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index 18a92494242..e3df487a57b 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -2,18 +2,18 @@ "title": "Remote", "device_automation": { "action_type": { - "toggle": "Toggle {entity_name}", - "turn_on": "Turn on {entity_name}", - "turn_off": "Turn off {entity_name}" + "toggle": "[%key:common::device_automation::action_type::toggle%]", + "turn_on": "[%key:common::device_automation::action_type::turn_on%]", + "turn_off": "[%key:common::device_automation::action_type::turn_off%]" }, "condition_type": { - "is_on": "{entity_name} is on", - "is_off": "{entity_name} is off" + "is_on": "[%key:common::device_automation::condition_type::is_on%]", + "is_off": "[%key:common::device_automation::condition_type::is_off%]" }, "trigger_type": { - "changed_states": "{entity_name} turned on or off", - "turned_on": "{entity_name} turned on", - "turned_off": "{entity_name} turned off" + "changed_states": "[%key:common::device_automation::trigger_type::changed_states%]", + "turned_on": "[%key:common::device_automation::trigger_type::turned_on%]", + "turned_off": "[%key:common::device_automation::trigger_type::turned_off%]" } }, "entity_component": { @@ -25,10 +25,90 @@ } } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Sends the power on command.", + "fields": { + "activity": { + "name": "Activity", + "description": "Activity ID or activity name to be started." + } + } + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles a device on/off." + }, + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Turns the device off." + }, + "send_command": { + "name": "Send command", + "description": "Sends a command or a list of commands to a device.", + "fields": { + "device": { + "name": "Device", + "description": "Device ID to send command to." + }, + "command": { + "name": "Command", + "description": "A single command or a list of commands to send." + }, + "num_repeats": { + "name": "Repeats", + "description": "The number of times you want to repeat the commands." + }, + "delay_secs": { + "name": "Delay seconds", + "description": "The time you want to wait in between repeated commands." + }, + "hold_secs": { + "name": "Hold seconds", + "description": "The time you want to have it held before the release is send." + } + } + }, + "learn_command": { + "name": "Learn command", + "description": "Learns a command or a list of commands from a device.", + "fields": { + "device": { + "name": "Device", + "description": "Device ID to learn command from." + }, + "command": { + "name": "Command", + "description": "A single command or a list of commands to learn." + }, + "command_type": { + "name": "Command type", + "description": "The type of command to be learned." + }, + "alternative": { + "name": "Alternative", + "description": "If code must be stored as an alternative. This is useful for discrete codes. Discrete codes are used for toggles that only perform one function. For example, a code to only turn a device on. If it is on already, sending the code won't change the state." + }, + "timeout": { + "name": "Timeout", + "description": "Timeout for the command to be learned." + } + } + }, + "delete_command": { + "name": "Delete command", + "description": "Deletes a command or a list of commands from the database.", + "fields": { + "device": { + "name": "Device", + "description": "Device from which commands will be deleted." + }, + "command": { + "name": "Command", + "description": "The single command or the list of commands to be deleted." + } + } } } } diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index b02938b1652..f69451290bc 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -26,7 +26,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b raise ConfigEntryAuthFailed() hass.data.setdefault(DOMAIN, {}) - await renault_hub.async_initialise(config_entry) + try: + await renault_hub.async_initialise(config_entry) + except aiohttp.ClientResponseError as exc: + raise ConfigEntryNotReady() from exc hass.data[DOMAIN][config_entry.entry_id] = renault_hub diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 83d86745d90..ef2d7196f04 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -87,7 +87,6 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( device_class=BinarySensorDeviceClass.PLUG, on_key="plugStatus", on_value=PlugState.PLUGGED.value, - translation_key="plugged_in", ), RenaultBinarySensorEntityDescription( key="charging", @@ -95,7 +94,6 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( device_class=BinarySensorDeviceClass.BATTERY_CHARGING, on_key="chargingStatus", on_value=ChargeState.CHARGE_IN_PROGRESS.value, - translation_key="charging", ), RenaultBinarySensorEntityDescription( key="hvac_status", @@ -112,7 +110,6 @@ BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( device_class=BinarySensorDeviceClass.LOCK, on_key="lockStatus", on_value="unlocked", - translation_key="lock_status", ), RenaultBinarySensorEntityDescription( key="hatch_status", diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 90ad70521df..050c5a930f6 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -165,7 +165,6 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - translation_key="battery_level", ), RenaultSensorEntityDescription( key="charge_state", diff --git a/homeassistant/components/renault/services.yaml b/homeassistant/components/renault/services.yaml index c8c3e9b12ba..2dc99833d5f 100644 --- a/homeassistant/components/renault/services.yaml +++ b/homeassistant/components/renault/services.yaml @@ -1,15 +1,11 @@ ac_start: - description: Start A/C on vehicle. fields: vehicle: - name: Vehicle - description: The vehicle to send the command to. required: true selector: device: integration: renault temperature: - description: Target A/C temperature in °C. example: "21" required: true selector: @@ -19,34 +15,26 @@ ac_start: step: 0.5 unit_of_measurement: °C when: - description: Timestamp for the start of the A/C (optional - defaults to now). example: "2020-05-01T17:45:00" selector: text: ac_cancel: - description: Cancel A/C on vehicle. fields: vehicle: - name: Vehicle - description: The vehicle to send the command to. required: true selector: device: integration: renault charge_set_schedules: - description: Update charge schedule on vehicle. fields: vehicle: - name: Vehicle - description: The vehicle to send the command to. required: true selector: device: integration: renault schedules: - description: Schedule details. example: >- [ { diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index 066b49abcc0..0b0c3d87822 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -34,9 +34,6 @@ }, "entity": { "binary_sensor": { - "charging": { - "name": "[%key:component::binary_sensor::entity_component::battery_charging::name%]" - }, "hatch_status": { "name": "Hatch" }, @@ -46,15 +43,9 @@ "hvac_status": { "name": "HVAC" }, - "lock_status": { - "name": "[%key:component::binary_sensor::entity_component::lock::name%]" - }, "passenger_door_status": { "name": "Passenger door" }, - "plugged_in": { - "name": "[%key:component::binary_sensor::entity_component::plug::name%]" - }, "rear_left_door_status": { "name": "Rear left door" }, @@ -75,7 +66,7 @@ }, "device_tracker": { "location": { - "name": "Location" + "name": "[%key:common::config_flow::data::location%]" } }, "select": { @@ -83,7 +74,7 @@ "name": "Charge mode", "state": { "always": "Instant", - "always_charging": "Instant", + "always_charging": "[%key:component::renault::entity::select::charge_mode::state::always%]", "schedule_mode": "Planner" } } @@ -101,9 +92,6 @@ "battery_last_activity": { "name": "Last battery activity" }, - "battery_level": { - "name": "Battery level" - }, "battery_temperature": { "name": "Battery temperature" }, @@ -163,5 +151,49 @@ "name": "Remote engine start code" } } + }, + "services": { + "ac_start": { + "name": "Start A/C", + "description": "Starts A/C on vehicle.", + "fields": { + "vehicle": { + "name": "Vehicle", + "description": "The vehicle to send the command to." + }, + "temperature": { + "name": "Temperature", + "description": "Target A/C temperature in °C." + }, + "when": { + "name": "When", + "description": "Timestamp for the start of the A/C (optional - defaults to now)." + } + } + }, + "ac_cancel": { + "name": "Cancel A/C", + "description": "Canceles A/C on vehicle.", + "fields": { + "vehicle": { + "name": "Vehicle", + "description": "[%key:component::renault::services::ac_start::fields::vehicle::description%]" + } + } + }, + "charge_set_schedules": { + "name": "Update charge schedule", + "description": "Updates charge schedule on vehicle.", + "fields": { + "vehicle": { + "name": "Vehicle", + "description": "[%key:component::renault::services::ac_start::fields::vehicle::description%]" + }, + "schedules": { + "name": "Schedules", + "description": "Schedule details." + } + } + } } } diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index 9817951b094..c8a355a0f7c 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -50,6 +50,16 @@ from . import RensonCoordinator, RensonData from .const import DOMAIN from .entity import RensonEntity +OPTIONS_MAPPING = { + "Off": "off", + "Level1": "level1", + "Level2": "level2", + "Level3": "level3", + "Level4": "level4", + "Breeze": "breeze", + "Holiday": "holiday", +} + @dataclass class RensonSensorEntityDescriptionMixin: @@ -63,13 +73,13 @@ class RensonSensorEntityDescriptionMixin: class RensonSensorEntityDescription( SensorEntityDescription, RensonSensorEntityDescriptionMixin ): - """Description of sensor.""" + """Description of a Renson sensor.""" SENSORS: tuple[RensonSensorEntityDescription, ...] = ( RensonSensorEntityDescription( key="CO2_QUALITY_FIELD", - name="CO2 quality category", + translation_key="co2_quality_category", field=CO2_QUALITY_FIELD, raw_format=False, device_class=SensorDeviceClass.ENUM, @@ -77,7 +87,7 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( ), RensonSensorEntityDescription( key="AIR_QUALITY_FIELD", - name="Air quality category", + translation_key="air_quality_category", field=AIR_QUALITY_FIELD, raw_format=False, device_class=SensorDeviceClass.ENUM, @@ -85,7 +95,6 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( ), RensonSensorEntityDescription( key="CO2_FIELD", - name="CO2 quality", field=CO2_FIELD, raw_format=True, state_class=SensorStateClass.MEASUREMENT, @@ -94,7 +103,7 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( ), RensonSensorEntityDescription( key="AIR_FIELD", - name="Air quality", + translation_key="air_quality", field=AIR_QUALITY_FIELD, state_class=SensorStateClass.MEASUREMENT, raw_format=True, @@ -102,15 +111,15 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( ), RensonSensorEntityDescription( key="CURRENT_LEVEL_FIELD", - name="Ventilation level", + translation_key="ventilation_level", field=CURRENT_LEVEL_FIELD, raw_format=False, device_class=SensorDeviceClass.ENUM, - options=["Off", "Level1", "Level2", "Level3", "Level4", "Breeze", "Holiday"], + options=["off", "level1", "level2", "level3", "level4", "breeze", "holiday"], ), RensonSensorEntityDescription( key="CURRENT_AIRFLOW_EXTRACT_FIELD", - name="Total airflow out", + translation_key="total_airflow_out", field=CURRENT_AIRFLOW_EXTRACT_FIELD, raw_format=False, state_class=SensorStateClass.MEASUREMENT, @@ -118,7 +127,7 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( ), RensonSensorEntityDescription( key="CURRENT_AIRFLOW_INGOING_FIELD", - name="Total airflow in", + translation_key="total_airflow_in", field=CURRENT_AIRFLOW_INGOING_FIELD, raw_format=False, state_class=SensorStateClass.MEASUREMENT, @@ -126,7 +135,7 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( ), RensonSensorEntityDescription( key="OUTDOOR_TEMP_FIELD", - name="Outdoor air temperature", + translation_key="outdoor_air_temperature", field=OUTDOOR_TEMP_FIELD, raw_format=False, device_class=SensorDeviceClass.TEMPERATURE, @@ -135,7 +144,7 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( ), RensonSensorEntityDescription( key="INDOOR_TEMP_FIELD", - name="Extract air temperature", + translation_key="extract_air_temperature", field=INDOOR_TEMP_FIELD, raw_format=False, device_class=SensorDeviceClass.TEMPERATURE, @@ -144,7 +153,7 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( ), RensonSensorEntityDescription( key="FILTER_REMAIN_FIELD", - name="Filter change", + translation_key="filter_change", field=FILTER_REMAIN_FIELD, raw_format=False, device_class=SensorDeviceClass.DURATION, @@ -153,7 +162,6 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( ), RensonSensorEntityDescription( key="HUMIDITY_FIELD", - name="Relative humidity", field=HUMIDITY_FIELD, raw_format=False, device_class=SensorDeviceClass.HUMIDITY, @@ -162,15 +170,15 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( ), RensonSensorEntityDescription( key="MANUAL_LEVEL_FIELD", - name="Manual level", + translation_key="manual_level", field=MANUAL_LEVEL_FIELD, raw_format=False, device_class=SensorDeviceClass.ENUM, - options=["Off", "Level1", "Level2", "Level3", "Level4", "Breeze", "Holiday"], + options=["off", "level1", "level2", "level3", "level4", "breeze", "holiday"], ), RensonSensorEntityDescription( key="BREEZE_TEMPERATURE_FIELD", - name="Breeze temperature", + translation_key="breeze_temperature", field=BREEZE_TEMPERATURE_FIELD, raw_format=False, device_class=SensorDeviceClass.TEMPERATURE, @@ -179,58 +187,48 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( ), RensonSensorEntityDescription( key="BREEZE_LEVEL_FIELD", - name="Breeze level", + translation_key="breeze_level", field=BREEZE_LEVEL_FIELD, raw_format=False, entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, - options=["Off", "Level1", "Level2", "Level3", "Level4", "Breeze"], + options=["off", "level1", "level2", "level3", "level4", "breeze"], ), RensonSensorEntityDescription( key="DAYTIME_FIELD", - name="Start day time", + translation_key="start_day_time", field=DAYTIME_FIELD, raw_format=False, entity_registry_enabled_default=False, ), RensonSensorEntityDescription( key="NIGHTTIME_FIELD", - name="Start night time", + translation_key="start_night_time", field=NIGHTTIME_FIELD, raw_format=False, entity_registry_enabled_default=False, ), RensonSensorEntityDescription( key="DAY_POLLUTION_FIELD", - name="Day pollution level", + translation_key="day_pollution_level", field=DAY_POLLUTION_FIELD, raw_format=False, entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, - options=[ - "Level1", - "Level2", - "Level3", - "Level4", - ], + options=["level1", "level2", "level3", "level4"], ), RensonSensorEntityDescription( key="NIGHT_POLLUTION_FIELD", - name="Night pollution level", + translation_key="co2_quality_category", field=NIGHT_POLLUTION_FIELD, raw_format=False, entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, - options=[ - "Level1", - "Level2", - "Level3", - "Level4", - ], + options=["level1", "level2", "level3", "level4"], ), RensonSensorEntityDescription( key="CO2_THRESHOLD_FIELD", - name="CO2 threshold", + translation_key="co2_threshold", field=CO2_THRESHOLD_FIELD, raw_format=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, @@ -238,7 +236,7 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( ), RensonSensorEntityDescription( key="CO2_HYSTERESIS_FIELD", - name="CO2 hysteresis", + translation_key="co2_hysteresis", field=CO2_HYSTERESIS_FIELD, raw_format=False, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, @@ -246,7 +244,7 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( ), RensonSensorEntityDescription( key="BYPASS_TEMPERATURE_FIELD", - name="Bypass activation temperature", + translation_key="bypass_activation_temperature", field=BYPASS_TEMPERATURE_FIELD, raw_format=False, device_class=SensorDeviceClass.TEMPERATURE, @@ -255,7 +253,7 @@ SENSORS: tuple[RensonSensorEntityDescription, ...] = ( ), RensonSensorEntityDescription( key="BYPASS_LEVEL_FIELD", - name="Bypass level", + translation_key="bypass_level", field=BYPASS_LEVEL_FIELD, raw_format=False, device_class=SensorDeviceClass.POWER_FACTOR, @@ -292,6 +290,10 @@ class RensonSensor(RensonEntity, SensorEntity): if self.raw_format: self._attr_native_value = value + elif self.entity_description.device_class == SensorDeviceClass.ENUM: + self._attr_native_value = OPTIONS_MAPPING.get( + self.api.parse_value(value, self.data_type), None + ) else: self._attr_native_value = self.api.parse_value(value, self.data_type) diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index 16c5de158a9..06636c9d503 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -11,5 +11,117 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "co2_quality_category": { + "name": "CO2 quality category", + "state": { + "good": "Good", + "bad": "Bad", + "poor": "Poor" + } + }, + "air_quality_category": { + "name": "Air quality category", + "state": { + "good": "[%key:component::renson::entity::sensor::co2_quality_category::state::good%]", + "bad": "[%key:component::renson::entity::sensor::co2_quality_category::state::bad%]", + "poor": "[%key:component::renson::entity::sensor::co2_quality_category::state::poor%]" + } + }, + "air_quality": { + "name": "Air quality" + }, + "ventilation_level": { + "name": "Ventilation level", + "state": { + "off": "[%key:common::state::off%]", + "level1": "Level 1", + "level2": "Level 2", + "level3": "Level 3", + "level4": "Level 4", + "breeze": "Breeze", + "holiday": "Holiday" + } + }, + "total_airflow_out": { + "name": "Total airflow out" + }, + "total_airflow_in": { + "name": "Total airflow in" + }, + "outdoor_air_temperature": { + "name": "Outdoor air temperature" + }, + "extract_air_temperature": { + "name": "Extract air temperature" + }, + "filter_change": { + "name": "Filter change" + }, + "manual_level": { + "name": "Manual level", + "state": { + "off": "[%key:common::state::off%]", + "level1": "[%key:component::renson::entity::sensor::ventilation_level::state::level1%]", + "level2": "[%key:component::renson::entity::sensor::ventilation_level::state::level2%]", + "level3": "[%key:component::renson::entity::sensor::ventilation_level::state::level3%]", + "level4": "[%key:component::renson::entity::sensor::ventilation_level::state::level4%]", + "breeze": "[%key:component::renson::entity::sensor::ventilation_level::state::breeze%]", + "holiday": "[%key:component::renson::entity::sensor::ventilation_level::state::holiday%]" + } + }, + "breeze_temperature": { + "name": "Breeze temperature" + }, + "breeze_level": { + "name": "Breeze level", + "state": { + "off": "[%key:common::state::off%]", + "level1": "[%key:component::renson::entity::sensor::ventilation_level::state::level1%]", + "level2": "[%key:component::renson::entity::sensor::ventilation_level::state::level2%]", + "level3": "[%key:component::renson::entity::sensor::ventilation_level::state::level3%]", + "level4": "[%key:component::renson::entity::sensor::ventilation_level::state::level4%]", + "breeze": "[%key:component::renson::entity::sensor::ventilation_level::state::breeze%]" + } + }, + "start_day_time": { + "name": "Start day time" + }, + "start_night_time": { + "name": "Start night time" + }, + "day_pollution_level": { + "name": "Day pollution level", + "state": { + "level1": "[%key:component::renson::entity::sensor::ventilation_level::state::level1%]", + "level2": "[%key:component::renson::entity::sensor::ventilation_level::state::level2%]", + "level3": "[%key:component::renson::entity::sensor::ventilation_level::state::level3%]", + "level4": "[%key:component::renson::entity::sensor::ventilation_level::state::level4%]" + } + }, + "night_pollution_level": { + "name": "Night pollution level", + "state": { + "level1": "[%key:component::renson::entity::sensor::ventilation_level::state::level1%]", + "level2": "[%key:component::renson::entity::sensor::ventilation_level::state::level2%]", + "level3": "[%key:component::renson::entity::sensor::ventilation_level::state::level3%]", + "level4": "[%key:component::renson::entity::sensor::ventilation_level::state::level4%]" + } + }, + "co2_threshold": { + "name": "CO2 threshold" + }, + "co2_hysteresis": { + "name": "CO2 hysteresis" + }, + "bypass_activation_temperature": { + "name": "Bypass activation temperature" + }, + "bypass_level": { + "name": "Bypass level" + } + } } } diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 923df261d84..88eec9780a1 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -9,6 +9,7 @@ import logging from typing import Literal import async_timeout +from reolink_aio.api import RETRY_ATTEMPTS from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError from homeassistant.config_entries import ConfigEntry @@ -30,6 +31,7 @@ PLATFORMS = [ Platform.LIGHT, Platform.NUMBER, Platform.SELECT, + Platform.SENSOR, Platform.SIREN, Platform.SWITCH, Platform.UPDATE, @@ -76,15 +78,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_device_config_update() -> None: """Update the host state cache and renew the ONVIF-subscription.""" - async with async_timeout.timeout(host.api.timeout): + async with async_timeout.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): try: await host.update_states() except ReolinkError as err: - raise UpdateFailed( - f"Error updating Reolink {host.api.nvr_name}" - ) from err + raise UpdateFailed(str(err)) from err - async with async_timeout.timeout(host.api.timeout): + async with async_timeout.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): await host.renew() async def async_check_firmware_update() -> str | Literal[False]: @@ -92,7 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if not host.api.supported(None, "update"): return False - async with async_timeout.timeout(host.api.timeout): + async with async_timeout.timeout(host.api.timeout * (RETRY_ATTEMPTS + 2)): try: return await host.api.check_new_firmware() except (ReolinkError, asyncio.exceptions.CancelledError) as err: diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 3aa5faa527b..7a6e2486c71 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -7,7 +7,11 @@ from typing import Any from reolink_aio.api import GuardEnum, Host, PtzEnum -from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -15,12 +19,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity @dataclass class ReolinkButtonEntityDescriptionMixin: - """Mixin values for Reolink button entities.""" + """Mixin values for Reolink button entities for a camera channel.""" method: Callable[[Host, int], Any] @@ -29,11 +33,27 @@ class ReolinkButtonEntityDescriptionMixin: class ReolinkButtonEntityDescription( ButtonEntityDescription, ReolinkButtonEntityDescriptionMixin ): - """A class that describes button entities.""" + """A class that describes button entities for a camera channel.""" supported: Callable[[Host, int], bool] = lambda api, ch: True +@dataclass +class ReolinkHostButtonEntityDescriptionMixin: + """Mixin values for Reolink button entities for the host.""" + + method: Callable[[Host], Any] + + +@dataclass +class ReolinkHostButtonEntityDescription( + ButtonEntityDescription, ReolinkHostButtonEntityDescriptionMixin +): + """A class that describes button entities for the host.""" + + supported: Callable[[Host], bool] = lambda api: True + + BUTTON_ENTITIES = ( ReolinkButtonEntityDescription( key="ptz_stop", @@ -95,6 +115,17 @@ BUTTON_ENTITIES = ( ), ) +HOST_BUTTON_ENTITIES = ( + ReolinkHostButtonEntityDescription( + key="reboot", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + supported=lambda api: api.supported(None, "reboot"), + method=lambda api: api.reboot(), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -104,12 +135,20 @@ async def async_setup_entry( """Set up a Reolink button entities.""" reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities( + entities: list[ReolinkButtonEntity | ReolinkHostButtonEntity] = [ ReolinkButtonEntity(reolink_data, channel, entity_description) for entity_description in BUTTON_ENTITIES for channel in reolink_data.host.api.channels if entity_description.supported(reolink_data.host.api, channel) + ] + entities.extend( + [ + ReolinkHostButtonEntity(reolink_data, entity_description) + for entity_description in HOST_BUTTON_ENTITIES + if entity_description.supported(reolink_data.host.api) + ] ) + async_add_entities(entities) class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): @@ -134,3 +173,24 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): async def async_press(self) -> None: """Execute the button action.""" await self.entity_description.method(self._host.api, self._channel) + + +class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): + """Base button entity class for Reolink IP cameras.""" + + entity_description: ReolinkHostButtonEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + entity_description: ReolinkHostButtonEntityDescription, + ) -> None: + """Initialize Reolink button entity.""" + super().__init__(reolink_data) + self.entity_description = entity_description + + self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" + + async def async_press(self) -> None: + """Execute the button action.""" + await self.entity_description.method(self._host.api) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 75ad26665c3..d24fd8d1f14 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN -from .exceptions import ReolinkException, UserNotAdmin +from .exceptions import ReolinkException, ReolinkWebhookException, UserNotAdmin from .host import ReolinkHost _LOGGER = logging.getLogger(__name__) @@ -133,6 +133,12 @@ class ReolinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except ApiError as err: placeholders["error"] = str(err) errors[CONF_HOST] = "api_error" + except ReolinkWebhookException as err: + placeholders["error"] = str(err) + placeholders[ + "more_info" + ] = "https://www.home-assistant.io/more-info/no-url-available/#configuring-the-instance-url" + errors["base"] = "webhook_exception" except (ReolinkError, ReolinkException) as err: placeholders["error"] = str(err) errors[CONF_HOST] = "cannot_connect" diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 81fbda63fef..9bcafb8f00d 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -24,8 +24,9 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotAdmin -DEFAULT_TIMEOUT = 60 +DEFAULT_TIMEOUT = 30 FIRST_ONVIF_TIMEOUT = 10 +FIRST_ONVIF_LONG_POLL_TIMEOUT = 90 SUBSCRIPTION_RENEW_THRESHOLD = 300 POLL_INTERVAL_NO_PUSH = 5 LONG_POLL_COOLDOWN = 0.75 @@ -205,7 +206,7 @@ class ReolinkHost: # ONVIF push is not received, start long polling and schedule check await self._async_start_long_polling() self._cancel_long_poll_check = async_call_later( - self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif_long_poll + self._hass, FIRST_ONVIF_LONG_POLL_TIMEOUT, self._async_check_onvif_long_poll ) self._cancel_onvif_check = None @@ -215,7 +216,7 @@ class ReolinkHost: if not self._long_poll_received: _LOGGER.debug( "Did not receive state through ONVIF long polling after %i seconds", - FIRST_ONVIF_TIMEOUT, + FIRST_ONVIF_LONG_POLL_TIMEOUT, ) ir.async_create_issue( self._hass, @@ -230,8 +231,24 @@ class ReolinkHost: "network_link": "https://my.home-assistant.io/redirect/network/", }, ) + if self._base_url.startswith("https"): + ir.async_create_issue( + self._hass, + DOMAIN, + "https_webhook", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="https_webhook", + translation_placeholders={ + "base_url": self._base_url, + "network_link": "https://my.home-assistant.io/redirect/network/", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "https_webhook") else: ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") + ir.async_delete_issue(self._hass, DOMAIN, "https_webhook") # If no ONVIF push or long polling state is received, start fast polling await self._async_poll_all_motion() @@ -267,7 +284,19 @@ class ReolinkHost: async def _async_start_long_polling(self): """Start ONVIF long polling task.""" if self._long_poll_task is None: - await self._api.subscribe(sub_type=SubType.long_poll) + try: + await self._api.subscribe(sub_type=SubType.long_poll) + except ReolinkError as err: + # make sure the long_poll_task is always created to try again later + if not self._lost_subscription: + self._lost_subscription = True + _LOGGER.error( + "Reolink %s event long polling subscription lost: %s", + self._api.nvr_name, + str(err), + ) + else: + self._lost_subscription = False self._long_poll_task = asyncio.create_task(self._async_long_polling()) async def _async_stop_long_polling(self): @@ -319,7 +348,13 @@ class ReolinkHost: try: await self._renew(SubType.push) if self._long_poll_task is not None: - await self._renew(SubType.long_poll) + if not self._api.subscribed(SubType.long_poll): + _LOGGER.debug("restarting long polling task") + # To prevent 5 minute request timeout + await self._async_stop_long_polling() + await self._async_start_long_polling() + else: + await self._renew(SubType.long_poll) except SubscriptionError as err: if not self._lost_subscription: self._lost_subscription = True @@ -408,22 +443,6 @@ class ReolinkHost: webhook_path = webhook.async_generate_path(event_id) self._webhook_url = f"{self._base_url}{webhook_path}" - if self._base_url.startswith("https"): - ir.async_create_issue( - self._hass, - DOMAIN, - "https_webhook", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="https_webhook", - translation_placeholders={ - "base_url": self._base_url, - "network_link": "https://my.home-assistant.io/redirect/network/", - }, - ) - else: - ir.async_delete_issue(self._hass, DOMAIN, "https_webhook") - _LOGGER.debug("Registered webhook: %s", event_id) def unregister_webhook(self): @@ -451,13 +470,15 @@ class ReolinkHost: await asyncio.sleep(LONG_POLL_ERROR_COOLDOWN) continue except Exception as ex: - _LOGGER.exception("Error while requesting ONVIF pull point: %s", ex) + _LOGGER.exception( + "Unexpected exception while requesting ONVIF pull point: %s", ex + ) await self._api.unsubscribe(sub_type=SubType.long_poll) raise ex self._long_poll_error = False - if not self._long_poll_received and channels != []: + if not self._long_poll_received: self._long_poll_received = True ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 00f0e0f518b..fa61f873cca 100644 --- a/homeassistant/components/reolink/manifest.json +++ b/homeassistant/components/reolink/manifest.json @@ -18,5 +18,5 @@ "documentation": "https://www.home-assistant.io/integrations/reolink", "iot_class": "local_push", "loggers": ["reolink_aio"], - "requirements": ["reolink-aio==0.7.3"] + "requirements": ["reolink-aio==0.7.6"] } diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py new file mode 100644 index 00000000000..af8d049dbc6 --- /dev/null +++ b/homeassistant/components/reolink/sensor.py @@ -0,0 +1,91 @@ +"""Component providing support for Reolink sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import date, datetime +from decimal import Decimal + +from reolink_aio.api import Host + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import ReolinkData +from .const import DOMAIN +from .entity import ReolinkHostCoordinatorEntity + + +@dataclass +class ReolinkHostSensorEntityDescriptionMixin: + """Mixin values for Reolink host sensor entities.""" + + value: Callable[[Host], bool] + + +@dataclass +class ReolinkHostSensorEntityDescription( + SensorEntityDescription, ReolinkHostSensorEntityDescriptionMixin +): + """A class that describes host sensor entities.""" + + supported: Callable[[Host], bool] = lambda host: True + + +HOST_SENSORS = ( + ReolinkHostSensorEntityDescription( + key="wifi_signal", + translation_key="wifi_signal", + icon="mdi:wifi", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value=lambda api: api.wifi_signal, + supported=lambda api: api.supported(None, "wifi") and api.wifi_connection, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a Reolink IP Camera.""" + reolink_data: ReolinkData = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ReolinkHostSensorEntity(reolink_data, entity_description) + for entity_description in HOST_SENSORS + if entity_description.supported(reolink_data.host.api) + ) + + +class ReolinkHostSensorEntity(ReolinkHostCoordinatorEntity, SensorEntity): + """Base sensor class for Reolink host sensors.""" + + entity_description: ReolinkHostSensorEntityDescription + + def __init__( + self, + reolink_data: ReolinkData, + entity_description: ReolinkHostSensorEntityDescription, + ) -> None: + """Initialize Reolink binary sensor.""" + super().__init__(reolink_data) + self.entity_description = entity_description + + self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" + + @property + def native_value(self) -> StateType | date | datetime | Decimal: + """Return the value reported by the sensor.""" + return self.entity_description.value(self._host.api) diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 8abbbf23aad..2389c433b20 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -22,7 +22,8 @@ "cannot_connect": "Failed to connect, check the IP address of the camera", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "not_admin": "User needs to be admin, user ''{username}'' has authorisation level ''{userlevel}''", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "webhook_exception": "Home Assistant URL is not available, go to Settings > System > Network > Home Assistant URL and correct the URLs, see {more_info}" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", @@ -60,7 +61,7 @@ "select": { "floodlight_mode": { "state": { - "off": "Off", + "off": "[%key:common::state::off%]", "auto": "Auto", "schedule": "Schedule", "adaptive": "Adaptive", @@ -76,7 +77,7 @@ }, "auto_quick_reply_message": { "state": { - "off": "Off" + "off": "[%key:common::state::off%]" } }, "auto_track_method": { @@ -93,6 +94,11 @@ "alwaysonatnight": "Auto & always on at night" } } + }, + "sensor": { + "wifi_signal": { + "name": "Wi-Fi signal" + } } } } diff --git a/homeassistant/components/rest/services.yaml b/homeassistant/components/rest/services.yaml index 9ba509b63f6..c983a105c93 100644 --- a/homeassistant/components/rest/services.yaml +++ b/homeassistant/components/rest/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload all rest entities and notify services diff --git a/homeassistant/components/rest/strings.json b/homeassistant/components/rest/strings.json new file mode 100644 index 00000000000..d2b15461c9e --- /dev/null +++ b/homeassistant/components/rest/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads REST entities from the YAML-configuration." + } + } +} diff --git a/homeassistant/components/rflink/services.yaml b/homeassistant/components/rflink/services.yaml index 8e233bc7aac..1b06a142a59 100644 --- a/homeassistant/components/rflink/services.yaml +++ b/homeassistant/components/rflink/services.yaml @@ -1,17 +1,11 @@ send_command: - name: Send command - description: Send device command through RFLink. fields: command: - name: Command - description: The command to be sent. required: true example: "on" selector: text: device_id: - name: Device ID - description: RFLink device ID. required: true example: newkaku_0000c6c2_1 selector: diff --git a/homeassistant/components/rflink/strings.json b/homeassistant/components/rflink/strings.json new file mode 100644 index 00000000000..2c8eb584ca8 --- /dev/null +++ b/homeassistant/components/rflink/strings.json @@ -0,0 +1,18 @@ +{ + "services": { + "send_command": { + "name": "Send command", + "description": "Sends device command through RFLink.", + "fields": { + "command": { + "name": "Command", + "description": "The command to be sent." + }, + "device_id": { + "name": "Device ID", + "description": "RFLink device ID." + } + } + } + } +} diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 3613a640f1a..60f35a93d1a 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -70,14 +70,12 @@ class RfxtrxSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES = ( RfxtrxSensorEntityDescription( key="Barometer", - name="Barometer", device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.HPA, ), RfxtrxSensorEntityDescription( key="Battery numeric", - name="Battery", device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, @@ -86,49 +84,46 @@ SENSOR_TYPES = ( ), RfxtrxSensorEntityDescription( key="Current", - name="Current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), RfxtrxSensorEntityDescription( key="Current Ch. 1", - name="Current Ch. 1", + translation_key="current_ch_1", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), RfxtrxSensorEntityDescription( key="Current Ch. 2", - name="Current Ch. 2", + translation_key="current_ch_2", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), RfxtrxSensorEntityDescription( key="Current Ch. 3", - name="Current Ch. 3", + translation_key="current_ch_3", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), RfxtrxSensorEntityDescription( key="Energy usage", - name="Instantaneous power", + translation_key="instantaneous_power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, ), RfxtrxSensorEntityDescription( key="Humidity", - name="Humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), RfxtrxSensorEntityDescription( key="Rssi numeric", - name="Signal strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -137,108 +132,104 @@ SENSOR_TYPES = ( ), RfxtrxSensorEntityDescription( key="Temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), RfxtrxSensorEntityDescription( key="Temperature2", - name="Temperature 2", + translation_key="temperature_2", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), RfxtrxSensorEntityDescription( key="Total usage", - name="Total energy usage", + translation_key="total_energy_usage", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), RfxtrxSensorEntityDescription( key="Voltage", - name="Voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, ), RfxtrxSensorEntityDescription( key="Wind direction", - name="Wind direction", + translation_key="wind_direction", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=DEGREE, ), RfxtrxSensorEntityDescription( key="Rain rate", - name="Rain rate", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), RfxtrxSensorEntityDescription( key="Sound", - name="Sound", + translation_key="sound", ), RfxtrxSensorEntityDescription( key="Sensor Status", - name="Sensor status", + translation_key="sensor_status", ), RfxtrxSensorEntityDescription( key="Count", - name="Count", + translation_key="count", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement="count", ), RfxtrxSensorEntityDescription( key="Counter value", - name="Counter value", + translation_key="counter_value", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement="count", ), RfxtrxSensorEntityDescription( key="Chill", - name="Chill", + translation_key="chill", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), RfxtrxSensorEntityDescription( key="Wind average speed", - name="Wind average speed", + translation_key="wind_average_speed", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.WIND_SPEED, ), RfxtrxSensorEntityDescription( key="Wind gust", - name="Wind gust", + translation_key="wind_gust", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.WIND_SPEED, ), RfxtrxSensorEntityDescription( key="Rain total", - name="Rain total", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), RfxtrxSensorEntityDescription( key="Forecast", - name="Forecast status", + translation_key="forecast_status", ), RfxtrxSensorEntityDescription( key="Forecast numeric", - name="Forecast", + translation_key="forecast", ), RfxtrxSensorEntityDescription( key="Humidity status", - name="Humidity status", + translation_key="humidity_status", ), RfxtrxSensorEntityDescription( key="UV", - name="UV index", + translation_key="uv_index", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, ), diff --git a/homeassistant/components/rfxtrx/services.yaml b/homeassistant/components/rfxtrx/services.yaml index 43695554ed0..00640a2ff59 100644 --- a/homeassistant/components/rfxtrx/services.yaml +++ b/homeassistant/components/rfxtrx/services.yaml @@ -1,10 +1,6 @@ send: - name: Send - description: Sends a raw event on radio. fields: event: - name: Event - description: A hexadecimal string to send. required: true example: "0b11009e00e6116202020070" selector: diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json index 4469fd59801..85ddf559cf5 100644 --- a/homeassistant/components/rfxtrx/strings.json +++ b/homeassistant/components/rfxtrx/strings.json @@ -25,13 +25,13 @@ "data": { "device": "Select device" }, - "title": "Device" + "title": "[%key:common::config_flow::data::device%]" }, "setup_serial_manual_path": { "data": { "device": "[%key:common::config_flow::data::usb_path%]" }, - "title": "Path" + "title": "[%key:common::config_flow::data::path%]" } } }, @@ -78,5 +78,75 @@ "status": "Received status: {subtype}", "command": "Received command: {subtype}" } + }, + "entity": { + "sensor": { + "current_ch_1": { + "name": "Current Ch. 1" + }, + "current_ch_2": { + "name": "Current Ch. 2" + }, + "current_ch_3": { + "name": "Current Ch. 3" + }, + "instantaneous_power": { + "name": "Instantaneous power" + }, + "temperature_2": { + "name": "Temperature 2" + }, + "total_energy_usage": { + "name": "Total energy usage" + }, + "wind_direction": { + "name": "Wind direction" + }, + "sound": { + "name": "Sound" + }, + "sensor_status": { + "name": "Sensor status" + }, + "count": { + "name": "Count" + }, + "counter_value": { + "name": "Counter value" + }, + "chill": { + "name": "Chill" + }, + "wind_average_speed": { + "name": "Wind average speed" + }, + "wind_gust": { + "name": "Wind gust" + }, + "forecast_status": { + "name": "Forecast status" + }, + "forecast": { + "name": "Forecast" + }, + "humidity_status": { + "name": "Humidity status" + }, + "uv_index": { + "name": "UV index" + } + } + }, + "services": { + "send": { + "name": "Send", + "description": "Sends a raw event on radio.", + "fields": { + "event": { + "name": "Event", + "description": "A hexadecimal string to send." + } + } + } } } diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index d2c01bbd4f3..ab7207f0ac4 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -35,13 +35,12 @@ class RingBinarySensorEntityDescription( BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = ( RingBinarySensorEntityDescription( key="ding", - name="Ding", + translation_key="ding", category=["doorbots", "authorized_doorbots"], device_class=BinarySensorDeviceClass.OCCUPANCY, ), RingBinarySensorEntityDescription( key="motion", - name="Motion", category=["doorbots", "authorized_doorbots", "stickup_cams"], device_class=BinarySensorDeviceClass.MOTION, ), @@ -85,7 +84,6 @@ class RingBinarySensor(RingEntityMixin, BinarySensorEntity): super().__init__(config_entry_id, device) self.entity_description = description self._ring = ring - self._attr_name = f"{device.name} {description.name}" self._attr_unique_id = f"{device.id}-{description.key}" self._update_alert() diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index e99fabfab2f..0b3f1509b18 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -48,11 +48,12 @@ async def async_setup_entry( class RingCam(RingEntityMixin, Camera): """An implementation of a Ring Door Bell camera.""" + _attr_name = None + def __init__(self, config_entry_id, ffmpeg_manager, device): """Initialize a Ring Door Bell camera.""" super().__init__(config_entry_id, device) - self._name = self._device.name self._ffmpeg_manager = ffmpeg_manager self._last_event = None self._last_video_id = None @@ -90,11 +91,6 @@ class RingCam(RingEntityMixin, Camera): self._expires_at = dt_util.utcnow() self.async_write_ha_state() - @property - def name(self): - """Return the name of this camera.""" - return self._name - @property def unique_id(self): """Return a unique ID.""" diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 16aa86511be..5fc438c2390 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -10,6 +10,7 @@ class RingEntityMixin(Entity): _attr_attribution = ATTRIBUTION _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, config_entry_id, device): """Initialize a sensor for Ring device.""" diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 143c333f600..2604e557b79 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -50,6 +50,7 @@ class RingLight(RingEntityMixin, LightEntity): _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_translation_key = "light" def __init__(self, config_entry_id, device): """Initialize the light.""" @@ -67,11 +68,6 @@ class RingLight(RingEntityMixin, LightEntity): self._light_on = self._device.lights == ON_STATE self.async_write_ha_state() - @property - def name(self): - """Name of the light.""" - return f"{self._device.name} light" - @property def unique_id(self): """Return a unique ID.""" diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 3d198ce7573..fbaeb8a4b5b 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -13,7 +13,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.icon import icon_for_battery_level from . import DOMAIN from .entity import RingEntityMixin @@ -53,8 +52,6 @@ class RingSensor(RingEntityMixin, SensorEntity): """Initialize a sensor for Ring device.""" super().__init__(config_entry_id, device) self.entity_description = description - self._extra = None - self._attr_name = f"{device.name} {description.name}" self._attr_unique_id = f"{device.id}-{description.key}" @property @@ -67,18 +64,6 @@ class RingSensor(RingEntityMixin, SensorEntity): if sensor_type == "battery": return self._device.battery_life - @property - def icon(self): - """Icon to use in the frontend, if any.""" - if ( - self.entity_description.key == "battery" - and self._device.battery_life is not None - ): - return icon_for_battery_level( - battery_level=self._device.battery_life, charging=False - ) - return self.entity_description.icon - class HealthDataRingSensor(RingSensor): """Ring sensor that relies on health data.""" @@ -204,7 +189,6 @@ class RingSensorEntityDescription(SensorEntityDescription, RingRequiredKeysMixin SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( RingSensorEntityDescription( key="battery", - name="Battery", category=["doorbots", "authorized_doorbots", "stickup_cams"], native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, @@ -212,7 +196,7 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( ), RingSensorEntityDescription( key="last_activity", - name="Last Activity", + translation_key="last_activity", category=["doorbots", "authorized_doorbots", "stickup_cams"], icon="mdi:history", device_class=SensorDeviceClass.TIMESTAMP, @@ -220,7 +204,7 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( ), RingSensorEntityDescription( key="last_ding", - name="Last Ding", + translation_key="last_ding", category=["doorbots", "authorized_doorbots"], icon="mdi:history", kind="ding", @@ -229,7 +213,7 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( ), RingSensorEntityDescription( key="last_motion", - name="Last Motion", + translation_key="last_motion", category=["doorbots", "authorized_doorbots", "stickup_cams"], icon="mdi:history", kind="motion", @@ -238,21 +222,21 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = ( ), RingSensorEntityDescription( key="volume", - name="Volume", + translation_key="volume", category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], icon="mdi:bell-ring", cls=RingSensor, ), RingSensorEntityDescription( key="wifi_signal_category", - name="WiFi Signal Category", + translation_key="wifi_signal_category", category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], icon="mdi:wifi", cls=HealthDataRingSensor, ), RingSensorEntityDescription( key="wifi_signal_strength", - name="WiFi Signal Strength", + translation_key="wifi_signal_strength", category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"], native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, icon="mdi:wifi", diff --git a/homeassistant/components/ring/services.yaml b/homeassistant/components/ring/services.yaml index c648f02139b..91b8669505b 100644 --- a/homeassistant/components/ring/services.yaml +++ b/homeassistant/components/ring/services.yaml @@ -1,3 +1 @@ update: - name: Update - description: Updates the data we have for all your ring devices diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py index 626444a9dcf..7f1b147471d 100644 --- a/homeassistant/components/ring/siren.py +++ b/homeassistant/components/ring/siren.py @@ -33,16 +33,15 @@ async def async_setup_entry( class RingChimeSiren(RingEntityMixin, SirenEntity): """Creates a siren to play the test chimes of a Chime device.""" + _attr_available_tones = CHIME_TEST_SOUND_KINDS + _attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TONES + _attr_translation_key = "siren" + def __init__(self, config_entry: ConfigEntry, device) -> None: """Initialize a Ring Chime siren.""" super().__init__(config_entry.entry_id, device) # Entity class attributes - self._attr_name = f"{self._device.name} Siren" self._attr_unique_id = f"{self._device.id}-siren" - self._attr_available_tones = CHIME_TEST_SOUND_KINDS - self._attr_supported_features = ( - SirenEntityFeature.TURN_ON | SirenEntityFeature.TONES - ) def turn_on(self, **kwargs: Any) -> None: """Play the test sound on a Ring Chime device.""" diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index c5b448ad68b..b300e335b19 100644 --- a/homeassistant/components/ring/strings.json +++ b/homeassistant/components/ring/strings.json @@ -22,5 +22,53 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "binary_sensor": { + "ding": { + "name": "Ding" + } + }, + "light": { + "light": { + "name": "[%key:component::light::title%]" + } + }, + "siren": { + "siren": { + "name": "[%key:component::siren::title%]" + } + }, + "sensor": { + "last_activity": { + "name": "Last activity" + }, + "last_ding": { + "name": "Last ding" + }, + "last_motion": { + "name": "Last motion" + }, + "volume": { + "name": "Volume" + }, + "wifi_signal_category": { + "name": "Wi-Fi signal category" + }, + "wifi_signal_strength": { + "name": "Wi-Fi signal strength" + } + }, + "switch": { + "siren": { + "name": "[%key:component::siren::title%]" + } + } + }, + "services": { + "update": { + "name": "Update", + "description": "Updates the data we have for all your ring devices." + } } } diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 9a3c80114e9..43bd303577a 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -52,11 +52,6 @@ class BaseRingSwitch(RingEntityMixin, SwitchEntity): self._device_type = device_type self._unique_id = f"{self._device.id}-{self._device_type}" - @property - def name(self): - """Name of the device.""" - return f"{self._device.name} {self._device_type}" - @property def unique_id(self): """Return a unique ID.""" @@ -66,6 +61,8 @@ class BaseRingSwitch(RingEntityMixin, SwitchEntity): class SirenSwitch(BaseRingSwitch): """Creates a switch to turn the ring cameras siren on and off.""" + _attr_translation_key = "siren" + def __init__(self, config_entry_id, device): """Initialize the switch for a device with a siren.""" super().__init__(config_entry_id, device, "siren") diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1a308f9dff9..b310b2bb2ba 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -36,24 +36,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } product_info = {product.id: product for product in home_data.products} # Create a mqtt_client, which is needed to get the networking information of the device for local connection and in the future, get the map. - mqtt_clients = [ - RoborockMqttClient( + mqtt_clients = { + device.duid: RoborockMqttClient( user_data, DeviceData(device, product_info[device.product_id].model) ) for device in device_map.values() - ] + } network_results = await asyncio.gather( - *(mqtt_client.get_networking() for mqtt_client in mqtt_clients) + *(mqtt_client.get_networking() for mqtt_client in mqtt_clients.values()) ) network_info = { device.duid: result for device, result in zip(device_map.values(), network_results) if result is not None } - await asyncio.gather( - *(mqtt_client.async_disconnect() for mqtt_client in mqtt_clients), - return_exceptions=True, - ) if not network_info: raise ConfigEntryNotReady( "Could not get network information about your devices" @@ -65,7 +61,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device, network_info[device_id], product_info[device.product_id], + mqtt_clients[device.duid], ) + await asyncio.gather( + *(coordinator.verify_api() for coordinator in coordinator_map.values()) + ) # If one device update fails - we still want to set up other devices await asyncio.gather( *( diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 287229c9fd1..2fc59134d14 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -6,4 +6,11 @@ CONF_ENTRY_CODE = "code" CONF_BASE_URL = "base_url" CONF_USER_DATA = "user_data" -PLATFORMS = [Platform.VACUUM, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.VACUUM, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, + Platform.NUMBER, +] diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index ba9571a95f5..6ba6f3915ec 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging +from roborock.cloud_api import RoborockMqttClient from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo from roborock.exceptions import RoborockException from roborock.local_api import RoborockLocalClient @@ -30,6 +31,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): device: HomeDataDevice, device_networking: NetworkInfo, product_info: HomeDataProduct, + cloud_api: RoborockMqttClient | None = None, ) -> None: """Initialize.""" super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) @@ -41,6 +43,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): ) device_data = DeviceData(device, product_info.model, device_networking.ip) self.api = RoborockLocalClient(device_data) + self.cloud_api = cloud_api self.device_info = DeviceInfo( name=self.roborock_device_info.device.name, identifiers={(DOMAIN, self.roborock_device_info.device.duid)}, @@ -49,6 +52,21 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): sw_version=self.roborock_device_info.device.fv, ) + async def verify_api(self) -> None: + """Verify that the api is reachable. If it is not, switch clients.""" + try: + await self.api.ping() + except RoborockException: + if isinstance(self.api, RoborockLocalClient): + _LOGGER.warning( + "Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance", + self.roborock_device_info.device.duid, + ) + # We use the cloud api if the local api fails to connect. + self.api = self.cloud_api + # Right now this should never be called if the cloud api is the primary api, + # but in the future if it is, a new else should be added. + async def release(self) -> None: """Disconnect from API.""" await self.api.async_disconnect() diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 90ca13c5146..c40e47ada99 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -2,11 +2,10 @@ from typing import Any -from roborock.api import AttributeCache +from roborock.api import AttributeCache, RoborockClient from roborock.command_cache import CacheableAttribute from roborock.containers import Status from roborock.exceptions import RoborockException -from roborock.local_api import RoborockLocalClient from roborock.roborock_typing import RoborockCommand from homeassistant.exceptions import HomeAssistantError @@ -22,7 +21,7 @@ class RoborockEntity(Entity): _attr_has_entity_name = True def __init__( - self, unique_id: str, device_info: DeviceInfo, api: RoborockLocalClient + self, unique_id: str, device_info: DeviceInfo, api: RoborockClient ) -> None: """Initialize the coordinated Roborock Device.""" self._attr_unique_id = unique_id @@ -30,7 +29,7 @@ class RoborockEntity(Entity): self._api = api @property - def api(self) -> RoborockLocalClient: + def api(self) -> RoborockClient: """Returns the api.""" return self._api @@ -39,7 +38,9 @@ class RoborockEntity(Entity): return self._api.cache.get(attribute) async def send( - self, command: RoborockCommand, params: dict[str, Any] | list[Any] | None = None + self, + command: RoborockCommand, + params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: """Send a command to a vacuum cleaner.""" try: @@ -87,7 +88,7 @@ class RoborockCoordinatedEntity( async def send( self, command: RoborockCommand, - params: dict[str, Any] | list[Any] | None = None, + params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: """Overloads normal send command but refreshes coordinator.""" res = await super().send(command, params) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 5f6aa63ce2f..d26116a7818 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.30.1"] + "requirements": ["python-roborock==0.30.2"] } diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py new file mode 100644 index 00000000000..4eaf1464f89 --- /dev/null +++ b/homeassistant/components/roborock/number.py @@ -0,0 +1,121 @@ +"""Support for Roborock number.""" +import asyncio +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from roborock.api import AttributeCache +from roborock.command_cache import CacheableAttribute +from roborock.exceptions import RoborockException + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class RoborockNumberDescriptionMixin: + """Define an entity description mixin for button entities.""" + + # Gets the status of the switch + cache_key: CacheableAttribute + # Sets the status of the switch + update_value: Callable[[AttributeCache, float], Coroutine[Any, Any, dict]] + + +@dataclass +class RoborockNumberDescription( + NumberEntityDescription, RoborockNumberDescriptionMixin +): + """Class to describe an Roborock number entity.""" + + +NUMBER_DESCRIPTIONS: list[RoborockNumberDescription] = [ + RoborockNumberDescription( + key="volume", + translation_key="volume", + icon="mdi:volume-source", + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + cache_key=CacheableAttribute.sound_volume, + entity_category=EntityCategory.CONFIG, + update_value=lambda cache, value: cache.update_value([int(value)]), + ) +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roborock number platform.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + possible_entities: list[ + tuple[RoborockDataUpdateCoordinator, RoborockNumberDescription] + ] = [ + (coordinator, description) + for coordinator in coordinators.values() + for description in NUMBER_DESCRIPTIONS + ] + # We need to check if this function is supported by the device. + results = await asyncio.gather( + *( + coordinator.api.cache.get(description.cache_key).async_value() + for coordinator, description in possible_entities + ), + return_exceptions=True, + ) + valid_entities: list[RoborockNumberEntity] = [] + for (coordinator, description), result in zip(possible_entities, results): + if result is None or isinstance(result, RoborockException): + _LOGGER.debug("Not adding entity because of %s", result) + else: + valid_entities.append( + RoborockNumberEntity( + f"{description.key}_{slugify(coordinator.roborock_device_info.device.duid)}", + coordinator, + description, + ) + ) + async_add_entities(valid_entities) + + +class RoborockNumberEntity(RoborockEntity, NumberEntity): + """A class to let you set options on a Roborock vacuum where the potential options are fixed.""" + + entity_description: RoborockNumberDescription + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + entity_description: RoborockNumberDescription, + ) -> None: + """Create a number entity.""" + self.entity_description = entity_description + super().__init__(unique_id, coordinator.device_info, coordinator.api) + + @property + def native_value(self) -> float | None: + """Get native value.""" + return self.get_cache(self.entity_description.cache_key).value + + async def async_set_native_value(self, value: float) -> None: + """Set number value.""" + await self.entity_description.update_value( + self.get_cache(self.entity_description.cache_key), value + ) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 8398995462f..818fd338ffb 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from roborock.containers import RoborockStateCode +from roborock.containers import RoborockErrorCode, RoborockStateCode from roborock.roborock_typing import DeviceProp from homeassistant.components.sensor import ( @@ -113,6 +113,15 @@ SENSOR_DESCRIPTIONS = [ value_fn=lambda data: data.clean_summary.square_meter_clean_area, native_unit_of_measurement=AREA_SQUARE_METERS, ), + RoborockSensorDescription( + key="vacuum_error", + icon="mdi:alert-circle", + translation_key="vacuum_error", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: data.status.error_code.name, + entity_category=EntityCategory.DIAGNOSTIC, + options=RoborockErrorCode.keys(), + ), ] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index f711ceaf74a..cd629e208e3 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -4,7 +4,7 @@ "user": { "description": "Enter your Roborock email address.", "data": { - "username": "Email" + "username": "[%key:common::config_flow::data::email%]" } }, "code": { @@ -27,6 +27,11 @@ } }, "entity": { + "number": { + "volume": { + "name": "Volume" + } + }, "sensor": { "cleaning_area": { "name": "Cleaning area" @@ -51,14 +56,14 @@ "state": { "starting": "Starting", "charger_disconnected": "Charger disconnected", - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "remote_control_active": "Remote control active", "cleaning": "Cleaning", "returning_home": "Returning home", "manual_mode": "Manual mode", "charging": "Charging", "charging_problem": "Charging problem", - "paused": "Paused", + "paused": "[%key:common::state::paused%]", "spot_cleaning": "Spot cleaning", "error": "Error", "shutting_down": "Shutting down", @@ -79,6 +84,38 @@ }, "total_cleaning_area": { "name": "Total cleaning area" + }, + "vacuum_error": { + "name": "Vacuum error", + "state": { + "none": "None", + "lidar_blocked": "Lidar blocked", + "bumper_stuck": "Bumper stuck", + "wheels_suspended": "Wheels suspended", + "cliff_sensor_error": "Cliff sensor error", + "main_brush_jammed": "Main brush jammed", + "side_brush_jammed": "Side brush jammed", + "wheels_jammed": "Wheels jammed", + "robot_trapped": "Robot trapped", + "no_dustbin": "No dustbin", + "low_battery": "Low battery", + "charging_error": "Charging error", + "battery_error": "Battery error", + "wall_sensor_dirty": "Wall sensor dirty", + "robot_tilted": "Robot tilted", + "side_brush_error": "Side brush error", + "fan_error": "Fan error", + "vertical_bumper_pressed": "Vertical bumper pressed", + "dock_locator_error": "Dock locator error", + "return_to_dock_fail": "Return to dock fail", + "nogo_zone_detected": "No-go zone detected", + "vibrarise_jammed": "VibraRise jammed", + "robot_on_carpet": "Robot on carpet", + "filter_blocked": "Filter blocked", + "invisible_wall_detected": "Invisible wall detected", + "cannot_cross_carpet": "Cannot cross carpet", + "internal_error": "Internal error" + } } }, "select": { @@ -95,14 +132,14 @@ "mop_intensity": { "name": "Mop intensity", "state": { - "off": "Off", + "off": "[%key:common::state::off%]", "low": "Low", "mild": "Mild", "medium": "Medium", "moderate": "Moderate", "high": "High", "intense": "Intense", - "custom": "Custom" + "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]" } } }, @@ -117,6 +154,14 @@ "name": "Status indicator light" } }, + "time": { + "dnd_start_time": { + "name": "Do not disturb begin" + }, + "dnd_end_time": { + "name": "Do not disturb end" + } + }, "vacuum": { "roborock": { "state_attributes": { @@ -124,20 +169,33 @@ "state": { "auto": "Auto", "balanced": "Balanced", - "custom": "Custom", + "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]", "gentle": "Gentle", - "off": "Off", + "off": "[%key:common::state::off%]", "max": "Max", "max_plus": "Max plus", "medium": "Medium", "quiet": "Quiet", "silent": "Silent", - "standard": "Standard", + "standard": "[%key:component::roborock::entity::select::mop_mode::state::standard%]", "turbo": "Turbo" } } } } } + }, + "issues": { + "service_deprecation_start_pause": { + "title": "Roborock vacuum support for vacuum.start_pause is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::roborock::issues::service_deprecation_start_pause::title%]", + "description": "Roborock vacuum support for the vacuum.start_pause service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call vacuum.pause or vacuum.start and select submit below to mark this issue as resolved." + } + } + } + } } } diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index a0b3d5be295..312753ced01 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -9,13 +9,11 @@ from typing import Any from roborock.api import AttributeCache from roborock.command_cache import CacheableAttribute -from roborock.local_api import RoborockLocalClient from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify @@ -121,9 +119,8 @@ async def async_setup_entry( valid_entities.append( RoborockSwitch( f"{description.key}_{slugify(coordinator.roborock_device_info.device.duid)}", - coordinator.device_info, + coordinator, description, - coordinator.api, ) ) async_add_entities(valid_entities) @@ -137,13 +134,12 @@ class RoborockSwitch(RoborockEntity, SwitchEntity): def __init__( self, unique_id: str, - device_info: DeviceInfo, - description: RoborockSwitchDescription, - api: RoborockLocalClient, + coordinator: RoborockDataUpdateCoordinator, + entity_description: RoborockSwitchDescription, ) -> None: """Initialize the entity.""" - super().__init__(unique_id, device_info, api) - self.entity_description = description + self.entity_description = entity_description + super().__init__(unique_id, coordinator.device_info, coordinator.api) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py new file mode 100644 index 00000000000..514d147d469 --- /dev/null +++ b/homeassistant/components/roborock/time.py @@ -0,0 +1,150 @@ +"""Support for Roborock time.""" +import asyncio +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import datetime +from datetime import time +import logging +from typing import Any + +from roborock.api import AttributeCache +from roborock.command_cache import CacheableAttribute +from roborock.exceptions import RoborockException + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class RoborockTimeDescriptionMixin: + """Define an entity description mixin for time entities.""" + + # Gets the status of the switch + cache_key: CacheableAttribute + # Sets the status of the switch + update_value: Callable[[AttributeCache, datetime.time], Coroutine[Any, Any, dict]] + # Attribute from cache + get_value: Callable[[AttributeCache], datetime.time] + + +@dataclass +class RoborockTimeDescription(TimeEntityDescription, RoborockTimeDescriptionMixin): + """Class to describe an Roborock time entity.""" + + +TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [ + RoborockTimeDescription( + key="dnd_start_time", + translation_key="dnd_start_time", + icon="mdi:bell-cancel", + cache_key=CacheableAttribute.dnd_timer, + update_value=lambda cache, desired_time: cache.update_value( + [ + desired_time.hour, + desired_time.minute, + cache.value.get("end_hour"), + cache.value.get("end_minute"), + ] + ), + get_value=lambda cache: datetime.time( + hour=cache.value.get("start_hour"), minute=cache.value.get("start_minute") + ), + entity_category=EntityCategory.CONFIG, + ), + RoborockTimeDescription( + key="dnd_end_time", + translation_key="dnd_end_time", + icon="mdi:bell-ring", + cache_key=CacheableAttribute.dnd_timer, + update_value=lambda cache, desired_time: cache.update_value( + [ + cache.value.get("start_hour"), + cache.value.get("start_minute"), + desired_time.hour, + desired_time.minute, + ] + ), + get_value=lambda cache: datetime.time( + hour=cache.value.get("end_hour"), minute=cache.value.get("end_minute") + ), + entity_category=EntityCategory.CONFIG, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roborock time platform.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + possible_entities: list[ + tuple[RoborockDataUpdateCoordinator, RoborockTimeDescription] + ] = [ + (coordinator, description) + for coordinator in coordinators.values() + for description in TIME_DESCRIPTIONS + ] + # We need to check if this function is supported by the device. + results = await asyncio.gather( + *( + coordinator.api.cache.get(description.cache_key).async_value() + for coordinator, description in possible_entities + ), + return_exceptions=True, + ) + valid_entities: list[RoborockTimeEntity] = [] + for (coordinator, description), result in zip(possible_entities, results): + if result is None or isinstance(result, RoborockException): + _LOGGER.debug("Not adding entity because of %s", result) + else: + valid_entities.append( + RoborockTimeEntity( + f"{description.key}_{slugify(coordinator.roborock_device_info.device.duid)}", + coordinator, + description, + ) + ) + async_add_entities(valid_entities) + + +class RoborockTimeEntity(RoborockEntity, TimeEntity): + """A class to let you set options on a Roborock vacuum where the potential options are fixed.""" + + entity_description: RoborockTimeDescription + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + entity_description: RoborockTimeDescription, + ) -> None: + """Create a time entity.""" + self.entity_description = entity_description + super().__init__(unique_id, coordinator.device_info, coordinator.api) + + @property + def native_value(self) -> time | None: + """Return the value reported by the time.""" + return self.entity_description.get_value( + self.get_cache(self.entity_description.cache_key) + ) + + async def async_set_value(self, value: time) -> None: + """Set the time.""" + await self.entity_description.update_value( + self.get_cache(self.entity_description.cache_key), value + ) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 5f66338ecc1..804c0826578 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -16,6 +16,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify @@ -75,7 +76,6 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.BATTERY - | VacuumEntityFeature.STATUS | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.LOCATE | VacuumEntityFeature.CLEAN_SPOT @@ -110,11 +110,6 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): """Return the fan speed of the vacuum cleaner.""" return self._device_status.fan_power.name - @property - def status(self) -> str | None: - """Return the status of the vacuum cleaner.""" - return self._device_status.state.name - async def async_start(self) -> None: """Start the vacuum.""" await self.send(RoborockCommand.APP_START) @@ -152,6 +147,16 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): await self.async_pause() else: await self.async_start() + ir.async_create_issue( + self.hass, + DOMAIN, + "service_deprecation_start_pause", + breaks_in_ha_version="2024.2.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation_start_pause", + ) async def async_send_command( self, diff --git a/homeassistant/components/roku/services.yaml b/homeassistant/components/roku/services.yaml index 16fd51ea95b..4a28db94fa4 100644 --- a/homeassistant/components/roku/services.yaml +++ b/homeassistant/components/roku/services.yaml @@ -1,14 +1,10 @@ search: - name: Search - description: Emulates opening the search screen and entering the search keyword. target: entity: integration: roku domain: media_player fields: keyword: - name: Keyword - description: The keyword to search for. required: true example: "Space Jam" selector: diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json index 04c504def03..3510a43c604 100644 --- a/homeassistant/components/roku/strings.json +++ b/homeassistant/components/roku/strings.json @@ -20,5 +20,17 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "services": { + "search": { + "name": "Search", + "description": "Emulates opening the search screen and entering the search keyword.", + "fields": { + "keyword": { + "name": "Keyword", + "description": "The keyword to search for." + } + } + } } } diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index 0acd655363f..f480839388c 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -28,11 +28,7 @@ class RoombaBinStatus(IRobotEntity, BinarySensorEntity): """Class to hold Roomba Sensor basic info.""" ICON = "mdi:delete-variant" - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} Bin Full" + _attr_translation_key = "bin_full" @property def unique_id(self): diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 8ec91acf965..5dbd1e986f3 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -39,7 +39,6 @@ SUPPORT_IROBOT = ( | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.START | VacuumEntityFeature.STATE - | VacuumEntityFeature.STATUS | VacuumEntityFeature.STOP | VacuumEntityFeature.LOCATE ) @@ -62,6 +61,7 @@ class IRobotEntity(Entity): """Base class for iRobot Entities.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, roomba, blid): """Initialize the iRobot handler.""" @@ -136,6 +136,8 @@ class IRobotEntity(Entity): class IRobotVacuum(IRobotEntity, StateVacuumEntity): """Base class for iRobot robots.""" + _attr_name = None + def __init__(self, roomba, blid): """Initialize the iRobot handler.""" super().__init__(roomba, blid) @@ -161,11 +163,6 @@ class IRobotVacuum(IRobotEntity, StateVacuumEntity): """Return True if entity is available.""" return True # Always available, otherwise setup will fail - @property - def name(self): - """Return the name of the device.""" - return self._name - @property def extra_state_attributes(self): """Return the state attributes of the device.""" diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index c0092922783..dd74a023ff1 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -1,11 +1,9 @@ """Sensor for checking the battery level of Roomba.""" from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.components.vacuum import STATE_DOCKED from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.icon import icon_for_battery_level from .const import BLID, DOMAIN, ROOMBA_SESSION from .irobot_base import IRobotEntity @@ -28,36 +26,14 @@ class RoombaBattery(IRobotEntity, SensorEntity): """Class to hold Roomba Sensor basic info.""" _attr_entity_category = EntityCategory.DIAGNOSTIC - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} Battery Level" + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE @property def unique_id(self): """Return the ID of this sensor.""" return f"battery_{self._blid}" - @property - def device_class(self): - """Return the device class of the sensor.""" - return SensorDeviceClass.BATTERY - - @property - def native_unit_of_measurement(self): - """Return the unit_of_measurement of the device.""" - return PERCENTAGE - - @property - def icon(self): - """Return the icon for the battery.""" - charging = bool(self._robot_state == STATE_DOCKED) - - return icon_for_battery_level( - battery_level=self._battery_level, charging=charging - ) - @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index a644797c1be..206e8c5bae0 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -18,7 +18,7 @@ }, "link": { "title": "Retrieve Password", - "description": "Make sure that the iRobot app is not running on any device. Press and hold the Home button on {name} until the device generates a sound (about two seconds), then submit within 30 seconds." + "description": "Make sure that the iRobot app is not running on any device. Press and hold the Home button (or both Home and Spot buttons) on {name} until the device generates a sound (about two seconds), then submit within 30 seconds." }, "link_manual": { "title": "Enter Password", @@ -47,5 +47,12 @@ } } } + }, + "entity": { + "binary_sensor": { + "bin_full": { + "name": "Bin full" + } + } } } diff --git a/homeassistant/components/roon/services.yaml b/homeassistant/components/roon/services.yaml index 9d9d02f0efc..1de3e14bbc9 100644 --- a/homeassistant/components/roon/services.yaml +++ b/homeassistant/components/roon/services.yaml @@ -1,14 +1,10 @@ transfer: - name: Transfer - description: Transfer playback from one player to another. target: entity: integration: roon domain: media_player fields: transfer_id: - name: Transfer ID - description: id of the destination player. required: true selector: entity: diff --git a/homeassistant/components/roon/strings.json b/homeassistant/components/roon/strings.json index ce5827e2c6c..f67779e9eaa 100644 --- a/homeassistant/components/roon/strings.json +++ b/homeassistant/components/roon/strings.json @@ -21,5 +21,17 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "services": { + "transfer": { + "name": "Transfer", + "description": "Transfers playback from one player to another.", + "fields": { + "transfer_id": { + "name": "Transfer ID", + "description": "ID of the destination player." + } + } + } } } diff --git a/homeassistant/components/route53/services.yaml b/homeassistant/components/route53/services.yaml index 4936a499764..e800a3a3eee 100644 --- a/homeassistant/components/route53/services.yaml +++ b/homeassistant/components/route53/services.yaml @@ -1,3 +1 @@ update_records: - name: Update records - description: Trigger update of records. diff --git a/homeassistant/components/route53/strings.json b/homeassistant/components/route53/strings.json new file mode 100644 index 00000000000..12b372d0ce2 --- /dev/null +++ b/homeassistant/components/route53/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "update_records": { + "name": "Update records", + "description": "Triggers update of records." + } + } +} diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py index 21effd3da3a..f68ffbd0eaf 100644 --- a/homeassistant/components/rova/sensor.py +++ b/homeassistant/components/rova/sensor.py @@ -20,7 +20,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle -from homeassistant.util.dt import get_time_zone, now +from homeassistant.util.dt import get_time_zone # Config for rova requests. CONF_ZIP_CODE = "zip_code" @@ -150,8 +150,7 @@ class RovaData: tzinfo=get_time_zone("Europe/Amsterdam") ) code = item["GarbageTypeCode"].lower() - - if code not in self.data and date > now(): + if code not in self.data: self.data[code] = date _LOGGER.debug("Updated Rova calendar: %s", self.data) diff --git a/homeassistant/components/rtsp_to_webrtc/strings.json b/homeassistant/components/rtsp_to_webrtc/strings.json index 939c30766e2..e52ab554473 100644 --- a/homeassistant/components/rtsp_to_webrtc/strings.json +++ b/homeassistant/components/rtsp_to_webrtc/strings.json @@ -20,8 +20,8 @@ }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "server_failure": "RTSPtoWebRTC server returned an error. Check logs for more information.", - "server_unreachable": "Unable to communicate with RTSPtoWebRTC server. Check logs for more information." + "server_failure": "[%key:component::rtsp_to_webrtc::config::error::server_failure%]", + "server_unreachable": "[%key:component::rtsp_to_webrtc::config::error::server_unreachable%]" } }, "options": { diff --git a/homeassistant/components/russound_rnet/media_player.py b/homeassistant/components/russound_rnet/media_player.py index 7a384656b66..b19f4b9dfee 100644 --- a/homeassistant/components/russound_rnet/media_player.py +++ b/homeassistant/components/russound_rnet/media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +import math from russound import russound import voluptuous as vol @@ -85,17 +86,25 @@ class RussoundRNETDevice(MediaPlayerEntity): self._attr_name = extra["name"] self._russ = russ self._attr_source_list = sources - self._zone_id = zone_id + # Each controller has a maximum of 6 zones, every increment of 6 zones + # maps to an additional controller for easier backward compatibility + self._controller_id = str(math.ceil(zone_id / 6)) + # Each zone resets to 1-6 per controller + self._zone_id = (zone_id - 1) % 6 + 1 def update(self) -> None: """Retrieve latest state.""" # Updated this function to make a single call to get_zone_info, so that # with a single call we can get On/Off, Volume and Source, reducing the # amount of traffic and speeding up the update process. - ret = self._russ.get_zone_info("1", self._zone_id, 4) + ret = self._russ.get_zone_info(self._controller_id, self._zone_id, 4) _LOGGER.debug("ret= %s", ret) if ret is not None: - _LOGGER.debug("Updating status for zone %s", self._zone_id) + _LOGGER.debug( + "Updating status for RNET zone %s on controller %s", + self._zone_id, + self._controller_id, + ) if ret[0] == 0: self._attr_state = MediaPlayerState.OFF else: @@ -118,23 +127,23 @@ class RussoundRNETDevice(MediaPlayerEntity): Translate this to a range of (0..100) as expected by _russ.set_volume() """ - self._russ.set_volume("1", self._zone_id, volume * 100) + self._russ.set_volume(self._controller_id, self._zone_id, volume * 100) def turn_on(self) -> None: """Turn the media player on.""" - self._russ.set_power("1", self._zone_id, "1") + self._russ.set_power(self._controller_id, self._zone_id, "1") def turn_off(self) -> None: """Turn off media player.""" - self._russ.set_power("1", self._zone_id, "0") + self._russ.set_power(self._controller_id, self._zone_id, "0") def mute_volume(self, mute: bool) -> None: """Send mute command.""" - self._russ.toggle_mute("1", self._zone_id) + self._russ.toggle_mute(self._controller_id, self._zone_id) def select_source(self, source: str) -> None: """Set the input source.""" if self.source_list and source in self.source_list: index = self.source_list.index(source) # 0 based value for source - self._russ.set_source("1", self._zone_id, index) + self._russ.set_source(self._controller_id, self._zone_id, index) diff --git a/homeassistant/components/rympro/sensor.py b/homeassistant/components/rympro/sensor.py index 80675d9dec8..2c1a3ecee11 100644 --- a/homeassistant/components/rympro/sensor.py +++ b/homeassistant/components/rympro/sensor.py @@ -34,7 +34,7 @@ class RymProSensor(CoordinatorEntity[RymProDataUpdateCoordinator], SensorEntity) """Sensor for RymPro meters.""" _attr_has_entity_name = True - _attr_name = "Total consumption" + _attr_translation_key = "total_consumption" _attr_device_class = SensorDeviceClass.WATER _attr_native_unit_of_measurement = UnitOfVolume.CUBIC_METERS _attr_state_class = SensorStateClass.TOTAL_INCREASING diff --git a/homeassistant/components/rympro/strings.json b/homeassistant/components/rympro/strings.json index b6e7adc9631..2909d6c1b9b 100644 --- a/homeassistant/components/rympro/strings.json +++ b/homeassistant/components/rympro/strings.json @@ -16,5 +16,12 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "total_consumption": { + "name": "Total consumption" + } + } } } diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 2e345905d50..babdbc573bd 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -127,7 +127,7 @@ def async_get_entry_id_for_service_call(hass: HomeAssistant, call: ServiceCall) def update_device_identifiers(hass: HomeAssistant, entry: ConfigEntry): """Update device identifiers to new identifiers.""" device_registry = async_get(hass) - device_entry = device_registry.async_get_device({(DOMAIN, DOMAIN)}) + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, DOMAIN)}) if device_entry and entry.entry_id in device_entry.config_entries: new_identifiers = {(DOMAIN, entry.entry_id)} _LOGGER.debug( diff --git a/homeassistant/components/sabnzbd/services.yaml b/homeassistant/components/sabnzbd/services.yaml index 2221eed169f..f1eea1c9469 100644 --- a/homeassistant/components/sabnzbd/services.yaml +++ b/homeassistant/components/sabnzbd/services.yaml @@ -1,36 +1,22 @@ pause: - name: Pause - description: Pauses downloads. fields: api_key: - name: Sabnzbd API key - description: The Sabnzbd API key to pause downloads required: true selector: text: resume: - name: Resume - description: Resumes downloads. fields: api_key: - name: Sabnzbd API key - description: The Sabnzbd API key to resume downloads required: true selector: text: set_speed: - name: Set speed - description: Sets the download speed limit. fields: api_key: - name: Sabnzbd API key - description: The Sabnzbd API key to set speed limit required: true selector: text: speed: - name: Speed - description: Speed limit. If specified as a number with no units, will be interpreted as a percent. If units are provided (e.g., 500K) will be interpreted absolutely. example: 100 default: 100 selector: diff --git a/homeassistant/components/sabnzbd/strings.json b/homeassistant/components/sabnzbd/strings.json index 501e0d33faf..a8e146eeb27 100644 --- a/homeassistant/components/sabnzbd/strings.json +++ b/homeassistant/components/sabnzbd/strings.json @@ -13,5 +13,41 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" } + }, + "services": { + "pause": { + "name": "[%key:common::action::pause%]", + "description": "Pauses downloads.", + "fields": { + "api_key": { + "name": "SABnzbd API key", + "description": "The SABnzbd API key to pause downloads." + } + } + }, + "resume": { + "name": "Resume", + "description": "Resumes downloads.", + "fields": { + "api_key": { + "name": "[%key:component::sabnzbd::services::pause::fields::api_key::name%]", + "description": "The SABnzbd API key to resume downloads." + } + } + }, + "set_speed": { + "name": "Set speed", + "description": "Sets the download speed limit.", + "fields": { + "api_key": { + "name": "[%key:component::sabnzbd::services::pause::fields::api_key::name%]", + "description": "The SABnzbd API key to set speed limit." + }, + "speed": { + "name": "Speed", + "description": "Speed limit. If specified as a number with no units, will be interpreted as a percent. If units are provided (e.g., 500K) will be interpreted absolutely." + } + } + } } } diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 9d00282d8da..d32e71c71c0 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.33.2" + "async-upnp-client==0.34.1" ], "ssdp": [ { diff --git a/homeassistant/components/scene/services.yaml b/homeassistant/components/scene/services.yaml index 202b4a98aa9..acd98b10255 100644 --- a/homeassistant/components/scene/services.yaml +++ b/homeassistant/components/scene/services.yaml @@ -1,16 +1,11 @@ # Describes the format for available scene services turn_on: - name: Activate - description: Activate a scene. target: entity: domain: scene fields: transition: - name: Transition - description: Transition duration it takes to bring devices to the state - defined in the scene. selector: number: min: 0 @@ -18,16 +13,9 @@ turn_on: unit_of_measurement: seconds reload: - name: Reload - description: Reload the scene configuration. - apply: - name: Apply - description: Activate a scene with configuration. fields: entities: - name: Entities state - description: The entities and the state that they need to be. required: true example: | light.kitchen: "on" @@ -37,9 +25,6 @@ apply: selector: object: transition: - name: Transition - description: Transition duration it takes to bring devices to the state - defined in the scene. selector: number: min: 0 @@ -47,19 +32,13 @@ apply: unit_of_measurement: seconds create: - name: Create - description: Creates a new scene. fields: scene_id: - name: Scene entity ID - description: The entity_id of the new scene. required: true example: all_lights selector: text: entities: - name: Entities state - description: The entities to control with the scene. example: | light.tv_back_light: "on" light.ceiling: @@ -68,8 +47,6 @@ create: selector: object: snapshot_entities: - name: Snapshot entities - description: The entities of which a snapshot is to be taken example: | - light.ceiling - light.kitchen diff --git a/homeassistant/components/scene/strings.json b/homeassistant/components/scene/strings.json index c92838ea322..3bfea1b09e7 100644 --- a/homeassistant/components/scene/strings.json +++ b/homeassistant/components/scene/strings.json @@ -1 +1,51 @@ -{ "title": "Scene" } +{ + "title": "Scene", + "services": { + "turn_on": { + "name": "Activate", + "description": "Activates a scene.", + "fields": { + "transition": { + "name": "Transition", + "description": "Time it takes the devices to transition into the states defined in the scene." + } + } + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads the scenes from the YAML-configuration." + }, + "apply": { + "name": "Apply", + "description": "Activates a scene with configuration.", + "fields": { + "entities": { + "name": "Entities state", + "description": "List of entities and their target state." + }, + "transition": { + "name": "Transition", + "description": "Time it takes the devices to transition into the states defined in the scene." + } + } + }, + "create": { + "name": "Create", + "description": "Creates a new scene.", + "fields": { + "scene_id": { + "name": "Scene entity ID", + "description": "The entity ID of the new scene." + }, + "entities": { + "name": "Entities state", + "description": "List of entities and their target state. If your entities are already in the target state right now, use `snapshot_entities` instead." + }, + "snapshot_entities": { + "name": "Snapshot entities", + "description": "List of entities to be included in the snapshot. By taking a snapshot, you record the current state of those entities. If you do not want to use the current state of all your entities for this scene, you can combine the `snapshot_entities` with `entities`." + } + } + } + } +} diff --git a/homeassistant/components/schedule/services.yaml b/homeassistant/components/schedule/services.yaml index b34dd5e83da..c983a105c93 100644 --- a/homeassistant/components/schedule/services.yaml +++ b/homeassistant/components/schedule/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload the schedule configuration diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json index 4c22e5ecead..a40c5814d36 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -20,5 +20,11 @@ } } } + }, + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads schedules from the YAML-configuration." + } } } diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 8953d9facd0..bf2ccb16b03 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -20,7 +20,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.template_entity import TEMPLATE_SENSOR_BASE_SCHEMA +from homeassistant.helpers.template_entity import ( + CONF_AVAILABILITY, + TEMPLATE_SENSOR_BASE_SCHEMA, +) from homeassistant.helpers.typing import ConfigType from .const import CONF_INDEX, CONF_SELECT, DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS @@ -29,6 +32,7 @@ from .coordinator import ScrapeCoordinator SENSOR_SCHEMA = vol.Schema( { **TEMPLATE_SENSOR_BASE_SCHEMA.schema, + vol.Optional(CONF_AVAILABILITY): cv.template, vol.Optional(CONF_ATTRIBUTE): cv.string, vol.Optional(CONF_INDEX, default=0): cv.positive_int, vol.Required(CONF_SELECT): cv.string, diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 8bfda778c79..42f9fdb05d5 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/scrape", "iot_class": "cloud_polling", - "requirements": ["beautifulsoup4==4.11.1", "lxml==4.9.1"] + "requirements": ["beautifulsoup4==4.11.1", "lxml==4.9.3"] } diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 5ddd6c48e43..cc4cd269606 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -6,13 +6,20 @@ from typing import Any, cast import voluptuous as vol -from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorEntity, +) from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ATTRIBUTE, + CONF_DEVICE_CLASS, + CONF_ICON, CONF_NAME, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback @@ -20,8 +27,10 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, TEMPLATE_SENSOR_BASE_SCHEMA, - TemplateSensor, + ManualTriggerEntity, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -53,17 +62,30 @@ async def async_setup_platform( if value_template is not None: value_template.hass = hass + trigger_entity_config = { + CONF_NAME: sensor_config[CONF_NAME], + CONF_DEVICE_CLASS: sensor_config.get(CONF_DEVICE_CLASS), + CONF_UNIQUE_ID: sensor_config.get(CONF_UNIQUE_ID), + } + if available := sensor_config.get(CONF_AVAILABILITY): + trigger_entity_config[CONF_AVAILABILITY] = available + if icon := sensor_config.get(CONF_ICON): + trigger_entity_config[CONF_ICON] = icon + if picture := sensor_config.get(CONF_PICTURE): + trigger_entity_config[CONF_PICTURE] = picture + entities.append( ScrapeSensor( hass, coordinator, - sensor_config, - sensor_config[CONF_NAME], - sensor_config.get(CONF_UNIQUE_ID), + trigger_entity_config, + sensor_config.get(CONF_UNIT_OF_MEASUREMENT), + sensor_config.get(CONF_STATE_CLASS), sensor_config[CONF_SELECT], sensor_config.get(CONF_ATTRIBUTE), sensor_config[CONF_INDEX], value_template, + True, ) ) @@ -84,60 +106,64 @@ async def async_setup_entry( )(sensor) name: str = sensor_config[CONF_NAME] - select: str = sensor_config[CONF_SELECT] - attr: str | None = sensor_config.get(CONF_ATTRIBUTE) - index: int = int(sensor_config[CONF_INDEX]) value_string: str | None = sensor_config.get(CONF_VALUE_TEMPLATE) - unique_id: str = sensor_config[CONF_UNIQUE_ID] value_template: Template | None = ( Template(value_string, hass) if value_string is not None else None ) + + trigger_entity_config = { + CONF_NAME: name, + CONF_DEVICE_CLASS: sensor_config.get(CONF_DEVICE_CLASS), + CONF_UNIQUE_ID: sensor_config[CONF_UNIQUE_ID], + } + entities.append( ScrapeSensor( hass, coordinator, - sensor_config, - name, - unique_id, - select, - attr, - index, + trigger_entity_config, + sensor_config.get(CONF_UNIT_OF_MEASUREMENT), + sensor_config.get(CONF_STATE_CLASS), + sensor_config[CONF_SELECT], + sensor_config.get(CONF_ATTRIBUTE), + sensor_config[CONF_INDEX], value_template, + False, ) ) async_add_entities(entities) -class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], TemplateSensor): +class ScrapeSensor( + CoordinatorEntity[ScrapeCoordinator], ManualTriggerEntity, SensorEntity +): """Representation of a web scrape sensor.""" def __init__( self, hass: HomeAssistant, coordinator: ScrapeCoordinator, - config: ConfigType, - name: str, - unique_id: str | None, + trigger_entity_config: ConfigType, + unit_of_measurement: str | None, + state_class: str | None, select: str, attr: str | None, index: int, value_template: Template | None, + yaml: bool, ) -> None: """Initialize a web scrape sensor.""" CoordinatorEntity.__init__(self, coordinator) - TemplateSensor.__init__( - self, - hass, - config=config, - fallback_name=name, - unique_id=unique_id, - ) + ManualTriggerEntity.__init__(self, hass, trigger_entity_config) + self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_state_class = state_class self._select = select self._attr = attr self._index = index self._value_template = value_template + self._attr_native_value = None def _extract_value(self) -> Any: """Parse the html extraction in the executor.""" @@ -164,12 +190,15 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], TemplateSensor): async def async_added_to_hass(self) -> None: """Ensure the data from the initial update is reflected in the state.""" - await super().async_added_to_hass() + await ManualTriggerEntity.async_added_to_hass(self) + # https://github.com/python/mypy/issues/15097 + await CoordinatorEntity.async_added_to_hass(self) # type: ignore[arg-type] self._async_update_from_rest_data() def _async_update_from_rest_data(self) -> None: """Update state from the rest data.""" value = self._extract_value() + raw_value = value if (template := self._value_template) is not None: value = template.async_render_with_possible_json_value(value, None) @@ -179,11 +208,21 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], TemplateSensor): SensorDeviceClass.TIMESTAMP, }: self._attr_native_value = value + self._process_manual_data(raw_value) return self._attr_native_value = async_parse_date_datetime( value, self.entity_id, self.device_class ) + self._process_manual_data(raw_value) + self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return if entity is available.""" + available1 = CoordinatorEntity.available.fget(self) # type: ignore[attr-defined] + available2 = ManualTriggerEntity.available.fget(self) # type: ignore[attr-defined] + return bool(available1 and available2) @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index e5ed8613fc4..4301bb7d5a0 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -155,6 +155,7 @@ "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "ph": "[%key:component::sensor::entity_component::ph::name%]", "pm1": "[%key:component::sensor::entity_component::pm1::name%]", "pm10": "[%key:component::sensor::entity_component::pm10::name%]", "pm25": "[%key:component::sensor::entity_component::pm25::name%]", diff --git a/homeassistant/components/screenlogic/services.yaml b/homeassistant/components/screenlogic/services.yaml index 439d020a432..8e4a82a1079 100644 --- a/homeassistant/components/screenlogic/services.yaml +++ b/homeassistant/components/screenlogic/services.yaml @@ -1,14 +1,10 @@ # ScreenLogic Services set_color_mode: - name: Set Color Mode - description: Sets the color mode for all color-capable lights attached to this ScreenLogic gateway. target: device: integration: screenlogic fields: color_mode: - name: Color Mode - description: The ScreenLogic color mode to set required: true selector: select: diff --git a/homeassistant/components/screenlogic/strings.json b/homeassistant/components/screenlogic/strings.json index b0958d31727..4894bc6437d 100644 --- a/homeassistant/components/screenlogic/strings.json +++ b/homeassistant/components/screenlogic/strings.json @@ -14,7 +14,7 @@ } }, "gateway_select": { - "title": "ScreenLogic", + "title": "[%key:component::screenlogic::config::step::gateway_entry::title%]", "description": "The following ScreenLogic gateways were discovered. Please select one to configure, or choose to manually configure a ScreenLogic gateway.", "data": { "selected_gateway": "Gateway" @@ -28,12 +28,24 @@ "options": { "step": { "init": { - "title": "ScreenLogic", + "title": "[%key:component::screenlogic::config::step::gateway_entry::title%]", "description": "Specify settings for {gateway_name}", "data": { "scan_interval": "Seconds between scans" } } } + }, + "services": { + "set_color_mode": { + "name": "Set Color Mode", + "description": "Sets the color mode for all color-capable lights attached to this ScreenLogic gateway.", + "fields": { + "color_mode": { + "name": "Color Mode", + "description": "The ScreenLogic color mode to set." + } + } + } } } diff --git a/homeassistant/components/script/config.py b/homeassistant/components/script/config.py index 10c7f08484b..c11bb37294f 100644 --- a/homeassistant/components/script/config.py +++ b/homeassistant/components/script/config.py @@ -23,6 +23,10 @@ from homeassistant.const import ( CONF_SELECTOR, CONF_SEQUENCE, CONF_VARIABLES, + SERVICE_RELOAD, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -57,6 +61,23 @@ _MINIMAL_SCRIPT_ENTITY_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +_INVALID_OBJECT_IDS = { + SERVICE_RELOAD, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_TOGGLE, +} + +_SCRIPT_OBJECT_ID_SCHEMA = vol.All( + cv.slug, + vol.NotIn( + _INVALID_OBJECT_IDS, + ( + "A script's object_id must not be one of " + f"{', '.join(sorted(_INVALID_OBJECT_IDS))}" + ), + ), +) SCRIPT_ENTITY_SCHEMA = make_script_schema( { @@ -170,7 +191,7 @@ async def _async_validate_config_item( script_name = f"Script with alias '{config[CONF_ALIAS]}'" try: - cv.slug(object_id) + _SCRIPT_OBJECT_ID_SCHEMA(object_id) except vol.Invalid as err: _log_invalid_script(err, script_name, "has invalid object id", object_id) raise diff --git a/homeassistant/components/script/services.yaml b/homeassistant/components/script/services.yaml index 1d3c0e8a8a9..6fc3d81f196 100644 --- a/homeassistant/components/script/services.yaml +++ b/homeassistant/components/script/services.yaml @@ -1,26 +1,17 @@ # Describes the format for available python_script services reload: - name: Reload - description: Reload all the available scripts - turn_on: - name: Turn on - description: Turn on script target: entity: domain: script turn_off: - name: Turn off - description: Turn off script target: entity: domain: script toggle: - name: Toggle - description: Toggle script target: entity: domain: script diff --git a/homeassistant/components/script/strings.json b/homeassistant/components/script/strings.json index b9624f16a31..f2d5997ae9d 100644 --- a/homeassistant/components/script/strings.json +++ b/homeassistant/components/script/strings.json @@ -31,5 +31,23 @@ } } } + }, + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads all the available scripts." + }, + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Runs the sequence of actions defined in a script." + }, + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Stops a running script." + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggle a script. Starts it, if isn't running, stops it otherwise." + } } } diff --git a/homeassistant/components/season/strings.json b/homeassistant/components/season/strings.json index bff02df5c6c..162daddd412 100644 --- a/homeassistant/components/season/strings.json +++ b/homeassistant/components/season/strings.json @@ -1,4 +1,5 @@ { + "title": "Season", "config": { "step": { "user": { @@ -11,12 +12,6 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, - "issues": { - "removed_yaml": { - "title": "The Season YAML configuration has been removed", - "description": "Configuring Season using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - }, "entity": { "sensor": { "season": { diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index af390a005a7..a8034588ed1 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -144,9 +144,10 @@ class SelectEntity(Entity): @final def state(self) -> str | None: """Return the entity state.""" - if self.current_option is None or self.current_option not in self.options: + current_option = self.current_option + if current_option is None or current_option not in self.options: return None - return self.current_option + return current_option @property def options(self) -> list[str]: @@ -209,21 +210,24 @@ class SelectEntity(Entity): async def _async_offset_index(self, offset: int, cycle: bool) -> None: """Offset current index.""" current_index = 0 - if self.current_option is not None and self.current_option in self.options: - current_index = self.options.index(self.current_option) + current_option = self.current_option + options = self.options + if current_option is not None and current_option in self.options: + current_index = self.options.index(current_option) new_index = current_index + offset if cycle: - new_index = new_index % len(self.options) + new_index = new_index % len(options) elif new_index < 0: new_index = 0 - elif new_index >= len(self.options): - new_index = len(self.options) - 1 + elif new_index >= len(options): + new_index = len(options) - 1 - await self.async_select_option(self.options[new_index]) + await self.async_select_option(options[new_index]) @final async def _async_select_index(self, idx: int) -> None: """Select new option by index.""" - new_index = idx % len(self.options) - await self.async_select_option(self.options[new_index]) + options = self.options + new_index = idx % len(options) + await self.async_select_option(options[new_index]) diff --git a/homeassistant/components/select/services.yaml b/homeassistant/components/select/services.yaml index 8fb55936fc9..dc6d4c6815a 100644 --- a/homeassistant/components/select/services.yaml +++ b/homeassistant/components/select/services.yaml @@ -1,56 +1,40 @@ select_first: - name: First - description: Select the first option of an select entity. target: entity: domain: select select_last: - name: Last - description: Select the last option of an select entity. target: entity: domain: select select_next: - name: Next - description: Select the next options of an select entity. target: entity: domain: select fields: cycle: - name: Cycle - description: If the option should cycle from the last to the first. default: true selector: boolean: select_option: - name: Select - description: Select an option of an select entity. target: entity: domain: select fields: option: - name: Option - description: Option to be selected. required: true example: '"Item A"' selector: text: select_previous: - name: Previous - description: Select the previous options of an select entity. target: entity: domain: select fields: cycle: - name: Cycle - description: If the option should cycle from the first to the last. default: true selector: boolean: diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index 53441d365b4..d058ff6e6f2 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -25,10 +25,44 @@ } } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "select_first": { + "name": "First", + "description": "Selects the first option." + }, + "select_last": { + "name": "Last", + "description": "Selects the last option." + }, + "select_next": { + "name": "Next", + "description": "Selects the next option.", + "fields": { + "cycle": { + "name": "Cycle", + "description": "If the option should cycle from the last to the first." + } + } + }, + "select_option": { + "name": "Select", + "description": "Selects an option.", + "fields": { + "option": { + "name": "Option", + "description": "Option to be selected." + } + } + }, + "select_previous": { + "name": "Previous", + "description": "Selects the previous option.", + "fields": { + "cycle": { + "name": "Cycle", + "description": "If the option should cycle from the first to the last." + } + } } } } diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index e57267a1658..08f45b94789 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -54,8 +54,8 @@ class SensiboDeviceBinarySensorEntityDescription( FILTER_CLEAN_REQUIRED_DESCRIPTION = SensiboDeviceBinarySensorEntityDescription( key="filter_clean", + translation_key="filter_clean", device_class=BinarySensorDeviceClass.PROBLEM, - name="Filter clean required", value_fn=lambda data: data.filter_clean, ) @@ -64,20 +64,18 @@ MOTION_SENSOR_TYPES: tuple[SensiboMotionBinarySensorEntityDescription, ...] = ( key="alive", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, - name="Alive", value_fn=lambda data: data.alive, ), SensiboMotionBinarySensorEntityDescription( key="is_main_sensor", + translation_key="is_main_sensor", entity_category=EntityCategory.DIAGNOSTIC, - name="Main sensor", icon="mdi:connection", value_fn=lambda data: data.is_main_sensor, ), SensiboMotionBinarySensorEntityDescription( key="motion", device_class=BinarySensorDeviceClass.MOTION, - name="Motion", icon="mdi:motion-sensor", value_fn=lambda data: data.motion, ), @@ -86,8 +84,8 @@ MOTION_SENSOR_TYPES: tuple[SensiboMotionBinarySensorEntityDescription, ...] = ( MOTION_DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( SensiboDeviceBinarySensorEntityDescription( key="room_occupied", + translation_key="room_occupied", device_class=BinarySensorDeviceClass.MOTION, - name="Room occupied", icon="mdi:motion-sensor", value_fn=lambda data: data.room_occupied, ), @@ -100,30 +98,30 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( SensiboDeviceBinarySensorEntityDescription( key="pure_ac_integration", + translation_key="pure_ac_integration", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, - name="Pure Boost linked with AC", value_fn=lambda data: data.pure_ac_integration, ), SensiboDeviceBinarySensorEntityDescription( key="pure_geo_integration", + translation_key="pure_geo_integration", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, - name="Pure Boost linked with presence", value_fn=lambda data: data.pure_geo_integration, ), SensiboDeviceBinarySensorEntityDescription( key="pure_measure_integration", + translation_key="pure_measure_integration", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, - name="Pure Boost linked with indoor air quality", value_fn=lambda data: data.pure_measure_integration, ), SensiboDeviceBinarySensorEntityDescription( key="pure_prime_integration", + translation_key="pure_prime_integration", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, - name="Pure Boost linked with outdoor air quality", value_fn=lambda data: data.pure_prime_integration, ), FILTER_CLEAN_REQUIRED_DESCRIPTION, diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index 1406d9d26c7..b47023f3ec4 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -33,7 +33,7 @@ class SensiboButtonEntityDescription( DEVICE_BUTTON_TYPES = SensiboButtonEntityDescription( key="reset_filter", - name="Reset filter", + translation_key="reset_filter", icon="mdi:air-filter", entity_category=EntityCategory.CONFIG, data_key="filter_clean", diff --git a/homeassistant/components/sensibo/diagnostics.py b/homeassistant/components/sensibo/diagnostics.py index 72029acc2f1..9d998e739f0 100644 --- a/homeassistant/components/sensibo/diagnostics.py +++ b/homeassistant/components/sensibo/diagnostics.py @@ -33,4 +33,8 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for Sensibo config entry.""" coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return async_redact_data(coordinator.data.raw, TO_REDACT) + diag_data = {} + diag_data["raw"] = async_redact_data(coordinator.data.raw, TO_REDACT) + for device, device_data in coordinator.data.parsed.items(): + diag_data[device] = async_redact_data(device_data.__dict__, TO_REDACT) + return diag_data diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index 8b46e3e7941..3696f618fd7 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -49,6 +49,8 @@ def async_handle_api_call( class SensiboBaseEntity(CoordinatorEntity[SensiboDataUpdateCoordinator]): """Representation of a Sensibo Base Entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: SensiboDataUpdateCoordinator, @@ -68,8 +70,6 @@ class SensiboBaseEntity(CoordinatorEntity[SensiboDataUpdateCoordinator]): class SensiboDeviceBaseEntity(SensiboBaseEntity): """Representation of a Sensibo Device.""" - _attr_has_entity_name = True - def __init__( self, coordinator: SensiboDataUpdateCoordinator, @@ -93,8 +93,6 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): class SensiboMotionBaseEntity(SensiboBaseEntity): """Representation of a Sensibo Motion Entity.""" - _attr_has_entity_name = True - def __init__( self, coordinator: SensiboDataUpdateCoordinator, diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index f99792f7dc1..26182102442 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -15,5 +15,5 @@ "iot_class": "cloud_polling", "loggers": ["pysensibo"], "quality_scale": "platinum", - "requirements": ["pysensibo==1.0.28"] + "requirements": ["pysensibo==1.0.32"] } diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index c39026265c7..94765a17a4d 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -38,8 +38,8 @@ class SensiboNumberEntityDescription( DEVICE_NUMBER_TYPES = ( SensiboNumberEntityDescription( key="calibration_temp", + translation_key="calibration_temperature", remote_key="temperature", - name="Temperature calibration", icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, @@ -50,8 +50,8 @@ DEVICE_NUMBER_TYPES = ( ), SensiboNumberEntityDescription( key="calibration_hum", + translation_key="calibration_humidity", remote_key="humidity", - name="Humidity calibration", icon="mdi:water", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 29ebdc89261..cda8a972ede 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -41,7 +41,6 @@ DEVICE_SELECT_TYPES = ( SensiboSelectEntityDescription( key="horizontalSwing", data_key="horizontal_swing_mode", - name="Horizontal swing", icon="mdi:air-conditioner", value_fn=lambda data: data.horizontal_swing_mode, options_fn=lambda data: data.horizontal_swing_modes, @@ -51,7 +50,6 @@ DEVICE_SELECT_TYPES = ( SensiboSelectEntityDescription( key="light", data_key="light_mode", - name="Light", icon="mdi:flashlight", value_fn=lambda data: data.light_mode, options_fn=lambda data: data.light_modes, diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 69d6a8cb78b..7208902456e 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -67,8 +67,8 @@ class SensiboDeviceSensorEntityDescription( FILTER_LAST_RESET_DESCRIPTION = SensiboDeviceSensorEntityDescription( key="filter_last_reset", + translation_key="filter_last_reset", device_class=SensorDeviceClass.TIMESTAMP, - name="Filter last reset", icon="mdi:timer", value_fn=lambda data: data.filter_last_reset, extra_fn=None, @@ -77,22 +77,22 @@ FILTER_LAST_RESET_DESCRIPTION = SensiboDeviceSensorEntityDescription( MOTION_SENSOR_TYPES: tuple[SensiboMotionSensorEntityDescription, ...] = ( SensiboMotionSensorEntityDescription( key="rssi", + translation_key="rssi", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, state_class=SensorStateClass.MEASUREMENT, - name="rssi", icon="mdi:wifi", value_fn=lambda data: data.rssi, entity_registry_enabled_default=False, ), SensiboMotionSensorEntityDescription( key="battery_voltage", + translation_key="battery_voltage", device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, - name="Battery voltage", icon="mdi:battery", value_fn=lambda data: data.battery_voltage, ), @@ -101,7 +101,6 @@ MOTION_SENSOR_TYPES: tuple[SensiboMotionSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - name="Humidity", icon="mdi:water", value_fn=lambda data: data.humidity, ), @@ -109,7 +108,6 @@ MOTION_SENSOR_TYPES: tuple[SensiboMotionSensorEntityDescription, ...] = ( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - name="Temperature", icon="mdi:thermometer", value_fn=lambda data: data.temperature, ), @@ -120,18 +118,16 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - name="PM2.5", icon="mdi:air-filter", value_fn=lambda data: data.pm25, extra_fn=None, ), SensiboDeviceSensorEntityDescription( key="pure_sensitivity", - name="Pure sensitivity", + translation_key="sensitivity", icon="mdi:air-filter", value_fn=lambda data: data.pure_sensitivity, extra_fn=None, - translation_key="sensitivity", ), FILTER_LAST_RESET_DESCRIPTION, ) @@ -139,35 +135,35 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( SensiboDeviceSensorEntityDescription( key="timer_time", + translation_key="timer_time", device_class=SensorDeviceClass.TIMESTAMP, - name="Timer end time", icon="mdi:timer", value_fn=lambda data: data.timer_time, extra_fn=lambda data: {"id": data.timer_id, "turn_on": data.timer_state_on}, ), SensiboDeviceSensorEntityDescription( key="feels_like", + translation_key="feels_like", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - name="Temperature feels like", value_fn=lambda data: data.feelslike, extra_fn=None, entity_registry_enabled_default=False, ), SensiboDeviceSensorEntityDescription( key="climate_react_low", + translation_key="climate_react_low", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - name="Climate React low temperature threshold", value_fn=lambda data: data.smart_low_temp_threshold, extra_fn=lambda data: data.smart_low_state, entity_registry_enabled_default=False, ), SensiboDeviceSensorEntityDescription( key="climate_react_high", + translation_key="climate_react_high", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - name="Climate React high temperature threshold", value_fn=lambda data: data.smart_high_temp_threshold, extra_fn=lambda data: data.smart_high_state, entity_registry_enabled_default=False, @@ -175,7 +171,6 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( SensiboDeviceSensorEntityDescription( key="climate_react_type", translation_key="smart_type", - name="Climate React type", value_fn=lambda data: data.smart_type, extra_fn=None, entity_registry_enabled_default=False, @@ -186,19 +181,19 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( AIRQ_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( SensiboDeviceSensorEntityDescription( key="airq_tvoc", + translation_key="airq_tvoc", native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, icon="mdi:air-filter", - name="AirQ TVOC", value_fn=lambda data: data.tvoc, extra_fn=None, ), SensiboDeviceSensorEntityDescription( key="airq_co2", + translation_key="airq_co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, - name="AirQ CO2", value_fn=lambda data: data.co2, extra_fn=None, ), @@ -210,15 +205,14 @@ ELEMENT_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - name="PM 2.5", value_fn=lambda data: data.pm25, extra_fn=None, ), SensiboDeviceSensorEntityDescription( key="tvoc", + translation_key="tvoc", native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, - name="TVOC", value_fn=lambda data: data.tvoc, extra_fn=None, ), @@ -227,7 +221,6 @@ ELEMENT_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, - name="CO2", value_fn=lambda data: data.co2, extra_fn=None, ), @@ -243,7 +236,6 @@ ELEMENT_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( key="iaq", device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, - name="Air quality", value_fn=lambda data: data.iaq, extra_fn=None, ), diff --git a/homeassistant/components/sensibo/services.yaml b/homeassistant/components/sensibo/services.yaml index f9f9365eb8e..7f8252af820 100644 --- a/homeassistant/components/sensibo/services.yaml +++ b/homeassistant/components/sensibo/services.yaml @@ -1,14 +1,10 @@ assume_state: - name: Assume state - description: Set Sensibo device to external state target: entity: integration: sensibo domain: climate fields: state: - name: State - description: State to set required: true example: "on" selector: @@ -17,16 +13,12 @@ assume_state: - "on" - "off" enable_timer: - name: Enable Timer - description: Enable the timer with custom time target: entity: integration: sensibo domain: climate fields: minutes: - name: Minutes - description: Countdown for timer (for timer state on) required: false example: 30 selector: @@ -35,44 +27,32 @@ enable_timer: step: 1 mode: box enable_pure_boost: - name: Enable Pure Boost - description: Enable and configure Pure Boost settings target: entity: integration: sensibo domain: climate fields: ac_integration: - name: AC Integration - description: Integrate with Air Conditioner required: true example: true selector: boolean: geo_integration: - name: Geo Integration - description: Integrate with Presence required: true example: true selector: boolean: indoor_integration: - name: Indoor Air Quality - description: Integrate with checking indoor air quality required: true example: true selector: boolean: outdoor_integration: - name: Outdoor Air Quality - description: Integrate with checking outdoor air quality required: true example: true selector: boolean: sensitivity: - name: Sensitivity - description: Set the sensitivity for Pure Boost required: true example: "Normal" selector: @@ -81,16 +61,12 @@ enable_pure_boost: - "Normal" - "Sensitive" full_state: - name: Set full state - description: Set full state for Sensibo device target: entity: integration: sensibo domain: climate fields: mode: - name: HVAC mode - description: HVAC mode to set required: true example: "heat" selector: @@ -103,8 +79,6 @@ full_state: - "dry" - "off" target_temperature: - name: Target Temperature - description: Optionally set target temperature required: false example: 23 selector: @@ -113,32 +87,24 @@ full_state: step: 1 mode: box fan_mode: - name: Fan mode - description: Optionally set fan mode required: false example: "low" selector: text: type: text swing_mode: - name: swing mode - description: Optionally set swing mode required: false example: "fixedBottom" selector: text: type: text horizontal_swing_mode: - name: Horizontal swing mode - description: Optionally set horizontal swing mode required: false example: "fixedLeft" selector: text: type: text light: - name: Light - description: Set light on or off required: false example: "on" selector: @@ -146,17 +112,14 @@ full_state: options: - "on" - "off" + - "dim" enable_climate_react: - name: Enable Climate React - description: Enable and configure Climate React target: entity: integration: sensibo domain: climate fields: high_temperature_threshold: - name: Threshold high - description: When temp/humidity goes above required: true example: 24 selector: @@ -166,14 +129,10 @@ enable_climate_react: step: 0.1 mode: box high_temperature_state: - name: State high threshold - description: What should happen at high threshold. Requires full state required: true selector: object: low_temperature_threshold: - name: Threshold low - description: When temp/humidity goes below required: true example: 19 selector: @@ -183,14 +142,10 @@ enable_climate_react: step: 0.1 mode: box low_temperature_state: - name: State low threshold - description: What should happen at low threshold. Requires full state required: true selector: object: smart_type: - name: Trigger type - description: Choose between temperature/feels like/humidity required: true example: "temperature" selector: diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index fb3559de91a..38ae94d4fa3 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -31,25 +31,47 @@ } }, "entity": { - "sensor": { - "sensitivity": { - "state": { - "n": "Normal", - "s": "Sensitive" - } + "binary_sensor": { + "filter_clean": { + "name": "Filter clean required" }, - "smart_type": { - "state": { - "temperature": "Temperature", - "feelslike": "Feels like", - "humidity": "Humidity" - } + "is_main_sensor": { + "name": "Main sensor" + }, + "room_occupied": { + "name": "Room occupied" + }, + "pure_ac_integration": { + "name": "Pure Boost linked with AC" + }, + "pure_geo_integration": { + "name": "Pure Boost linked with presence" + }, + "pure_measure_integration": { + "name": "Pure Boost linked with indoor air quality" + }, + "pure_prime_integration": { + "name": "Pure Boost linked with outdoor air quality" + } + }, + "button": { + "reset_filter": { + "name": "Reset filter" + } + }, + "number": { + "calibration_temperature": { + "name": "Temperature calibration" + }, + "calibration_humidity": { + "name": "Humidity calibration" } }, "select": { "horizontalswing": { + "name": "Horizontal swing", "state": { - "stopped": "Stopped", + "stopped": "[%key:common::state::off%]", "fixedleft": "Fixed left", "fixedcenterleft": "Fixed center left", "fixedcenter": "Fixed center", @@ -61,10 +83,363 @@ } }, "light": { + "name": "[%key:component::light::title%]", "state": { - "on": "On", + "on": "[%key:common::state::on%]", "dim": "Dim", - "off": "Off" + "off": "[%key:common::state::off%]" + } + } + }, + "sensor": { + "filter_last_reset": { + "name": "Filter last reset" + }, + "rssi": { + "name": "RSSI" + }, + "battery_voltage": { + "name": "Battery voltage" + }, + "sensitivity": { + "name": "Pure sensitivity", + "state": { + "n": "Normal", + "s": "Sensitive" + } + }, + "timer_time": { + "name": "Timer end time" + }, + "feels_like": { + "name": "Temperature feels like" + }, + "climate_react_low": { + "name": "Climate React low temperature threshold", + "state_attributes": { + "fanlevel": { + "name": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::name%]", + "state": { + "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", + "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", + "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium_high": "Medium high", + "quiet": "Quiet" + } + }, + "horizontalswing": { + "name": "Horizontal swing", + "state": { + "stopped": "[%key:common::state::off%]", + "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]", + "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]", + "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]", + "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]", + "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]", + "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]", + "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]", + "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]" + } + }, + "light": { + "name": "[%key:component::light::title%]", + "state": { + "on": "[%key:common::state::on%]", + "dim": "[%key:component::sensibo::entity::select::light::state::dim%]", + "off": "[%key:common::state::off%]" + } + }, + "mode": { + "name": "Mode", + "state": { + "off": "[%key:common::state::off%]", + "heat": "[%key:component::climate::entity_component::_::state::heat%]", + "cool": "[%key:component::climate::entity_component::_::state::cool%]", + "heat_cool": "[%key:component::climate::entity_component::_::state::heat_cool%]", + "auto": "[%key:component::climate::entity_component::_::state::auto%]", + "dry": "[%key:component::climate::entity_component::_::state::dry%]", + "fan_only": "[%key:component::climate::entity_component::_::state::fan_only%]" + } + }, + "on": { + "name": "[%key:common::state::on%]", + "state": { + "true": "[%key:common::state::on%]", + "false": "[%key:common::state::off%]" + } + }, + "swing": { + "name": "[%key:component::climate::entity_component::_::state_attributes::swing_mode::name%]", + "state": { + "both": "[%key:component::climate::entity_component::_::state_attributes::swing_mode::state::both%]", + "fixedbottom": "Fixed bottom", + "fixedmiddle": "Fixed middle", + "fixedmiddlebottom": "Fixed middle bottom", + "fixedmiddletop": "Fixed middle top", + "fixedtop": "Fixed top", + "horizontal": "Horizontal", + "rangebottom": "Range bottom", + "rangefull": "Range full", + "rangemiddle": "Range middle", + "rangetop": "Range top", + "stopped": "[%key:common::state::off%]" + } + }, + "targettemperature": { + "name": "[%key:component::climate::entity_component::_::state_attributes::temperature::name%]" + }, + "temperatureunit": { + "name": "Temperature unit", + "state": { + "c": "Celsius", + "f": "Fahrenheit" + } + } + } + }, + "climate_react_high": { + "name": "Climate React high temperature threshold", + "state_attributes": { + "fanlevel": { + "name": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::name%]", + "state": { + "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", + "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", + "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", + "medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]", + "quiet": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::quiet%]" + } + }, + "horizontalswing": { + "name": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::name%]", + "state": { + "stopped": "[%key:common::state::off%]", + "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]", + "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]", + "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]", + "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]", + "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]", + "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]", + "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]", + "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]" + } + }, + "light": { + "name": "[%key:component::light::title%]", + "state": { + "on": "[%key:common::state::on%]", + "dim": "[%key:component::sensibo::entity::select::light::state::dim%]", + "off": "[%key:common::state::off%]" + } + }, + "mode": { + "name": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::mode::name%]", + "state": { + "off": "[%key:common::state::off%]", + "heat": "[%key:component::climate::entity_component::_::state::heat%]", + "cool": "[%key:component::climate::entity_component::_::state::cool%]", + "heat_cool": "[%key:component::climate::entity_component::_::state::heat_cool%]", + "auto": "[%key:component::climate::entity_component::_::state::auto%]", + "dry": "[%key:component::climate::entity_component::_::state::dry%]", + "fan_only": "[%key:component::climate::entity_component::_::state::fan_only%]" + } + }, + "on": { + "name": "[%key:common::state::on%]", + "state": { + "true": "[%key:common::state::on%]", + "false": "[%key:common::state::off%]" + } + }, + "swing": { + "name": "[%key:component::climate::entity_component::_::state_attributes::swing_mode::name%]", + "state": { + "both": "[%key:component::climate::entity_component::_::state_attributes::swing_mode::state::both%]", + "fixedbottom": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedbottom%]", + "fixedmiddle": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedmiddle%]", + "fixedmiddlebottom": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedmiddlebottom%]", + "fixedmiddletop": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedmiddletop%]", + "fixedtop": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedtop%]", + "horizontal": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::horizontal%]", + "rangebottom": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangebottom%]", + "rangefull": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangefull%]", + "rangemiddle": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangemiddle%]", + "rangetop": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangetop%]", + "stopped": "[%key:common::state::off%]" + } + }, + "targettemperature": { + "name": "[%key:component::climate::entity_component::_::state_attributes::temperature::name%]" + }, + "temperatureunit": { + "name": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::temperatureunit::name%]", + "state": { + "c": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::temperatureunit::state::c%]", + "f": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::temperatureunit::state::f%]" + } + } + } + }, + "smart_type": { + "name": "Climate React type", + "state": { + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "feelslike": "[%key:component::sensibo::entity::switch::climate_react_switch::state_attributes::type::state::feelslike%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]" + } + }, + "airq_tvoc": { + "name": "AirQ TVOC" + }, + "airq_co2": { + "name": "AirQ CO2" + }, + "tvoc": { + "name": "TVOC" + }, + "ethanol": { + "name": "Ethanol" + } + }, + "switch": { + "timer_on_switch": { + "name": "Timer", + "state_attributes": { + "id": { "name": "Id" }, + "turn_on": { + "name": "Turns on", + "state": { + "true": "[%key:common::state::on%]", + "false": "[%key:common::state::off%]" + } + } + } + }, + "climate_react_switch": { + "name": "Climate React", + "state_attributes": { + "type": { + "name": "Type", + "state": { + "feelslike": "Feels like", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]" + } + } + } + }, + "pure_boost_switch": { + "name": "Pure Boost" + } + }, + "update": { + "fw_ver_available": { + "name": "Update available" + } + } + }, + "services": { + "assume_state": { + "name": "Assume state", + "description": "Sets Sensibo device to external state.", + "fields": { + "state": { + "name": "State", + "description": "State to set." + } + } + }, + "enable_timer": { + "name": "Enable timer", + "description": "Enables the timer with custom time.", + "fields": { + "minutes": { + "name": "Minutes", + "description": "Countdown for timer (for timer state on)." + } + } + }, + "enable_pure_boost": { + "name": "Enable pure boost", + "description": "Enables and configures Pure Boost settings.", + "fields": { + "ac_integration": { + "name": "AC integration", + "description": "Integrate with Air Conditioner." + }, + "geo_integration": { + "name": "Geo integration", + "description": "Integrate with Presence." + }, + "indoor_integration": { + "name": "Indoor air quality", + "description": "Integrate with checking indoor air quality." + }, + "outdoor_integration": { + "name": "Outdoor air quality", + "description": "Integrate with checking outdoor air quality." + }, + "sensitivity": { + "name": "Sensitivity", + "description": "Set the sensitivity for Pure Boost." + } + } + }, + "full_state": { + "name": "Set full state", + "description": "Sets full state for Sensibo device.", + "fields": { + "mode": { + "name": "HVAC mode", + "description": "HVAC mode to set." + }, + "target_temperature": { + "name": "Target temperature", + "description": "Set target temperature." + }, + "fan_mode": { + "name": "Fan mode", + "description": "set fan mode." + }, + "swing_mode": { + "name": "Swing mode", + "description": "Set swing mode." + }, + "horizontal_swing_mode": { + "name": "Horizontal swing mode", + "description": "Set horizontal swing mode." + }, + "light": { + "name": "Light", + "description": "Set light on or off." + } + } + }, + "enable_climate_react": { + "name": "Enable climate react", + "description": "Enables and configures climate react.", + "fields": { + "high_temperature_threshold": { + "name": "Threshold high", + "description": "When temp/humidity goes above." + }, + "high_temperature_state": { + "name": "State high threshold", + "description": "What should happen at high threshold. Requires full state." + }, + "low_temperature_threshold": { + "name": "Threshold low", + "description": "When temp/humidity goes below." + }, + "low_temperature_state": { + "name": "State low threshold", + "description": "What should happen at low threshold. Requires full state." + }, + "smart_type": { + "name": "Trigger type", + "description": "Choose between temperature/feels like/humidity." } } } diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index cce72dfaae6..204ed622f13 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -45,8 +45,8 @@ class SensiboDeviceSwitchEntityDescription( DEVICE_SWITCH_TYPES: tuple[SensiboDeviceSwitchEntityDescription, ...] = ( SensiboDeviceSwitchEntityDescription( key="timer_on_switch", + translation_key="timer_on_switch", device_class=SwitchDeviceClass.SWITCH, - name="Timer", icon="mdi:timer", value_fn=lambda data: data.timer_on, extra_fn=lambda data: {"id": data.timer_id, "turn_on": data.timer_state_on}, @@ -56,8 +56,8 @@ DEVICE_SWITCH_TYPES: tuple[SensiboDeviceSwitchEntityDescription, ...] = ( ), SensiboDeviceSwitchEntityDescription( key="climate_react_switch", + translation_key="climate_react_switch", device_class=SwitchDeviceClass.SWITCH, - name="Climate React", icon="mdi:wizard-hat", value_fn=lambda data: data.smart_on, extra_fn=lambda data: {"type": data.smart_type}, @@ -70,8 +70,8 @@ DEVICE_SWITCH_TYPES: tuple[SensiboDeviceSwitchEntityDescription, ...] = ( PURE_SWITCH_TYPES: tuple[SensiboDeviceSwitchEntityDescription, ...] = ( SensiboDeviceSwitchEntityDescription( key="pure_boost_switch", + translation_key="pure_boost_switch", device_class=SwitchDeviceClass.SWITCH, - name="Pure Boost", value_fn=lambda data: data.pure_boost_enabled, extra_fn=None, command_on="async_turn_on_off_pure_boost", diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 4cfb6058740..46b9b860ca6 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -41,9 +41,9 @@ class SensiboDeviceUpdateEntityDescription( DEVICE_SENSOR_TYPES: tuple[SensiboDeviceUpdateEntityDescription, ...] = ( SensiboDeviceUpdateEntityDescription( key="fw_ver_available", + translation_key="fw_ver_available", device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.DIAGNOSTIC, - name="Update available", icon="mdi:rocket-launch", value_version=lambda data: data.fw_ver, value_available=lambda data: data.fw_ver_available, diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index ad09a1b5fdb..cbdaa24ec83 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -10,9 +10,8 @@ from decimal import Decimal, InvalidOperation as DecimalInvalidOperation import logging from math import ceil, floor, log10 import re -from typing import Any, Final, cast, final - -from typing_extensions import Self +import sys +from typing import Any, Final, Self, cast, final from homeassistant.config_entries import ConfigEntry @@ -92,6 +91,8 @@ ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" NEGATIVE_ZERO_PATTERN = re.compile(r"^-(0\.?0*)$") +PY_311 = sys.version_info >= (3, 11, 0) + SCAN_INTERVAL: Final = timedelta(seconds=30) __all__ = [ @@ -450,7 +451,7 @@ class SensorEntity(Entity): return self._sensor_option_unit_of_measurement # Second priority, for non registered entities: unit suggested by integration - if not self.unique_id and ( + if not self.registry_entry and ( suggested_unit_of_measurement := self.suggested_unit_of_measurement ): return suggested_unit_of_measurement @@ -601,14 +602,11 @@ class SensorEntity(Entity): else: numerical_value = value - if ( - native_unit_of_measurement != unit_of_measurement - and device_class in UNIT_CONVERTERS + if native_unit_of_measurement != unit_of_measurement and ( + converter := UNIT_CONVERTERS.get(device_class) ): # Unit conversion needed - converter = UNIT_CONVERTERS[device_class] - - converted_numerical_value = UNIT_CONVERTERS[device_class].convert( + converted_numerical_value = converter.convert( float(numerical_value), native_unit_of_measurement, unit_of_measurement, @@ -638,10 +636,12 @@ class SensorEntity(Entity): ) precision = precision + floor(ratio_log) - value = f"{converted_numerical_value:.{precision}f}" - # This can be replaced with adding the z option when we drop support for - # Python 3.10 - value = NEGATIVE_ZERO_PATTERN.sub(r"\1", value) + if PY_311: + value = f"{converted_numerical_value:z.{precision}f}" + else: + value = f"{converted_numerical_value:.{precision}f}" + if value.startswith("-0") and NEGATIVE_ZERO_PATTERN.match(value): + value = value[1:] else: value = converted_numerical_value @@ -883,29 +883,31 @@ def async_update_suggested_units(hass: HomeAssistant) -> None: ) +def _display_precision(hass: HomeAssistant, entity_id: str) -> int | None: + """Return the display precision.""" + if not (entry := er.async_get(hass).async_get(entity_id)) or not ( + sensor_options := entry.options.get(DOMAIN) + ): + return None + if (display_precision := sensor_options.get("display_precision")) is not None: + return cast(int, display_precision) + return sensor_options.get("suggested_display_precision") + + @callback def async_rounded_state(hass: HomeAssistant, entity_id: str, state: State) -> str: """Return the state rounded for presentation.""" - - def display_precision() -> int | None: - """Return the display precision.""" - if not (entry := er.async_get(hass).async_get(entity_id)) or not ( - sensor_options := entry.options.get(DOMAIN) - ): - return None - if (display_precision := sensor_options.get("display_precision")) is not None: - return cast(int, display_precision) - return sensor_options.get("suggested_display_precision") - value = state.state - if (precision := display_precision()) is None: + if (precision := _display_precision(hass, entity_id)) is None: return value with suppress(TypeError, ValueError): numerical_value = float(value) - value = f"{numerical_value:.{precision}f}" - # This can be replaced with adding the z option when we drop support for - # Python 3.10 - value = NEGATIVE_ZERO_PATTERN.sub(r"\1", value) + if PY_311: + value = f"{numerical_value:z.{precision}f}" + else: + value = f"{numerical_value:.{precision}f}" + if value.startswith("-0") and NEGATIVE_ZERO_PATTERN.match(value): + value = value[1:] return value diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 17155912e48..139725ee1ab 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -1,11 +1,11 @@ """Constants for sensor.""" from __future__ import annotations +from enum import StrEnum from typing import Final import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -73,12 +73,6 @@ class SensorDeviceClass(StrEnum): ISO8601 format: https://en.wikipedia.org/wiki/ISO_8601 """ - DURATION = "duration" - """Fixed duration. - - Unit of measurement: `d`, `h`, `min`, `s`, `ms` - """ - ENUM = "enum" """Enumeration. @@ -158,6 +152,12 @@ class SensorDeviceClass(StrEnum): - USCS / imperial: `in`, `ft`, `yd`, `mi` """ + DURATION = "duration" + """Fixed duration. + + Unit of measurement: `d`, `h`, `min`, `s`, `ms` + """ + ENERGY = "energy" """Energy. @@ -247,8 +247,14 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `µg/m³` """ + PH = "ph" + """Potential hydrogen (acidity/alkalinity). + + Unit of measurement: Unitless + """ + PM1 = "pm1" - """Particulate matter <= 0.1 μm. + """Particulate matter <= 1 μm. Unit of measurement: `µg/m³` """ @@ -509,6 +515,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.NITROGEN_MONOXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.NITROUS_OXIDE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.OZONE: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, + SensorDeviceClass.PH: {None}, SensorDeviceClass.PM1: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.PM10: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, SensorDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, @@ -576,6 +583,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.NITROGEN_MONOXIDE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.NITROUS_OXIDE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.OZONE: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.PH: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.PM1: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.PM10: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.PM25: {SensorStateClass.MEASUREMENT}, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 7d6c57de296..b12cdb570eb 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -57,6 +57,7 @@ CONF_IS_NITROGEN_DIOXIDE = "is_nitrogen_dioxide" CONF_IS_NITROGEN_MONOXIDE = "is_nitrogen_monoxide" CONF_IS_NITROUS_OXIDE = "is_nitrous_oxide" CONF_IS_OZONE = "is_ozone" +CONF_IS_PH = "is_ph" CONF_IS_PM1 = "is_pm1" CONF_IS_PM10 = "is_pm10" CONF_IS_PM25 = "is_pm25" @@ -107,6 +108,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.OZONE: [{CONF_TYPE: CONF_IS_OZONE}], SensorDeviceClass.POWER: [{CONF_TYPE: CONF_IS_POWER}], SensorDeviceClass.POWER_FACTOR: [{CONF_TYPE: CONF_IS_POWER_FACTOR}], + SensorDeviceClass.PH: [{CONF_TYPE: CONF_IS_PH}], SensorDeviceClass.PM1: [{CONF_TYPE: CONF_IS_PM1}], SensorDeviceClass.PM10: [{CONF_TYPE: CONF_IS_PM10}], SensorDeviceClass.PM25: [{CONF_TYPE: CONF_IS_PM25}], @@ -167,6 +169,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_OZONE, CONF_IS_POWER, CONF_IS_POWER_FACTOR, + CONF_IS_PH, CONF_IS_PM1, CONF_IS_PM10, CONF_IS_PM25, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 1bb41eb2d30..1c0da89692b 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -56,6 +56,7 @@ CONF_NITROGEN_DIOXIDE = "nitrogen_dioxide" CONF_NITROGEN_MONOXIDE = "nitrogen_monoxide" CONF_NITROUS_OXIDE = "nitrous_oxide" CONF_OZONE = "ozone" +CONF_PH = "ph" CONF_PM1 = "pm1" CONF_PM10 = "pm10" CONF_PM25 = "pm25" @@ -104,6 +105,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.NITROGEN_MONOXIDE: [{CONF_TYPE: CONF_NITROGEN_MONOXIDE}], SensorDeviceClass.NITROUS_OXIDE: [{CONF_TYPE: CONF_NITROUS_OXIDE}], SensorDeviceClass.OZONE: [{CONF_TYPE: CONF_OZONE}], + SensorDeviceClass.PH: [{CONF_TYPE: CONF_PH}], SensorDeviceClass.PM1: [{CONF_TYPE: CONF_PM1}], SensorDeviceClass.PM10: [{CONF_TYPE: CONF_PM10}], SensorDeviceClass.PM25: [{CONF_TYPE: CONF_PM25}], @@ -165,6 +167,7 @@ TRIGGER_SCHEMA = vol.All( CONF_NITROGEN_MONOXIDE, CONF_NITROUS_OXIDE, CONF_OZONE, + CONF_PH, CONF_PM1, CONF_PM10, CONF_PM25, diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index f9fdc252537..2b75c1114ce 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Iterable, MutableMapping +from collections.abc import Callable, Iterable, MutableMapping import datetime import itertools import logging @@ -224,6 +224,8 @@ def _normalize_states( converter = statistics.STATISTIC_UNIT_TO_UNIT_CONVERTER[statistics_unit] valid_fstates: list[tuple[float, State]] = [] + convert: Callable[[float], float] + last_unit: str | None | object = object() for fstate, state in fstates: state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -247,15 +249,13 @@ def _normalize_states( LINK_DEV_STATISTICS, ) continue + if state_unit != last_unit: + # The unit of measurement has changed since the last state change + # recreate the converter factory + convert = converter.converter_factory(state_unit, statistics_unit) + last_unit = state_unit - valid_fstates.append( - ( - converter.convert( - fstate, from_unit=state_unit, to_unit=statistics_unit - ), - state, - ) - ) + valid_fstates.append((convert(fstate), state)) return statistics_unit, valid_fstates diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index d3dbbc678b0..1db5e4c8cfd 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -25,6 +25,7 @@ "is_nitrogen_monoxide": "Current {entity_name} nitrogen monoxide concentration level", "is_nitrous_oxide": "Current {entity_name} nitrous oxide concentration level", "is_ozone": "Current {entity_name} ozone concentration level", + "is_ph": "Current {entity_name} pH level", "is_pm1": "Current {entity_name} PM1 concentration level", "is_pm10": "Current {entity_name} PM10 concentration level", "is_pm25": "Current {entity_name} PM2.5 concentration level", @@ -72,6 +73,7 @@ "nitrogen_monoxide": "{entity_name} nitrogen monoxide concentration changes", "nitrous_oxide": "{entity_name} nitrous oxide concentration changes", "ozone": "{entity_name} ozone concentration changes", + "ph": "{entity_name} pH level changes", "pm1": "{entity_name} PM1 concentration changes", "pm10": "{entity_name} PM10 concentration changes", "pm25": "{entity_name} PM2.5 concentration changes", @@ -198,6 +200,9 @@ "ozone": { "name": "Ozone" }, + "ph": { + "name": "pH" + }, "pm1": { "name": "PM1" }, @@ -267,11 +272,5 @@ "wind_speed": { "name": "Wind speed" } - }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" - } } } diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 336c1cbc7ef..149e503d0f8 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.25.1"] + "requirements": ["sentry-sdk==1.28.1"] } diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py index f52a94d2c9a..0c49368001d 100644 --- a/homeassistant/components/senz/climate.py +++ b/homeassistant/components/senz/climate.py @@ -43,6 +43,8 @@ class SENZClimate(CoordinatorEntity, ClimateEntity): _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_max_temp = 35 _attr_min_temp = 5 + _attr_has_entity_name = True + _attr_name = None def __init__( self, @@ -52,7 +54,6 @@ class SENZClimate(CoordinatorEntity, ClimateEntity): """Init SENZ climate.""" super().__init__(coordinator) self._thermostat = thermostat - self._attr_name = thermostat.name self._attr_unique_id = thermostat.serial_number self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, thermostat.serial_number)}, diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index e9b2e9e2e9c..ed8638d8419 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", - "requirements": ["Pillow==9.5.0"] + "requirements": ["Pillow==10.0.0"] } diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index e4d41fb0cb8..9e8201bc1b5 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -112,7 +113,9 @@ class SFRBoxBinarySensor( self._attr_unique_id = ( f"{system_info.mac_addr}_{coordinator.name}_{description.key}" ) - self._attr_device_info = {"identifiers": {(DOMAIN, system_info.mac_addr)}} + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, system_info.mac_addr)}, + ) @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py index f6741da1398..13a1563034f 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -19,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -66,7 +67,6 @@ BUTTON_TYPES: tuple[SFRBoxButtonEntityDescription, ...] = ( device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, key="system_reboot", - translation_key="reboot", ), ) @@ -100,7 +100,9 @@ class SFRBoxButton(ButtonEntity): self.entity_description = description self._box = box self._attr_unique_id = f"{system_info.mac_addr}_{description.key}" - self._attr_device_info = {"identifiers": {(DOMAIN, system_info.mac_addr)}} + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, system_info.mac_addr)}, + ) @with_error_wrapping async def async_press(self) -> None: diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index 19512f43821..c01d298daff 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -20,6 +20,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -179,7 +180,6 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, - translation_key="voltage", value_fn=lambda x: x.alimvoltage, ), SFRBoxSensorEntityDescription[SystemInfo]( @@ -188,7 +188,6 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - translation_key="temperature", value_fn=lambda x: x.temperature / 1000, ), ) @@ -252,7 +251,9 @@ class SFRBoxSensor(CoordinatorEntity[SFRDataUpdateCoordinator[_T]], SensorEntity self._attr_unique_id = ( f"{system_info.mac_addr}_{coordinator.name}_{description.key}" ) - self._attr_device_info = {"identifiers": {(DOMAIN, system_info.mac_addr)}} + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, system_info.mac_addr)}, + ) @property def native_value(self) -> StateType: diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index cf74e9eb656..7ea18304164 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -42,11 +42,6 @@ "name": "WAN status" } }, - "button": { - "reboot": { - "name": "[%key:component::button::entity_component::restart::name%]" - } - }, "sensor": { "dsl_attenuation_down": { "name": "DSL attenuation down" @@ -89,7 +84,7 @@ "dsl_training": { "name": "DSL training", "state": { - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "g_994_training": "G.994 Training", "g_992_started": "G.992 Started", "g_922_channel_analysis": "G.922 Channel Analysis", @@ -110,12 +105,6 @@ "unknown": "Unknown" } }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, - "voltage": { - "name": "[%key:component::sensor::entity_component::voltage::name%]" - }, "wan_mode": { "name": "WAN mode", "state": { diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 9121811af3c..ca24212a96c 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -77,7 +77,6 @@ class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuum | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START | VacuumEntityFeature.STATE - | VacuumEntityFeature.STATUS | VacuumEntityFeature.STOP | VacuumEntityFeature.LOCATE ) diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index 0cc979a321f..8430d7284ee 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -9,10 +9,16 @@ import shlex import async_timeout import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.typing import ConfigType +from homeassistant.util.json import JsonObjectType DOMAIN = "shell_command" @@ -31,7 +37,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cache: dict[str, tuple[str, str | None, template.Template | None]] = {} - async def async_service_handler(service: ServiceCall) -> None: + async def async_service_handler(service: ServiceCall) -> ServiceResponse: """Execute a shell command service.""" cmd = conf[service.service] @@ -54,7 +60,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) except TemplateError as ex: _LOGGER.exception("Error rendering command template: %s", ex) - return + raise else: rendered_args = None @@ -86,7 +92,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async with async_timeout.timeout(COMMAND_TIMEOUT): stdout_data, stderr_data = await process.communicate() except asyncio.TimeoutError: - _LOGGER.exception( + _LOGGER.error( "Timed out running command: `%s`, after: %ss", cmd, COMMAND_TIMEOUT ) if process: @@ -97,9 +103,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: process._transport.close() # type: ignore[attr-defined] del process - return + raise + + service_response: JsonObjectType = { + "stdout": "", + "stderr": "", + "returncode": process.returncode, + } if stdout_data: + service_response["stdout"] = stdout_data.decode("utf-8").strip() _LOGGER.debug( "Stdout of command: `%s`, return code: %s:\n%s", cmd, @@ -107,6 +120,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: stdout_data, ) if stderr_data: + service_response["stderr"] = stderr_data.decode("utf-8").strip() _LOGGER.debug( "Stderr of command: `%s`, return code: %s:\n%s", cmd, @@ -118,6 +132,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "Error running command: `%s`, return code: %s", cmd, process.returncode ) + return service_response + for name in conf: - hass.services.async_register(DOMAIN, name, async_service_handler) + hass.services.async_register( + DOMAIN, + name, + async_service_handler, + supports_response=SupportsResponse.OPTIONAL, + ) return True diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 69959453a78..e5e90bf19af 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import ( @@ -30,6 +31,7 @@ from .const import ( DEFAULT_COAP_PORT, DOMAIN, LOGGER, + PUSH_UPDATE_ISSUE_ID, ) from .coordinator import ( ShellyBlockCoordinator, @@ -143,7 +145,6 @@ async def _async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> b device_entry = None if entry.unique_id is not None: device_entry = dev_reg.async_get_device( - identifiers=set(), connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, ) # https://github.com/home-assistant/core/pull/48076 @@ -227,7 +228,6 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo device_entry = None if entry.unique_id is not None: device_entry = dev_reg.async_get_device( - identifiers=set(), connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, ) # https://github.com/home-assistant/core/pull/48076 @@ -325,6 +325,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok + # delete push update issue if it exists + LOGGER.debug( + "Deleting issue %s", PUSH_UPDATE_ISSUE_ID.format(unique=entry.unique_id) + ) + ir.async_delete_issue( + hass, DOMAIN, PUSH_UPDATE_ISSUE_ID.format(unique=entry.unique_id) + ) + platforms = BLOCK_SLEEPING_PLATFORMS if not entry.data.get(CONF_SLEEP_PERIOD): diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 1474906cacb..a5889cd11a7 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -164,6 +164,14 @@ RPC_SENSORS: Final = { entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), + "external_power": RpcBinarySensorDescription( + key="devicepower:0", + sub_key="external", + name="External power", + value=lambda status, _: status["present"], + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), "overtemp": RpcBinarySensorDescription( key="switch", sub_key="errors", diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 6cd4c19c638..04c211a98cb 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -20,6 +20,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -33,7 +34,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.unit_conversion import TemperatureConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .const import LOGGER, SHTRV_01_TEMPERATURE_SETTINGS +from .const import ( + DOMAIN, + LOGGER, + NOT_CALIBRATED_ISSUE_ID, + SHTRV_01_TEMPERATURE_SETTINGS, +) from .coordinator import ShellyBlockCoordinator, get_entry_data @@ -210,7 +216,7 @@ class BlockSleepingClimate( """Device availability.""" if self.device_block is not None: return not cast(bool, self.device_block.valveError) - return self.coordinator.last_update_success + return super().available @property def hvac_mode(self) -> HVACMode: @@ -254,7 +260,9 @@ class BlockSleepingClimate( @property def device_info(self) -> DeviceInfo: """Device info.""" - return {"connections": {(CONNECTION_NETWORK_MAC, self.coordinator.mac)}} + return DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self.coordinator.mac)}, + ) def _check_is_off(self) -> bool: """Return if valve is off or on.""" @@ -337,6 +345,27 @@ class BlockSleepingClimate( self.async_write_ha_state() return + if self.coordinator.device.status.get("calibrated") is False: + ir.async_create_issue( + self.hass, + DOMAIN, + NOT_CALIBRATED_ISSUE_ID.format(unique=self.coordinator.mac), + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.ERROR, + translation_key="device_not_calibrated", + translation_placeholders={ + "device_name": self.name, + "ip_address": self.coordinator.device.ip_address, + }, + ) + else: + ir.async_delete_issue( + self.hass, + DOMAIN, + NOT_CALIBRATED_ISSUE_ID.format(unique=self.coordinator.mac), + ) + assert self.coordinator.device.blocks for block in self.coordinator.device.blocks: diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 7aa86af1e9a..cc82f0ad700 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -1,14 +1,13 @@ """Constants for the Shelly integration.""" from __future__ import annotations +from enum import StrEnum from logging import Logger, getLogger import re from typing import Final from awesomeversion import AwesomeVersion -from homeassistant.backports.enum import StrEnum - DOMAIN: Final = "shelly" LOGGER: Logger = getLogger(__package__) @@ -174,3 +173,9 @@ class BLEScannerMode(StrEnum): DISABLED = "disabled" ACTIVE = "active" PASSIVE = "passive" + + +MAX_PUSH_UPDATE_FAILURES = 5 +PUSH_UPDATE_ISSUE_ID = "push_update_{unique}" + +NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 6d7b3496880..0d4a091b729 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -17,6 +17,7 @@ from awesomeversion import AwesomeVersion from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, @@ -41,7 +42,9 @@ from .const import ( EVENT_SHELLY_CLICK, INPUTS_EVENTS_DICT, LOGGER, + MAX_PUSH_UPDATE_FAILURES, MODELS_SUPPORTING_LIGHT_EFFECTS, + PUSH_UPDATE_ISSUE_ID, REST_SENSORS_UPDATE_INTERVAL, RPC_INPUTS_EVENTS_TYPES, RPC_RECONNECT_INTERVAL, @@ -162,6 +165,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): self._last_effect: int | None = None self._last_input_events_count: dict = {} self._last_target_temp: float | None = None + self._push_update_failures: int = 0 entry.async_on_unload( self.async_add_listener(self._async_device_updates_handler) @@ -270,6 +274,25 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): except InvalidAuthError: self.entry.async_start_reauth(self.hass) else: + self._push_update_failures += 1 + if self._push_update_failures > MAX_PUSH_UPDATE_FAILURES: + LOGGER.debug( + "Creating issue %s", PUSH_UPDATE_ISSUE_ID.format(unique=self.mac) + ) + ir.async_create_issue( + self.hass, + DOMAIN, + PUSH_UPDATE_ISSUE_ID.format(unique=self.mac), + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.ERROR, + learn_more_url="https://www.home-assistant.io/integrations/shelly/#shelly-device-configuration-generation-1", + translation_key="push_update_failure", + translation_placeholders={ + "device_name": self.entry.title, + "ip_address": self.device.ip_address, + }, + ) device_update_info(self.hass, self.device, self.entry) def async_setup(self) -> None: diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 9f95ea5ac21..548428c444c 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -332,11 +332,6 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): ) self._attr_unique_id = f"{coordinator.mac}-{block.description}" - @property - def available(self) -> bool: - """Available.""" - return self.coordinator.last_update_success - async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) @@ -375,11 +370,6 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) - @property - def available(self) -> bool: - """Available.""" - return self.coordinator.last_update_success - @property def status(self) -> dict: """Device status by entity key.""" diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index 57df5d1ab0a..d55ffe0fd28 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -5,8 +5,7 @@ from collections.abc import Callable from homeassistant.components.logbook import LOGBOOK_ENTRY_MESSAGE, LOGBOOK_ENTRY_NAME from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.typing import EventType +from homeassistant.core import Event, HomeAssistant, callback from .const import ( ATTR_CHANNEL, @@ -27,12 +26,12 @@ from .utils import get_rpc_entity_name @callback def async_describe_events( hass: HomeAssistant, - async_describe_event: Callable[[str, str, Callable[[EventType], dict]], None], + async_describe_event: Callable[[str, str, Callable[[Event], dict]], None], ) -> None: """Describe logbook events.""" @callback - def async_describe_shelly_click_event(event: EventType) -> dict[str, str]: + def async_describe_shelly_click_event(event: Event) -> dict[str, str]: """Describe shelly.click logbook event (block device).""" device_id = event.data[ATTR_DEVICE_ID] click_type = event.data[ATTR_CLICK_TYPE] diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 0260a540f0c..b52e176b521 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -27,6 +27,7 @@ from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, + UnitOfFrequency, UnitOfPower, UnitOfTemperature, ) @@ -336,6 +337,14 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), + "power_pm1": RpcSensorDescription( + key="pm1", + sub_key="apower", + name="Power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), "a_act_power": RpcSensorDescription( key="em", sub_key="a_act_power", @@ -360,6 +369,14 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), + "total_act_power": RpcSensorDescription( + key="em", + sub_key="total_act_power", + name="Total active power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), "a_aprt_power": RpcSensorDescription( key="em", sub_key="a_aprt_power", @@ -384,6 +401,14 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, ), + "total_aprt_power": RpcSensorDescription( + key="em", + sub_key="total_aprt_power", + name="Total apparent power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + ), "a_pf": RpcSensorDescription( key="em", sub_key="a_pf", @@ -416,6 +441,17 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "voltage_pm1": RpcSensorDescription( + key="pm1", + sub_key="voltage", + name="Voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "a_voltage": RpcSensorDescription( key="em", sub_key="a_voltage", @@ -453,6 +489,16 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "current_pm1": RpcSensorDescription( + key="pm1", + sub_key="current", + name="Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value=lambda status, _: None if status is None else float(status), + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "a_current": RpcSensorDescription( key="em", sub_key="a_current", @@ -480,6 +526,25 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "n_current": RpcSensorDescription( + key="em", + sub_key="n_current", + name="Phase N current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + available=lambda status: status["n_current"] is not None, + entity_registry_enabled_default=False, + ), + "total_current": RpcSensorDescription( + key="em", + sub_key="total_current", + name="Total current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "energy": RpcSensorDescription( key="switch", sub_key="aenergy", @@ -491,6 +556,17 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "energy_pm1": RpcSensorDescription( + key="pm1", + sub_key="aenergy", + name="Energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: status["total"], + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), "total_act": RpcSensorDescription( key="emdata", sub_key="total_act", @@ -585,6 +661,56 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), + "freq": RpcSensorDescription( + key="switch", + sub_key="freq", + name="Frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + suggested_display_precision=0, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "freq_pm1": RpcSensorDescription( + key="pm1", + sub_key="freq", + name="Frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + suggested_display_precision=0, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "a_freq": RpcSensorDescription( + key="em", + sub_key="a_freq", + name="Phase A frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + suggested_display_precision=0, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "b_freq": RpcSensorDescription( + key="em", + sub_key="b_freq", + name="Phase B frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + suggested_display_precision=0, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + "c_freq": RpcSensorDescription( + key="em", + sub_key="c_freq", + name="Phase C frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + suggested_display_precision=0, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "illuminance": RpcSensorDescription( key="illuminance", sub_key="lux", diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 265184e6227..6ff48f5b85b 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -76,8 +76,8 @@ "selector": { "ble_scanner_mode": { "options": { - "disabled": "Disabled", - "active": "Active", + "disabled": "[%key:common::state::disabled%]", + "active": "[%key:common::state::active%]", "passive": "Passive" } } @@ -118,5 +118,15 @@ } } } + }, + "issues": { + "device_not_calibrated": { + "title": "Shelly device {device_name} is not calibrated", + "description": "Shelly device {device_name} with IP address {ip_address} requires calibration. To calibrate the device, it must be rebooted after proper installation on the valve. You can reboot the device in its web panel, go to 'Settings' > 'Device Reboot'." + }, + "push_update_failure": { + "title": "Shelly device {device_name} push update failure", + "description": "Home Assistant is not receiving push updates from the Shelly device {device_name} with IP address {ip_address}. Check the CoIoT configuration in the web panel of the device and your network configuration." + } } } diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 03df3da346b..a66b77ed94b 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -12,7 +12,7 @@ from aioshelly.rpc_device import RpcDevice, WsServer from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import singleton from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, @@ -20,7 +20,6 @@ from homeassistant.helpers.device_registry import ( format_mac, ) from homeassistant.helpers.entity_registry import async_get as er_async_get -from homeassistant.helpers.typing import EventType from homeassistant.util.dt import utcnow from .const import ( @@ -211,7 +210,7 @@ async def get_coap_context(hass: HomeAssistant) -> COAP: await context.initialize(port) @callback - def shutdown_listener(ev: EventType) -> None: + def shutdown_listener(ev: Event) -> None: context.close() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_listener) diff --git a/homeassistant/components/shopping_list/intent.py b/homeassistant/components/shopping_list/intent.py index c709322e0b7..d6a29eb73f3 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -29,7 +29,6 @@ class AddItemIntent(intent.IntentHandler): await intent_obj.hass.data[DOMAIN].async_add(item) response = intent_obj.create_response() - response.async_set_speech(f"I've added {item} to your shopping list") intent_obj.hass.bus.async_fire(EVENT_SHOPPING_LIST_UPDATED) return response diff --git a/homeassistant/components/shopping_list/services.yaml b/homeassistant/components/shopping_list/services.yaml index 250912f49cd..402a6c24aeb 100644 --- a/homeassistant/components/shopping_list/services.yaml +++ b/homeassistant/components/shopping_list/services.yaml @@ -1,69 +1,41 @@ add_item: - name: Add item - description: Add an item to the shopping list. fields: name: - name: Name - description: The name of the item to add. required: true example: Beer selector: text: remove_item: - name: Remove item - description: Remove the first item with matching name from the shopping list. fields: name: - name: Name - description: The name of the item to remove. required: true example: Beer selector: text: complete_item: - name: Complete item - description: Mark the first item with matching name as completed in the shopping list. fields: name: - name: Name - description: The name of the item to mark as completed (without removing). required: true example: Beer selector: text: incomplete_item: - name: Incomplete item - description: Mark the first item with matching name as incomplete in the shopping list. fields: name: - description: The name of the item to mark as incomplete. example: Beer required: true selector: text: complete_all: - name: Complete all - description: Mark all items as completed in the shopping list (without removing them from the list). - incomplete_all: - name: Incomplete all - description: Mark all items as incomplete in the shopping list. - clear_completed_items: - name: Clear completed items - description: Clear completed items from the shopping list. - sort: - name: Sort all items - description: Sort all items by name in the shopping list. fields: reverse: - name: Sort reverse - description: Whether to sort in reverse (descending) order. default: false selector: boolean: diff --git a/homeassistant/components/shopping_list/strings.json b/homeassistant/components/shopping_list/strings.json index 5b8197177a0..ddac4713fac 100644 --- a/homeassistant/components/shopping_list/strings.json +++ b/homeassistant/components/shopping_list/strings.json @@ -3,12 +3,76 @@ "config": { "step": { "user": { - "title": "Shopping List", + "title": "[%key:component::shopping_list::title%]", "description": "Do you want to configure the shopping list?" } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "services": { + "add_item": { + "name": "Add item", + "description": "Adds an item to the shopping list.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "The name of the item to add." + } + } + }, + "remove_item": { + "name": "Remove item", + "description": "Removes the first item with matching name from the shopping list.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "The name of the item to remove." + } + } + }, + "complete_item": { + "name": "Complete item", + "description": "Marks the first item with matching name as completed in the shopping list.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "The name of the item to mark as completed (without removing)." + } + } + }, + "incomplete_item": { + "name": "Incomplete item", + "description": "Marks the first item with matching name as incomplete in the shopping list.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "The name of the item to mark as incomplete." + } + } + }, + "complete_all": { + "name": "Complete all", + "description": "Marks all items as completed in the shopping list (without removing them from the list)." + }, + "incomplete_all": { + "name": "Incomplete all", + "description": "Marks all items as incomplete in the shopping list." + }, + "clear_completed_items": { + "name": "Clear completed items", + "description": "Clears completed items from the shopping list." + }, + "sort": { + "name": "Sort all items", + "description": "Sorts all items by name in the shopping list.", + "fields": { + "reverse": { + "name": "Sort reverse", + "description": "Whether to sort in reverse (descending) order." + } + } + } } } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 2fdf15a4a10..33080a9c1a2 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sighthound", "iot_class": "cloud_polling", "loggers": ["simplehound"], - "requirements": ["Pillow==9.5.0", "simplehound==0.3"] + "requirements": ["Pillow==10.0.0", "simplehound==0.3"] } diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 17fc6f3cc4d..dec1b35d346 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -268,7 +268,7 @@ def _async_register_base_station( # Check for an old system ID format and remove it: if old_base_station := device_registry.async_get_device( - {(DOMAIN, system.system_id)} # type: ignore[arg-type] + identifiers={(DOMAIN, system.system_id)} # type: ignore[arg-type] ): # Update the new base station with any properties the user might have configured # on the old base station: diff --git a/homeassistant/components/simplisafe/services.yaml b/homeassistant/components/simplisafe/services.yaml index 8aeefcf7846..de4d8fbe534 100644 --- a/homeassistant/components/simplisafe/services.yaml +++ b/homeassistant/components/simplisafe/services.yaml @@ -1,11 +1,7 @@ # Describes the format for available SimpliSafe services remove_pin: - name: Remove PIN - description: Remove a PIN by its label or value. fields: device_id: - name: System - description: The system to remove the PIN from required: true selector: device: @@ -13,19 +9,13 @@ remove_pin: entity: domain: alarm_control_panel label_or_pin: - name: Label/PIN - description: The label/value to remove. required: true example: Test PIN selector: text: set_pin: - name: Set PIN - description: Set/update a PIN fields: device_id: - name: System - description: The system to set the PIN on required: true selector: device: @@ -33,26 +23,18 @@ set_pin: entity: domain: alarm_control_panel label: - name: Label - description: The label of the PIN required: true example: Test PIN selector: text: pin: - name: PIN - description: The value of the PIN required: true example: 1256 selector: text: set_system_properties: - name: Set system properties - description: Set one or more system properties fields: device_id: - name: System - description: The system whose properties should be set required: true selector: device: @@ -60,16 +42,12 @@ set_system_properties: entity: domain: alarm_control_panel alarm_duration: - name: Alarm duration - description: The length of a triggered alarm selector: number: min: 30 max: 480 unit_of_measurement: seconds alarm_volume: - name: Alarm volume - description: The volume level of a triggered alarm selector: select: options: @@ -78,8 +56,6 @@ set_system_properties: - "high" - "off" chime_volume: - name: Chime volume - description: The volume level of the door chime selector: select: options: @@ -88,45 +64,33 @@ set_system_properties: - "high" - "off" entry_delay_away: - name: Entry delay away - description: How long to delay when entering while "away" selector: number: min: 30 max: 255 unit_of_measurement: seconds entry_delay_home: - name: Entry delay home - description: How long to delay when entering while "home" selector: number: min: 0 max: 255 unit_of_measurement: seconds exit_delay_away: - name: Exit delay away - description: How long to delay when exiting while "away" selector: number: min: 45 max: 255 unit_of_measurement: seconds exit_delay_home: - name: Exit delay home - description: How long to delay when exiting while "home" selector: number: min: 0 max: 255 unit_of_measurement: seconds light: - name: Light - description: Whether the armed light should be visible selector: boolean: voice_prompt_volume: - name: Voice prompt volume - description: The volume level of the voice prompt selector: select: options: diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json index 4f230442f85..99216035080 100644 --- a/homeassistant/components/simplisafe/strings.json +++ b/homeassistant/components/simplisafe/strings.json @@ -36,5 +36,85 @@ "name": "Clear notifications" } } + }, + "services": { + "remove_pin": { + "name": "Remove PIN", + "description": "Removes a PIN by its label or value.", + "fields": { + "device_id": { + "name": "System", + "description": "The system to remove the PIN from." + }, + "label_or_pin": { + "name": "Label/PIN", + "description": "The label/value to remove." + } + } + }, + "set_pin": { + "name": "Set PIN", + "description": "Sets/updates a PIN.", + "fields": { + "device_id": { + "name": "[%key:component::simplisafe::services::remove_pin::fields::device_id::name%]", + "description": "The system to set the PIN on." + }, + "label": { + "name": "Label", + "description": "The label of the PIN." + }, + "pin": { + "name": "PIN", + "description": "The value of the PIN." + } + } + }, + "set_system_properties": { + "name": "Set system properties", + "description": "Sets one or more system properties.", + "fields": { + "device_id": { + "name": "[%key:component::simplisafe::services::remove_pin::fields::device_id::name%]", + "description": "The system whose properties should be set." + }, + "alarm_duration": { + "name": "Alarm duration", + "description": "The length of a triggered alarm." + }, + "alarm_volume": { + "name": "Alarm volume", + "description": "The volume level of a triggered alarm." + }, + "chime_volume": { + "name": "Chime volume", + "description": "The volume level of the door chime." + }, + "entry_delay_away": { + "name": "Entry delay away", + "description": "How long to delay when entering while \"away\"." + }, + "entry_delay_home": { + "name": "Entry delay home", + "description": "How long to delay when entering while \"home\"." + }, + "exit_delay_away": { + "name": "Exit delay away", + "description": "How long to delay when exiting while \"away\"." + }, + "exit_delay_home": { + "name": "Exit delay home", + "description": "How long to delay when exiting while \"home\"." + }, + "light": { + "name": "Light", + "description": "Whether the armed light should be visible." + }, + "voice_prompt_volume": { + "name": "Voice prompt volume", + "description": "The volume level of the voice prompt." + } + } + } } } diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index 0f82918d82a..a8907ba3b68 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -131,7 +131,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: SERVICE_TOGGLE, {}, "async_toggle", - [SirenEntityFeature.TURN_ON & SirenEntityFeature.TURN_OFF], + [SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF], ) return True diff --git a/homeassistant/components/siren/services.yaml b/homeassistant/components/siren/services.yaml index 18bf782eaf2..4c2f612bcbc 100644 --- a/homeassistant/components/siren/services.yaml +++ b/homeassistant/components/siren/services.yaml @@ -1,20 +1,25 @@ # Describes the format for available siren services turn_on: - description: Turn siren on. target: entity: domain: siren + supported_features: + - siren.SirenEntityFeature.TURN_ON fields: tone: - description: The tone to emit when turning the siren on. When `available_tones` property is a map, either the key or the value can be used. Must be supported by the integration. example: fire + filter: + supported_features: + - siren.SirenEntityFeature.TONES required: false selector: text: volume_level: - description: The volume level of the noise to emit when turning the siren on. Must be supported by the integration. example: 0.5 + filter: + supported_features: + - siren.SirenEntityFeature.VOLUME_SET required: false selector: number: @@ -22,20 +27,25 @@ turn_on: max: 1 step: 0.05 duration: - description: The duration in seconds of the noise to emit when turning the siren on. Must be supported by the integration. example: 15 + filter: + supported_features: + - siren.SirenEntityFeature.DURATION required: false selector: text: turn_off: - description: Turn siren off. target: entity: domain: siren + supported_features: + - siren.SirenEntityFeature.TURN_OFF toggle: - description: Toggles a siren. target: entity: domain: siren + supported_features: + - - siren.SirenEntityFeature.TURN_OFF + - siren.SirenEntityFeature.TURN_ON diff --git a/homeassistant/components/siren/strings.json b/homeassistant/components/siren/strings.json index c3dde16a99f..90725da9e8f 100644 --- a/homeassistant/components/siren/strings.json +++ b/homeassistant/components/siren/strings.json @@ -14,10 +14,32 @@ } } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Turns the siren on.", + "fields": { + "tone": { + "name": "Tone", + "description": "The tone to emit. When `available_tones` property is a map, either the key or the value can be used. Must be supported by the integration." + }, + "volume_level": { + "name": "Volume", + "description": "The volume. 0 is inaudible, 1 is the maximum volume. Must be supported by the integration." + }, + "duration": { + "name": "Duration", + "description": "Number of seconds the sound is played. Must be supported by the integration." + } + } + }, + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Turns the siren off." + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles the siren on/off." } } } diff --git a/homeassistant/components/skybell/binary_sensor.py b/homeassistant/components/skybell/binary_sensor.py index 6b49307d439..fa55b352f61 100644 --- a/homeassistant/components/skybell/binary_sensor.py +++ b/homeassistant/components/skybell/binary_sensor.py @@ -19,12 +19,11 @@ from .entity import SkybellEntity BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="button", - name="Button", + translation_key="button", device_class=BinarySensorDeviceClass.OCCUPANCY, ), BinarySensorEntityDescription( key="motion", - name="Motion", device_class=BinarySensorDeviceClass.MOTION, ), ) diff --git a/homeassistant/components/skybell/camera.py b/homeassistant/components/skybell/camera.py index b9aba0e82ac..1e510687a02 100644 --- a/homeassistant/components/skybell/camera.py +++ b/homeassistant/components/skybell/camera.py @@ -17,8 +17,14 @@ from .coordinator import SkybellDataUpdateCoordinator from .entity import SkybellEntity CAMERA_TYPES: tuple[CameraEntityDescription, ...] = ( - CameraEntityDescription(key="activity", name="Last activity"), - CameraEntityDescription(key="avatar", name="Camera"), + CameraEntityDescription( + key="activity", + translation_key="activity", + ), + CameraEntityDescription( + key="avatar", + translation_key="camera", + ), ) diff --git a/homeassistant/components/skybell/light.py b/homeassistant/components/skybell/light.py index 311122c28e7..70fe01fdb5e 100644 --- a/homeassistant/components/skybell/light.py +++ b/homeassistant/components/skybell/light.py @@ -35,6 +35,7 @@ class SkybellLight(SkybellEntity, LightEntity): _attr_color_mode = ColorMode.RGB _attr_supported_color_modes = {ColorMode.RGB} + _attr_name = None async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index 4658f0f99c0..130196a990d 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -39,27 +39,27 @@ class SkybellSensorEntityDescription( SENSOR_TYPES: tuple[SkybellSensorEntityDescription, ...] = ( SkybellSensorEntityDescription( key="chime_level", - name="Chime level", + translation_key="chime_level", icon="mdi:bell-ring", value_fn=lambda device: device.outdoor_chime_level, ), SkybellSensorEntityDescription( key="last_button_event", - name="Last button event", + translation_key="last_button_event", icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda device: device.latest("button").get(CONST.CREATED_AT), ), SkybellSensorEntityDescription( key="last_motion_event", - name="Last motion event", + translation_key="last_motion_event", icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda device: device.latest("motion").get(CONST.CREATED_AT), ), SkybellSensorEntityDescription( key=CONST.ATTR_LAST_CHECK_IN, - name="Last check in", + translation_key="last_check_in", icon="mdi:clock", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, @@ -68,7 +68,7 @@ SENSOR_TYPES: tuple[SkybellSensorEntityDescription, ...] = ( ), SkybellSensorEntityDescription( key="motion_threshold", - name="Motion threshold", + translation_key="motion_threshold", icon="mdi:walk", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -76,14 +76,14 @@ SENSOR_TYPES: tuple[SkybellSensorEntityDescription, ...] = ( ), SkybellSensorEntityDescription( key="video_profile", - name="Video profile", + translation_key="video_profile", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.video_profile, ), SkybellSensorEntityDescription( key=CONST.ATTR_WIFI_SSID, - name="Wifi SSID", + translation_key="wifi_ssid", icon="mdi:wifi-settings", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, @@ -91,7 +91,7 @@ SENSOR_TYPES: tuple[SkybellSensorEntityDescription, ...] = ( ), SkybellSensorEntityDescription( key=CONST.ATTR_WIFI_STATUS, - name="Wifi status", + translation_key="wifi_status", icon="mdi:wifi-strength-3", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/skybell/strings.json b/homeassistant/components/skybell/strings.json index 4289c3ed3c3..28a66df2d02 100644 --- a/homeassistant/components/skybell/strings.json +++ b/homeassistant/components/skybell/strings.json @@ -24,5 +24,57 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "button": { + "name": "Button" + } + }, + "camera": { + "activity": { + "name": "Last activity" + }, + "camera": { + "name": "[%key:component::camera::title%]" + } + }, + "sensor": { + "chime_level": { + "name": "Chime level" + }, + "last_button_event": { + "name": "Last button event" + }, + "last_motion_event": { + "name": "Last motion event" + }, + "last_check_in": { + "name": "Last check in" + }, + "motion_threshold": { + "name": "Motion threshold" + }, + "video_profile": { + "name": "Video profile" + }, + "wifi_ssid": { + "name": "Wi-Fi SSID" + }, + "wifi_status": { + "name": "Wi-Fi status" + } + }, + "switch": { + "do_not_disturb": { + "name": "Do not disturb" + }, + "do_not_ring": { + "name": "Do not ring" + }, + "motion_sensor": { + "name": "Motion sensor" + } + } } } diff --git a/homeassistant/components/skybell/switch.py b/homeassistant/components/skybell/switch.py index b3cb8c53032..f67cca41ac9 100644 --- a/homeassistant/components/skybell/switch.py +++ b/homeassistant/components/skybell/switch.py @@ -14,15 +14,15 @@ from .entity import SkybellEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( key="do_not_disturb", - name="Do not disturb", + translation_key="do_not_disturb", ), SwitchEntityDescription( key="do_not_ring", - name="Do not ring", + translation_key="do_not_ring", ), SwitchEntityDescription( key="motion_sensor", - name="Motion sensor", + translation_key="motion_sensor", ), ) diff --git a/homeassistant/components/slack/sensor.py b/homeassistant/components/slack/sensor.py index b190e6151ed..4e65fdfc26d 100644 --- a/homeassistant/components/slack/sensor.py +++ b/homeassistant/components/slack/sensor.py @@ -29,7 +29,7 @@ async def async_setup_entry( hass.data[DOMAIN][entry.entry_id][SLACK_DATA], SensorEntityDescription( key="do_not_disturb_until", - name="Do not disturb until", + translation_key="do_not_disturb_until", icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, ), diff --git a/homeassistant/components/slack/strings.json b/homeassistant/components/slack/strings.json index f14129cf156..13b48644ffd 100644 --- a/homeassistant/components/slack/strings.json +++ b/homeassistant/components/slack/strings.json @@ -25,5 +25,12 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "entity": { + "sensor": { + "do_not_disturb_until": { + "name": "Do not disturb until" + } + } } } diff --git a/homeassistant/components/slimproto/manifest.json b/homeassistant/components/slimproto/manifest.json index 1ef87e84933..b221db96262 100644 --- a/homeassistant/components/slimproto/manifest.json +++ b/homeassistant/components/slimproto/manifest.json @@ -6,5 +6,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/slimproto", "iot_class": "local_push", - "requirements": ["aioslimproto==2.3.2"] + "requirements": ["aioslimproto==2.3.3"] } diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 024f04b0dc9..6606352ffc8 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -452,9 +452,11 @@ class SmartThingsEntity(Entity): return DeviceInfo( configuration_url="https://account.smartthings.com", identifiers={(DOMAIN, self._device.device_id)}, - manufacturer="Unavailable", - model=self._device.device_type_name, + manufacturer=self._device.status.ocf_manufacturer_name, + model=self._device.status.ocf_model_number, name=self._device.label, + hw_version=self._device.status.ocf_hardware_version, + sw_version=self._device.status.ocf_firmware_version, ) @property diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index 76e79fcf949..3b8b727015b 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -3,7 +3,6 @@ "name": "SmartTub", "codeowners": ["@mdz"], "config_flow": true, - "dependencies": [], "documentation": "https://www.home-assistant.io/integrations/smarttub", "iot_class": "cloud_polling", "loggers": ["smarttub"], diff --git a/homeassistant/components/smarttub/services.yaml b/homeassistant/components/smarttub/services.yaml index d9890dba35a..65bd4afb8b7 100644 --- a/homeassistant/components/smarttub/services.yaml +++ b/homeassistant/components/smarttub/services.yaml @@ -1,14 +1,10 @@ set_primary_filtration: - name: Update primary filtration settings - description: Updates the primary filtration settings target: entity: integration: smarttub domain: sensor fields: duration: - name: Duration - description: The desired duration of the primary filtration cycle default: 8 selector: number: @@ -18,7 +14,6 @@ set_primary_filtration: mode: slider example: 8 start_hour: - description: The hour of the day at which to begin the primary filtration cycle default: 0 example: 2 selector: @@ -28,15 +23,12 @@ set_primary_filtration: unit_of_measurement: "hour" set_secondary_filtration: - name: Update secondary filtration settings - description: Updates the secondary filtration settings target: entity: integration: smarttub domain: sensor fields: mode: - description: The secondary filtration mode. selector: select: options: @@ -47,16 +39,12 @@ set_secondary_filtration: example: "frequent" snooze_reminder: - name: Snooze a reminder - description: Delay a reminder, so that it won't trigger again for a period of time. target: entity: integration: smarttub domain: binary_sensor fields: days: - name: Days - description: The number of days to delay the reminder. required: true example: 7 selector: @@ -66,16 +54,12 @@ snooze_reminder: unit_of_measurement: days reset_reminder: - name: Reset a reminder - description: Reset a reminder, and set the next time it will be triggered. target: entity: integration: smarttub domain: binary_sensor fields: days: - name: Days - description: The number of days when the next reminder should trigger. required: true example: 180 selector: diff --git a/homeassistant/components/smarttub/strings.json b/homeassistant/components/smarttub/strings.json index 25528b8a374..974e5fb7d37 100644 --- a/homeassistant/components/smarttub/strings.json +++ b/homeassistant/components/smarttub/strings.json @@ -21,5 +21,51 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "services": { + "set_primary_filtration": { + "name": "Update primary filtration settings", + "description": "Updates the primary filtration settings.", + "fields": { + "duration": { + "name": "Duration", + "description": "The desired duration of the primary filtration cycle." + }, + "start_hour": { + "name": "Start hour", + "description": "The hour of the day at which to begin the primary filtration cycle." + } + } + }, + "set_secondary_filtration": { + "name": "Update secondary filtration settings", + "description": "Updates the secondary filtration settings.", + "fields": { + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "The secondary filtration mode." + } + } + }, + "snooze_reminder": { + "name": "Snooze a reminder", + "description": "Delay a reminder, so that it won't trigger again for a period of time.", + "fields": { + "days": { + "name": "Days", + "description": "The number of days to delay the reminder." + } + } + }, + "reset_reminder": { + "name": "Reset a reminder", + "description": "Reset a reminder, and set the next time it will be triggered.", + "fields": { + "days": { + "name": "[%key:component::smarttub::services::snooze_reminder::fields::days::name%]", + "description": "The number of days when the next reminder should trigger." + } + } + } } } diff --git a/homeassistant/components/sms/sensor.py b/homeassistant/components/sms/sensor.py index cfa31d56e80..0ad727faf2c 100644 --- a/homeassistant/components/sms/sensor.py +++ b/homeassistant/components/sms/sensor.py @@ -17,7 +17,6 @@ from .const import DOMAIN, GATEWAY, NETWORK_COORDINATOR, SIGNAL_COORDINATOR, SMS SIGNAL_SENSORS = ( SensorEntityDescription( key="SignalStrength", - translation_key="signal_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, diff --git a/homeassistant/components/sms/strings.json b/homeassistant/components/sms/strings.json index 6bf8cbcc166..ae3324aa156 100644 --- a/homeassistant/components/sms/strings.json +++ b/homeassistant/components/sms/strings.json @@ -4,7 +4,7 @@ "user": { "title": "Connect to the modem", "data": { - "device": "Device", + "device": "[%key:common::config_flow::data::device%]", "baud_speed": "Baud Speed" } } @@ -20,16 +20,27 @@ }, "entity": { "sensor": { - "bit_error_rate": { "name": "Bit error rate" }, - "cid": { "name": "Cell ID" }, - "lac": { "name": "Local area code" }, - "network_code": { "name": "GSM network code" }, - "network_name": { "name": "Network name" }, - "signal_percent": { "name": "Signal percent" }, - "signal_strength": { - "name": "[%key:component::sensor::entity_component::signal_strength::name%]" + "bit_error_rate": { + "name": "Bit error rate" }, - "state": { "name": "Network status" } + "cid": { + "name": "Cell ID" + }, + "lac": { + "name": "Local area code" + }, + "network_code": { + "name": "GSM network code" + }, + "network_name": { + "name": "Network name" + }, + "signal_percent": { + "name": "Signal percent" + }, + "state": { + "name": "Network status" + } } } } diff --git a/homeassistant/components/smtp/services.yaml b/homeassistant/components/smtp/services.yaml index c4380a4fc62..c983a105c93 100644 --- a/homeassistant/components/smtp/services.yaml +++ b/homeassistant/components/smtp/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload smtp notify services. diff --git a/homeassistant/components/smtp/strings.json b/homeassistant/components/smtp/strings.json new file mode 100644 index 00000000000..b711c2f2009 --- /dev/null +++ b/homeassistant/components/smtp/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads smtp notify services." + } + } +} diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 096e3829bc7..9dadae2e3e2 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -102,12 +102,17 @@ async def async_setup_platform( """Set up the Snapcast platform.""" async_create_issue( hass, - DOMAIN, - "deprecated_yaml", + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", breaks_in_ha_version="2023.11.0", is_fixable=False, + issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Snapcast", + }, ) config[CONF_PORT] = config.get(CONF_PORT, CONTROL_PORT) diff --git a/homeassistant/components/snapcast/services.yaml b/homeassistant/components/snapcast/services.yaml index f80b22dba7e..aa1a26c3537 100644 --- a/homeassistant/components/snapcast/services.yaml +++ b/homeassistant/components/snapcast/services.yaml @@ -1,18 +1,12 @@ join: - name: Join - description: Group players together. fields: master: - name: Master - description: Entity ID of the player to synchronize to. required: true selector: entity: integration: snapcast domain: media_player entity_id: - name: Entity - description: The players to join to the "master". selector: target: entity: @@ -20,40 +14,30 @@ join: domain: media_player unjoin: - name: Unjoin - description: Unjoin the player from a group. target: entity: integration: snapcast domain: media_player snapshot: - name: Snapshot - description: Take a snapshot of the media player. target: entity: integration: snapcast domain: media_player restore: - name: Restore - description: Restore a snapshot of the media player. target: entity: integration: snapcast domain: media_player set_latency: - name: Set latency - description: Set client set_latency target: entity: integration: snapcast domain: media_player fields: latency: - name: Latency - description: Latency in master required: true selector: number: diff --git a/homeassistant/components/snapcast/strings.json b/homeassistant/components/snapcast/strings.json index 0087b70d820..0d51c7543f1 100644 --- a/homeassistant/components/snapcast/strings.json +++ b/homeassistant/components/snapcast/strings.json @@ -7,7 +7,7 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]" }, - "title": "Connect" + "title": "[%key:common::action::connect%]" } }, "abort": { @@ -18,10 +18,42 @@ "invalid_host": "[%key:common::config_flow::error::invalid_host%]" } }, - "issues": { - "deprecated_yaml": { - "title": "The Snapcast YAML configuration is being removed", - "description": "Configuring Snapcast using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Snapcast YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + "services": { + "join": { + "name": "Join", + "description": "Groups players together.", + "fields": { + "master": { + "name": "Master", + "description": "Entity ID of the player to synchronize to." + }, + "entity_id": { + "name": "Entity", + "description": "The players to join to the \"master\"." + } + } + }, + "unjoin": { + "name": "Unjoin", + "description": "Unjoins the player from a group." + }, + "snapshot": { + "name": "Snapshot", + "description": "Takes a snapshot of the media player." + }, + "restore": { + "name": "Restore", + "description": "Restores a snapshot of the media player." + }, + "set_latency": { + "name": "Set latency", + "description": "Sets client set_latency.", + "fields": { + "latency": { + "name": "Latency", + "description": "Latency in master." + } + } } } } diff --git a/homeassistant/components/snips/services.yaml b/homeassistant/components/snips/services.yaml index df3a46281c8..522e1b5b348 100644 --- a/homeassistant/components/snips/services.yaml +++ b/homeassistant/components/snips/services.yaml @@ -1,83 +1,55 @@ feedback_off: - name: Feedback off - description: Turns feedback sounds off. fields: site_id: - name: Site ID - description: Site to turn sounds on, defaults to all sites. example: bedroom default: default selector: text: feedback_on: - name: Feedback on - description: Turns feedback sounds on. fields: site_id: - name: Site ID - description: Site to turn sounds on, defaults to all sites. example: bedroom default: default selector: text: say: - name: Say - description: Send a TTS message to Snips. fields: custom_data: - name: Custom data - description: custom data that will be included with all messages in this session example: user=UserName default: "" selector: text: site_id: - name: Site ID - description: Site to use to start session, defaults to default. example: bedroom default: default selector: text: text: - name: Text - description: Text to say. required: true example: My name is snips selector: text: say_action: - name: Say action - description: Send a TTS message to Snips to listen for a response. fields: can_be_enqueued: - name: Can be enqueued - description: If True, session waits for an open session to end, if False session is dropped if one is running default: true selector: boolean: custom_data: - name: Custom data - description: custom data that will be included with all messages in this session example: user=UserName default: "" selector: text: intent_filter: - name: Intent filter - description: Optional Array of Strings - A list of intents names to restrict the NLU resolution to on the first query. example: "turnOnLights, turnOffLights" selector: object: site_id: - name: Site ID - description: Site to use to start session, defaults to default. example: bedroom default: default selector: text: text: - name: Text - description: Text to say required: true example: My name is snips selector: diff --git a/homeassistant/components/snips/strings.json b/homeassistant/components/snips/strings.json new file mode 100644 index 00000000000..724e1a86477 --- /dev/null +++ b/homeassistant/components/snips/strings.json @@ -0,0 +1,68 @@ +{ + "services": { + "feedback_off": { + "name": "Feedback off", + "description": "Turns feedback sounds off.", + "fields": { + "site_id": { + "name": "Site ID", + "description": "Site to turn sounds on, defaults to all sites." + } + } + }, + "feedback_on": { + "name": "Feedback on", + "description": "Turns feedback sounds on.", + "fields": { + "site_id": { + "name": "Site ID", + "description": "[%key:component::snips::services::feedback_off::fields::site_id::description%]" + } + } + }, + "say": { + "name": "Say", + "description": "Sends a TTS message to Snips.", + "fields": { + "custom_data": { + "name": "Custom data", + "description": "Custom data that will be included with all messages in this session." + }, + "site_id": { + "name": "Site ID", + "description": "Site to use to start session, defaults to default." + }, + "text": { + "name": "Text", + "description": "Text to say." + } + } + }, + "say_action": { + "name": "Say action", + "description": "Sends a TTS message to Snips to listen for a response.", + "fields": { + "can_be_enqueued": { + "name": "Can be enqueued", + "description": "If True, session waits for an open session to end, if False session is dropped if one is running." + }, + "custom_data": { + "name": "[%key:component::snips::services::say::fields::custom_data::name%]", + "description": "[%key:component::snips::services::say::fields::custom_data::description%]" + }, + "intent_filter": { + "name": "Intent filter", + "description": "Optional Array of Strings - A list of intents names to restrict the NLU resolution to on the first query." + }, + "site_id": { + "name": "Site ID", + "description": "[%key:component::snips::services::say::fields::site_id::description%]" + }, + "text": { + "name": "Text", + "description": "[%key:component::snips::services::say::fields::text::description%]" + } + } + } + } +} diff --git a/homeassistant/components/snooz/services.yaml b/homeassistant/components/snooz/services.yaml index f795edf213a..ca9f4883a69 100644 --- a/homeassistant/components/snooz/services.yaml +++ b/homeassistant/components/snooz/services.yaml @@ -1,14 +1,10 @@ transition_on: - name: Transition on - description: Transition to a target volume level over time. target: entity: integration: snooz domain: fan fields: duration: - name: Transition duration - description: Time it takes to reach the target volume level. selector: number: min: 1 @@ -16,8 +12,6 @@ transition_on: unit_of_measurement: seconds mode: box volume: - name: Target volume - description: If not specified, the volume level is read from the device. selector: number: min: 1 @@ -25,16 +19,12 @@ transition_on: unit_of_measurement: "%" transition_off: - name: Transition off - description: Transition volume off over time. target: entity: integration: snooz domain: fan fields: duration: - name: Transition duration - description: Time it takes to turn off. selector: number: min: 1 diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json index 2f957f87072..bc1e68db02f 100644 --- a/homeassistant/components/snooz/strings.json +++ b/homeassistant/components/snooz/strings.json @@ -23,5 +23,31 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "services": { + "transition_on": { + "name": "Transition on", + "description": "Transitions to a target volume level over time.", + "fields": { + "duration": { + "name": "Transition duration", + "description": "Time it takes to reach the target volume level." + }, + "volume": { + "name": "Target volume", + "description": "If not specified, the volume level is read from the device." + } + } + }, + "transition_off": { + "name": "Transition off", + "description": "Transitions volume off over time.", + "fields": { + "duration": { + "name": "[%key:component::snooz::services::transition_on::fields::duration::name%]", + "description": "Time it takes to turn off." + } + } + } } } diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index 059dab3da78..d8ba49adbec 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -1,204 +1,8 @@ """Constants for the Solar-Log integration.""" from __future__ import annotations -from collections.abc import Callable -from dataclasses import dataclass -from datetime import datetime - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.const import ( - PERCENTAGE, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfPower, -) -from homeassistant.util.dt import as_local - DOMAIN = "solarlog" # Default config for solarlog. DEFAULT_HOST = "http://solar-log" DEFAULT_NAME = "solarlog" - - -@dataclass -class SolarLogSensorEntityDescription(SensorEntityDescription): - """Describes Solarlog sensor entity.""" - - value: Callable[[float | int], float] | Callable[[datetime], datetime] | None = None - - -SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( - SolarLogSensorEntityDescription( - key="time", - name="last update", - device_class=SensorDeviceClass.TIMESTAMP, - value=as_local, - ), - SolarLogSensorEntityDescription( - key="power_ac", - name="power AC", - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - SolarLogSensorEntityDescription( - key="power_dc", - name="power DC", - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - SolarLogSensorEntityDescription( - key="voltage_ac", - name="voltage AC", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - SolarLogSensorEntityDescription( - key="voltage_dc", - name="voltage DC", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - ), - SolarLogSensorEntityDescription( - key="yield_day", - name="yield day", - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), - ), - SolarLogSensorEntityDescription( - key="yield_yesterday", - name="yield yesterday", - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), - ), - SolarLogSensorEntityDescription( - key="yield_month", - name="yield month", - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), - ), - SolarLogSensorEntityDescription( - key="yield_year", - name="yield year", - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), - ), - SolarLogSensorEntityDescription( - key="yield_total", - name="yield total", - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - value=lambda value: round(value / 1000, 3), - ), - SolarLogSensorEntityDescription( - key="consumption_ac", - name="consumption AC", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - SolarLogSensorEntityDescription( - key="consumption_day", - name="consumption day", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), - ), - SolarLogSensorEntityDescription( - key="consumption_yesterday", - name="consumption yesterday", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), - ), - SolarLogSensorEntityDescription( - key="consumption_month", - name="consumption month", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), - ), - SolarLogSensorEntityDescription( - key="consumption_year", - name="consumption year", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - value=lambda value: round(value / 1000, 3), - ), - SolarLogSensorEntityDescription( - key="consumption_total", - name="consumption total", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL, - value=lambda value: round(value / 1000, 3), - ), - SolarLogSensorEntityDescription( - key="total_power", - name="installed peak power", - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - ), - SolarLogSensorEntityDescription( - key="alternator_loss", - name="alternator loss", - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - SolarLogSensorEntityDescription( - key="capacity", - name="capacity", - icon="mdi:solar-power", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.POWER_FACTOR, - state_class=SensorStateClass.MEASUREMENT, - value=lambda value: round(value * 100, 1), - ), - SolarLogSensorEntityDescription( - key="efficiency", - name="efficiency", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.POWER_FACTOR, - state_class=SensorStateClass.MEASUREMENT, - value=lambda value: round(value * 100, 1), - ), - SolarLogSensorEntityDescription( - key="power_available", - name="power available", - icon="mdi:solar-power", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - SolarLogSensorEntityDescription( - key="usage", - name="usage", - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.POWER_FACTOR, - state_class=SensorStateClass.MEASUREMENT, - value=lambda value: round(value * 100, 1), - ), -) diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index 4180d48cdef..a69d2a4c382 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -1,13 +1,208 @@ """Platform for solarlog sensors.""" -from homeassistant.components.sensor import SensorEntity +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.dt import as_local from . import SolarlogData -from .const import DOMAIN, SENSOR_TYPES, SolarLogSensorEntityDescription +from .const import DOMAIN + + +@dataclass +class SolarLogSensorEntityDescription(SensorEntityDescription): + """Describes Solarlog sensor entity.""" + + value: Callable[[float | int], float] | Callable[[datetime], datetime] | None = None + + +SENSOR_TYPES: tuple[SolarLogSensorEntityDescription, ...] = ( + SolarLogSensorEntityDescription( + key="time", + translation_key="last_update", + device_class=SensorDeviceClass.TIMESTAMP, + value=as_local, + ), + SolarLogSensorEntityDescription( + key="power_ac", + translation_key="power_ac", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="power_dc", + translation_key="power_dc", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="voltage_ac", + translation_key="voltage_ac", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="voltage_dc", + translation_key="voltage_dc", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="yield_day", + translation_key="yield_day", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value=lambda value: round(value / 1000, 3), + ), + SolarLogSensorEntityDescription( + key="yield_yesterday", + translation_key="yield_yesterday", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value=lambda value: round(value / 1000, 3), + ), + SolarLogSensorEntityDescription( + key="yield_month", + translation_key="yield_month", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value=lambda value: round(value / 1000, 3), + ), + SolarLogSensorEntityDescription( + key="yield_year", + translation_key="yield_year", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value=lambda value: round(value / 1000, 3), + ), + SolarLogSensorEntityDescription( + key="yield_total", + translation_key="yield_total", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value=lambda value: round(value / 1000, 3), + ), + SolarLogSensorEntityDescription( + key="consumption_ac", + translation_key="consumption_ac", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="consumption_day", + translation_key="consumption_day", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value=lambda value: round(value / 1000, 3), + ), + SolarLogSensorEntityDescription( + key="consumption_yesterday", + translation_key="consumption_yesterday", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value=lambda value: round(value / 1000, 3), + ), + SolarLogSensorEntityDescription( + key="consumption_month", + translation_key="consumption_month", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value=lambda value: round(value / 1000, 3), + ), + SolarLogSensorEntityDescription( + key="consumption_year", + translation_key="consumption_year", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + value=lambda value: round(value / 1000, 3), + ), + SolarLogSensorEntityDescription( + key="consumption_total", + translation_key="consumption_total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + value=lambda value: round(value / 1000, 3), + ), + SolarLogSensorEntityDescription( + key="total_power", + translation_key="total_power", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + ), + SolarLogSensorEntityDescription( + key="alternator_loss", + translation_key="alternator_loss", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="capacity", + translation_key="capacity", + icon="mdi:solar-power", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + value=lambda value: round(value * 100, 1), + ), + SolarLogSensorEntityDescription( + key="efficiency", + translation_key="efficiency", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + value=lambda value: round(value * 100, 1), + ), + SolarLogSensorEntityDescription( + key="power_available", + translation_key="power_available", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + SolarLogSensorEntityDescription( + key="usage", + translation_key="usage", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + value=lambda value: round(value * 100, 1), + ), +) async def async_setup_entry( diff --git a/homeassistant/components/solarlog/strings.json b/homeassistant/components/solarlog/strings.json index 068132dea41..62e923a766d 100644 --- a/homeassistant/components/solarlog/strings.json +++ b/homeassistant/components/solarlog/strings.json @@ -16,5 +16,75 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "last_update": { + "name": "Last update" + }, + "power_ac": { + "name": "Power AC" + }, + "power_dc": { + "name": "Power DC" + }, + "voltage_ac": { + "name": "Voltage AC" + }, + "voltage_dc": { + "name": "Voltage DC" + }, + "yield_day": { + "name": "Yield day" + }, + "yield_yesterday": { + "name": "Yield yesterday" + }, + "yield_month": { + "name": "Yield month" + }, + "yield_year": { + "name": "Yield year" + }, + "yield_total": { + "name": "Yield total" + }, + "consumption_ac": { + "name": "Consumption AC" + }, + "consumption_day": { + "name": "Consumption day" + }, + "consumption_yesterday": { + "name": "Consumption yesterday" + }, + "consumption_month": { + "name": "Consumption month" + }, + "consumption_year": { + "name": "Consumption year" + }, + "consumption_total": { + "name": "Consumption total" + }, + "total_power": { + "name": "Installed peak power" + }, + "alternator_loss": { + "name": "Alternator loss" + }, + "capacity": { + "name": "Capacity" + }, + "efficiency": { + "name": "Efficiency" + }, + "power_available": { + "name": "Power available" + }, + "usage": { + "name": "Usage" + } + } } } diff --git a/homeassistant/components/soma/__init__.py b/homeassistant/components/soma/__init__.py index 09576f07e6b..a929bd24b25 100644 --- a/homeassistant/components/soma/__init__.py +++ b/homeassistant/components/soma/__init__.py @@ -108,6 +108,8 @@ def soma_api_call(api_call): class SomaEntity(Entity): """Representation of a generic Soma device.""" + _attr_has_entity_name = True + def __init__(self, device, api): """Initialize the Soma device.""" self.device = device @@ -127,11 +129,6 @@ class SomaEntity(Entity): """Return the unique id base on the id returned by pysoma API.""" return self.device["mac"] - @property - def name(self): - """Return the name of the device.""" - return self.device["name"] - @property def device_info(self) -> DeviceInfo: """Return device specific attributes. @@ -141,7 +138,7 @@ class SomaEntity(Entity): return DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, manufacturer="Wazombi Labs", - name=self.name, + name=self.device["name"], ) def set_position(self, position: int) -> None: diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index 144c793ac57..26487756a44 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -43,6 +43,7 @@ async def async_setup_entry( class SomaTilt(SomaEntity, CoverEntity): """Representation of a Soma Tilt device.""" + _attr_name = None _attr_device_class = CoverDeviceClass.BLIND _attr_supported_features = ( CoverEntityFeature.OPEN_TILT @@ -118,6 +119,7 @@ class SomaTilt(SomaEntity, CoverEntity): class SomaShade(SomaEntity, CoverEntity): """Representation of a Soma Shade device.""" + _attr_name = None _attr_device_class = CoverDeviceClass.SHADE _attr_supported_features = ( CoverEntityFeature.OPEN diff --git a/homeassistant/components/soma/sensor.py b/homeassistant/components/soma/sensor.py index a53bcd26e83..6472f6934e0 100644 --- a/homeassistant/components/soma/sensor.py +++ b/homeassistant/components/soma/sensor.py @@ -34,11 +34,6 @@ class SomaSensor(SomaEntity, SensorEntity): _attr_device_class = SensorDeviceClass.BATTERY _attr_native_unit_of_measurement = PERCENTAGE - @property - def name(self): - """Return the name of the device.""" - return self.device["name"] + " battery level" - @property def native_value(self): """Return the state of the entity.""" diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index 1c4b9afb08d..def44d382ce 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -88,7 +88,7 @@ def get_wanted_attr(wanted: SonarrWantedMissing) -> dict[str, str]: SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { "commands": SonarrSensorEntityDescription[list[Command]]( key="commands", - name="Commands", + translation_key="commands", icon="mdi:code-braces", native_unit_of_measurement="Commands", entity_registry_enabled_default=False, @@ -97,7 +97,7 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { ), "diskspace": SonarrSensorEntityDescription[list[Diskspace]]( key="diskspace", - name="Disk space", + translation_key="diskspace", icon="mdi:harddisk", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -107,7 +107,7 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { ), "queue": SonarrSensorEntityDescription[SonarrQueue]( key="queue", - name="Queue", + translation_key="queue", icon="mdi:download", native_unit_of_measurement="Episodes", entity_registry_enabled_default=False, @@ -116,7 +116,7 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { ), "series": SonarrSensorEntityDescription[list[SonarrSeries]]( key="series", - name="Shows", + translation_key="series", icon="mdi:television", native_unit_of_measurement="Series", entity_registry_enabled_default=False, @@ -130,7 +130,7 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { ), "upcoming": SonarrSensorEntityDescription[list[SonarrCalendar]]( key="upcoming", - name="Upcoming", + translation_key="upcoming", icon="mdi:television", native_unit_of_measurement="Episodes", value_fn=len, @@ -140,7 +140,7 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { ), "wanted": SonarrSensorEntityDescription[SonarrWantedMissing]( key="wanted", - name="Wanted", + translation_key="wanted", icon="mdi:television", native_unit_of_measurement="Episodes", entity_registry_enabled_default=False, diff --git a/homeassistant/components/sonarr/strings.json b/homeassistant/components/sonarr/strings.json index b8537e11442..5b17f3283e8 100644 --- a/homeassistant/components/sonarr/strings.json +++ b/homeassistant/components/sonarr/strings.json @@ -33,5 +33,27 @@ } } } + }, + "entity": { + "sensor": { + "commands": { + "name": "Commands" + }, + "diskspace": { + "name": "Disk space" + }, + "queue": { + "name": "Queue" + }, + "series": { + "name": "Shows" + }, + "upcoming": { + "name": "Upcoming" + }, + "wanted": { + "name": "Wanted" + } + } } } diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 0d41aec699b..bc5e15ba989 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -100,6 +100,8 @@ class SongpalEntity(MediaPlayerEntity): | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF ) + _attr_has_entity_name = True + _attr_name = None def __init__(self, name, device): """Init.""" @@ -197,11 +199,6 @@ class SongpalEntity(MediaPlayerEntity): self.hass.loop.create_task(self._dev.listen_notifications()) - @property - def name(self): - """Return name of the device.""" - return self._name - @property def unique_id(self): """Return a unique ID.""" @@ -220,7 +217,7 @@ class SongpalEntity(MediaPlayerEntity): identifiers={(DOMAIN, self.unique_id)}, manufacturer="Sony Corporation", model=self._model, - name=self.name, + name=self._name, sw_version=self._sysinfo.version, ) diff --git a/homeassistant/components/songpal/services.yaml b/homeassistant/components/songpal/services.yaml index 93485ce4788..26da134acdd 100644 --- a/homeassistant/components/songpal/services.yaml +++ b/homeassistant/components/songpal/services.yaml @@ -1,21 +1,15 @@ set_sound_setting: - name: Set sound setting - description: Change sound setting. target: entity: integration: songpal domain: media_player fields: name: - name: Name - description: Name of the setting. required: true example: "nightMode" selector: text: value: - name: Value - description: Value to set. required: true example: "on" selector: diff --git a/homeassistant/components/songpal/strings.json b/homeassistant/components/songpal/strings.json index 62bff00c786..d6874f94f95 100644 --- a/homeassistant/components/songpal/strings.json +++ b/homeassistant/components/songpal/strings.json @@ -18,5 +18,21 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "not_songpal_device": "Not a Songpal device" } + }, + "services": { + "set_sound_setting": { + "name": "Sets sound setting", + "description": "Change sound setting.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "Name of the setting." + }, + "value": { + "name": "Value", + "description": "Value to set." + } + } + } } } diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py index 6025af19a60..4a41e572c1a 100644 --- a/homeassistant/components/sonos/binary_sensor.py +++ b/homeassistant/components/sonos/binary_sensor.py @@ -58,7 +58,6 @@ class SonosPowerEntity(SonosEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING - _attr_name = "Power" def __init__(self, speaker: SonosSpeaker) -> None: """Initialize the power entity binary sensor.""" @@ -92,7 +91,7 @@ class SonosMicrophoneSensorEntity(SonosEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_icon = "mdi:microphone" - _attr_name = "Microphone" + _attr_translation_key = "microphone" def __init__(self, speaker: SonosSpeaker) -> None: """Initialize the microphone binary sensor entity.""" diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 526ddd2bcc7..08f2b08f4df 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -195,6 +195,8 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.GROUPING + | MediaPlayerEntityFeature.MEDIA_ANNOUNCE + | MediaPlayerEntityFeature.MEDIA_ENQUEUE | MediaPlayerEntityFeature.NEXT_TRACK | MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index 8a9b8e9af70..375ed58035b 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -110,7 +110,7 @@ class SonosLevelEntity(SonosEntity, NumberEntity): """Initialize the level entity.""" super().__init__(speaker) self._attr_unique_id = f"{self.soco.uid}-{level_type}" - self._attr_name = level_type.replace("_", " ").capitalize() + self._attr_translation_key = level_type self.level_type = level_type self._attr_native_min_value, self._attr_native_max_value = valid_range diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index dab70466c85..ca3cc89d750 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -79,7 +79,6 @@ class SonosBatteryEntity(SonosEntity, SensorEntity): _attr_device_class = SensorDeviceClass.BATTERY _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_name = "Battery" _attr_native_unit_of_measurement = PERCENTAGE def __init__(self, speaker: SonosSpeaker) -> None: @@ -107,7 +106,7 @@ class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_icon = "mdi:import" - _attr_name = "Audio input format" + _attr_translation_key = "audio_input_format" _attr_should_poll = True def __init__(self, speaker: SonosSpeaker, audio_format: str) -> None: diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml index 9d61c20f7cb..f6df83ef6ed 100644 --- a/homeassistant/components/sonos/services.yaml +++ b/homeassistant/components/sonos/services.yaml @@ -1,49 +1,33 @@ snapshot: - name: Snapshot - description: Take a snapshot of the media player. fields: entity_id: - name: Entity - description: Name of entity that will be snapshot. selector: entity: integration: sonos domain: media_player with_group: - name: With group - description: True or False. Also snapshot the group layout. default: true selector: boolean: restore: - name: Restore - description: Restore a snapshot of the media player. fields: entity_id: - name: Entity - description: Name of entity that will be restored. selector: entity: integration: sonos domain: media_player with_group: - name: With group - description: True or False. Also restore the group layout. default: true selector: boolean: set_sleep_timer: - name: Set timer - description: Set a Sonos timer. target: device: integration: sonos fields: sleep_time: - name: Sleep Time - description: Number of seconds to set the timer. selector: number: min: 0 @@ -51,22 +35,16 @@ set_sleep_timer: unit_of_measurement: seconds clear_sleep_timer: - name: Clear timer - description: Clear a Sonos timer. target: device: integration: sonos play_queue: - name: Play queue - description: Start playing the queue from the first item. target: device: integration: sonos fields: queue_position: - name: Queue position - description: Position of the song in the queue to start playing from. selector: number: min: 0 @@ -74,15 +52,11 @@ play_queue: mode: box remove_from_queue: - name: Remove from queue - description: Removes an item from the queue. target: device: integration: sonos fields: queue_position: - name: Queue position - description: Position in the queue to remove. selector: number: min: 0 @@ -90,15 +64,11 @@ remove_from_queue: mode: box update_alarm: - name: Update alarm - description: Updates an alarm with new time and volume settings. target: device: integration: sonos fields: alarm_id: - name: Alarm ID - description: ID for the alarm to be updated. required: true selector: number: @@ -106,26 +76,18 @@ update_alarm: max: 1440 mode: box time: - name: Time - description: Set time for the alarm. example: "07:00" selector: time: volume: - name: Volume - description: Set alarm volume level. selector: number: min: 0 max: 1 step: 0.01 enabled: - name: Alarm enabled - description: Enable or disable the alarm. selector: boolean: include_linked_zones: - name: Include linked zones - description: Enable or disable including grouped rooms. selector: boolean: diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 75c1b850146..fb10167f1d0 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -16,5 +16,159 @@ "title": "Networking error: subscriptions failed", "description": "Falling back to polling, functionality may be limited.\n\nSonos device at {device_ip} cannot reach Home Assistant at {listener_address}.\n\nSee our [documentation]({sub_fail_url}) for more information on how to solve this issue." } + }, + "entity": { + "binary_sensor": { + "microphone": { + "name": "Microphone" + } + }, + "number": { + "audio_delay": { + "name": "Audio delay" + }, + "bass": { + "name": "Bass" + }, + "balance": { + "name": "Balance" + }, + "treble": { + "name": "Treble" + }, + "sub_gain": { + "name": "Sub gain" + }, + "surround_level": { + "name": "Surround level" + }, + "music_surround_level": { + "name": "Music surround level" + } + }, + "sensor": { + "audio_input_format": { + "name": "Audio input format" + } + }, + "switch": { + "cross_fade": { + "name": "Crossfade" + }, + "loudness": { + "name": "Loudness" + }, + "surround_mode": { + "name": "Surround music full volume" + }, + "night_mode": { + "name": "Night sound" + }, + "dialog_level": { + "name": "Speech enhancement" + }, + "status_light": { + "name": "Status light" + }, + "sub_enabled": { + "name": "Subwoofer enabled" + }, + "surround_enabled": { + "name": "Surround enabled" + }, + "buttons_enabled": { + "name": "Touch controls" + } + } + }, + "services": { + "snapshot": { + "name": "Snapshot", + "description": "Takes a snapshot of the media player.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name of entity that will be snapshot." + }, + "with_group": { + "name": "With group", + "description": "True or False. Also snapshot the group layout." + } + } + }, + "restore": { + "name": "Restore", + "description": "Restores a snapshot of the media player.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name of entity that will be restored." + }, + "with_group": { + "name": "[%key:component::sonos::services::snapshot::fields::with_group::name%]", + "description": "True or False. Also restore the group layout." + } + } + }, + "set_sleep_timer": { + "name": "Set timer", + "description": "Sets a Sonos timer.", + "fields": { + "sleep_time": { + "name": "Sleep Time", + "description": "Number of seconds to set the timer." + } + } + }, + "clear_sleep_timer": { + "name": "Clear timer", + "description": "Clears a Sonos timer." + }, + "play_queue": { + "name": "Play queue", + "description": "Start playing the queue from the first item.", + "fields": { + "queue_position": { + "name": "Queue position", + "description": "Position of the song in the queue to start playing from." + } + } + }, + "remove_from_queue": { + "name": "Remove from queue", + "description": "Removes an item from the queue.", + "fields": { + "queue_position": { + "name": "[%key:component::sonos::services::play_queue::fields::queue_position::name%]", + "description": "Position in the queue to remove." + } + } + }, + "update_alarm": { + "name": "Update alarm", + "description": "Updates an alarm with new time and volume settings.", + "fields": { + "alarm_id": { + "name": "Alarm ID", + "description": "ID for the alarm to be updated." + }, + "time": { + "name": "Time", + "description": "Set time for the alarm." + }, + "volume": { + "name": "Volume", + "description": "Set alarm volume level." + }, + "enabled": { + "name": "Alarm enabled", + "description": "Enable or disable the alarm." + }, + "include_linked_zones": { + "name": "Include linked zones", + "description": "Enable or disable including grouped rooms." + } + } + } } } diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 1201ed96490..c551d4a00d3 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -67,18 +67,6 @@ POLL_REQUIRED = ( ATTR_STATUS_LIGHT, ) -FRIENDLY_NAMES = { - ATTR_CROSSFADE: "Crossfade", - ATTR_LOUDNESS: "Loudness", - ATTR_MUSIC_PLAYBACK_FULL_VOLUME: "Surround music full volume", - ATTR_NIGHT_SOUND: "Night sound", - ATTR_SPEECH_ENHANCEMENT: "Speech enhancement", - ATTR_STATUS_LIGHT: "Status light", - ATTR_SUB_ENABLED: "Subwoofer enabled", - ATTR_SURROUND_ENABLED: "Surround enabled", - ATTR_TOUCH_CONTROLS: "Touch controls", -} - FEATURE_ICONS = { ATTR_LOUDNESS: "mdi:bullhorn-variant", ATTR_MUSIC_PLAYBACK_FULL_VOLUME: "mdi:music-note-plus", @@ -140,7 +128,7 @@ async def async_setup_entry( ) _LOGGER.debug( "Creating %s switch on %s", - FRIENDLY_NAMES[feature_type], + feature_type, speaker.zone_name, ) entities.append(SonosSwitchEntity(feature_type, speaker)) @@ -163,7 +151,7 @@ class SonosSwitchEntity(SonosPollingEntity, SwitchEntity): self.feature_type = feature_type self.needs_coordinator = feature_type in COORDINATOR_FEATURES self._attr_entity_category = EntityCategory.CONFIG - self._attr_name = FRIENDLY_NAMES[feature_type] + self._attr_translation_key = feature_type self._attr_unique_id = f"{speaker.soco.uid}-{feature_type}" self._attr_icon = FEATURE_ICONS.get(feature_type) diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 9cd94330812..f8670074c5c 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -73,6 +73,8 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): | MediaPlayerEntityFeature.BROWSE_MEDIA ) _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_has_entity_name = True + _attr_name = None def __init__(self, device: SoundTouchDevice) -> None: """Create SoundTouch media player entity.""" @@ -80,7 +82,6 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): self._device = device self._attr_unique_id = self._device.config.device_id - self._attr_name = self._device.config.name self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._device.config.device_id)}, connections={ diff --git a/homeassistant/components/soundtouch/services.yaml b/homeassistant/components/soundtouch/services.yaml index 82709053496..10ae15a3cb9 100644 --- a/homeassistant/components/soundtouch/services.yaml +++ b/homeassistant/components/soundtouch/services.yaml @@ -1,10 +1,6 @@ play_everywhere: - name: Play everywhere - description: Play on all Bose SoundTouch devices. fields: master: - name: Master - description: Name of the master entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices required: true selector: entity: @@ -12,20 +8,14 @@ play_everywhere: domain: media_player create_zone: - name: Create zone - description: Create a SoundTouch multi-room zone. fields: master: - name: Master - description: Name of the master entity that will coordinate the multi-room zone. Platform dependent. required: true selector: entity: integration: soundtouch domain: media_player slaves: - name: Slaves - description: Name of slaves entities to add to the new zone. required: true selector: entity: @@ -34,20 +24,14 @@ create_zone: domain: media_player add_zone_slave: - name: Add zone slave - description: Add a slave to a SoundTouch multi-room zone. fields: master: - name: Master - description: Name of the master entity that is coordinating the multi-room zone. Platform dependent. required: true selector: entity: integration: soundtouch domain: media_player slaves: - name: Slaves - description: Name of slaves entities to add to the existing zone. required: true selector: entity: @@ -56,20 +40,14 @@ add_zone_slave: domain: media_player remove_zone_slave: - name: Remove zone slave - description: Remove a slave from the SoundTouch multi-room zone. fields: master: - name: Master - description: Name of the master entity that is coordinating the multi-room zone. Platform dependent. required: true selector: entity: integration: soundtouch domain: media_player slaves: - name: Slaves - description: Name of slaves entities to remove from the existing zone. required: true selector: entity: diff --git a/homeassistant/components/soundtouch/strings.json b/homeassistant/components/soundtouch/strings.json index 7ebcd4c5285..7af95aab38c 100644 --- a/homeassistant/components/soundtouch/strings.json +++ b/homeassistant/components/soundtouch/strings.json @@ -17,5 +17,59 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "services": { + "play_everywhere": { + "name": "Play everywhere", + "description": "Plays on all Bose SoundTouch devices.", + "fields": { + "master": { + "name": "Master", + "description": "Name of the master entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices." + } + } + }, + "create_zone": { + "name": "Create zone", + "description": "Creates a SoundTouch multi-room zone.", + "fields": { + "master": { + "name": "Master", + "description": "Name of the master entity that will coordinate the multi-room zone. Platform dependent." + }, + "slaves": { + "name": "Slaves", + "description": "Name of slaves entities to add to the new zone." + } + } + }, + "add_zone_slave": { + "name": "Add zone slave", + "description": "Adds a slave to a SoundTouch multi-room zone.", + "fields": { + "master": { + "name": "Master", + "description": "Name of the master entity that is coordinating the multi-room zone. Platform dependent." + }, + "slaves": { + "name": "[%key:component::soundtouch::services::create_zone::fields::slaves::name%]", + "description": "Name of slaves entities to add to the existing zone." + } + } + }, + "remove_zone_slave": { + "name": "Remove zone slave", + "description": "Removes a slave from the SoundTouch multi-room zone.", + "fields": { + "master": { + "name": "Master", + "description": "[%key:component::soundtouch::services::add_zone_slave::fields::master::description%]" + }, + "slaves": { + "name": "[%key:component::soundtouch::services::create_zone::fields::slaves::name%]", + "description": "Name of slaves entities to remove from the existing zone." + } + } + } } } diff --git a/homeassistant/components/speedtestdotnet/manifest.json b/homeassistant/components/speedtestdotnet/manifest.json index 6cb8e2b7d92..79999eb8ad9 100644 --- a/homeassistant/components/speedtestdotnet/manifest.json +++ b/homeassistant/components/speedtestdotnet/manifest.json @@ -3,7 +3,6 @@ "name": "Speedtest.net", "codeowners": ["@rohankapoorcom", "@engrbm87"], "config_flow": true, - "dependencies": [], "documentation": "https://www.home-assistant.io/integrations/speedtestdotnet", "iot_class": "cloud_polling", "requirements": ["speedtest-cli==2.1.3"] diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index d44d66bbd47..a5ccb78baed 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -44,20 +44,20 @@ class SpeedtestSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: tuple[SpeedtestSensorEntityDescription, ...] = ( SpeedtestSensorEntityDescription( key="ping", - name="Ping", + translation_key="ping", native_unit_of_measurement=UnitOfTime.MILLISECONDS, state_class=SensorStateClass.MEASUREMENT, ), SpeedtestSensorEntityDescription( key="download", - name="Download", + translation_key="download", native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, value=lambda value: round(value / 10**6, 2), ), SpeedtestSensorEntityDescription( key="upload", - name="Upload", + translation_key="upload", native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, value=lambda value: round(value / 10**6, 2), diff --git a/homeassistant/components/speedtestdotnet/strings.json b/homeassistant/components/speedtestdotnet/strings.json index 09515dfd4c8..740716db78e 100644 --- a/homeassistant/components/speedtestdotnet/strings.json +++ b/homeassistant/components/speedtestdotnet/strings.json @@ -17,5 +17,18 @@ } } } + }, + "entity": { + "sensor": { + "ping": { + "name": "Ping" + }, + "download": { + "name": "Download" + }, + "upload": { + "name": "Upload" + } + } } } diff --git a/homeassistant/components/spider/climate.py b/homeassistant/components/spider/climate.py index 261d1565160..2769d045c0b 100644 --- a/homeassistant/components/spider/climate.py +++ b/homeassistant/components/spider/climate.py @@ -40,6 +40,8 @@ async def async_setup_entry( class SpiderThermostat(ClimateEntity): """Representation of a thermostat.""" + _attr_has_entity_name = True + _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__(self, api, thermostat): @@ -77,11 +79,6 @@ class SpiderThermostat(ClimateEntity): """Return the id of the thermostat, if any.""" return self.thermostat.id - @property - def name(self): - """Return the name of the thermostat, if any.""" - return self.thermostat.name - @property def current_temperature(self): """Return the current temperature.""" diff --git a/homeassistant/components/spider/sensor.py b/homeassistant/components/spider/sensor.py index 259c0fa4974..5b326db1e45 100644 --- a/homeassistant/components/spider/sensor.py +++ b/homeassistant/components/spider/sensor.py @@ -32,6 +32,8 @@ async def async_setup_entry( class SpiderPowerPlugEnergy(SensorEntity): """Representation of a Spider Power Plug (energy).""" + _attr_has_entity_name = True + _attr_translation_key = "total_energy_today" _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR _attr_device_class = SensorDeviceClass.ENERGY _attr_state_class = SensorStateClass.TOTAL_INCREASING @@ -56,11 +58,6 @@ class SpiderPowerPlugEnergy(SensorEntity): """Return the ID of this sensor.""" return f"{self.power_plug.id}_total_energy_today" - @property - def name(self) -> str: - """Return the name of the sensor if any.""" - return f"{self.power_plug.name} Total Energy Today" - @property def native_value(self) -> float: """Return todays energy usage in Kwh.""" @@ -74,6 +71,8 @@ class SpiderPowerPlugEnergy(SensorEntity): class SpiderPowerPlugPower(SensorEntity): """Representation of a Spider Power Plug (power).""" + _attr_has_entity_name = True + _attr_translation_key = "power_consumption" _attr_device_class = SensorDeviceClass.POWER _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = UnitOfPower.WATT @@ -98,11 +97,6 @@ class SpiderPowerPlugPower(SensorEntity): """Return the ID of this sensor.""" return f"{self.power_plug.id}_power_consumption" - @property - def name(self) -> str: - """Return the name of the sensor if any.""" - return f"{self.power_plug.name} Power Consumption" - @property def native_value(self) -> float: """Return the current power usage in W.""" diff --git a/homeassistant/components/spider/strings.json b/homeassistant/components/spider/strings.json index 2e86f47dd2d..c8d67be36ae 100644 --- a/homeassistant/components/spider/strings.json +++ b/homeassistant/components/spider/strings.json @@ -16,5 +16,15 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "entity": { + "sensor": { + "power_consumption": { + "name": "Power consumption" + }, + "total_energy_today": { + "name": "Total energy today" + } + } } } diff --git a/homeassistant/components/spider/switch.py b/homeassistant/components/spider/switch.py index 607e4c5b84a..28bbf0fcc18 100644 --- a/homeassistant/components/spider/switch.py +++ b/homeassistant/components/spider/switch.py @@ -26,6 +26,9 @@ async def async_setup_entry( class SpiderPowerPlug(SwitchEntity): """Representation of a Spider Power Plug.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, api, power_plug): """Initialize the Spider Power Plug.""" self.api = api @@ -47,11 +50,6 @@ class SpiderPowerPlug(SwitchEntity): """Return the ID of this switch.""" return self.power_plug.id - @property - def name(self): - """Return the name of the switch if any.""" - return self.power_plug.name - @property def is_on(self): """Return true if switch is on. Standby is on.""" diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index cb6484c5e3e..ca9f63bbd1c 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -13,13 +13,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, ) -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .browse_media import async_browse_media @@ -30,7 +27,6 @@ from .util import ( spotify_uri_from_media_browser_url, ) -CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) PLATFORMS = [Platform.MEDIA_PLAYER] @@ -53,22 +49,6 @@ class HomeAssistantSpotifyData: session: OAuth2Session -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Spotify integration.""" - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Spotify from a config entry.""" implementation = await async_get_config_entry_implementation(hass, entry) diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py index e6a1f16eede..162369fd27d 100644 --- a/homeassistant/components/spotify/browse_media.py +++ b/homeassistant/components/spotify/browse_media.py @@ -1,6 +1,7 @@ """Support for Spotify media browsing.""" from __future__ import annotations +from enum import StrEnum from functools import partial import logging from typing import Any @@ -8,7 +9,6 @@ from typing import Any from spotipy import Spotify import yarl -from homeassistant.backports.enum import StrEnum from homeassistant.components.media_player import ( BrowseError, BrowseMedia, diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 0145d6f0906..41d27b68672 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -12,7 +12,9 @@ from spotipy import SpotifyException from yarl import URL from homeassistant.components.media_player import ( + ATTR_MEDIA_ENQUEUE, BrowseMedia, + MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, @@ -120,9 +122,6 @@ class SpotifyMediaPlayer(MediaPlayerEntity): self._attr_unique_id = user_id - if self.data.current_user["product"] == "premium": - self._attr_supported_features = SUPPORT_SPOTIFY - self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, user_id)}, manufacturer="Spotify AB", @@ -137,6 +136,16 @@ class SpotifyMediaPlayer(MediaPlayerEntity): ) self._currently_playing: dict | None = {} self._playlist: dict | None = None + self._restricted_device: bool = False + + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Return the supported features.""" + if self.data.current_user["product"] != "premium": + return MediaPlayerEntityFeature(0) + if self._restricted_device or not self._currently_playing: + return MediaPlayerEntityFeature.SELECT_SOURCE + return SUPPORT_SPOTIFY @property def state(self) -> MediaPlayerState: @@ -329,6 +338,10 @@ class SpotifyMediaPlayer(MediaPlayerEntity): """Play media.""" media_type = media_type.removeprefix(MEDIA_PLAYER_PREFIX) + enqueue: MediaPlayerEnqueue = kwargs.get( + ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE + ) + kwargs = {} # Spotify can't handle URI's with query strings or anchors @@ -350,6 +363,17 @@ class SpotifyMediaPlayer(MediaPlayerEntity): ): kwargs["device_id"] = self.data.devices.data[0].get("id") + if enqueue == MediaPlayerEnqueue.ADD: + if media_type not in { + MediaType.TRACK, + MediaType.EPISODE, + MediaType.MUSIC, + }: + raise ValueError( + f"Media type {media_type} is not supported when enqueue is ADD" + ) + return self.data.client.add_to_queue(media_id, kwargs.get("device_id")) + self.data.client.start_playback(**kwargs) @spotify_exception_handler @@ -391,13 +415,25 @@ class SpotifyMediaPlayer(MediaPlayerEntity): ) self._currently_playing = current or {} - context = self._currently_playing.get("context") - if context is not None and ( - self._playlist is None or self._playlist["uri"] != context["uri"] - ): + context = self._currently_playing.get("context") or {} + + # For some users in some cases, the uri is formed like + # "spotify:user:{name}:playlist:{id}" and spotipy wants + # the type to be playlist. + uri = context.get("uri") + if uri is not None: + parts = uri.split(":") + if len(parts) == 5 and parts[1] == "user" and parts[3] == "playlist": + uri = ":".join([parts[0], parts[3], parts[4]]) + + if context and (self._playlist is None or self._playlist["uri"] != uri): self._playlist = None if context["type"] == MediaType.PLAYLIST: - self._playlist = self.data.client.playlist(current["context"]["uri"]) + self._playlist = self.data.client.playlist(uri) + + device = self._currently_playing.get("device") + if device is not None: + self._restricted_device = device["is_restricted"] async def async_browse_media( self, diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index 4405bd21310..ec2721aba8b 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -10,22 +10,18 @@ } }, "abort": { - "authorize_url_timeout": "Timeout generating authorize URL.", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "The Spotify integration is not configured. Please follow the documentation.", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication." }, - "create_entry": { "default": "Successfully authenticated with Spotify." } + "create_entry": { + "default": "Successfully authenticated with Spotify." + } }, "system_health": { "info": { "api_endpoint_reachable": "Spotify API endpoint reachable" } - }, - "issues": { - "removed_yaml": { - "title": "The Spotify YAML configuration has been removed", - "description": "Configuring Spotify using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index dd5480450e2..316e816fd6f 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_CLASS, + CONF_ICON, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -23,6 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.template_entity import CONF_AVAILABILITY, CONF_PICTURE from homeassistant.helpers.typing import ConfigType from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN, PLATFORMS @@ -41,7 +43,7 @@ def validate_sql_select(value: str) -> str: QUERY_SCHEMA = vol.Schema( { vol.Required(CONF_COLUMN_NAME): cv.string, - vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_NAME): cv.template, vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select), vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -49,6 +51,9 @@ QUERY_SCHEMA = vol.Schema( vol.Optional(CONF_DB_URL): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_AVAILABILITY): cv.template, + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_PICTURE): cv.template, } ) diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index a6c526a6a7f..bd0a6d30369 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -12,7 +12,17 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.recorder import CONF_DB_URL, get_instance -from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, +) from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector @@ -22,6 +32,8 @@ from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) +NONE_SENTINEL = "none" + OPTIONS_SCHEMA: vol.Schema = vol.Schema( { vol.Optional( @@ -39,6 +51,34 @@ OPTIONS_SCHEMA: vol.Schema = vol.Schema( vol.Optional( CONF_VALUE_TEMPLATE, ): selector.TemplateSelector(), + vol.Optional( + CONF_DEVICE_CLASS, + default=NONE_SENTINEL, + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[NONE_SENTINEL] + + sorted( + [ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ] + ), + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="device_class", + ) + ), + vol.Optional( + CONF_STATE_CLASS, + default=NONE_SENTINEL, + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[NONE_SENTINEL] + + sorted([cls.value for cls in SensorStateClass]), + mode=selector.SelectSelectorMode.DROPDOWN, + translation_key="state_class", + ) + ), } ) @@ -139,6 +179,10 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): options[CONF_UNIT_OF_MEASUREMENT] = uom if value_template := user_input.get(CONF_VALUE_TEMPLATE): options[CONF_VALUE_TEMPLATE] = value_template + if (device_class := user_input[CONF_DEVICE_CLASS]) != NONE_SENTINEL: + options[CONF_DEVICE_CLASS] = device_class + if (state_class := user_input[CONF_STATE_CLASS]) != NONE_SENTINEL: + options[CONF_STATE_CLASS] = state_class if db_url_for_validation != get_instance(self.hass).db_url: options[CONF_DB_URL] = db_url_for_validation @@ -204,6 +248,10 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): options[CONF_UNIT_OF_MEASUREMENT] = uom if value_template := user_input.get(CONF_VALUE_TEMPLATE): options[CONF_VALUE_TEMPLATE] = value_template + if (device_class := user_input[CONF_DEVICE_CLASS]) != NONE_SENTINEL: + options[CONF_DEVICE_CLASS] = device_class + if (state_class := user_input[CONF_STATE_CLASS]) != NONE_SENTINEL: + options[CONF_STATE_CLASS] = state_class if db_url_for_validation != get_instance(self.hass).db_url: options[CONF_DB_URL] = db_url_for_validation diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index 2a8ea80580b..aecc34d7009 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import date import decimal import logging +from typing import Any import sqlalchemy from sqlalchemy import lambda_stmt @@ -27,6 +28,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_CLASS, + CONF_ICON, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -40,6 +42,11 @@ from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ( + CONF_AVAILABILITY, + CONF_PICTURE, + ManualTriggerEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN @@ -61,7 +68,7 @@ async def async_setup_platform( if (conf := discovery_info) is None: return - name: str = conf[CONF_NAME] + name: Template = conf[CONF_NAME] query_str: str = conf[CONF_QUERY] unit: str | None = conf.get(CONF_UNIT_OF_MEASUREMENT) value_template: Template | None = conf.get(CONF_VALUE_TEMPLATE) @@ -70,13 +77,28 @@ async def async_setup_platform( db_url: str = resolve_db_url(hass, conf.get(CONF_DB_URL)) device_class: SensorDeviceClass | None = conf.get(CONF_DEVICE_CLASS) state_class: SensorStateClass | None = conf.get(CONF_STATE_CLASS) + availability: Template | None = conf.get(CONF_AVAILABILITY) + icon: Template | None = conf.get(CONF_ICON) + picture: Template | None = conf.get(CONF_PICTURE) if value_template is not None: value_template.hass = hass + trigger_entity_config = { + CONF_NAME: name, + CONF_DEVICE_CLASS: device_class, + CONF_UNIQUE_ID: unique_id, + } + if availability: + trigger_entity_config[CONF_AVAILABILITY] = availability + if icon: + trigger_entity_config[CONF_ICON] = icon + if picture: + trigger_entity_config[CONF_PICTURE] = picture + await async_setup_sensor( hass, - name, + trigger_entity_config, query_str, column_name, unit, @@ -84,7 +106,6 @@ async def async_setup_platform( unique_id, db_url, True, - device_class, state_class, async_add_entities, ) @@ -101,6 +122,8 @@ async def async_setup_entry( unit: str | None = entry.options.get(CONF_UNIT_OF_MEASUREMENT) template: str | None = entry.options.get(CONF_VALUE_TEMPLATE) column_name: str = entry.options[CONF_COLUMN_NAME] + device_class: SensorDeviceClass | None = entry.options.get(CONF_DEVICE_CLASS, None) + state_class: SensorStateClass | None = entry.options.get(CONF_STATE_CLASS, None) value_template: Template | None = None if template is not None: @@ -112,9 +135,16 @@ async def async_setup_entry( if value_template is not None: value_template.hass = hass + name_template = Template(name, hass) + trigger_entity_config = { + CONF_NAME: name_template, + CONF_DEVICE_CLASS: device_class, + CONF_UNIQUE_ID: entry.entry_id, + } + await async_setup_sensor( hass, - name, + trigger_entity_config, query_str, column_name, unit, @@ -122,8 +152,7 @@ async def async_setup_entry( entry.entry_id, db_url, False, - None, - None, + state_class, async_add_entities, ) @@ -160,7 +189,7 @@ def _async_get_or_init_domain_data(hass: HomeAssistant) -> SQLData: async def async_setup_sensor( hass: HomeAssistant, - name: str, + trigger_entity_config: ConfigType, query_str: str, column_name: str, unit: str | None, @@ -168,7 +197,6 @@ async def async_setup_sensor( unique_id: str | None, db_url: str, yaml: bool, - device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, async_add_entities: AddEntitiesCallback, ) -> None: @@ -243,20 +271,17 @@ async def async_setup_sensor( async_add_entities( [ SQLSensor( - name, + trigger_entity_config, sessmaker, query_str, column_name, unit, value_template, - unique_id, yaml, - device_class, state_class, use_database_executor, ) ], - True, ) @@ -293,55 +318,62 @@ def _generate_lambda_stmt(query: str) -> StatementLambdaElement: return lambda_stmt(lambda: text, lambda_cache=_SQL_LAMBDA_CACHE) -class SQLSensor(SensorEntity): +class SQLSensor(ManualTriggerEntity, SensorEntity): """Representation of an SQL sensor.""" - _attr_icon = "mdi:database-search" - _attr_has_entity_name = True - def __init__( self, - name: str, + trigger_entity_config: ConfigType, sessmaker: scoped_session, query: str, column: str, unit: str | None, value_template: Template | None, - unique_id: str | None, yaml: bool, - device_class: SensorDeviceClass | None, state_class: SensorStateClass | None, use_database_executor: bool, ) -> None: """Initialize the SQL sensor.""" + super().__init__(self.hass, trigger_entity_config) self._query = query - self._attr_name = name if yaml else None self._attr_native_unit_of_measurement = unit - self._attr_device_class = device_class self._attr_state_class = state_class self._template = value_template self._column_name = column self.sessionmaker = sessmaker self._attr_extra_state_attributes = {} - self._attr_unique_id = unique_id self._use_database_executor = use_database_executor self._lambda_stmt = _generate_lambda_stmt(query) - if not yaml and unique_id: + if not yaml: + self._attr_name = None + self._attr_has_entity_name = True + if not yaml and trigger_entity_config.get(CONF_UNIQUE_ID): self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, unique_id)}, + identifiers={(DOMAIN, trigger_entity_config[CONF_UNIQUE_ID])}, manufacturer="SQL", - name=name, + name=self.name, ) + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + await self.async_update() + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return extra attributes.""" + return dict(self._attr_extra_state_attributes) + async def async_update(self) -> None: """Retrieve sensor data from the query using the right executor.""" if self._use_database_executor: - await get_instance(self.hass).async_add_executor_job(self._update) + data = await get_instance(self.hass).async_add_executor_job(self._update) else: - await self.hass.async_add_executor_job(self._update) + data = await self.hass.async_add_executor_job(self._update) + self._process_manual_data(data) - def _update(self) -> None: + def _update(self) -> Any: """Retrieve sensor data from the query.""" data = None self._attr_extra_state_attributes = {} @@ -382,3 +414,4 @@ class SQLSensor(SensorEntity): _LOGGER.warning("%s returned no results", self._query) sess.close() + return data diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index 6888652cb4c..9ac8bd22027 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -16,7 +16,9 @@ "query": "Select Query", "column": "Column", "unit_of_measurement": "Unit of Measure", - "value_template": "Value Template" + "value_template": "Value Template", + "device_class": "Device Class", + "state_class": "State Class" }, "data_description": { "db_url": "Database URL, leave empty to use HA recorder database", @@ -24,7 +26,9 @@ "query": "Query to run, needs to start with 'SELECT'", "column": "Column for returned query to present as state", "unit_of_measurement": "Unit of Measure (optional)", - "value_template": "Value Template (optional)" + "value_template": "Value Template (optional)", + "device_class": "The type/class of the sensor to set the icon in the frontend", + "state_class": "The state_class of the sensor" } } } @@ -38,7 +42,9 @@ "query": "[%key:component::sql::config::step::user::data::query%]", "column": "[%key:component::sql::config::step::user::data::column%]", "unit_of_measurement": "[%key:component::sql::config::step::user::data::unit_of_measurement%]", - "value_template": "[%key:component::sql::config::step::user::data::value_template%]" + "value_template": "[%key:component::sql::config::step::user::data::value_template%]", + "device_class": "[%key:component::sql::config::step::user::data::device_class%]", + "state_class": "[%key:component::sql::config::step::user::data::state_class%]" }, "data_description": { "db_url": "[%key:component::sql::config::step::user::data_description::db_url%]", @@ -46,7 +52,9 @@ "query": "[%key:component::sql::config::step::user::data_description::query%]", "column": "[%key:component::sql::config::step::user::data_description::column%]", "unit_of_measurement": "[%key:component::sql::config::step::user::data_description::unit_of_measurement%]", - "value_template": "[%key:component::sql::config::step::user::data_description::value_template%]" + "value_template": "[%key:component::sql::config::step::user::data_description::value_template%]", + "device_class": "[%key:component::sql::config::step::user::data_description::device_class%]", + "state_class": "[%key:component::sql::config::step::user::data_description::state_class%]" } } }, @@ -56,6 +64,70 @@ "column_invalid": "[%key:component::sql::config::error::column_invalid%]" } }, + "selector": { + "device_class": { + "options": { + "none": "No device class", + "date": "[%key:component::sensor::entity_component::date::name%]", + "duration": "[%key:component::sensor::entity_component::duration::name%]", + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "data_size": "[%key:component::sensor::entity_component::data_size::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "monetary": "[%key:component::sensor::entity_component::monetary::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "ph": "[%key:component::sensor::entity_component::ph::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", + "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", + "voltage": "[%key:component::sensor::entity_component::voltage::name%]", + "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, + "state_class": { + "options": { + "none": "No state class", + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } + } + }, "issues": { "entity_id_query_does_full_table_scan": { "title": "SQL query does full table scan", diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index d3fae39bc4d..d57ba8ba49d 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -234,6 +234,7 @@ class SqueezeBoxEntity(MediaPlayerEntity): | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.GROUPING + | MediaPlayerEntityFeature.MEDIA_ENQUEUE ) def __init__(self, player): diff --git a/homeassistant/components/squeezebox/services.yaml b/homeassistant/components/squeezebox/services.yaml index 4c2d34ba88b..90f9bf2d769 100644 --- a/homeassistant/components/squeezebox/services.yaml +++ b/homeassistant/components/squeezebox/services.yaml @@ -1,69 +1,47 @@ call_method: - name: Call method - description: Call a custom Squeezebox JSONRPC API. target: entity: integration: squeezebox domain: media_player fields: command: - name: Command - description: Command to pass to Logitech Media Server (p0 in the CLI documentation). required: true example: "playlist" selector: text: parameters: - name: Parameters - description: > - Array of additional parameters to pass to Logitech Media Server (p1, ..., pN in the CLI documentation). example: '["loadtracks", "album.titlesearch=Revolver"]' advanced: true selector: object: call_query: - name: Call query - description: > - Call a custom Squeezebox JSONRPC API. Result will be stored in 'query_result' attribute of the Squeezebox entity. target: entity: integration: squeezebox domain: media_player fields: command: - name: Command - description: Command to pass to Logitech Media Server (p0 in the CLI documentation). required: true example: "albums" selector: text: parameters: - name: Parameters - description: > - Array of additional parameters to pass to Logitech Media Server (p1, ..., pN in the CLI documentation). example: '["0", "20", "search:Revolver"]' advanced: true selector: object: sync: - name: Sync - description: > - Add another player to this player's sync group. If the other player is already in a sync group, it will leave it. target: entity: integration: squeezebox domain: media_player fields: other_player: - name: Other player - description: Name of the other Squeezebox player to link. required: true example: "media_player.living_room" selector: text: unsync: - name: Unsync - description: Remove this player from its sync group. target: entity: integration: squeezebox diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 4ae8d69bacd..87881e3414b 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -27,5 +27,49 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_server_found": "No LMS server found." } + }, + "services": { + "call_method": { + "name": "Call method", + "description": "Calls a custom Squeezebox JSONRPC API.", + "fields": { + "command": { + "name": "Command", + "description": "Command to pass to Logitech Media Server (p0 in the CLI documentation)." + }, + "parameters": { + "name": "Parameters", + "description": "Array of additional parameters to pass to Logitech Media Server (p1, ..., pN in the CLI documentation).\n." + } + } + }, + "call_query": { + "name": "Call query", + "description": "Calls a custom Squeezebox JSONRPC API. Result will be stored in 'query_result' attribute of the Squeezebox entity.\n.", + "fields": { + "command": { + "name": "Command", + "description": "[%key:component::squeezebox::services::call_method::fields::command::description%]" + }, + "parameters": { + "name": "Parameters", + "description": "[%key:component::squeezebox::services::call_method::fields::parameters::description%]" + } + } + }, + "sync": { + "name": "Sync", + "description": "Adds another player to this player's sync group. If the other player is already in a sync group, it will leave it.\n.", + "fields": { + "other_player": { + "name": "Other player", + "description": "Name of the other Squeezebox player to link." + } + } + }, + "unsync": { + "name": "Unsync", + "description": "Removes this player from its sync group." + } } } diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index caae5801b21..61b6b05d9d6 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.33.2"] + "requirements": ["async-upnp-client==0.34.1"] } diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index b427967ded5..bef724392b7 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -1,8 +1,6 @@ """Reads vehicle status from StarLine API.""" from __future__ import annotations -from dataclasses import dataclass - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -16,45 +14,30 @@ from .account import StarlineAccount, StarlineDevice from .const import DOMAIN from .entity import StarlineEntity - -@dataclass -class StarlineRequiredKeysMixin: - """Mixin for required keys.""" - - name_: str - - -@dataclass -class StarlineBinarySensorEntityDescription( - BinarySensorEntityDescription, StarlineRequiredKeysMixin -): - """Describes Starline binary_sensor entity.""" - - -BINARY_SENSOR_TYPES: tuple[StarlineBinarySensorEntityDescription, ...] = ( - StarlineBinarySensorEntityDescription( +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( key="hbrake", - name_="Hand Brake", + translation_key="hand_brake", device_class=BinarySensorDeviceClass.POWER, ), - StarlineBinarySensorEntityDescription( + BinarySensorEntityDescription( key="hood", - name_="Hood", + translation_key="hood", device_class=BinarySensorDeviceClass.DOOR, ), - StarlineBinarySensorEntityDescription( + BinarySensorEntityDescription( key="trunk", - name_="Trunk", + translation_key="trunk", device_class=BinarySensorDeviceClass.DOOR, ), - StarlineBinarySensorEntityDescription( + BinarySensorEntityDescription( key="alarm", - name_="Alarm", + translation_key="alarm", device_class=BinarySensorDeviceClass.PROBLEM, ), - StarlineBinarySensorEntityDescription( + BinarySensorEntityDescription( key="door", - name_="Doors", + translation_key="doors", device_class=BinarySensorDeviceClass.LOCK, ), ) @@ -78,16 +61,14 @@ async def async_setup_entry( class StarlineSensor(StarlineEntity, BinarySensorEntity): """Representation of a StarLine binary sensor.""" - entity_description: StarlineBinarySensorEntityDescription - def __init__( self, account: StarlineAccount, device: StarlineDevice, - description: StarlineBinarySensorEntityDescription, + description: BinarySensorEntityDescription, ) -> None: """Initialize sensor.""" - super().__init__(account, device, description.key, description.name_) + super().__init__(account, device, description.key) self.entity_description = description @property diff --git a/homeassistant/components/starline/device_tracker.py b/homeassistant/components/starline/device_tracker.py index 6dadfdbd3ea..ca8118d6b43 100644 --- a/homeassistant/components/starline/device_tracker.py +++ b/homeassistant/components/starline/device_tracker.py @@ -25,9 +25,11 @@ async def async_setup_entry( class StarlineDeviceTracker(StarlineEntity, TrackerEntity, RestoreEntity): """StarLine device tracker.""" + _attr_translation_key = "location" + def __init__(self, account: StarlineAccount, device: StarlineDevice) -> None: """Set up StarLine entity.""" - super().__init__(account, device, "location", "Location") + super().__init__(account, device, "location") @property def extra_state_attributes(self): diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py index 20e5eaed07e..7eee5e7a7f8 100644 --- a/homeassistant/components/starline/entity.py +++ b/homeassistant/components/starline/entity.py @@ -12,15 +12,15 @@ class StarlineEntity(Entity): """StarLine base entity class.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__( - self, account: StarlineAccount, device: StarlineDevice, key: str, name: str + self, account: StarlineAccount, device: StarlineDevice, key: str ) -> None: """Initialize StarLine entity.""" self._account = account self._device = device self._key = key - self._name = name self._unsubscribe_api: Callable | None = None @property @@ -33,11 +33,6 @@ class StarlineEntity(Entity): """Return the unique ID of the entity.""" return f"starline-{self._key}-{self._device.device_id}" - @property - def name(self): - """Return the name of the entity.""" - return f"{self._device.name} {self._name}" - @property def device_info(self): """Return the device info.""" diff --git a/homeassistant/components/starline/lock.py b/homeassistant/components/starline/lock.py index 4fb8457a779..f663c472a78 100644 --- a/homeassistant/components/starline/lock.py +++ b/homeassistant/components/starline/lock.py @@ -30,9 +30,11 @@ async def async_setup_entry( class StarlineLock(StarlineEntity, LockEntity): """Representation of a StarLine lock.""" + _attr_translation_key = "security" + def __init__(self, account: StarlineAccount, device: StarlineDevice) -> None: """Initialize the lock.""" - super().__init__(account, device, "lock", "Security") + super().__init__(account, device, "lock") @property def available(self) -> bool: diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 1acddb27721..4b787ae5212 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -1,8 +1,6 @@ """Reads vehicle status from StarLine API.""" from __future__ import annotations -from dataclasses import dataclass - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -24,63 +22,48 @@ from .account import StarlineAccount, StarlineDevice from .const import DOMAIN from .entity import StarlineEntity - -@dataclass -class StarlineRequiredKeysMixin: - """Mixin for required keys.""" - - name_: str - - -@dataclass -class StarlineSensorEntityDescription( - SensorEntityDescription, StarlineRequiredKeysMixin -): - """Describes Starline binary_sensor entity.""" - - -SENSOR_TYPES: tuple[StarlineSensorEntityDescription, ...] = ( - StarlineSensorEntityDescription( +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( key="battery", - name_="Battery", + translation_key="battery", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, ), - StarlineSensorEntityDescription( + SensorEntityDescription( key="balance", - name_="Balance", + translation_key="balance", icon="mdi:cash-multiple", ), - StarlineSensorEntityDescription( + SensorEntityDescription( key="ctemp", - name_="Interior Temperature", + translation_key="interior_temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), - StarlineSensorEntityDescription( + SensorEntityDescription( key="etemp", - name_="Engine Temperature", + translation_key="engine_temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), - StarlineSensorEntityDescription( + SensorEntityDescription( key="gsm_lvl", - name_="GSM Signal", + translation_key="gsm_signal", native_unit_of_measurement=PERCENTAGE, ), - StarlineSensorEntityDescription( + SensorEntityDescription( key="fuel", - name_="Fuel Volume", + translation_key="fuel", icon="mdi:fuel", ), - StarlineSensorEntityDescription( + SensorEntityDescription( key="errors", - name_="OBD Errors", + translation_key="errors", icon="mdi:alert-octagon", ), - StarlineSensorEntityDescription( + SensorEntityDescription( key="mileage", - name_="Mileage", + translation_key="mileage", native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, icon="mdi:counter", @@ -106,16 +89,14 @@ async def async_setup_entry( class StarlineSensor(StarlineEntity, SensorEntity): """Representation of a StarLine sensor.""" - entity_description: StarlineSensorEntityDescription - def __init__( self, account: StarlineAccount, device: StarlineDevice, - description: StarlineSensorEntityDescription, + description: SensorEntityDescription, ) -> None: """Initialize StarLine sensor.""" - super().__init__(account, device, description.key, description.name_) + super().__init__(account, device, description.key) self.entity_description = description @property diff --git a/homeassistant/components/starline/services.yaml b/homeassistant/components/starline/services.yaml index 4c3e4d360e8..1d7041f0eb5 100644 --- a/homeassistant/components/starline/services.yaml +++ b/homeassistant/components/starline/services.yaml @@ -1,15 +1,7 @@ update_state: - name: Update state - description: > - Fetch the last state of the devices from the StarLine server. set_scan_interval: - name: Set scan interval - description: > - Set update frequency. fields: scan_interval: - name: Scan interval - description: Update frequency. selector: number: min: 10 @@ -17,13 +9,8 @@ set_scan_interval: step: 5 unit_of_measurement: seconds set_scan_obd_interval: - name: Set scan OBD interval - description: > - Set OBD info update frequency. fields: scan_interval: - name: Scan interval - description: Update frequency. selector: number: min: 180 diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index 10e99f93814..800fd3a65f3 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -37,5 +37,100 @@ "error_auth_user": "Incorrect username or password", "error_auth_mfa": "Incorrect code" } + }, + "entity": { + "binary_sensor": { + "hand_brake": { + "name": "Hand brake" + }, + "hood": { + "name": "Hood" + }, + "trunk": { + "name": "Trunk" + }, + "alarm": { + "name": "Alarm" + }, + "doors": { + "name": "Doors" + } + }, + "device_tracker": { + "location": { + "name": "Location" + } + }, + "lock": { + "security": { + "name": "Security" + } + }, + "sensor": { + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + }, + "balance": { + "name": "Balance" + }, + "interior_temperature": { + "name": "Interior temperature" + }, + "engine_temperature": { + "name": "Engine temperature" + }, + "gsm_signal": { + "name": "GSM signal" + }, + "fuel": { + "name": "Fuel volume" + }, + "errors": { + "name": "OBD errors" + }, + "mileage": { + "name": "Mileage" + } + }, + "switch": { + "engine": { + "name": "Engine" + }, + "webasto": { + "name": "Webasto" + }, + "additional_channel": { + "name": "Additional channel" + }, + "horn": { + "name": "Horn" + } + } + }, + "services": { + "update_state": { + "name": "Update state", + "description": "Fetches the last state of the devices from the StarLine server.\n." + }, + "set_scan_interval": { + "name": "Set scan interval", + "description": "Sets update frequency.", + "fields": { + "scan_interval": { + "name": "Scan interval", + "description": "Update frequency." + } + } + }, + "set_scan_obd_interval": { + "name": "Set scan OBD interval", + "description": "Sets OBD info update frequency.", + "fields": { + "scan_interval": { + "name": "Scan interval", + "description": "[%key:component::starline::services::set_scan_interval::fields::scan_interval::description%]" + } + } + } } } diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index 412c08b9ff7..b254fa8133f 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -18,7 +18,6 @@ from .entity import StarlineEntity class StarlineRequiredKeysMixin: """Mixin for required keys.""" - name_: str icon_on: str icon_off: str @@ -33,25 +32,25 @@ class StarlineSwitchEntityDescription( SWITCH_TYPES: tuple[StarlineSwitchEntityDescription, ...] = ( StarlineSwitchEntityDescription( key="ign", - name_="Engine", + translation_key="engine", icon_on="mdi:engine-outline", icon_off="mdi:engine-off-outline", ), StarlineSwitchEntityDescription( key="webasto", - name_="Webasto", + translation_key="webasto", icon_on="mdi:radiator", icon_off="mdi:radiator-off", ), StarlineSwitchEntityDescription( key="out", - name_="Additional Channel", + translation_key="additional_channel", icon_on="mdi:access-point-network", icon_off="mdi:access-point-network-off", ), StarlineSwitchEntityDescription( key="poke", - name_="Horn", + translation_key="horn", icon_on="mdi:bullhorn-outline", icon_off="mdi:bullhorn-outline", ), @@ -85,7 +84,7 @@ class StarlineSwitch(StarlineEntity, SwitchEntity): description: StarlineSwitchEntityDescription, ) -> None: """Initialize the switch.""" - super().__init__(account, device, description.key, description.name_) + super().__init__(account, device, description.key) self.entity_description = description @property diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py index 22d1c5042f5..87614460096 100644 --- a/homeassistant/components/starlink/binary_sensor.py +++ b/homeassistant/components/starlink/binary_sensor.py @@ -60,64 +60,63 @@ class StarlinkBinarySensorEntity(StarlinkEntity, BinarySensorEntity): BINARY_SENSORS = [ StarlinkBinarySensorEntityDescription( key="update", - name="Update available", device_class=BinarySensorDeviceClass.UPDATE, value_fn=lambda data: data.alert["alert_install_pending"], ), StarlinkBinarySensorEntityDescription( key="roaming", - name="Roaming mode", + translation_key="roaming", value_fn=lambda data: data.alert["alert_roaming"], ), StarlinkBinarySensorEntityDescription( key="currently_obstructed", - name="Obstructed", + translation_key="currently_obstructed", device_class=BinarySensorDeviceClass.PROBLEM, value_fn=lambda data: data.status["currently_obstructed"], ), StarlinkBinarySensorEntityDescription( key="heating", - name="Heating", + translation_key="heating", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.alert["alert_is_heating"], ), StarlinkBinarySensorEntityDescription( key="power_save_idle", - name="Idle", + translation_key="power_save_idle", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.alert["alert_is_power_save_idle"], ), StarlinkBinarySensorEntityDescription( key="mast_near_vertical", - name="Mast near vertical", + translation_key="mast_near_vertical", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.alert["alert_mast_not_near_vertical"], ), StarlinkBinarySensorEntityDescription( key="motors_stuck", - name="Motors stuck", + translation_key="motors_stuck", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.alert["alert_motors_stuck"], ), StarlinkBinarySensorEntityDescription( key="slow_ethernet", - name="Ethernet speeds", + translation_key="slow_ethernet", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.alert["alert_slow_ethernet_speeds"], ), StarlinkBinarySensorEntityDescription( key="thermal_throttle", - name="Thermal throttle", + translation_key="thermal_throttle", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.alert["alert_thermal_throttle"], ), StarlinkBinarySensorEntityDescription( key="unexpected_location", - name="Unexpected location", + translation_key="unexpected_location", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.alert["alert_unexpected_location"], diff --git a/homeassistant/components/starlink/button.py b/homeassistant/components/starlink/button.py index 43e276332c8..2df9d9b033b 100644 --- a/homeassistant/components/starlink/button.py +++ b/homeassistant/components/starlink/button.py @@ -58,7 +58,6 @@ class StarlinkButtonEntity(StarlinkEntity, ButtonEntity): BUTTONS = [ StarlinkButtonEntityDescription( key="reboot", - name="Reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.DIAGNOSTIC, press_fn=lambda coordinator: coordinator.async_reboot_starlink(), diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index a1cc60da79e..ab76a8dffdd 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -68,7 +68,7 @@ class StarlinkSensorEntity(StarlinkEntity, SensorEntity): SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( StarlinkSensorEntityDescription( key="ping", - name="Ping", + translation_key="ping", icon="mdi:speedometer", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.MILLISECONDS, @@ -77,7 +77,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( ), StarlinkSensorEntityDescription( key="azimuth", - name="Azimuth", + translation_key="azimuth", icon="mdi:compass", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -88,7 +88,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( ), StarlinkSensorEntityDescription( key="elevation", - name="Elevation", + translation_key="elevation", icon="mdi:compass", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -99,7 +99,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( ), StarlinkSensorEntityDescription( key="uplink_throughput", - name="Uplink throughput", + translation_key="uplink_throughput", icon="mdi:upload", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DATA_RATE, @@ -109,7 +109,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( ), StarlinkSensorEntityDescription( key="downlink_throughput", - name="Downlink throughput", + translation_key="downlink_throughput", icon="mdi:download", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DATA_RATE, @@ -119,7 +119,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( ), StarlinkSensorEntityDescription( key="last_boot_time", - name="Last boot time", + translation_key="last_boot_time", icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, @@ -127,9 +127,9 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( ), StarlinkSensorEntityDescription( key="ping_drop_rate", - name="Ping Drop Rate", + translation_key="ping_drop_rate", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: data.status["pop_ping_drop_rate"], + value_fn=lambda data: data.status["pop_ping_drop_rate"] * 100, ), ) diff --git a/homeassistant/components/starlink/strings.json b/homeassistant/components/starlink/strings.json index dddbada730d..a9e50f5d39f 100644 --- a/homeassistant/components/starlink/strings.json +++ b/homeassistant/components/starlink/strings.json @@ -13,5 +13,64 @@ } } } + }, + "entity": { + "binary_sensor": { + "roaming": { + "name": "Roaming mode" + }, + "currently_obstructed": { + "name": "Obstructed" + }, + "heating": { + "name": "Heating" + }, + "power_save_idle": { + "name": "[%key:common::state::idle%]" + }, + "mast_near_vertical": { + "name": "Mast near vertical" + }, + "motors_stuck": { + "name": "Motors stuck" + }, + "slow_ethernet": { + "name": "Ethernet speeds" + }, + "thermal_throttle": { + "name": "Thermal throttle" + }, + "unexpected_location": { + "name": "Unexpected location" + } + }, + "sensor": { + "ping": { + "name": "Ping" + }, + "azimuth": { + "name": "Azimuth" + }, + "elevation": { + "name": "[%key:common::config_flow::data::elevation%]" + }, + "uplink_throughput": { + "name": "Uplink throughput" + }, + "downlink_throughput": { + "name": "Downlink throughput" + }, + "last_boot_time": { + "name": "Last boot time" + }, + "ping_drop_rate": { + "name": "Ping drop rate" + } + }, + "switch": { + "stowed": { + "name": "Stowed" + } + } } } diff --git a/homeassistant/components/starlink/switch.py b/homeassistant/components/starlink/switch.py index daa7b45b305..31932fe9854 100644 --- a/homeassistant/components/starlink/switch.py +++ b/homeassistant/components/starlink/switch.py @@ -69,7 +69,7 @@ class StarlinkSwitchEntity(StarlinkEntity, SwitchEntity): SWITCHES = [ StarlinkSwitchEntityDescription( key="stowed", - name="Stowed", + translation_key="stowed", device_class=SwitchDeviceClass.SWITCH, value_fn=lambda data: data.status["state"] == "STOWED", turn_on_fn=lambda coordinator: coordinator.async_stow_starlink(True), diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 078eb59fe72..e86a4741080 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -32,7 +32,6 @@ from homeassistant.const import ( ) from homeassistant.core import ( CALLBACK_TYPE, - Event, HomeAssistant, State, callback, @@ -41,12 +40,18 @@ from homeassistant.core import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( + EventStateChangedData, async_track_point_in_utc_time, async_track_state_change_event, ) from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.start import async_at_start -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType +from homeassistant.helpers.typing import ( + ConfigType, + DiscoveryInfoType, + EventType, + StateType, +) from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum @@ -308,9 +313,11 @@ class StatisticsSensor(SensorEntity): """Register callbacks.""" @callback - def async_stats_sensor_state_listener(event: Event) -> None: + def async_stats_sensor_state_listener( + event: EventType[EventStateChangedData], + ) -> None: """Handle the sensor state changes.""" - if (new_state := event.data.get("new_state")) is None: + if (new_state := event.data["new_state"]) is None: return self._add_state_to_queue(new_state) self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/statistics/services.yaml b/homeassistant/components/statistics/services.yaml index 8c2c8f8464a..c983a105c93 100644 --- a/homeassistant/components/statistics/services.yaml +++ b/homeassistant/components/statistics/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload all statistics entities. diff --git a/homeassistant/components/statistics/strings.json b/homeassistant/components/statistics/strings.json new file mode 100644 index 00000000000..6d7bda36fae --- /dev/null +++ b/homeassistant/components/statistics/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads statistics sensors from the YAML-configuration." + } + } +} diff --git a/homeassistant/components/steamist/strings.json b/homeassistant/components/steamist/strings.json index a3cd4879c6a..8827df6a08a 100644 --- a/homeassistant/components/steamist/strings.json +++ b/homeassistant/components/steamist/strings.json @@ -10,7 +10,7 @@ }, "pick_device": { "data": { - "device": "Device" + "device": "[%key:common::config_flow::data::device%]" } }, "discovery_confirm": { diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index cdd5ac2a567..1a125da6a6b 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -1,9 +1,8 @@ """Constants for the Stookwijzer integration.""" +from enum import StrEnum import logging from typing import Final -from homeassistant.backports.enum import StrEnum - DOMAIN: Final = "stookwijzer" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 8b8e9b8a427..96474ceb7eb 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.6.7", "ha-av==10.1.0", "numpy==1.23.2"] + "requirements": ["PyTurboJPEG==1.7.1", "ha-av==10.1.1", "numpy==1.23.2"] } diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index caa3d974d04..c237a820e58 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -8,11 +8,10 @@ import datetime from io import SEEK_END, BytesIO import logging from threading import Event -from typing import Any, cast +from typing import Any, Self, cast import attr import av -from typing_extensions import Self from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/streamlabswater/services.yaml b/homeassistant/components/streamlabswater/services.yaml index b54c2cf15eb..7504a911123 100644 --- a/homeassistant/components/streamlabswater/services.yaml +++ b/homeassistant/components/streamlabswater/services.yaml @@ -1,10 +1,6 @@ set_away_mode: - name: Set away mode - description: "Set the home/away mode for a Streamlabs Water Monitor." fields: away_mode: - name: Away mode - description: home or away required: true selector: select: diff --git a/homeassistant/components/streamlabswater/strings.json b/homeassistant/components/streamlabswater/strings.json new file mode 100644 index 00000000000..56b35ab1044 --- /dev/null +++ b/homeassistant/components/streamlabswater/strings.json @@ -0,0 +1,14 @@ +{ + "services": { + "set_away_mode": { + "name": "Set away mode", + "description": "Sets the home/away mode for a Streamlabs Water Monitor.", + "fields": { + "away_mode": { + "name": "Away mode", + "description": "Home or away." + } + } + } + } +} diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py index 0e57373625a..342fe34b97d 100644 --- a/homeassistant/components/subaru/lock.py +++ b/homeassistant/components/subaru/lock.py @@ -58,13 +58,15 @@ class SubaruLock(LockEntity): Note that the Subaru API currently does not support returning the status of the locks. Lock status is always unknown. """ + _attr_has_entity_name = True + _attr_translation_key = "door_locks" + def __init__(self, vehicle_info, controller): """Initialize the locks for the vehicle.""" self.controller = controller self.vehicle_info = vehicle_info vin = vehicle_info[VEHICLE_VIN] self.car_name = vehicle_info[VEHICLE_NAME] - self._attr_name = f"{self.car_name} Door Locks" self._attr_unique_id = f"{vin}_door_locks" self._attr_device_info = get_device_info(vehicle_info) diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index 6c8e8fc100b..eda8c20b10e 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -55,9 +55,9 @@ KM_PER_MI = DistanceConverter.convert(1, UnitOfLength.MILES, UnitOfLength.KILOME SAFETY_SENSORS = [ SensorEntityDescription( key=sc.ODOMETER, + translation_key="odometer", device_class=SensorDeviceClass.DISTANCE, icon="mdi:road-variant", - name="Odometer", native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -67,44 +67,44 @@ SAFETY_SENSORS = [ API_GEN_2_SENSORS = [ SensorEntityDescription( key=sc.AVG_FUEL_CONSUMPTION, + translation_key="average_fuel_consumption", icon="mdi:leaf", - name="Avg fuel consumption", native_unit_of_measurement=FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.DIST_TO_EMPTY, + translation_key="range", device_class=SensorDeviceClass.DISTANCE, icon="mdi:gas-station", - name="Range", native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_FL, + translation_key="tire_pressure_front_left", device_class=SensorDeviceClass.PRESSURE, - name="Tire pressure FL", native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_FR, + translation_key="tire_pressure_front_right", device_class=SensorDeviceClass.PRESSURE, - name="Tire pressure FR", native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_RL, + translation_key="tire_pressure_rear_left", device_class=SensorDeviceClass.PRESSURE, - name="Tire pressure RL", native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_RR, + translation_key="tire_pressure_rear_right", device_class=SensorDeviceClass.PRESSURE, - name="Tire pressure RR", native_unit_of_measurement=UnitOfPressure.HPA, state_class=SensorStateClass.MEASUREMENT, ), @@ -114,8 +114,8 @@ API_GEN_2_SENSORS = [ API_GEN_3_SENSORS = [ SensorEntityDescription( key=sc.REMAINING_FUEL_PERCENT, + translation_key="fuel_level", icon="mdi:gas-station", - name="Fuel level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), @@ -125,23 +125,23 @@ API_GEN_3_SENSORS = [ EV_SENSORS = [ SensorEntityDescription( key=sc.EV_DISTANCE_TO_EMPTY, + translation_key="ev_range", device_class=SensorDeviceClass.DISTANCE, icon="mdi:ev-station", - name="EV range", native_unit_of_measurement=UnitOfLength.MILES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.EV_STATE_OF_CHARGE_PERCENT, + translation_key="ev_battery_level", device_class=SensorDeviceClass.BATTERY, - name="EV battery level", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.EV_TIME_TO_FULLY_CHARGED_UTC, + translation_key="ev_time_to_full_charge", device_class=SensorDeviceClass.TIMESTAMP, - name="EV time to full charge", ), ] @@ -276,14 +276,19 @@ async def _async_migrate_entries( """Migrate sensor entries from HA<=2022.10 to use preferred unique_id.""" entity_registry = er.async_get(hass) - all_sensors = [] - all_sensors.extend(EV_SENSORS) - all_sensors.extend(API_GEN_2_SENSORS) - all_sensors.extend(SAFETY_SENSORS) - - # Old unique_id is (previously title-cased) sensor name - # (e.g. "VIN_Avg Fuel Consumption") - replacements = {str(s.name).upper(): s.key for s in all_sensors} + replacements = { + "ODOMETER": sc.ODOMETER, + "AVG FUEL CONSUMPTION": sc.AVG_FUEL_CONSUMPTION, + "RANGE": sc.DIST_TO_EMPTY, + "TIRE PRESSURE FL": sc.TIRE_PRESSURE_FL, + "TIRE PRESSURE FR": sc.TIRE_PRESSURE_FR, + "TIRE PRESSURE RL": sc.TIRE_PRESSURE_RL, + "TIRE PRESSURE RR": sc.TIRE_PRESSURE_RR, + "FUEL LEVEL": sc.REMAINING_FUEL_PERCENT, + "EV RANGE": sc.EV_DISTANCE_TO_EMPTY, + "EV BATTERY LEVEL": sc.EV_STATE_OF_CHARGE_PERCENT, + "EV TIME TO FULL CHARGE": sc.EV_TIME_TO_FULLY_CHARGED_UTC, + } @callback def update_unique_id(entry: er.RegistryEntry) -> dict[str, Any] | None: diff --git a/homeassistant/components/subaru/services.yaml b/homeassistant/components/subaru/services.yaml index 58be48f9d18..bc760d2469e 100644 --- a/homeassistant/components/subaru/services.yaml +++ b/homeassistant/components/subaru/services.yaml @@ -1,14 +1,10 @@ unlock_specific_door: - name: Unlock Specific Door - description: Unlocks specific door(s) target: entity: domain: lock integration: subaru fields: door: - name: Door - description: "One of the following: 'all', 'driver', 'tailgate'" example: driver required: true selector: diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index abde396ba75..5e6db32d4ad 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -11,21 +11,21 @@ } }, "two_factor": { - "title": "Subaru Starlink Configuration", + "title": "[%key:component::subaru::config::step::user::title%]", "description": "Two factor authentication required", "data": { "contact_method": "Please select a contact method:" } }, "two_factor_validate": { - "title": "Subaru Starlink Configuration", + "title": "[%key:component::subaru::config::step::user::title%]", "description": "Please enter validation code received", "data": { "validation_code": "Validation code" } }, "pin": { - "title": "Subaru Starlink Configuration", + "title": "[%key:component::subaru::config::step::user::title%]", "description": "Please enter your MySubaru PIN\nNOTE: All vehicles in account must have the same PIN", "data": { "pin": "PIN" @@ -46,7 +46,6 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } }, - "options": { "step": { "init": { @@ -57,5 +56,59 @@ } } } + }, + "entity": { + "lock": { + "door_locks": { + "name": "Door locks" + } + }, + "sensor": { + "odometer": { + "name": "Odometer" + }, + "average_fuel_consumption": { + "name": "Average fuel consumption" + }, + "range": { + "name": "Range" + }, + "tire_pressure_front_left": { + "name": "Tire pressure front left" + }, + "tire_pressure_front_right": { + "name": "Tire pressure front right" + }, + "tire_pressure_rear_left": { + "name": "Tire pressure rear left" + }, + "tire_pressure_rear_right": { + "name": "Tire pressure rear right" + }, + "fuel_level": { + "name": "Fuel level" + }, + "ev_range": { + "name": "EV range" + }, + "ev_battery_level": { + "name": "EV battery level" + }, + "ev_time_to_full_charge": { + "name": "EV time to full charge" + } + } + }, + "services": { + "unlock_specific_door": { + "name": "Unlock specific door", + "description": "Unlocks specific door(s).", + "fields": { + "door": { + "name": "Door", + "description": "One of the following: 'all', 'driver', 'tailgate'." + } + } + } } } diff --git a/homeassistant/components/surepetcare/services.yaml b/homeassistant/components/surepetcare/services.yaml index 3c3919f5d01..1d42c8fc102 100644 --- a/homeassistant/components/surepetcare/services.yaml +++ b/homeassistant/components/surepetcare/services.yaml @@ -1,17 +1,11 @@ set_lock_state: - name: Set lock state - description: Sets lock state fields: flap_id: - name: Flap ID - description: Flap ID to lock/unlock required: true example: "123456" selector: text: lock_state: - name: Lock state - description: New lock state. required: true selector: select: @@ -22,17 +16,13 @@ set_lock_state: - "unlocked" set_pet_location: - name: Set pet location - description: Set pet location fields: pet_name: - description: Name of pet example: My_cat required: true selector: text: location: - description: Pet location (Inside or Outside) example: Inside required: true selector: diff --git a/homeassistant/components/surepetcare/strings.json b/homeassistant/components/surepetcare/strings.json index f7a539fe0e6..2d297cc829e 100644 --- a/homeassistant/components/surepetcare/strings.json +++ b/homeassistant/components/surepetcare/strings.json @@ -16,5 +16,35 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" } + }, + "services": { + "set_lock_state": { + "name": "Set lock state", + "description": "Sets lock state.", + "fields": { + "flap_id": { + "name": "Flap ID", + "description": "Flap ID to lock/unlock." + }, + "lock_state": { + "name": "Lock state", + "description": "New lock state." + } + } + }, + "set_pet_location": { + "name": "Set pet location", + "description": "Sets pet location.", + "fields": { + "pet_name": { + "name": "Pet name", + "description": "Name of pet." + }, + "location": { + "name": "[%key:common::config_flow::data::location%]", + "description": "Pet location (Inside or Outside)." + } + } + } } } diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 6eb2a275e18..bf3c3424142 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -3,11 +3,11 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +from enum import StrEnum import logging import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_TOGGLE, diff --git a/homeassistant/components/switch/light.py b/homeassistant/components/switch/light.py index fd2a5afff1c..ffd345cea3b 100644 --- a/homeassistant/components/switch/light.py +++ b/homeassistant/components/switch/light.py @@ -15,12 +15,15 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from .const import DOMAIN as SWITCH_DOMAIN @@ -93,7 +96,9 @@ class LightSwitch(LightEntity): """Register callbacks.""" @callback - def async_state_changed_listener(event: Event | None = None) -> None: + def async_state_changed_listener( + event: EventType[EventStateChangedData] | None = None, + ) -> None: """Handle child updates.""" if ( state := self.hass.states.get(self._switch_entity_id) diff --git a/homeassistant/components/switch/services.yaml b/homeassistant/components/switch/services.yaml index 33f66070bfb..5da203d8a80 100644 --- a/homeassistant/components/switch/services.yaml +++ b/homeassistant/components/switch/services.yaml @@ -1,22 +1,16 @@ # Describes the format for available switch services turn_on: - name: Turn on - description: Turn a switch on target: entity: domain: switch turn_off: - name: Turn off - description: Turn a switch off target: entity: domain: switch toggle: - name: Toggle - description: Toggles a switch state target: entity: domain: switch diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index 2bb6c82a8c1..b50709ed76f 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -2,18 +2,18 @@ "title": "Switch", "device_automation": { "action_type": { - "toggle": "Toggle {entity_name}", - "turn_on": "Turn on {entity_name}", - "turn_off": "Turn off {entity_name}" + "toggle": "[%key:common::device_automation::action_type::toggle%]", + "turn_on": "[%key:common::device_automation::action_type::turn_on%]", + "turn_off": "[%key:common::device_automation::action_type::turn_off%]" }, "condition_type": { - "is_on": "{entity_name} is on", - "is_off": "{entity_name} is off" + "is_on": "[%key:common::device_automation::condition_type::is_on%]", + "is_off": "[%key:common::device_automation::condition_type::is_off%]" }, "trigger_type": { - "changed_states": "{entity_name} turned on or off", - "turned_on": "{entity_name} turned on", - "turned_off": "{entity_name} turned off" + "changed_states": "[%key:common::device_automation::trigger_type::changed_states%]", + "turned_on": "[%key:common::device_automation::trigger_type::turned_on%]", + "turned_off": "[%key:common::device_automation::trigger_type::turned_off%]" } }, "entity_component": { @@ -31,10 +31,18 @@ "name": "Outlet" } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Turns a switch on." + }, + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Turns a switch off." + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles a switch on/off." } } } diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index ef64a86c6e8..e2ad91e990e 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -8,9 +8,10 @@ import voluptuous as vol from homeassistant.components.homeassistant import exposed_entities from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.typing import EventType from .const import CONF_TARGET_DOMAIN from .light import LightSwitch @@ -55,7 +56,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False - async def async_registry_updated(event: Event) -> None: + async def async_registry_updated( + event: EventType[er.EventEntityRegistryUpdatedData], + ) -> None: """Handle entity registry update.""" data = event.data if data["action"] == "remove": diff --git a/homeassistant/components/switch_as_x/cover.py b/homeassistant/components/switch_as_x/cover.py index 7df3b177217..b7fe0fbf364 100644 --- a/homeassistant/components/switch_as_x/cover.py +++ b/homeassistant/components/switch_as_x/cover.py @@ -17,9 +17,11 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import EventStateChangedData +from homeassistant.helpers.typing import EventType from .entity import BaseEntity @@ -74,7 +76,9 @@ class CoverSwitch(BaseEntity, CoverEntity): ) @callback - def async_state_changed_listener(self, event: Event | None = None) -> None: + def async_state_changed_listener( + self, event: EventType[EventStateChangedData] | None = None + ) -> None: """Handle child updates.""" super().async_state_changed_listener(event) if ( diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index a73271bdc83..3718c4ebe99 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -12,10 +12,14 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import DeviceInfo, Entity, ToggleEntity -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import EventType from .const import DOMAIN as SWITCH_AS_X_DOMAIN @@ -63,7 +67,9 @@ class BaseEntity(Entity): ) @callback - def async_state_changed_listener(self, event: Event | None = None) -> None: + def async_state_changed_listener( + self, event: EventType[EventStateChangedData] | None = None + ) -> None: """Handle child updates.""" if ( state := self.hass.states.get(self._switch_entity_id) @@ -77,7 +83,9 @@ class BaseEntity(Entity): """Register callbacks and copy the wrapped entity's custom name if set.""" @callback - def _async_state_changed_listener(event: Event | None = None) -> None: + def _async_state_changed_listener( + event: EventType[EventStateChangedData] | None = None, + ) -> None: """Handle child updates.""" self.async_state_changed_listener(event) self.async_write_ha_state() @@ -157,7 +165,9 @@ class BaseToggleEntity(BaseEntity, ToggleEntity): ) @callback - def async_state_changed_listener(self, event: Event | None = None) -> None: + def async_state_changed_listener( + self, event: EventType[EventStateChangedData] | None = None + ) -> None: """Handle child updates.""" super().async_state_changed_listener(event) if ( diff --git a/homeassistant/components/switch_as_x/lock.py b/homeassistant/components/switch_as_x/lock.py index 9778caf8e60..9e7606865a1 100644 --- a/homeassistant/components/switch_as_x/lock.py +++ b/homeassistant/components/switch_as_x/lock.py @@ -13,9 +13,11 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import EventStateChangedData +from homeassistant.helpers.typing import EventType from .entity import BaseEntity @@ -68,7 +70,9 @@ class LockSwitch(BaseEntity, LockEntity): ) @callback - def async_state_changed_listener(self, event: Event | None = None) -> None: + def async_state_changed_listener( + self, event: EventType[EventStateChangedData] | None = None + ) -> None: """Handle child updates.""" super().async_state_changed_listener(event) if ( diff --git a/homeassistant/components/switch_as_x/manifest.json b/homeassistant/components/switch_as_x/manifest.json index b54509ea5e3..d14a6bcb390 100644 --- a/homeassistant/components/switch_as_x/manifest.json +++ b/homeassistant/components/switch_as_x/manifest.json @@ -1,6 +1,6 @@ { "domain": "switch_as_x", - "name": "Switch as X", + "name": "Change device type of a switch", "codeowners": ["@home-assistant/core"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/switch_as_x", diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index cb11c64f16a..7169f01b38f 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -25,12 +25,12 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { ), "motion_detected": BinarySensorEntityDescription( key="pir_state", - translation_key="motion", + name=None, device_class=BinarySensorDeviceClass.MOTION, ), "contact_open": BinarySensorEntityDescription( key="contact_open", - translation_key="door_open", + name=None, device_class=BinarySensorDeviceClass.DOOR, ), "contact_timeout": BinarySensorEntityDescription( @@ -41,12 +41,11 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { ), "is_light": BinarySensorEntityDescription( key="is_light", - translation_key="light", device_class=BinarySensorDeviceClass.LIGHT, ), "door_open": BinarySensorEntityDescription( key="door_status", - translation_key="door_open", + name=None, device_class=BinarySensorDeviceClass.DOOR, ), "unclosed_alarm": BinarySensorEntityDescription( diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 17e95486298..0f7d1407fc5 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -1,7 +1,7 @@ """Constants for the switchbot integration.""" -from switchbot import SwitchbotModel +from enum import StrEnum -from homeassistant.backports.enum import StrEnum +from switchbot import SwitchbotModel DOMAIN = "switchbot" MANUFACTURER = "switchbot" diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index b5b34bf54ec..a408bcb58bc 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -46,7 +46,6 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { ), "battery": SensorEntityDescription( key="battery", - translation_key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, @@ -60,21 +59,19 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { ), "humidity": SensorEntityDescription( key="humidity", - translation_key="humidity", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.HUMIDITY, ), "temperature": SensorEntityDescription( key="temperature", - translation_key="temperature", + name=None, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, ), "power": SensorEntityDescription( key="power", - translation_key="power", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index fb9f906527c..8eab1ec6f1a 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -64,18 +64,9 @@ "calibration": { "name": "Calibration" }, - "motion": { - "name": "[%key:component::binary_sensor::entity_component::motion::name%]" - }, - "door_open": { - "name": "[%key:component::binary_sensor::entity_component::door::name%]" - }, "door_timeout": { "name": "Timeout" }, - "light": { - "name": "[%key:component::binary_sensor::entity_component::light::name%]" - }, "door_unclosed_alarm": { "name": "Unclosed alarm" }, @@ -93,20 +84,8 @@ "wifi_signal": { "name": "Wi-Fi signal" }, - "battery": { - "name": "[%key:component::sensor::entity_component::battery::name%]" - }, "light_level": { "name": "Light level" - }, - "humidity": { - "name": "[%key:component::sensor::entity_component::humidity::name%]" - }, - "temperature": { - "name": "[%key:component::sensor::entity_component::temperature::name%]" - }, - "power": { - "name": "[%key:component::sensor::entity_component::power::name%]" } }, "cover": { diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 823f2c5463f..9accda95912 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -3,7 +3,7 @@ "name": "Switcher", "codeowners": ["@thecode"], "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/switcher_kis/", + "documentation": "https://www.home-assistant.io/integrations/switcher_kis", "iot_class": "local_push", "loggers": ["aioswitcher"], "quality_scale": "platinum", diff --git a/homeassistant/components/switcher_kis/services.yaml b/homeassistant/components/switcher_kis/services.yaml index a7c3df5903e..1dcb15fa482 100644 --- a/homeassistant/components/switcher_kis/services.yaml +++ b/homeassistant/components/switcher_kis/services.yaml @@ -1,6 +1,4 @@ set_auto_off: - name: Set auto off - description: "Update Switcher device auto off setting." target: entity: integration: switcher_kis @@ -8,16 +6,12 @@ set_auto_off: device_class: switch fields: auto_off: - name: Auto off - description: "Time period string containing hours and minutes." required: true example: '"02:30"' selector: text: turn_on_with_timer: - name: Turn on with timer - description: "Turn on the Switcher device with timer." target: entity: integration: switcher_kis @@ -25,8 +19,6 @@ turn_on_with_timer: device_class: switch fields: timer_minutes: - name: Timer - description: "Time to turn on." required: true selector: number: diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json index ad8f0f41ae7..4c4080a8394 100644 --- a/homeassistant/components/switcher_kis/strings.json +++ b/homeassistant/components/switcher_kis/strings.json @@ -9,5 +9,27 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "services": { + "set_auto_off": { + "name": "Set auto off", + "description": "Updates Switcher device auto off setting.", + "fields": { + "auto_off": { + "name": "Auto off", + "description": "Time period string containing hours and minutes." + } + } + }, + "turn_on_with_timer": { + "name": "Turn on with timer", + "description": "Turns on the Switcher device with timer.", + "fields": { + "timer_minutes": { + "name": "Timer", + "description": "Time to turn on." + } + } + } } } diff --git a/homeassistant/components/synology_dsm/services.yaml b/homeassistant/components/synology_dsm/services.yaml index 245d45fc800..32baeec11c1 100644 --- a/homeassistant/components/synology_dsm/services.yaml +++ b/homeassistant/components/synology_dsm/services.yaml @@ -1,23 +1,15 @@ # synology-dsm service entries description. reboot: - name: Reboot - description: Reboot the NAS. This service is deprecated and will be removed in future release. Please use the corresponding button entity. fields: serial: - name: Serial - description: serial of the NAS to reboot; required when multiple NAS are configured. example: 1NDVC86409 selector: text: shutdown: - name: Shutdown - description: Shutdown the NAS. This service is deprecated and will be removed in future release. Please use the corresponding button entity. fields: serial: - name: Serial - description: serial of the NAS to shutdown; required when multiple NAS are configured. example: 1NDVC86409 selector: text: diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 92903b1d2ae..f7ae9c9f238 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -63,48 +63,130 @@ }, "entity": { "binary_sensor": { - "disk_below_remain_life_thr": { "name": "Below min remaining life" }, - "disk_exceed_bad_sector_thr": { "name": "Exceeded max bad sectors" }, - "status": { "name": "Security status" } + "disk_below_remain_life_thr": { + "name": "Below min remaining life" + }, + "disk_exceed_bad_sector_thr": { + "name": "Exceeded max bad sectors" + }, + "status": { + "name": "Security status" + } }, "sensor": { - "cpu_15min_load": { "name": "CPU load average (15 min)" }, - "cpu_1min_load": { "name": "CPU load average (1 min)" }, - "cpu_5min_load": { "name": "CPU load average (5 min)" }, - "cpu_other_load": { "name": "CPU utilization (other)" }, - "cpu_system_load": { "name": "CPU utilization (system)" }, - "cpu_total_load": { "name": "CPU utilization (total)" }, - "cpu_user_load": { "name": "CPU utilization (user)" }, - "disk_smart_status": { "name": "Status (smart)" }, - "disk_status": { "name": "Status" }, + "cpu_15min_load": { + "name": "CPU load average (15 min)" + }, + "cpu_1min_load": { + "name": "CPU load average (1 min)" + }, + "cpu_5min_load": { + "name": "CPU load average (5 min)" + }, + "cpu_other_load": { + "name": "CPU utilization (other)" + }, + "cpu_system_load": { + "name": "CPU utilization (system)" + }, + "cpu_total_load": { + "name": "CPU utilization (total)" + }, + "cpu_user_load": { + "name": "CPU utilization (user)" + }, + "disk_smart_status": { + "name": "Status (smart)" + }, + "disk_status": { + "name": "Status" + }, "disk_temp": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, - "memory_available_real": { "name": "Memory available (real)" }, - "memory_available_swap": { "name": "Memory available (swap)" }, - "memory_cached": { "name": "Memory cached" }, - "memory_real_usage": { "name": "Memory usage (real)" }, - "memory_size": { "name": "Memory size" }, - "memory_total_real": { "name": "Memory total (real)" }, - "memory_total_swap": { "name": "Memory total (swap)" }, - "network_down": { "name": "Download throughput" }, - "network_up": { "name": "Upload throughput" }, + "memory_available_real": { + "name": "Memory available (real)" + }, + "memory_available_swap": { + "name": "Memory available (swap)" + }, + "memory_cached": { + "name": "Memory cached" + }, + "memory_real_usage": { + "name": "Memory usage (real)" + }, + "memory_size": { + "name": "Memory size" + }, + "memory_total_real": { + "name": "Memory total (real)" + }, + "memory_total_swap": { + "name": "Memory total (swap)" + }, + "network_down": { + "name": "Download throughput" + }, + "network_up": { + "name": "Upload throughput" + }, "temperature": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, - "uptime": { "name": "Last boot" }, - "volume_disk_temp_avg": { "name": "Average disk temp" }, - "volume_disk_temp_max": { "name": "Maximum disk temp" }, - "volume_percentage_used": { "name": "Volume used" }, - "volume_size_total": { "name": "Total size" }, - "volume_size_used": { "name": "Used space" }, - "volume_status": { "name": "Status" } + "uptime": { + "name": "Last boot" + }, + "volume_disk_temp_avg": { + "name": "Average disk temp" + }, + "volume_disk_temp_max": { + "name": "Maximum disk temp" + }, + "volume_percentage_used": { + "name": "Volume used" + }, + "volume_size_total": { + "name": "Total size" + }, + "volume_size_used": { + "name": "Used space" + }, + "volume_status": { + "name": "Status" + } }, "switch": { - "home_mode": { "name": "Home mode" } + "home_mode": { + "name": "Home mode" + } }, "update": { - "update": { "name": "DSM update" } + "update": { + "name": "DSM update" + } + } + }, + "services": { + "reboot": { + "name": "Reboot", + "description": "Reboots the NAS. This service is deprecated and will be removed in future release. Please use the corresponding button entity.", + "fields": { + "serial": { + "name": "Serial", + "description": "Serial of the NAS to reboot; required when multiple NAS are configured." + } + } + }, + "shutdown": { + "name": "Shutdown", + "description": "Shutdowns the NAS. This service is deprecated and will be removed in future release. Please use the corresponding button entity.", + "fields": { + "serial": { + "name": "[%key:component::synology_dsm::services::reboot::fields::serial::name%]", + "description": "Serial of the NAS to shutdown; required when multiple NAS are configured." + } + } } } } diff --git a/homeassistant/components/system_bridge/services.yaml b/homeassistant/components/system_bridge/services.yaml index d33235ffba4..78d6e87f218 100644 --- a/homeassistant/components/system_bridge/services.yaml +++ b/homeassistant/components/system_bridge/services.yaml @@ -1,71 +1,47 @@ open_path: - name: Open Path - description: Open a file on the server using the default application. fields: bridge: - name: Bridge - description: The server to talk to. required: true selector: device: integration: system_bridge path: - name: Path - description: Path to open. required: true example: "C:\\test\\image.png" selector: text: open_url: - name: Open URL - description: Open a URL on the server using the default application. fields: bridge: - name: Bridge - description: The server to talk to. required: true selector: device: integration: system_bridge url: - name: URL - description: URL to open. required: true example: "https://www.home-assistant.io" selector: text: send_keypress: - name: Send Keyboard Keypress - description: Sends a keyboard keypress. fields: bridge: - name: Bridge - description: The server to send the command to. required: true selector: device: integration: system_bridge key: - name: Key - description: "Key to press. List available here: http://robotjs.io/docs/syntax#keys" required: true example: "audio_play" selector: text: send_text: - name: Send Keyboard Text - description: Sends text for the server to type. fields: bridge: - name: Bridge - description: The server to send the command to. required: true selector: device: integration: system_bridge text: - name: Text - description: "Text to type." required: true example: "Hello world" selector: diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json index 209bce9078a..c3e1f949152 100644 --- a/homeassistant/components/system_bridge/strings.json +++ b/homeassistant/components/system_bridge/strings.json @@ -27,5 +27,63 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "services": { + "open_path": { + "name": "Open path", + "description": "Opens a file on the server using the default application.", + "fields": { + "bridge": { + "name": "Bridge", + "description": "The server to talk to." + }, + "path": { + "name": "[%key:common::config_flow::data::path%]", + "description": "Path to open." + } + } + }, + "open_url": { + "name": "Open URL", + "description": "Opens a URL on the server using the default application.", + "fields": { + "bridge": { + "name": "[%key:component::system_bridge::services::open_path::fields::bridge::name%]", + "description": "[%key:component::system_bridge::services::open_path::fields::bridge::description%]" + }, + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "URL to open." + } + } + }, + "send_keypress": { + "name": "Send keyboard keypress", + "description": "Sends a keyboard keypress.", + "fields": { + "bridge": { + "name": "[%key:component::system_bridge::services::open_path::fields::bridge::name%]", + "description": "The server to send the command to." + }, + "key": { + "name": "Key", + "description": "Key to press. List available here: http://robotjs.io/docs/syntax#keys." + } + } + }, + "send_text": { + "name": "Send keyboard text", + "description": "Sends text for the server to type.", + "fields": { + "bridge": { + "name": "[%key:component::system_bridge::services::open_path::fields::bridge::name%]", + "description": "[%key:component::system_bridge::services::send_keypress::fields::bridge::description%]" + }, + "text": { + "name": "Text", + "description": "Text to type." + } + } + } } } diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 8a5f53d52de..f025013cc2b 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -116,6 +116,19 @@ def _safe_get_message(record: logging.LogRecord) -> str: class LogEntry: """Store HA log entries.""" + __slots__ = ( + "first_occurred", + "timestamp", + "name", + "level", + "message", + "exception", + "root_cause", + "source", + "count", + "key", + ) + def __init__(self, record: logging.LogRecord, source: tuple[str, int]) -> None: """Initialize a log entry.""" self.first_occurred = self.timestamp = record.created @@ -134,7 +147,7 @@ class LogEntry: self.root_cause = str(traceback.extract_tb(tb)[-1]) self.source = source self.count = 1 - self.hash = str([self.name, *self.source, self.root_cause]) + self.key = (self.name, source, self.root_cause) def to_dict(self): """Convert object into dict to maintain backward compatibility.""" @@ -160,7 +173,7 @@ class DedupStore(OrderedDict): def add_entry(self, entry: LogEntry) -> None: """Add a new entry.""" - key = entry.hash + key = entry.key if key in self: # Update stored entry diff --git a/homeassistant/components/system_log/manifest.json b/homeassistant/components/system_log/manifest.json index 5d0fa29b2e8..e9a24cfe1e1 100644 --- a/homeassistant/components/system_log/manifest.json +++ b/homeassistant/components/system_log/manifest.json @@ -2,7 +2,6 @@ "domain": "system_log", "name": "System Log", "codeowners": [], - "dependencies": [], "documentation": "https://www.home-assistant.io/integrations/system_log", "integration_type": "system", "quality_scale": "internal" diff --git a/homeassistant/components/system_log/services.yaml b/homeassistant/components/system_log/services.yaml index 0f9ae61ba4c..9ab3bb6bce3 100644 --- a/homeassistant/components/system_log/services.yaml +++ b/homeassistant/components/system_log/services.yaml @@ -1,39 +1,23 @@ clear: - name: Clear all - description: Clear all log entries. - write: - name: Write - description: Write log entry. fields: message: - name: Message - description: Message to log. required: true example: Something went wrong selector: text: level: - name: Level - description: "Log level." default: error selector: select: options: - - label: "Debug" - value: "debug" - - label: "Info" - value: "info" - - label: "Warning" - value: "warning" - - label: "Error" - value: "error" - - label: "Critical" - value: "critical" + - "debug" + - "info" + - "warning" + - "error" + - "critical" + translation_key: level logger: - name: Logger - description: Logger name under which to log the message. Defaults to - 'system_log.external'. example: mycomponent.myplatform selector: text: diff --git a/homeassistant/components/system_log/strings.json b/homeassistant/components/system_log/strings.json new file mode 100644 index 00000000000..ed1ca79fe07 --- /dev/null +++ b/homeassistant/components/system_log/strings.json @@ -0,0 +1,37 @@ +{ + "services": { + "clear": { + "name": "Clear all", + "description": "Clears all log entries." + }, + "write": { + "name": "Write", + "description": "Write log entry.", + "fields": { + "message": { + "name": "Message", + "description": "Message to log." + }, + "level": { + "name": "Level", + "description": "Log level." + }, + "logger": { + "name": "Logger", + "description": "Logger name under which to log the message. Defaults to `system_log.external`." + } + } + } + }, + "selector": { + "level": { + "options": { + "debug": "Debug", + "info": "Info", + "warning": "Warning", + "error": "Error", + "critical": "Critical" + } + } + } +} diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index 24d62d76026..c5222112c02 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -50,31 +50,28 @@ class TadoBinarySensorEntityDescription( BATTERY_STATE_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( key="battery state", - name="Battery state", state_fn=lambda data: data["batteryState"] == "LOW", device_class=BinarySensorDeviceClass.BATTERY, ) CONNECTION_STATE_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( key="connection state", - name="Connection state", + translation_key="connection_state", state_fn=lambda data: data.get("connectionState", {}).get("value", False), device_class=BinarySensorDeviceClass.CONNECTIVITY, ) POWER_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( key="power", - name="Power", state_fn=lambda data: data.power == "ON", device_class=BinarySensorDeviceClass.POWER, ) LINK_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( key="link", - name="Link", state_fn=lambda data: data.link == "ONLINE", device_class=BinarySensorDeviceClass.CONNECTIVITY, ) OVERLAY_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( key="overlay", - name="Overlay", + translation_key="overlay", state_fn=lambda data: data.overlay_active, attributes_fn=lambda data: {"termination": data.overlay_termination_type} if data.overlay_active @@ -83,14 +80,13 @@ OVERLAY_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( ) OPEN_WINDOW_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( key="open window", - name="Open window", state_fn=lambda data: bool(data.open_window or data.open_window_detected), attributes_fn=lambda data: data.open_window_attr, device_class=BinarySensorDeviceClass.WINDOW, ) EARLY_START_ENTITY_DESCRIPTION = TadoBinarySensorEntityDescription( key="early start", - name="Early start", + translation_key="early_start", state_fn=lambda data: data.preparation, device_class=BinarySensorDeviceClass.POWER, ) @@ -173,8 +169,6 @@ class TadoDeviceBinarySensor(TadoDeviceEntity, BinarySensorEntity): entity_description: TadoBinarySensorEntityDescription - _attr_has_entity_name = True - def __init__( self, tado, device_info, entity_description: TadoBinarySensorEntityDescription ) -> None: @@ -227,8 +221,6 @@ class TadoZoneBinarySensor(TadoZoneEntity, BinarySensorEntity): entity_description: TadoBinarySensorEntityDescription - _attr_has_entity_name = True - def __init__( self, tado, diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 2b8bc4060d6..36a2ab671c9 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -218,6 +218,7 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): """Representation of a Tado climate entity.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_name = None def __init__( self, @@ -244,7 +245,6 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self.zone_type = zone_type self._attr_unique_id = f"{zone_type} {zone_id} {tado.home_id}" - self._attr_name = zone_name self._attr_temperature_unit = UnitOfTemperature.CELSIUS self._attr_translation_key = DOMAIN diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index c825bafc4b9..5e3065bfb53 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -8,6 +8,7 @@ class TadoDeviceEntity(Entity): """Base implementation for Tado device.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, device_info): """Initialize a Tado device.""" @@ -34,6 +35,7 @@ class TadoHomeEntity(Entity): """Base implementation for Tado home.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, tado): """Initialize a Tado home.""" @@ -56,6 +58,7 @@ class TadoHomeEntity(Entity): class TadoZoneEntity(Entity): """Base implementation for Tado zone.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__(self, zone_name, home_id, zone_id): diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 7742f6b0dca..f7ba1682e18 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -55,7 +55,7 @@ class TadoSensorEntityDescription( HOME_SENSORS = [ TadoSensorEntityDescription( key="outdoor temperature", - name="Outdoor temperature", + translation_key="outdoor_temperature", state_fn=lambda data: data["outsideTemperature"]["celsius"], attributes_fn=lambda data: { "time": data["outsideTemperature"]["timestamp"], @@ -67,7 +67,7 @@ HOME_SENSORS = [ ), TadoSensorEntityDescription( key="solar percentage", - name="Solar percentage", + translation_key="solar_percentage", state_fn=lambda data: data["solarIntensity"]["percentage"], attributes_fn=lambda data: { "time": data["solarIntensity"]["timestamp"], @@ -78,28 +78,28 @@ HOME_SENSORS = [ ), TadoSensorEntityDescription( key="weather condition", - name="Weather condition", + translation_key="weather_condition", state_fn=lambda data: format_condition(data["weatherState"]["value"]), attributes_fn=lambda data: {"time": data["weatherState"]["timestamp"]}, data_category=SENSOR_DATA_CATEGORY_WEATHER, ), TadoSensorEntityDescription( key="tado mode", - name="Tado mode", + translation_key="tado_mode", # pylint: disable=unnecessary-lambda state_fn=lambda data: get_tado_mode(data), data_category=SENSOR_DATA_CATEGORY_GEOFENCE, ), TadoSensorEntityDescription( key="geofencing mode", - name="Geofencing mode", + translation_key="geofencing_mode", # pylint: disable=unnecessary-lambda state_fn=lambda data: get_geofencing_mode(data), data_category=SENSOR_DATA_CATEGORY_GEOFENCE, ), TadoSensorEntityDescription( key="automatic geofencing", - name="Automatic geofencing", + translation_key="automatic_geofencing", # pylint: disable=unnecessary-lambda state_fn=lambda data: get_automatic_geofencing(data), data_category=SENSOR_DATA_CATEGORY_GEOFENCE, @@ -108,7 +108,6 @@ HOME_SENSORS = [ TEMPERATURE_ENTITY_DESCRIPTION = TadoSensorEntityDescription( key="temperature", - name="Temperature", state_fn=lambda data: data.current_temp, attributes_fn=lambda data: { "time": data.current_temp_timestamp, @@ -120,7 +119,6 @@ TEMPERATURE_ENTITY_DESCRIPTION = TadoSensorEntityDescription( ) HUMIDITY_ENTITY_DESCRIPTION = TadoSensorEntityDescription( key="humidity", - name="Humidity", state_fn=lambda data: data.current_humidity, attributes_fn=lambda data: {"time": data.current_humidity_timestamp}, native_unit_of_measurement=PERCENTAGE, @@ -129,12 +127,12 @@ HUMIDITY_ENTITY_DESCRIPTION = TadoSensorEntityDescription( ) TADO_MODE_ENTITY_DESCRIPTION = TadoSensorEntityDescription( key="tado mode", - name="Tado mode", + translation_key="tado_mode", state_fn=lambda data: data.tado_mode, ) HEATING_ENTITY_DESCRIPTION = TadoSensorEntityDescription( key="heating", - name="Heating", + translation_key="heating", state_fn=lambda data: data.heating_power_percentage, attributes_fn=lambda data: {"time": data.heating_power_timestamp}, native_unit_of_measurement=PERCENTAGE, @@ -142,6 +140,7 @@ HEATING_ENTITY_DESCRIPTION = TadoSensorEntityDescription( ) AC_ENTITY_DESCRIPTION = TadoSensorEntityDescription( key="ac", + translation_key="ac", name="AC", state_fn=lambda data: data.ac_power, attributes_fn=lambda data: {"time": data.ac_power_timestamp}, @@ -244,8 +243,6 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): entity_description: TadoSensorEntityDescription - _attr_has_entity_name = True - def __init__(self, tado, entity_description: TadoSensorEntityDescription) -> None: """Initialize of the Tado Sensor.""" self.entity_description = entity_description @@ -298,8 +295,6 @@ class TadoZoneSensor(TadoZoneEntity, SensorEntity): entity_description: TadoSensorEntityDescription - _attr_has_entity_name = True - def __init__( self, tado, diff --git a/homeassistant/components/tado/services.yaml b/homeassistant/components/tado/services.yaml index 211ae4cd1ff..0f66798f864 100644 --- a/homeassistant/components/tado/services.yaml +++ b/homeassistant/components/tado/services.yaml @@ -1,14 +1,10 @@ set_climate_timer: - name: Set climate timer - description: Turn on climate entities for a set time. target: entity: integration: tado domain: climate fields: temperature: - name: Temperature - description: Temperature to set climate entity to required: true selector: number: @@ -17,15 +13,11 @@ set_climate_timer: step: 0.5 unit_of_measurement: "°" time_period: - name: Time period - description: Choose this or Overlay. Set the time period for the change if you want to be specific. Alternatively use Overlay required: false example: "01:30:00" selector: text: requested_overlay: - name: Overlay - description: Choose this or Time Period. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on tado app setting required: false example: "MANUAL" selector: @@ -36,24 +28,18 @@ set_climate_timer: - "TADO_DEFAULT" set_water_heater_timer: - name: Set water heater timer - description: Turn on water heater for a set time. target: entity: integration: tado domain: water_heater fields: time_period: - name: Time period - description: Set the time period for the boost. required: true example: "01:30:00" default: "01:00:00" selector: text: temperature: - name: Temperature - description: Temperature to set heater to selector: number: min: 0 @@ -62,16 +48,12 @@ set_water_heater_timer: unit_of_measurement: "°" set_climate_temperature_offset: - name: Set climate temperature offset - description: Set the temperature offset of climate entities target: entity: integration: tado domain: climate fields: offset: - name: Offset - description: Offset you would like (depending on your device). default: 0 selector: number: diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 3decfe3cd0c..9858b7aa51b 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -31,6 +31,17 @@ } }, "entity": { + "binary_sensor": { + "connection_state": { + "name": "Connection state" + }, + "overlay": { + "name": "Overlay" + }, + "early_start": { + "name": "Early start" + } + }, "climate": { "tado": { "state_attributes": { @@ -41,6 +52,76 @@ } } } + }, + "sensor": { + "outdoor_temperature": { + "name": "Outdoor temperature" + }, + "solar_percentage": { + "name": "Solar percentage" + }, + "weather_condition": { + "name": "Weather condition" + }, + "tado_mode": { + "name": "Tado mode" + }, + "geofencing_mode": { + "name": "Geofencing mode" + }, + "automatic_geofencing": { + "name": "Automatic geofencing" + }, + "heating": { + "name": "Heating" + }, + "ac": { + "name": "AC" + } + } + }, + "services": { + "set_climate_timer": { + "name": "Set climate timer", + "description": "Turns on climate entities for a set time.", + "fields": { + "temperature": { + "name": "Temperature", + "description": "Temperature to set climate entity to." + }, + "time_period": { + "name": "Time period", + "description": "Choose this or Overlay. Set the time period for the change if you want to be specific. Alternatively use Overlay." + }, + "requested_overlay": { + "name": "Overlay", + "description": "Choose this or Time Period. Allows you to choose an overlay. MANUAL:=Overlay until user removes; NEXT_TIME_BLOCK:=Overlay until next timeblock; TADO_DEFAULT:=Overlay based on tado app setting." + } + } + }, + "set_water_heater_timer": { + "name": "Set water heater timer", + "description": "Turns on water heater for a set time.", + "fields": { + "time_period": { + "name": "Time period", + "description": "Set the time period for the boost." + }, + "temperature": { + "name": "Temperature", + "description": "Temperature to set heater to." + } + } + }, + "set_climate_temperature_offset": { + "name": "Set climate temperature offset", + "description": "Sets the temperature offset of climate entities.", + "fields": { + "offset": { + "name": "Offset", + "description": "Offset you would like (depending on your device)." + } + } } } } diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index f7a1dcd0966..6d17c85c981 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -119,6 +119,8 @@ def create_water_heater_entity(tado, name: str, zone_id: int, zone: str): class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): """Representation of a Tado water heater.""" + _attr_name = None + def __init__( self, tado, @@ -166,11 +168,6 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): ) self._async_update_data() - @property - def name(self): - """Return the name of the entity.""" - return self.zone_name - @property def unique_id(self): """Return the unique id.""" diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index 9570c4a4628..ecc561f0355 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -37,49 +37,49 @@ class TailscaleBinarySensorEntityDescription( BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = ( TailscaleBinarySensorEntityDescription( key="update_available", - name="Client", + translation_key="client", device_class=BinarySensorDeviceClass.UPDATE, entity_category=EntityCategory.DIAGNOSTIC, is_on_fn=lambda device: device.update_available, ), TailscaleBinarySensorEntityDescription( key="client_supports_hair_pinning", - name="Supports hairpinning", + translation_key="client_supports_hair_pinning", icon="mdi:wan", entity_category=EntityCategory.DIAGNOSTIC, is_on_fn=lambda device: device.client_connectivity.client_supports.hair_pinning, ), TailscaleBinarySensorEntityDescription( key="client_supports_ipv6", - name="Supports IPv6", + translation_key="client_supports_ipv6", icon="mdi:wan", entity_category=EntityCategory.DIAGNOSTIC, is_on_fn=lambda device: device.client_connectivity.client_supports.ipv6, ), TailscaleBinarySensorEntityDescription( key="client_supports_pcp", - name="Supports PCP", + translation_key="client_supports_pcp", icon="mdi:wan", entity_category=EntityCategory.DIAGNOSTIC, is_on_fn=lambda device: device.client_connectivity.client_supports.pcp, ), TailscaleBinarySensorEntityDescription( key="client_supports_pmp", - name="Supports NAT-PMP", + translation_key="client_supports_pmp", icon="mdi:wan", entity_category=EntityCategory.DIAGNOSTIC, is_on_fn=lambda device: device.client_connectivity.client_supports.pmp, ), TailscaleBinarySensorEntityDescription( key="client_supports_udp", - name="Supports UDP", + translation_key="client_supports_udp", icon="mdi:wan", entity_category=EntityCategory.DIAGNOSTIC, is_on_fn=lambda device: device.client_connectivity.client_supports.udp, ), TailscaleBinarySensorEntityDescription( key="client_supports_upnp", - name="Supports UPnP", + translation_key="client_supports_upnp", icon="mdi:wan", entity_category=EntityCategory.DIAGNOSTIC, is_on_fn=lambda device: device.client_connectivity.client_supports.upnp, diff --git a/homeassistant/components/tailscale/sensor.py b/homeassistant/components/tailscale/sensor.py index 71fc7d848ea..75dca4ed840 100644 --- a/homeassistant/components/tailscale/sensor.py +++ b/homeassistant/components/tailscale/sensor.py @@ -38,21 +38,21 @@ class TailscaleSensorEntityDescription( SENSORS: tuple[TailscaleSensorEntityDescription, ...] = ( TailscaleSensorEntityDescription( key="expires", - name="Expires", + translation_key="expires", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.expires, ), TailscaleSensorEntityDescription( key="ip", - name="IP address", + translation_key="ip", icon="mdi:ip-network", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.addresses[0] if device.addresses else None, ), TailscaleSensorEntityDescription( key="last_seen", - name="Last seen", + translation_key="last_seen", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda device: device.last_seen, ), diff --git a/homeassistant/components/tailscale/strings.json b/homeassistant/components/tailscale/strings.json index c03b5a3f841..b110e53ee64 100644 --- a/homeassistant/components/tailscale/strings.json +++ b/homeassistant/components/tailscale/strings.json @@ -23,5 +23,41 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "binary_sensor": { + "client": { + "name": "Client" + }, + "client_supports_hair_pinning": { + "name": "Supports hairpinning" + }, + "client_supports_ipv6": { + "name": "Supports IPv6" + }, + "client_supports_pcp": { + "name": "Supports PCP" + }, + "client_supports_pmp": { + "name": "Supports NAT-PMP" + }, + "client_supports_udp": { + "name": "Supports UDP" + }, + "client_supports_upnp": { + "name": "Supports UPnP" + } + }, + "sensor": { + "expires": { + "name": "Expires" + }, + "ip": { + "name": "IP address" + }, + "last_seen": { + "name": "Last seen" + } + } } } diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index b68359a5176..dea370f45b3 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -38,7 +38,7 @@ "init": { "title": "Tankerkoenig options", "data": { - "stations": "Stations", + "stations": "[%key:component::tankerkoenig::config::step::select_station::data::stations%]", "show_on_map": "Show stations on map" } } diff --git a/homeassistant/components/tasmota/__init__.py b/homeassistant/components/tasmota/__init__.py index 2123ee74f1b..7d4331f0d40 100644 --- a/homeassistant/components/tasmota/__init__.py +++ b/homeassistant/components/tasmota/__init__.py @@ -119,7 +119,9 @@ async def _remove_device( device_registry: DeviceRegistry, ) -> None: """Remove a discovered Tasmota device.""" - device = device_registry.async_get_device(set(), {(CONNECTION_NETWORK_MAC, mac)}) + device = device_registry.async_get_device( + connections={(CONNECTION_NETWORK_MAC, mac)} + ) if device is None or config_entry.entry_id not in device.config_entries: return diff --git a/homeassistant/components/tasmota/device_trigger.py b/homeassistant/components/tasmota/device_trigger.py index 49caf30b010..f01cdddb1db 100644 --- a/homeassistant/components/tasmota/device_trigger.py +++ b/homeassistant/components/tasmota/device_trigger.py @@ -223,8 +223,7 @@ async def async_setup_trigger( device_registry = dr.async_get(hass) device = device_registry.async_get_device( - set(), - {(CONNECTION_NETWORK_MAC, tasmota_trigger.cfg.mac)}, + connections={(CONNECTION_NETWORK_MAC, tasmota_trigger.cfg.mac)}, ) if device is None: diff --git a/homeassistant/components/tasmota/discovery.py b/homeassistant/components/tasmota/discovery.py index b490b4c724c..70cedd9dd3d 100644 --- a/homeassistant/components/tasmota/discovery.py +++ b/homeassistant/components/tasmota/discovery.py @@ -302,7 +302,7 @@ async def async_start( # noqa: C901 device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) device = device_registry.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) if device is None: diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index 11dfdf67b35..a64f4312de1 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -61,14 +61,14 @@ SENSOR_TYPES: tuple[TautulliSensorEntityDescription, ...] = ( TautulliSensorEntityDescription( icon="mdi:plex", key="watching_count", - name="Watching", + translation_key="watching_count", native_unit_of_measurement="Watching", value_fn=lambda home_stats, activity, _: cast(int, activity.stream_count), ), TautulliSensorEntityDescription( icon="mdi:plex", key="stream_count_direct_play", - name="Direct plays", + translation_key="stream_count_direct_play", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement="Streams", entity_registry_enabled_default=False, @@ -79,7 +79,7 @@ SENSOR_TYPES: tuple[TautulliSensorEntityDescription, ...] = ( TautulliSensorEntityDescription( icon="mdi:plex", key="stream_count_direct_stream", - name="Direct streams", + translation_key="stream_count_direct_stream", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement="Streams", entity_registry_enabled_default=False, @@ -90,7 +90,7 @@ SENSOR_TYPES: tuple[TautulliSensorEntityDescription, ...] = ( TautulliSensorEntityDescription( icon="mdi:plex", key="stream_count_transcode", - name="Transcodes", + translation_key="stream_count_transcode", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement="Streams", entity_registry_enabled_default=False, @@ -100,7 +100,7 @@ SENSOR_TYPES: tuple[TautulliSensorEntityDescription, ...] = ( ), TautulliSensorEntityDescription( key="total_bandwidth", - name="Total bandwidth", + translation_key="total_bandwidth", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.KILOBITS, device_class=SensorDeviceClass.DATA_SIZE, @@ -109,7 +109,7 @@ SENSOR_TYPES: tuple[TautulliSensorEntityDescription, ...] = ( ), TautulliSensorEntityDescription( key="lan_bandwidth", - name="LAN bandwidth", + translation_key="lan_bandwidth", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.KILOBITS, device_class=SensorDeviceClass.DATA_SIZE, @@ -119,7 +119,7 @@ SENSOR_TYPES: tuple[TautulliSensorEntityDescription, ...] = ( ), TautulliSensorEntityDescription( key="wan_bandwidth", - name="WAN bandwidth", + translation_key="wan_bandwidth", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.KILOBITS, device_class=SensorDeviceClass.DATA_SIZE, @@ -130,21 +130,21 @@ SENSOR_TYPES: tuple[TautulliSensorEntityDescription, ...] = ( TautulliSensorEntityDescription( icon="mdi:movie-open", key="top_movies", - name="Top movie", + translation_key="top_movies", entity_registry_enabled_default=False, value_fn=get_top_stats, ), TautulliSensorEntityDescription( icon="mdi:television", key="top_tv", - name="Top TV show", + translation_key="top_tv", entity_registry_enabled_default=False, value_fn=get_top_stats, ), TautulliSensorEntityDescription( icon="mdi:walk", key=ATTR_TOP_USER, - name="Top user", + translation_key="top_user", entity_registry_enabled_default=False, value_fn=get_top_stats, ), @@ -169,26 +169,26 @@ SESSION_SENSOR_TYPES: tuple[TautulliSessionSensorEntityDescription, ...] = ( TautulliSessionSensorEntityDescription( icon="mdi:plex", key="state", - name="State", + translation_key="state", value_fn=lambda session: cast(str, session.state), ), TautulliSessionSensorEntityDescription( key="full_title", - name="Full title", + translation_key="full_title", entity_registry_enabled_default=False, value_fn=lambda session: cast(str, session.full_title), ), TautulliSessionSensorEntityDescription( icon="mdi:progress-clock", key="progress", - name="Progress", + translation_key="progress", native_unit_of_measurement=PERCENTAGE, entity_registry_enabled_default=False, value_fn=lambda session: cast(str, session.progress_percent), ), TautulliSessionSensorEntityDescription( key="stream_resolution", - name="Stream resolution", + translation_key="stream_resolution", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda session: cast(str, session.stream_video_resolution), @@ -196,21 +196,21 @@ SESSION_SENSOR_TYPES: tuple[TautulliSessionSensorEntityDescription, ...] = ( TautulliSessionSensorEntityDescription( icon="mdi:plex", key="transcode_decision", - name="Transcode decision", + translation_key="transcode_decision", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda session: cast(str, session.transcode_decision), ), TautulliSessionSensorEntityDescription( key="session_thumb", - name="session thumbnail", + translation_key="session_thumb", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda session: cast(str, session.user_thumb), ), TautulliSessionSensorEntityDescription( key="video_resolution", - name="Video resolution", + translation_key="video_resolution", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda session: cast(str, session.video_resolution), diff --git a/homeassistant/components/tautulli/strings.json b/homeassistant/components/tautulli/strings.json index 90c64a6a8d6..4278c6a3bec 100644 --- a/homeassistant/components/tautulli/strings.json +++ b/homeassistant/components/tautulli/strings.json @@ -26,5 +26,60 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "entity": { + "sensor": { + "watching_count": { + "name": "Watching" + }, + "stream_count_direct_play": { + "name": "Direct plays" + }, + "stream_count_direct_stream": { + "name": "Direct streams" + }, + "stream_count_transcode": { + "name": "Transcodes" + }, + "total_bandwidth": { + "name": "Total bandwidth" + }, + "lan_bandwidth": { + "name": "LAN bandwidth" + }, + "wan_bandwidth": { + "name": "WAN bandwidth" + }, + "top_movies": { + "name": "Top movie" + }, + "top_tv": { + "name": "Top TV show" + }, + "top_user": { + "name": "Top user" + }, + "state": { + "name": "State" + }, + "full_title": { + "name": "Full title" + }, + "progress": { + "name": "Progress" + }, + "stream_resolution": { + "name": "Stream resolution" + }, + "transcode_decision": { + "name": "Transcode decision" + }, + "session_thumb": { + "name": "Session thumbnail" + }, + "video_resolution": { + "name": "Video resolution" + } + } } } diff --git a/homeassistant/components/telegram/services.yaml b/homeassistant/components/telegram/services.yaml index bbdd82768f5..c983a105c93 100644 --- a/homeassistant/components/telegram/services.yaml +++ b/homeassistant/components/telegram/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload telegram notify services. diff --git a/homeassistant/components/telegram/strings.json b/homeassistant/components/telegram/strings.json new file mode 100644 index 00000000000..34a98f908dc --- /dev/null +++ b/homeassistant/components/telegram/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads telegram notify services." + } + } +} diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 31876bd542d..cdb50d55943 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -1,31 +1,21 @@ # Describes the format for available Telegram bot services send_message: - name: Send message - description: Send a notification. fields: message: - name: Message - description: Message body of the notification. required: true example: The garage door has been open for 10 minutes. selector: text: title: - name: Title - description: Optional title for your notification. Will be composed as '%title\n%message' example: "Your Garage Door Friend" selector: text: target: - name: Target - description: An array of pre-authorized chat_ids to send the notification to. If not present, first allowed chat_id is the default. example: "[12345, 67890] or 12345" selector: object: parse_mode: - name: Parse mode - description: "Parser for the message text." selector: select: options: @@ -33,18 +23,12 @@ send_message: - "markdown" - "markdown2" disable_notification: - name: Disable notification - description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. selector: boolean: disable_web_page_preview: - name: Disable web page preview - description: Disables link previews for links in the message. selector: boolean: timeout: - name: Timeout - description: Timeout for send message. Will help with timeout errors (poor internet connection, etc)s selector: number: min: 1 @@ -52,61 +36,44 @@ send_message: unit_of_measurement: seconds keyboard: - name: Keyboard - description: List of rows of commands, comma-separated, to make a custom keyboard. Empty list clears a previously set keyboard. example: '["/command1, /command2", "/command3"]' selector: object: inline_keyboard: - name: Inline keyboard - description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data. - example: '["/button1, /button2", "/button3"] or ["Text button1:/button1, Text button2:/button2", "Text button3:/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + example: + '["/button1, /button2", "/button3"] or ["Text button1:/button1, Text + button2:/button2", "Text button3:/button3"] or [[["Text button1", "/button1"], + ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: message_tag: - name: Message tag - description: "Tag for sent message. In telegram_sent event data: {{trigger.event.data.message_tag}}" example: "msg_to_edit" selector: text: send_photo: - name: Send photo - description: Send a photo. fields: url: - name: URL - description: Remote path to an image. example: "http://example.org/path/to/the/image.png" selector: text: file: - name: File - description: Local path to an image. example: "/path/to/the/image.png" selector: text: caption: - name: Caption - description: The title of the image. example: "My image" selector: text: username: - name: Username - description: Username for a URL which require HTTP authentication. example: myuser selector: text: password: - name: Password - description: Password (or bearer token) for a URL which require HTTP authentication. example: myuser_pwd selector: text: authentication: - name: Authentication method - description: Define which authentication method to use. Set to `digest` to use HTTP digest authentication, or `bearer_token` for OAuth 2.0 bearer token authentication. Defaults to `basic`. default: digest selector: select: @@ -114,14 +81,10 @@ send_photo: - "digest" - "bearer_token" target: - name: Target - description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. example: "[12345, 67890] or 12345" selector: object: parse_mode: - name: Parse mode - description: "Parser for the message text." selector: select: options: @@ -129,79 +92,55 @@ send_photo: - "markdown" - "markdown2" disable_notification: - name: Disable notification - description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. selector: boolean: verify_ssl: - name: Verify SSL - description: Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server. selector: boolean: timeout: - name: Timeout - description: Timeout for send photo. Will help with timeout errors (poor internet connection, etc) selector: number: min: 1 max: 3600 unit_of_measurement: seconds keyboard: - name: Keyboard - description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' selector: object: inline_keyboard: - name: Inline keyboard - description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data. - example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + example: + '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], + ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: message_tag: - name: Message tag - description: "Tag for sent message. In telegram_sent event data: {{trigger.event.data.message_tag}}" example: "msg_to_edit" selector: text: send_sticker: - name: Send sticker - description: Send a sticker. fields: url: - name: URL - description: Remote path to a static .webp or animated .tgs sticker. example: "http://example.org/path/to/the/sticker.webp" selector: text: file: - name: File - description: Local path to a static .webp or animated .tgs sticker. example: "/path/to/the/sticker.webp" selector: text: sticker_id: - name: Sticker ID - description: ID of a sticker that exists on telegram servers example: CAACAgIAAxkBAAEDDldhZD-hqWclr6krLq-FWSfCrGNmOQAC9gAD9HsZAAFeYY-ltPYnrCEE selector: text: username: - name: Username - description: Username for a URL which require HTTP authentication. example: myuser selector: text: password: - name: Password - description: Password (or bearer token) for a URL which require HTTP authentication. example: myuser_pwd selector: text: authentication: - name: Authentication method - description: Define which authentication method to use. Set to `digest` to use HTTP digest authentication, or `bearer_token` for OAuth 2.0 bearer token authentication. Defaults to `basic`. default: digest selector: select: @@ -209,85 +148,59 @@ send_sticker: - "digest" - "bearer_token" target: - name: Target - description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. example: "[12345, 67890] or 12345" selector: object: disable_notification: - name: Disable notification - description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. selector: boolean: verify_ssl: - name: Verify SSL - description: Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server. selector: boolean: timeout: - name: Timeout - description: Timeout for send sticker. Will help with timeout errors (poor internet connection, etc) selector: number: min: 1 max: 3600 unit_of_measurement: seconds keyboard: - name: Keyboard - description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' selector: object: inline_keyboard: - name: Inline keyboard - description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data. - example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + example: + '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], + ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: message_tag: - name: Message tag - description: "Tag for sent message. In telegram_sent event data: {{trigger.event.data.message_tag}}" example: "msg_to_edit" selector: text: send_animation: - name: Send animation - description: Send an anmiation. fields: url: - name: URL - description: Remote path to a GIF or H.264/MPEG-4 AVC video without sound. example: "http://example.org/path/to/the/animation.gif" selector: text: file: - name: File - description: Local path to a GIF or H.264/MPEG-4 AVC video without sound. example: "/path/to/the/animation.gif" selector: text: caption: - name: Caption - description: The title of the animation. example: "My animation" selector: text: username: - name: Username - description: Username for a URL which require HTTP authentication. example: myuser selector: text: password: - name: Password - description: Password (or bearer token) for a URL which require HTTP authentication. example: myuser_pwd selector: text: authentication: - name: Authentication method - description: Define which authentication method to use. Set to `digest` to use HTTP digest authentication, or `bearer_token` for OAuth 2.0 bearer token authentication. Defaults to `basic`. default: digest selector: select: @@ -295,14 +208,10 @@ send_animation: - "digest" - "bearer_token" target: - name: Target - description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. example: "[12345, 67890] or 12345" selector: object: parse_mode: - name: Parse Mode - description: "Parser for the message text." selector: select: options: @@ -310,73 +219,51 @@ send_animation: - "markdown" - "markdown2" disable_notification: - name: Disable notification - description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. selector: boolean: verify_ssl: - name: Verify SSL - description: Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server. selector: boolean: timeout: - name: Timeout - description: Timeout for send sticker. Will help with timeout errors (poor internet connection, etc) selector: number: min: 1 max: 3600 unit_of_measurement: seconds keyboard: - name: Keyboard - description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' selector: object: inline_keyboard: - name: Inline keyboard - description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data. - example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + example: + '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], + ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: send_video: - name: Send video - description: Send a video. fields: url: - name: URL - description: Remote path to a video. example: "http://example.org/path/to/the/video.mp4" selector: text: file: - name: File - description: Local path to a video. example: "/path/to/the/video.mp4" selector: text: caption: - name: Caption - description: The title of the video. example: "My video" selector: text: username: - name: Username - description: Username for a URL which require HTTP authentication. example: myuser selector: text: password: - name: Password - description: Password (or bearer token) for a URL which require HTTP authentication. example: myuser_pwd selector: text: authentication: - name: Authentication method - description: Define which authentication method to use. Set to `digest` to use HTTP digest authentication, or `bearer_token` for OAuth 2.0 bearer token authentication. Defaults to `basic`. default: digest selector: select: @@ -384,14 +271,10 @@ send_video: - "digest" - "bearer_token" target: - name: Target - description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. example: "[12345, 67890] or 12345" selector: object: parse_mode: - name: Parse mode - description: "Parser for the message text." selector: select: options: @@ -399,79 +282,55 @@ send_video: - "markdown" - "markdown2" disable_notification: - name: Disable notification - description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. selector: boolean: verify_ssl: - name: Verify SSL - description: Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server. selector: boolean: timeout: - name: Timeout - description: Timeout for send video. Will help with timeout errors (poor internet connection, etc) selector: number: min: 1 max: 3600 unit_of_measurement: seconds keyboard: - name: Keyboard - description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' selector: object: inline_keyboard: - name: Inline keyboard - description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data. - example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + example: + '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], + ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: message_tag: - name: Message tag - description: "Tag for sent message. In telegram_sent event data: {{trigger.event.data.message_tag}}" example: "msg_to_edit" selector: text: send_voice: - name: Send voice - description: Send a voice message. fields: url: - name: URL - description: Remote path to a voice message. example: "http://example.org/path/to/the/voice.opus" selector: text: file: - name: File - description: Local path to a voice message. example: "/path/to/the/voice.opus" selector: text: caption: - name: Caption - description: The title of the voice message. example: "My microphone recording" selector: text: username: - name: Username - description: Username for a URL which require HTTP authentication. example: myuser selector: text: password: - name: Password - description: Password (or bearer token) for a URL which require HTTP authentication. example: myuser_pwd selector: text: authentication: - name: Authentication method - description: Define which authentication method to use. Set to `digest` to use HTTP digest authentication, or `bearer_token` for OAuth 2.0 bearer token authentication. Defaults to `basic`. default: digest selector: select: @@ -479,85 +338,59 @@ send_voice: - "digest" - "bearer_token" target: - name: Target - description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. example: "[12345, 67890] or 12345" selector: object: disable_notification: - name: Disable notification - description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. selector: boolean: verify_ssl: - name: Verify SSL - description: Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server. selector: boolean: timeout: - name: Timeout - description: Timeout for send voice. Will help with timeout errors (poor internet connection, etc) selector: number: min: 1 max: 3600 unit_of_measurement: seconds keyboard: - name: Keyboard - description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' selector: object: inline_keyboard: - name: Inline keyboard - description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data. - example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + example: + '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], + ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: message_tag: - name: Message tag - description: "Tag for sent message. In telegram_sent event data: {{trigger.event.data.message_tag}}" example: "msg_to_edit" selector: text: send_document: - name: Send document - description: Send a document. fields: url: - name: URL - description: Remote path to a document. example: "http://example.org/path/to/the/document.odf" selector: text: file: - name: File - description: Local path to a document. example: "/tmp/whatever.odf" selector: text: caption: - name: Caption - description: The title of the document. example: Document Title xy selector: text: username: - name: Username - description: Username for a URL which require HTTP authentication. example: myuser selector: text: password: - name: Password - description: Password (or bearer token) for a URL which require HTTP authentication. example: myuser_pwd selector: text: authentication: - name: Authentication method - description: Define which authentication method to use. Set to `digest` to use HTTP digest authentication, or `bearer_token` for OAuth 2.0 bearer token authentication. Defaults to `basic`. default: digest selector: select: @@ -565,14 +398,10 @@ send_document: - "digest" - "bearer_token" target: - name: Target - description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. example: "[12345, 67890] or 12345" selector: object: parse_mode: - name: Parse mode - description: "Parser for the message text." selector: select: options: @@ -580,49 +409,35 @@ send_document: - "markdown" - "markdown2" disable_notification: - name: Disable notification - description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. selector: boolean: verify_ssl: - name: Verify SSL - description: Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server. selector: boolean: timeout: - name: Timeout - description: Timeout for send document. Will help with timeout errors (poor internet connection, etc) selector: number: min: 1 max: 3600 unit_of_measurement: seconds keyboard: - name: Keyboard - description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' selector: object: inline_keyboard: - name: Inline keyboard - description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data. - example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + example: + '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], + ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: message_tag: - name: Message tag - description: "Tag for sent message. In telegram_sent event data: {{trigger.event.data.message_tag}}" example: "msg_to_edit" selector: text: send_location: - name: Send location - description: Send a location. fields: latitude: - name: Latitude - description: The latitude to send. required: true selector: number: @@ -631,8 +446,6 @@ send_location: step: 0.001 unit_of_measurement: "°" longitude: - name: Longitude - description: The longitude to send. required: true selector: number: @@ -641,91 +454,63 @@ send_location: step: 0.001 unit_of_measurement: "°" target: - name: Target - description: An array of pre-authorized chat_ids to send the location to. If not present, first allowed chat_id is the default. example: "[12345, 67890] or 12345" selector: object: disable_notification: - name: Disable notification - description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. selector: boolean: timeout: - name: Timeout - description: Timeout for send photo. Will help with timeout errors (poor internet connection, etc) selector: number: min: 1 max: 3600 unit_of_measurement: seconds keyboard: - name: Keyboard - description: List of rows of commands, comma-separated, to make a custom keyboard. example: '["/command1, /command2", "/command3"]' selector: object: inline_keyboard: - name: Inline keyboard - description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data. - example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + example: + '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], + ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: message_tag: - name: Message tag - description: "Tag for sent message. In telegram_sent event data: {{trigger.event.data.message_tag}}" example: "msg_to_edit" selector: text: send_poll: - name: Send poll - description: Send a poll. fields: target: - name: Target - description: An array of pre-authorized chat_ids to send the location to. If not present, first allowed chat_id is the default. example: "[12345, 67890] or 12345" selector: object: question: - name: Question - description: Poll question, 1-300 characters required: true selector: text: options: - name: Options - description: List of answer options, 2-10 strings 1-100 characters each required: true selector: object: is_anonymous: - name: Is Anonymous - description: If the poll needs to be anonymous, defaults to True selector: boolean: allows_multiple_answers: - name: Allow Multiple Answers - description: If the poll allows multiple answers, defaults to False selector: boolean: open_period: - name: Open Period - description: Amount of time in seconds the poll will be active after creation, 5-600. selector: number: min: 5 max: 600 unit_of_measurement: seconds disable_notification: - name: Disable notification - description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. selector: boolean: timeout: - name: Timeout - description: Timeout for send poll. Will help with timeout errors (poor internet connection, etc) selector: number: min: 1 @@ -733,38 +518,26 @@ send_poll: unit_of_measurement: seconds edit_message: - name: Edit message - description: Edit a previously sent message. fields: message_id: - name: Message ID - description: id of the message to edit. required: true example: "{{ trigger.event.data.message.message_id }}" selector: text: chat_id: - name: Chat ID - description: The chat_id where to edit the message. required: true example: 12345 selector: text: message: - name: Message - description: Message body of the notification. example: The garage door has been open for 10 minutes. selector: text: title: - name: Title - description: Optional title for your notification. Will be composed as '%title\n%message' example: "Your Garage Door Friend" selector: text: parse_mode: - name: Parse mode - description: "Parser for the message text." selector: select: options: @@ -772,102 +545,76 @@ edit_message: - "markdown" - "markdown2" disable_web_page_preview: - name: Disable web page preview - description: Disables link previews for links in the message. selector: boolean: inline_keyboard: - name: Inline keyboard - description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data. - example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + example: + '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], + ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: edit_caption: - name: Edit caption - description: Edit the caption of a previously sent message. fields: message_id: - name: Message ID - description: id of the message to edit. required: true example: "{{ trigger.event.data.message.message_id }}" selector: text: chat_id: - name: Chat ID - description: The chat_id where to edit the caption. required: true example: 12345 selector: text: caption: - name: Caption - description: Message body of the notification. required: true example: The garage door has been open for 10 minutes. selector: text: inline_keyboard: - name: Inline keyboard - description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data. - example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + example: + '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], + ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: edit_replymarkup: - name: Edit reply markup - description: Edit the inline keyboard of a previously sent message. fields: message_id: - name: Message ID - description: id of the message to edit. required: true example: "{{ trigger.event.data.message.message_id }}" selector: text: chat_id: - name: Chat ID - description: The chat_id where to edit the reply_markup. required: true example: 12345 selector: text: inline_keyboard: - name: Inline keyboard - description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data. required: true - example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + example: + '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], + ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' selector: object: answer_callback_query: - name: Answer callback query - description: Respond to a callback query originated by clicking on an online keyboard button. The answer will be displayed to the user as a notification at the top of the chat screen or as an alert. fields: message: - name: Message - description: Unformatted text message body of the notification. required: true example: "OK, I'm listening" selector: text: callback_query_id: - name: Callback query ID - description: Unique id of the callback response. required: true example: "{{ trigger.event.data.id }}" selector: text: show_alert: - name: Show alert - description: Show a permanent notification. required: true selector: boolean: timeout: - name: Timeout - description: Timeout for sending the answer. Will help with timeout errors (poor internet connection, etc) selector: number: min: 1 @@ -875,19 +622,13 @@ answer_callback_query: unit_of_measurement: seconds delete_message: - name: Delete message - description: Delete a previously sent message. fields: message_id: - name: Message ID - description: id of the message to delete. required: true example: "{{ trigger.event.data.message.message_id }}" selector: text: chat_id: - name: Chat ID - description: The chat_id where to delete the message. required: true example: 12345 selector: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json new file mode 100644 index 00000000000..eeca235ab44 --- /dev/null +++ b/homeassistant/components/telegram_bot/strings.json @@ -0,0 +1,596 @@ +{ + "services": { + "send_message": { + "name": "Send message", + "description": "Sends a notification.", + "fields": { + "message": { + "name": "Message", + "description": "Message body of the notification." + }, + "title": { + "name": "Title", + "description": "Optional title for your notification. Will be composed as '%title\\n%message'." + }, + "target": { + "name": "Target", + "description": "An array of pre-authorized chat_ids to send the notification to. If not present, first allowed chat_id is the default." + }, + "parse_mode": { + "name": "Parse mode", + "description": "Parser for the message text." + }, + "disable_notification": { + "name": "Disable notification", + "description": "Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound." + }, + "disable_web_page_preview": { + "name": "Disable web page preview", + "description": "Disables link previews for links in the message." + }, + "timeout": { + "name": "Timeout", + "description": "Timeout for send message. Will help with timeout errors (poor internet connection, etc)s." + }, + "keyboard": { + "name": "Keyboard", + "description": "List of rows of commands, comma-separated, to make a custom keyboard. Empty list clears a previously set keyboard." + }, + "inline_keyboard": { + "name": "Inline keyboard", + "description": "List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data." + }, + "message_tag": { + "name": "Message tag", + "description": "Tag for sent message. In telegram_sent event data: {{trigger.event.data.message_tag}}." + } + } + }, + "send_photo": { + "name": "Send photo", + "description": "Sends a photo.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "Remote path to an image." + }, + "file": { + "name": "File", + "description": "Local path to an image." + }, + "caption": { + "name": "Caption", + "description": "The title of the image." + }, + "username": { + "name": "[%key:common::config_flow::data::username%]", + "description": "Username for a URL which require HTTP authentication." + }, + "password": { + "name": "[%key:common::config_flow::data::password%]", + "description": "Password (or bearer token) for a URL which require HTTP authentication." + }, + "authentication": { + "name": "Authentication method", + "description": "Define which authentication method to use. Set to `digest` to use HTTP digest authentication, or `bearer_token` for OAuth 2.0 bearer token authentication. Defaults to `basic`." + }, + "target": { + "name": "Target", + "description": "An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default." + }, + "parse_mode": { + "name": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::description%]" + }, + "disable_notification": { + "name": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::description%]" + }, + "verify_ssl": { + "name": "Verify SSL", + "description": "Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server." + }, + "timeout": { + "name": "Timeout", + "description": "Timeout for send photo. Will help with timeout errors (poor internet connection, etc)." + }, + "keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", + "description": "List of rows of commands, comma-separated, to make a custom keyboard." + }, + "inline_keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + } + } + }, + "send_sticker": { + "name": "Send sticker", + "description": "Sends a sticker.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "Remote path to a static .webp or animated .tgs sticker." + }, + "file": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::file::name%]", + "description": "Local path to a static .webp or animated .tgs sticker." + }, + "sticker_id": { + "name": "Sticker ID", + "description": "ID of a sticker that exists on telegram servers." + }, + "username": { + "name": "[%key:common::config_flow::data::username%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::username::description%]" + }, + "password": { + "name": "[%key:common::config_flow::data::password%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::password::description%]" + }, + "authentication": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::authentication::name%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::authentication::description%]" + }, + "target": { + "name": "Target", + "description": "[%key:component::telegram_bot::services::send_photo::fields::target::description%]" + }, + "disable_notification": { + "name": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::description%]" + }, + "verify_ssl": { + "name": "Verify SSL", + "description": "[%key:component::telegram_bot::services::send_photo::fields::verify_ssl::description%]" + }, + "timeout": { + "name": "Timeout", + "description": "Timeout for send sticker. Will help with timeout errors (poor internet connection, etc)." + }, + "keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::keyboard::description%]" + }, + "inline_keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + } + } + }, + "send_animation": { + "name": "Send animation", + "description": "Sends an anmiation.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "Remote path to a GIF or H.264/MPEG-4 AVC video without sound." + }, + "file": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::file::name%]", + "description": "Local path to a GIF or H.264/MPEG-4 AVC video without sound." + }, + "caption": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]", + "description": "The title of the animation." + }, + "username": { + "name": "[%key:common::config_flow::data::username%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::username::description%]" + }, + "password": { + "name": "[%key:common::config_flow::data::password%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::password::description%]" + }, + "authentication": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::authentication::name%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::authentication::description%]" + }, + "target": { + "name": "Target", + "description": "[%key:component::telegram_bot::services::send_photo::fields::target::description%]" + }, + "parse_mode": { + "name": "Parse Mode", + "description": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::description%]" + }, + "disable_notification": { + "name": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::description%]" + }, + "verify_ssl": { + "name": "Verify SSL", + "description": "[%key:component::telegram_bot::services::send_photo::fields::verify_ssl::description%]" + }, + "timeout": { + "name": "Timeout", + "description": "[%key:component::telegram_bot::services::send_sticker::fields::timeout::description%]" + }, + "keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::keyboard::description%]" + }, + "inline_keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + } + } + }, + "send_video": { + "name": "Send video", + "description": "Sends a video.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "Remote path to a video." + }, + "file": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::file::name%]", + "description": "Local path to a video." + }, + "caption": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]", + "description": "The title of the video." + }, + "username": { + "name": "[%key:common::config_flow::data::username%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::username::description%]" + }, + "password": { + "name": "[%key:common::config_flow::data::password%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::password::description%]" + }, + "authentication": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::authentication::name%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::authentication::description%]" + }, + "target": { + "name": "Target", + "description": "[%key:component::telegram_bot::services::send_photo::fields::target::description%]" + }, + "parse_mode": { + "name": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::description%]" + }, + "disable_notification": { + "name": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::description%]" + }, + "verify_ssl": { + "name": "Verify SSL", + "description": "[%key:component::telegram_bot::services::send_photo::fields::verify_ssl::description%]" + }, + "timeout": { + "name": "Timeout", + "description": "Timeout for send video. Will help with timeout errors (poor internet connection, etc)." + }, + "keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::keyboard::description%]" + }, + "inline_keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + } + } + }, + "send_voice": { + "name": "Send voice", + "description": "Sends a voice message.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "Remote path to a voice message." + }, + "file": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::file::name%]", + "description": "Local path to a voice message." + }, + "caption": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]", + "description": "The title of the voice message." + }, + "username": { + "name": "[%key:common::config_flow::data::username%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::username::description%]" + }, + "password": { + "name": "[%key:common::config_flow::data::password%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::password::description%]" + }, + "authentication": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::authentication::name%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::authentication::description%]" + }, + "target": { + "name": "Target", + "description": "[%key:component::telegram_bot::services::send_photo::fields::target::description%]" + }, + "disable_notification": { + "name": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::description%]" + }, + "verify_ssl": { + "name": "Verify SSL", + "description": "[%key:component::telegram_bot::services::send_photo::fields::verify_ssl::description%]" + }, + "timeout": { + "name": "Timeout", + "description": "Timeout for send voice. Will help with timeout errors (poor internet connection, etc)." + }, + "keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::keyboard::description%]" + }, + "inline_keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + } + } + }, + "send_document": { + "name": "Send document", + "description": "Sends a document.", + "fields": { + "url": { + "name": "[%key:common::config_flow::data::url%]", + "description": "Remote path to a document." + }, + "file": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::file::name%]", + "description": "Local path to a document." + }, + "caption": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]", + "description": "The title of the document." + }, + "username": { + "name": "[%key:common::config_flow::data::username%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::username::description%]" + }, + "password": { + "name": "[%key:common::config_flow::data::password%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::password::description%]" + }, + "authentication": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::authentication::name%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::authentication::description%]" + }, + "target": { + "name": "Target", + "description": "[%key:component::telegram_bot::services::send_photo::fields::target::description%]" + }, + "parse_mode": { + "name": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::description%]" + }, + "disable_notification": { + "name": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::description%]" + }, + "verify_ssl": { + "name": "Verify SSL", + "description": "[%key:component::telegram_bot::services::send_photo::fields::verify_ssl::description%]" + }, + "timeout": { + "name": "Timeout", + "description": "Timeout for send document. Will help with timeout errors (poor internet connection, etc)." + }, + "keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::keyboard::description%]" + }, + "inline_keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + } + } + }, + "send_location": { + "name": "Send location", + "description": "Sends a location.", + "fields": { + "latitude": { + "name": "[%key:common::config_flow::data::latitude%]", + "description": "The latitude to send." + }, + "longitude": { + "name": "[%key:common::config_flow::data::longitude%]", + "description": "The longitude to send." + }, + "target": { + "name": "Target", + "description": "An array of pre-authorized chat_ids to send the location to. If not present, first allowed chat_id is the default." + }, + "disable_notification": { + "name": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::description%]" + }, + "timeout": { + "name": "Timeout", + "description": "[%key:component::telegram_bot::services::send_photo::fields::timeout::description%]" + }, + "keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_photo::fields::keyboard::description%]" + }, + "inline_keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + }, + "message_tag": { + "name": "[%key:component::telegram_bot::services::send_message::fields::message_tag::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::message_tag::description%]" + } + } + }, + "send_poll": { + "name": "Send poll", + "description": "Sends a poll.", + "fields": { + "target": { + "name": "Target", + "description": "[%key:component::telegram_bot::services::send_location::fields::target::description%]" + }, + "question": { + "name": "Question", + "description": "Poll question, 1-300 characters." + }, + "options": { + "name": "Options", + "description": "List of answer options, 2-10 strings 1-100 characters each." + }, + "is_anonymous": { + "name": "Is anonymous", + "description": "If the poll needs to be anonymous, defaults to True." + }, + "allows_multiple_answers": { + "name": "Allow multiple answers", + "description": "If the poll allows multiple answers, defaults to False." + }, + "open_period": { + "name": "Open period", + "description": "Amount of time in seconds the poll will be active after creation, 5-600." + }, + "disable_notification": { + "name": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::disable_notification::description%]" + }, + "timeout": { + "name": "Timeout", + "description": "Timeout for send poll. Will help with timeout errors (poor internet connection, etc)." + } + } + }, + "edit_message": { + "name": "Edit message", + "description": "Edits a previously sent message.", + "fields": { + "message_id": { + "name": "Message ID", + "description": "Id of the message to edit." + }, + "chat_id": { + "name": "Chat ID", + "description": "The chat_id where to edit the message." + }, + "message": { + "name": "Message", + "description": "Message body of the notification." + }, + "title": { + "name": "Title", + "description": "[%key:component::telegram_bot::services::send_message::fields::title::description%]" + }, + "parse_mode": { + "name": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::parse_mode::description%]" + }, + "disable_web_page_preview": { + "name": "[%key:component::telegram_bot::services::send_message::fields::disable_web_page_preview::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::disable_web_page_preview::description%]" + }, + "inline_keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + } + } + }, + "edit_caption": { + "name": "Edit caption", + "description": "Edits the caption of a previously sent message.", + "fields": { + "message_id": { + "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", + "description": "[%key:component::telegram_bot::services::edit_message::fields::message_id::description%]" + }, + "chat_id": { + "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", + "description": "The chat_id where to edit the caption." + }, + "caption": { + "name": "[%key:component::telegram_bot::services::send_photo::fields::caption::name%]", + "description": "Message body of the notification." + }, + "inline_keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + } + } + }, + "edit_replymarkup": { + "name": "Edit reply markup", + "description": "Edit the inline keyboard of a previously sent message.", + "fields": { + "message_id": { + "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", + "description": "[%key:component::telegram_bot::services::edit_message::fields::message_id::description%]" + }, + "chat_id": { + "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", + "description": "The chat_id where to edit the reply_markup." + }, + "inline_keyboard": { + "name": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::name%]", + "description": "[%key:component::telegram_bot::services::send_message::fields::inline_keyboard::description%]" + } + } + }, + "answer_callback_query": { + "name": "Answer callback query", + "description": "Responds to a callback query originated by clicking on an online keyboard button. The answer will be displayed to the user as a notification at the top of the chat screen or as an alert.", + "fields": { + "message": { + "name": "Message", + "description": "Unformatted text message body of the notification." + }, + "callback_query_id": { + "name": "Callback query ID", + "description": "Unique id of the callback response." + }, + "show_alert": { + "name": "Show alert", + "description": "Show a permanent notification." + }, + "timeout": { + "name": "Timeout", + "description": "Timeout for sending the answer. Will help with timeout errors (poor internet connection, etc)." + } + } + }, + "delete_message": { + "name": "Delete message", + "description": "Deletes a previously sent message.", + "fields": { + "message_id": { + "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", + "description": "Id of the message to delete." + }, + "chat_id": { + "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", + "description": "The chat_id where to delete the message." + } + } + } + } +} diff --git a/homeassistant/components/telnet/switch.py b/homeassistant/components/telnet/switch.py index f919834139b..14e8900f000 100644 --- a/homeassistant/components/telnet/switch.py +++ b/homeassistant/components/telnet/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta import logging -import telnetlib +import telnetlib # pylint: disable=deprecated-module from typing import Any import voluptuous as vol diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 923b6167851..61df78307f0 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -5,9 +5,8 @@ from dataclasses import dataclass from datetime import datetime, timedelta from functools import partial import logging -from typing import Any +from typing import Any, Self -from typing_extensions import Self import voluptuous as vol from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/template/services.yaml b/homeassistant/components/template/services.yaml index 6186bc6dccb..c983a105c93 100644 --- a/homeassistant/components/template/services.yaml +++ b/homeassistant/components/template/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload all template entities. diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json new file mode 100644 index 00000000000..fce7129353e --- /dev/null +++ b/homeassistant/components/template/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads template entities from the YAML-configuration." + } + } +} diff --git a/homeassistant/components/template/trigger.py b/homeassistant/components/template/trigger.py index 0cc53d5fb2d..113da3aa3ee 100644 --- a/homeassistant/components/template/trigger.py +++ b/homeassistant/components/template/trigger.py @@ -1,5 +1,7 @@ """Offer template automation rules.""" +from datetime import timedelta import logging +from typing import Any import voluptuous as vol @@ -8,13 +10,15 @@ from homeassistant.const import CONF_FOR, CONF_PLATFORM, CONF_VALUE_TEMPLATE from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.event import ( + EventStateChangedData, TrackTemplate, + TrackTemplateResult, async_call_later, async_track_template_result, ) from homeassistant.helpers.template import Template, result_as_boolean from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType _LOGGER = logging.getLogger(__name__) @@ -59,7 +63,10 @@ async def async_attach_trigger( ) @callback - def template_listener(event, updates): + def template_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: """Listen for state changes and calls action.""" nonlocal delay_cancel, armed result = updates.pop().result @@ -88,9 +95,9 @@ async def async_attach_trigger( # Fire! armed = False - entity_id = event and event.data.get("entity_id") - from_s = event and event.data.get("old_state") - to_s = event and event.data.get("new_state") + entity_id = event and event.data["entity_id"] + from_s = event and event.data["old_state"] + to_s = event and event.data["new_state"] if entity_id is not None: description = f"{entity_id} via template" @@ -110,7 +117,7 @@ async def async_attach_trigger( } @callback - def call_action(*_): + def call_action(*_: Any) -> None: """Call action with right context.""" nonlocal trigger_variables hass.async_run_hass_job( @@ -124,7 +131,7 @@ async def async_attach_trigger( return try: - period = cv.positive_time_period( + period: timedelta = cv.positive_time_period( template.render_complex(time_delta, {"trigger": template_variables}) ) except (exceptions.TemplateError, vol.Invalid) as ex: diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index f95c2660164..c5705c34076 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -148,7 +148,9 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): self._template = config.get(CONF_VALUE_TEMPLATE) self._battery_level_template = config.get(CONF_BATTERY_LEVEL_TEMPLATE) self._fan_speed_template = config.get(CONF_FAN_SPEED_TEMPLATE) - self._attr_supported_features = VacuumEntityFeature.START + self._attr_supported_features = ( + VacuumEntityFeature.START | VacuumEntityFeature.STATE + ) self._start_script = Script(hass, config[SERVICE_START], friendly_name, DOMAIN) @@ -192,8 +194,6 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity): self._battery_level = None self._attr_fan_speed = None - if self._template: - self._attr_supported_features |= VacuumEntityFeature.STATE if self._battery_level_template: self._attr_supported_features |= VacuumEntityFeature.BATTERY diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index ecdefd36b2a..71952431b5a 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,6 +10,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.6", "numpy==1.23.2", - "Pillow==9.5.0" + "Pillow==10.0.0" ] } diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py index 179576334a9..2c2d0ca154b 100644 --- a/homeassistant/components/tesla_wall_connector/__init__.py +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -122,11 +122,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -def prefix_entity_name(name: str) -> str: - """Prefixes entity name.""" - return f"{WALLCONNECTOR_DEVICE_NAME} {name}" - - def get_unique_id(serial_number: str, key: str) -> str: """Get a unique entity name.""" return f"{serial_number}-{key}" @@ -135,6 +130,8 @@ def get_unique_id(serial_number: str, key: str) -> str: class WallConnectorEntity(CoordinatorEntity): """Base class for Wall Connector entities.""" + _attr_has_entity_name = True + def __init__(self, wall_connector_data: WallConnectorData) -> None: """Initialize WallConnector Entity.""" self.wall_connector_data = wall_connector_data @@ -148,10 +145,10 @@ class WallConnectorEntity(CoordinatorEntity): """Return information about the device.""" return DeviceInfo( identifiers={(DOMAIN, self.wall_connector_data.serial_number)}, - default_name=WALLCONNECTOR_DEVICE_NAME, + name=WALLCONNECTOR_DEVICE_NAME, model=self.wall_connector_data.part_number, sw_version=self.wall_connector_data.firmware_version, - default_manufacturer="Tesla", + manufacturer="Tesla", ) diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py index 2218ec2a6b4..e0a34460c8c 100644 --- a/homeassistant/components/tesla_wall_connector/binary_sensor.py +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -16,7 +16,6 @@ from . import ( WallConnectorData, WallConnectorEntity, WallConnectorLambdaValueGetterMixin, - prefix_entity_name, ) from .const import DOMAIN, WALLCONNECTOR_DATA_VITALS @@ -33,14 +32,14 @@ class WallConnectorBinarySensorDescription( WALL_CONNECTOR_SENSORS = [ WallConnectorBinarySensorDescription( key="vehicle_connected", - name=prefix_entity_name("Vehicle connected"), + translation_key="vehicle_connected", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].vehicle_connected, device_class=BinarySensorDeviceClass.PLUG, ), WallConnectorBinarySensorDescription( key="contactor_closed", - name=prefix_entity_name("Contactor closed"), + translation_key="contactor_closed", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].contactor_closed, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 1f83e38030a..0322830890a 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -24,7 +24,6 @@ from . import ( WallConnectorData, WallConnectorEntity, WallConnectorLambdaValueGetterMixin, - prefix_entity_name, ) from .const import DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS @@ -41,13 +40,13 @@ class WallConnectorSensorDescription( WALL_CONNECTOR_SENSORS = [ WallConnectorSensorDescription( key="evse_state", - name=prefix_entity_name("State"), + translation_key="evse_state", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].evse_state, ), WallConnectorSensorDescription( key="handle_temp_c", - name=prefix_entity_name("Handle Temperature"), + translation_key="handle_temp_c", native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_fn=lambda data: round(data[WALLCONNECTOR_DATA_VITALS].handle_temp_c, 1), device_class=SensorDeviceClass.TEMPERATURE, @@ -56,7 +55,7 @@ WALL_CONNECTOR_SENSORS = [ ), WallConnectorSensorDescription( key="grid_v", - name=prefix_entity_name("Grid Voltage"), + translation_key="grid_v", native_unit_of_measurement=UnitOfElectricPotential.VOLT, value_fn=lambda data: round(data[WALLCONNECTOR_DATA_VITALS].grid_v, 1), device_class=SensorDeviceClass.VOLTAGE, @@ -65,7 +64,7 @@ WALL_CONNECTOR_SENSORS = [ ), WallConnectorSensorDescription( key="grid_hz", - name=prefix_entity_name("Grid Frequency"), + translation_key="grid_hz", native_unit_of_measurement=UnitOfFrequency.HERTZ, value_fn=lambda data: round(data[WALLCONNECTOR_DATA_VITALS].grid_hz, 3), device_class=SensorDeviceClass.FREQUENCY, @@ -74,7 +73,7 @@ WALL_CONNECTOR_SENSORS = [ ), WallConnectorSensorDescription( key="current_a_a", - name=prefix_entity_name("Phase A Current"), + translation_key="current_a_a", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].currentA_a, device_class=SensorDeviceClass.CURRENT, @@ -83,7 +82,7 @@ WALL_CONNECTOR_SENSORS = [ ), WallConnectorSensorDescription( key="current_b_a", - name=prefix_entity_name("Phase B Current"), + translation_key="current_b_a", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].currentB_a, device_class=SensorDeviceClass.CURRENT, @@ -92,7 +91,7 @@ WALL_CONNECTOR_SENSORS = [ ), WallConnectorSensorDescription( key="current_c_a", - name=prefix_entity_name("Phase C Current"), + translation_key="current_c_a", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].currentC_a, device_class=SensorDeviceClass.CURRENT, @@ -101,7 +100,7 @@ WALL_CONNECTOR_SENSORS = [ ), WallConnectorSensorDescription( key="voltage_a_v", - name=prefix_entity_name("Phase A Voltage"), + translation_key="voltage_a_v", native_unit_of_measurement=UnitOfElectricPotential.VOLT, value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].voltageA_v, device_class=SensorDeviceClass.VOLTAGE, @@ -110,7 +109,7 @@ WALL_CONNECTOR_SENSORS = [ ), WallConnectorSensorDescription( key="voltage_b_v", - name=prefix_entity_name("Phase B Voltage"), + translation_key="voltage_b_v", native_unit_of_measurement=UnitOfElectricPotential.VOLT, value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].voltageB_v, device_class=SensorDeviceClass.VOLTAGE, @@ -119,7 +118,7 @@ WALL_CONNECTOR_SENSORS = [ ), WallConnectorSensorDescription( key="voltage_c_v", - name=prefix_entity_name("Phase C Voltage"), + translation_key="voltage_c_v", native_unit_of_measurement=UnitOfElectricPotential.VOLT, value_fn=lambda data: data[WALLCONNECTOR_DATA_VITALS].voltageC_v, device_class=SensorDeviceClass.VOLTAGE, @@ -128,7 +127,6 @@ WALL_CONNECTOR_SENSORS = [ ), WallConnectorSensorDescription( key="energy_kWh", - name=prefix_entity_name("Energy"), native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, value_fn=lambda data: data[WALLCONNECTOR_DATA_LIFETIME].energy_wh, device_class=SensorDeviceClass.ENERGY, diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json index 907209cdcca..982894eb17c 100644 --- a/homeassistant/components/tesla_wall_connector/strings.json +++ b/homeassistant/components/tesla_wall_connector/strings.json @@ -16,5 +16,47 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "binary_sensor": { + "vehicle_connected": { + "name": "Vehicle connected" + }, + "contactor_closed": { + "name": "Contactor closed" + } + }, + "sensor": { + "evse_state": { + "name": "State" + }, + "handle_temp_c": { + "name": "Handle temperature" + }, + "grid_v": { + "name": "Grid voltage" + }, + "grid_hz": { + "name": "Grid frequency" + }, + "current_a_a": { + "name": "Phase A current" + }, + "current_b_a": { + "name": "Phase B current" + }, + "current_c_a": { + "name": "Phase C current" + }, + "voltage_a_v": { + "name": "Phase A voltage" + }, + "voltage_b_v": { + "name": "Phase B voltage" + }, + "voltage_c_v": { + "name": "Phase C voltage" + } + } } } diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index f07a672afbd..4182b177bf6 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -3,13 +3,13 @@ from __future__ import annotations from dataclasses import asdict, dataclass from datetime import timedelta +from enum import StrEnum import logging import re from typing import Any, final import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.const import MAX_LENGTH_STATE_STATE from homeassistant.core import HomeAssistant, ServiceCall diff --git a/homeassistant/components/text/services.yaml b/homeassistant/components/text/services.yaml index 00dd0ecafd2..b8461037b8b 100644 --- a/homeassistant/components/text/services.yaml +++ b/homeassistant/components/text/services.yaml @@ -1,13 +1,9 @@ set_value: - name: Set value - description: Set value of a text entity. target: entity: domain: text fields: value: - name: Value - description: Value to set. required: true example: "Hello world!" selector: diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index d8f55dbe4e7..e6b3d99ced4 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -28,10 +28,16 @@ } } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "set_value": { + "name": "Set value", + "description": "Sets the value.", + "fields": { + "value": { + "name": "Value", + "description": "Enter your text." + } + } } } } diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py index fbbfdef6f02..4b4878fa1c8 100644 --- a/homeassistant/components/thermoworks_smoke/sensor.py +++ b/homeassistant/components/thermoworks_smoke/sensor.py @@ -111,9 +111,7 @@ class ThermoworksSmokeSensor(SensorEntity): self.type = sensor_type self.serial = serial self.mgr = mgr - self._attr_name = "{name} {sensor}".format( - name=mgr.name(serial), sensor=SENSOR_TYPES[sensor_type] - ) + self._attr_name = f"{mgr.name(serial)} {SENSOR_TYPES[sensor_type]}" self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT self._attr_unique_id = f"{serial}-{sensor_type}" self._attr_device_class = SensorDeviceClass.TEMPERATURE diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py index e42ee4478e0..0ad2942fb04 100644 --- a/homeassistant/components/thomson/device_tracker.py +++ b/homeassistant/components/thomson/device_tracker.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging import re -import telnetlib +import telnetlib # pylint: disable=deprecated-module import voluptuous as vol diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 0ce54496539..71dbb786eb5 100644 --- a/homeassistant/components/thread/manifest.json +++ b/homeassistant/components/thread/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/thread", "integration_type": "service", "iot_class": "local_polling", - "requirements": ["python-otbr-api==2.2.0", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.3.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 09f928303bf..a6621c096c3 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -21,7 +21,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -29,8 +29,11 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from .const import CONF_HYSTERESIS, CONF_LOWER, CONF_UPPER @@ -210,7 +213,9 @@ class ThresholdSensor(BinarySensorEntity): self._update_state() @callback - def async_threshold_sensor_state_listener(event: Event) -> None: + def async_threshold_sensor_state_listener( + event: EventType[EventStateChangedData], + ) -> None: """Handle sensor state changes.""" _update_sensor_state() self.async_write_ha_state() diff --git a/homeassistant/components/threshold/strings.json b/homeassistant/components/threshold/strings.json index 8bfd9fb96b1..832f3b4f899 100644 --- a/homeassistant/components/threshold/strings.json +++ b/homeassistant/components/threshold/strings.json @@ -9,7 +9,7 @@ "entity_id": "Input sensor", "hysteresis": "Hysteresis", "lower": "Lower limit", - "name": "Name", + "name": "[%key:common::config_flow::data::name%]", "upper": "Upper limit" } } diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 1b6c5e3045a..c668430914f 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.27.2"] + "requirements": ["pyTibber==0.28.0"] } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 242c2179a05..996490282d5 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -289,7 +289,9 @@ async def async_setup_entry( ) # migrate to new device ids - device_entry = device_registry.async_get_device({(TIBBER_DOMAIN, old_id)}) + device_entry = device_registry.async_get_device( + identifiers={(TIBBER_DOMAIN, old_id)} + ) if device_entry and entry.entry_id in device_entry.config_entries: device_registry.async_update_device( device_entry.id, new_identifiers={(TIBBER_DOMAIN, home.home_id)} diff --git a/homeassistant/components/tile/strings.json b/homeassistant/components/tile/strings.json index da53f79b697..504823c4d16 100644 --- a/homeassistant/components/tile/strings.json +++ b/homeassistant/components/tile/strings.json @@ -26,7 +26,7 @@ "options": { "step": { "init": { - "title": "Configure Tile", + "title": "[%key:component::tile::config::step::user::title%]", "data": { "show_inactive": "Show inactive Tiles" } diff --git a/homeassistant/components/time/services.yaml b/homeassistant/components/time/services.yaml index a8c843ab55a..ee3d9150870 100644 --- a/homeassistant/components/time/services.yaml +++ b/homeassistant/components/time/services.yaml @@ -1,13 +1,9 @@ set_value: - name: Set Time - description: Set the time for a time entity. target: entity: domain: time fields: time: - name: Time - description: The time to set. required: true example: "22:15" selector: diff --git a/homeassistant/components/time/strings.json b/homeassistant/components/time/strings.json index 9cbcf718d73..1b7c53b1a8a 100644 --- a/homeassistant/components/time/strings.json +++ b/homeassistant/components/time/strings.json @@ -5,10 +5,16 @@ "name": "[%key:component::time::title%]" } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "set_value": { + "name": "Set Time", + "description": "Sets the time.", + "fields": { + "time": { + "name": "Time", + "description": "The time to set." + } + } } } } diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 3752f9c9cb5..228e2071b4a 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -4,8 +4,8 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta import logging +from typing import Self -from typing_extensions import Self import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml index 68caa44a699..74eeae22b23 100644 --- a/homeassistant/components/timer/services.yaml +++ b/homeassistant/components/timer/services.yaml @@ -1,48 +1,36 @@ # Describes the format for available timer services start: - name: Start - description: Start a timer target: entity: domain: timer fields: duration: - description: Duration the timer requires to finish. [optional] example: "00:01:00 or 60" selector: text: pause: - name: Pause - description: Pause a timer. target: entity: domain: timer cancel: - name: Cancel - description: Cancel a timer. target: entity: domain: timer finish: - name: Finish - description: Finish a timer. target: entity: domain: timer change: - name: Change - description: Change a timer target: entity: domain: timer fields: duration: - description: Duration to add or subtract to the running timer default: 0 required: true example: "00:01:00, 60 or -60" diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 217de09a534..56cb46d26b4 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -29,5 +29,39 @@ } } } + }, + "services": { + "start": { + "name": "[%key:common::action::start%]", + "description": "Starts a timer.", + "fields": { + "duration": { + "name": "Duration", + "description": "Duration the timer requires to finish. [optional]." + } + } + }, + "pause": { + "name": "[%key:common::action::pause%]", + "description": "Pauses a timer." + }, + "cancel": { + "name": "Cancel", + "description": "Cancels a timer." + }, + "finish": { + "name": "Finish", + "description": "Finishes a timer." + }, + "change": { + "name": "Change", + "description": "Changes a timer.", + "fields": { + "duration": { + "name": "Duration", + "description": "Duration to add or subtract to the running timer." + } + } + } } } diff --git a/homeassistant/components/tod/strings.json b/homeassistant/components/tod/strings.json index 41e40525081..bd4a48df915 100644 --- a/homeassistant/components/tod/strings.json +++ b/homeassistant/components/tod/strings.json @@ -8,7 +8,7 @@ "data": { "after_time": "On time", "before_time": "Off time", - "name": "Name" + "name": "[%key:common::config_flow::data::name%]" } } } diff --git a/homeassistant/components/todoist/services.yaml b/homeassistant/components/todoist/services.yaml index 3cab4d2bf67..9593b6bb6a4 100644 --- a/homeassistant/components/todoist/services.yaml +++ b/homeassistant/components/todoist/services.yaml @@ -1,49 +1,33 @@ new_task: - name: New task - description: Create a new task and add it to a project. fields: content: - name: Content - description: The name of the task. required: true example: Pick up the mail. selector: text: project: - name: Project - description: The name of the project this task should belong to. example: Errands default: Inbox selector: text: labels: - name: Labels - description: Any labels that you want to apply to this task, separated by a comma. example: Chores,Delivieries selector: text: assignee: - name: Assignee - description: A members username of a shared project to assign this task to. example: username selector: text: priority: - name: Priority - description: The priority of this task, from 1 (normal) to 4 (urgent). selector: number: min: 1 max: 4 due_date_string: - name: Due date string - description: The day this task is due, in natural language. example: Tomorrow selector: text: due_date_lang: - name: Due data language - description: The language of due_date_string. selector: select: options: @@ -62,20 +46,14 @@ new_task: - "sv" - "zh" due_date: - name: Due date - description: The time this task is due, in format YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS, in UTC timezone. example: "2019-10-22" selector: text: reminder_date_string: - name: Reminder date string - description: When should user be reminded of this task, in natural language. example: Tomorrow selector: text: reminder_date_lang: - name: Reminder data language - description: The language of reminder_date_string. selector: select: options: @@ -94,8 +72,6 @@ new_task: - "sv" - "zh" reminder_date: - name: Reminder date - description: When should user be reminded of this task, in format YYYY-MM-DDTHH:MM:SS, in UTC timezone. example: "2019-10-22T10:30:00" selector: text: diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json new file mode 100644 index 00000000000..1ed092e5cf6 --- /dev/null +++ b/homeassistant/components/todoist/strings.json @@ -0,0 +1,54 @@ +{ + "services": { + "new_task": { + "name": "New task", + "description": "Creates a new task and add it to a project.", + "fields": { + "content": { + "name": "Content", + "description": "The name of the task." + }, + "project": { + "name": "Project", + "description": "The name of the project this task should belong to." + }, + "labels": { + "name": "Labels", + "description": "Any labels that you want to apply to this task, separated by a comma." + }, + "assignee": { + "name": "Assignee", + "description": "A members username of a shared project to assign this task to." + }, + "priority": { + "name": "Priority", + "description": "The priority of this task, from 1 (normal) to 4 (urgent)." + }, + "due_date_string": { + "name": "Due date string", + "description": "The day this task is due, in natural language." + }, + "due_date_lang": { + "name": "Due data language", + "description": "The language of due_date_string." + }, + "due_date": { + "name": "Due date", + "description": "The time this task is due, in format YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS, in UTC timezone." + }, + "reminder_date_string": { + "name": "Reminder date string", + "description": "When should user be reminded of this task, in natural language." + }, + "reminder_date_lang": { + "name": "Reminder data language", + "description": "The language of reminder_date_string." + }, + "reminder_date": { + "name": "Reminder date", + "description": "When should user be reminded of this task, in format YYYY-MM-DDTHH:MM:SS, in UTC timezone." + } + } + } + } +} diff --git a/homeassistant/components/tolo/__init__.py b/homeassistant/components/tolo/__init__.py index a75cb7ce298..bb894753fb8 100644 --- a/homeassistant/components/tolo/__init__.py +++ b/homeassistant/components/tolo/__init__.py @@ -95,6 +95,8 @@ class ToloSaunaUpdateCoordinator(DataUpdateCoordinator[ToloSaunaData]): class ToloSaunaCoordinatorEntity(CoordinatorEntity[ToloSaunaUpdateCoordinator]): """CoordinatorEntity for TOLO Sauna.""" + _attr_has_entity_name = True + def __init__( self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/tolo/binary_sensor.py b/homeassistant/components/tolo/binary_sensor.py index 0ee1cb08bb2..124cd45d78b 100644 --- a/homeassistant/components/tolo/binary_sensor.py +++ b/homeassistant/components/tolo/binary_sensor.py @@ -32,7 +32,7 @@ class ToloFlowInBinarySensor(ToloSaunaCoordinatorEntity, BinarySensorEntity): """Water In Valve Sensor.""" _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_name = "Water In Valve" + _attr_translation_key = "water_in_valve" _attr_device_class = BinarySensorDeviceClass.OPENING _attr_icon = "mdi:water-plus-outline" @@ -54,7 +54,7 @@ class ToloFlowOutBinarySensor(ToloSaunaCoordinatorEntity, BinarySensorEntity): """Water Out Valve Sensor.""" _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_name = "Water Out Valve" + _attr_translation_key = "water_out_valve" _attr_device_class = BinarySensorDeviceClass.OPENING _attr_icon = "mdi:water-minus-outline" diff --git a/homeassistant/components/tolo/button.py b/homeassistant/components/tolo/button.py index 5d041a74104..3b81477ab37 100644 --- a/homeassistant/components/tolo/button.py +++ b/homeassistant/components/tolo/button.py @@ -31,7 +31,7 @@ class ToloLampNextColorButton(ToloSaunaCoordinatorEntity, ButtonEntity): _attr_entity_category = EntityCategory.CONFIG _attr_icon = "mdi:palette" - _attr_name = "Next Color" + _attr_translation_key = "next_color" def __init__( self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py index 849a9f5b3ed..74f2a5a6f55 100644 --- a/homeassistant/components/tolo/climate.py +++ b/homeassistant/components/tolo/climate.py @@ -47,7 +47,7 @@ class SaunaClimate(ToloSaunaCoordinatorEntity, ClimateEntity): _attr_max_temp = DEFAULT_MAX_TEMP _attr_min_humidity = DEFAULT_MIN_HUMIDITY _attr_min_temp = DEFAULT_MIN_TEMP - _attr_name = "Sauna Climate" + _attr_name = None _attr_precision = PRECISION_WHOLE _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE diff --git a/homeassistant/components/tolo/fan.py b/homeassistant/components/tolo/fan.py index e767be9a3ce..7065290f2a8 100644 --- a/homeassistant/components/tolo/fan.py +++ b/homeassistant/components/tolo/fan.py @@ -26,7 +26,7 @@ async def async_setup_entry( class ToloFan(ToloSaunaCoordinatorEntity, FanEntity): """Sauna fan control.""" - _attr_name = "Fan" + _attr_translation_key = "fan" def __init__( self, coordinator: ToloSaunaUpdateCoordinator, entry: ConfigEntry diff --git a/homeassistant/components/tolo/light.py b/homeassistant/components/tolo/light.py index 715a1327e4b..4b76d4270c6 100644 --- a/homeassistant/components/tolo/light.py +++ b/homeassistant/components/tolo/light.py @@ -26,7 +26,7 @@ class ToloLight(ToloSaunaCoordinatorEntity, LightEntity): """Sauna light control.""" _attr_color_mode = ColorMode.ONOFF - _attr_name = "Sauna Light" + _attr_translation_key = "light" _attr_supported_color_modes = {ColorMode.ONOFF} def __init__( diff --git a/homeassistant/components/tolo/number.py b/homeassistant/components/tolo/number.py index aa12198b52c..3e07392c336 100644 --- a/homeassistant/components/tolo/number.py +++ b/homeassistant/components/tolo/number.py @@ -40,8 +40,8 @@ class ToloNumberEntityDescription( NUMBERS = ( ToloNumberEntityDescription( key="power_timer", + translation_key="power_timer", icon="mdi:power-settings", - name="Power Timer", native_unit_of_measurement=UnitOfTime.MINUTES, native_max_value=POWER_TIMER_MAX, getter=lambda settings: settings.power_timer, @@ -49,8 +49,8 @@ NUMBERS = ( ), ToloNumberEntityDescription( key="salt_bath_timer", + translation_key="salt_bath_timer", icon="mdi:shaker-outline", - name="Salt Bath Timer", native_unit_of_measurement=UnitOfTime.MINUTES, native_max_value=SALT_BATH_TIMER_MAX, getter=lambda settings: settings.salt_bath_timer, @@ -58,8 +58,8 @@ NUMBERS = ( ), ToloNumberEntityDescription( key="fan_timer", + translation_key="fan_timer", icon="mdi:fan-auto", - name="Fan Timer", native_unit_of_measurement=UnitOfTime.MINUTES, native_max_value=FAN_TIMER_MAX, getter=lambda settings: settings.fan_timer, diff --git a/homeassistant/components/tolo/select.py b/homeassistant/components/tolo/select.py index a47207d3d98..8e4ecb47f48 100644 --- a/homeassistant/components/tolo/select.py +++ b/homeassistant/components/tolo/select.py @@ -29,7 +29,6 @@ class ToloLampModeSelect(ToloSaunaCoordinatorEntity, SelectEntity): _attr_entity_category = EntityCategory.CONFIG _attr_icon = "mdi:lightbulb-multiple-outline" - _attr_name = "Lamp Mode" _attr_options = [lamp_mode.name.lower() for lamp_mode in LampMode] _attr_translation_key = "lamp_mode" diff --git a/homeassistant/components/tolo/sensor.py b/homeassistant/components/tolo/sensor.py index 2c5eccc1c1d..2ff901939ae 100644 --- a/homeassistant/components/tolo/sensor.py +++ b/homeassistant/components/tolo/sensor.py @@ -46,27 +46,27 @@ class ToloSensorEntityDescription( SENSORS = ( ToloSensorEntityDescription( key="water_level", + translation_key="water_level", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:waves-arrow-up", - name="Water Level", native_unit_of_measurement=PERCENTAGE, getter=lambda status: status.water_level_percent, availability_checker=None, ), ToloSensorEntityDescription( key="tank_temperature", + translation_key="tank_temperature", device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, - name="Tank Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, getter=lambda status: status.tank_temperature, availability_checker=None, ), ToloSensorEntityDescription( key="power_timer_remaining", + translation_key="power_timer_remaining", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:power-settings", - name="Power Timer", native_unit_of_measurement=UnitOfTime.MINUTES, getter=lambda status: status.power_timer, availability_checker=lambda settings, status: status.power_on @@ -74,9 +74,9 @@ SENSORS = ( ), ToloSensorEntityDescription( key="salt_bath_timer_remaining", + translation_key="salt_bath_timer_remaining", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:shaker-outline", - name="Salt Bath Timer", native_unit_of_measurement=UnitOfTime.MINUTES, getter=lambda status: status.salt_bath_timer, availability_checker=lambda settings, status: status.salt_bath_on @@ -84,9 +84,9 @@ SENSORS = ( ), ToloSensorEntityDescription( key="fan_timer_remaining", + translation_key="fan_timer_remaining", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:fan-auto", - name="Fan Timer", native_unit_of_measurement=UnitOfTime.MINUTES, getter=lambda status: status.fan_timer, availability_checker=lambda settings, status: status.fan_on diff --git a/homeassistant/components/tolo/strings.json b/homeassistant/components/tolo/strings.json index 5e6647edae4..f48e26c5276 100644 --- a/homeassistant/components/tolo/strings.json +++ b/homeassistant/components/tolo/strings.json @@ -20,13 +20,65 @@ } }, "entity": { + "binary_sensor": { + "water_in_valve": { + "name": "Water in valve" + }, + "water_out_valve": { + "name": "Water out valve" + } + }, + "button": { + "next_color": { + "name": "Next color" + } + }, + "fan": { + "fan": { + "name": "[%key:component::fan::title%]" + } + }, + "light": { + "light": { + "name": "[%key:component::light::title%]" + } + }, + "number": { + "power_timer": { + "name": "Power timer" + }, + "salt_bath_timer": { + "name": "Salt bath timer" + }, + "fan_timer": { + "name": "Fan timer" + } + }, "select": { "lamp_mode": { + "name": "Lamp mode", "state": { "automatic": "Automatic", "manual": "Manual" } } + }, + "sensor": { + "water_level": { + "name": "Water level" + }, + "tank_temperature": { + "name": "Tank temperature" + }, + "power_timer_remaining": { + "name": "Power timer" + }, + "salt_bath_timer_remaining": { + "name": "Salt bath timer" + }, + "fan_timer_remaining": { + "name": "Fan timer" + } } } } diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index b8a1ba64423..ce5ec4191c5 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -72,6 +72,8 @@ from .const import ( TMRW_ATTR_TEMPERATURE, TMRW_ATTR_TEMPERATURE_HIGH, TMRW_ATTR_TEMPERATURE_LOW, + TMRW_ATTR_UV_HEALTH_CONCERN, + TMRW_ATTR_UV_INDEX, TMRW_ATTR_VISIBILITY, TMRW_ATTR_WIND_DIRECTION, TMRW_ATTR_WIND_GUST, @@ -291,6 +293,8 @@ class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): TMRW_ATTR_PRESSURE_SURFACE_LEVEL, TMRW_ATTR_SOLAR_GHI, TMRW_ATTR_SULPHUR_DIOXIDE, + TMRW_ATTR_UV_INDEX, + TMRW_ATTR_UV_HEALTH_CONCERN, TMRW_ATTR_WIND_GUST, ], [ diff --git a/homeassistant/components/tomorrowio/const.py b/homeassistant/components/tomorrowio/const.py index 4b1e2487da8..51d8d5f31cc 100644 --- a/homeassistant/components/tomorrowio/const.py +++ b/homeassistant/components/tomorrowio/const.py @@ -115,3 +115,5 @@ TMRW_ATTR_PRESSURE_SURFACE_LEVEL = "pressureSurfaceLevel" TMRW_ATTR_SOLAR_GHI = "solarGHI" TMRW_ATTR_CLOUD_BASE = "cloudBase" TMRW_ATTR_CLOUD_CEILING = "cloudCeiling" +TMRW_ATTR_UV_INDEX = "uvIndex" +TMRW_ATTR_UV_HEALTH_CONCERN = "uvHealthConcern" diff --git a/homeassistant/components/tomorrowio/manifest.json b/homeassistant/components/tomorrowio/manifest.json index 325a852c6d8..95e164f1276 100644 --- a/homeassistant/components/tomorrowio/manifest.json +++ b/homeassistant/components/tomorrowio/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pytomorrowio"], - "requirements": ["pytomorrowio==0.3.5"] + "requirements": ["pytomorrowio==0.3.6"] } diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 046dc79f2c6..aba5b44f284 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -11,6 +11,7 @@ from pytomorrowio.const import ( PollenIndex, PrecipitationType, PrimaryPollutantType, + UVDescription, ) from homeassistant.components.sensor import ( @@ -64,6 +65,8 @@ from .const import ( TMRW_ATTR_PRESSURE_SURFACE_LEVEL, TMRW_ATTR_SOLAR_GHI, TMRW_ATTR_SULPHUR_DIOXIDE, + TMRW_ATTR_UV_HEALTH_CONCERN, + TMRW_ATTR_UV_INDEX, TMRW_ATTR_WIND_GUST, ) @@ -92,6 +95,10 @@ class TomorrowioSensorEntityDescription(SensorEntityDescription): "they must both be None" ) + if self.value_map is not None: + self.device_class = SensorDeviceClass.ENUM + self.options = [item.name.lower() for item in self.value_map] + # From https://cfpub.epa.gov/ncer_abstracts/index.cfm/fuseaction/display.files/fileID/14285 # x ug/m^3 = y ppb * molecular weight / 24.45 @@ -173,8 +180,6 @@ SENSOR_TYPES = ( key=TMRW_ATTR_PRECIPITATION_TYPE, name="Precipitation Type", value_map=PrecipitationType, - device_class=SensorDeviceClass.ENUM, - options=["freezing_rain", "ice_pellets", "none", "rain", "snow"], translation_key="precipitation_type", icon="mdi:weather-snowy-rainy", ), @@ -234,20 +239,12 @@ SENSOR_TYPES = ( key=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, name="US EPA Primary Pollutant", value_map=PrimaryPollutantType, + translation_key="primary_pollutant", ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_EPA_HEALTH_CONCERN, name="US EPA Health Concern", value_map=HealthConcernType, - device_class=SensorDeviceClass.ENUM, - options=[ - "good", - "hazardous", - "moderate", - "unhealthy_for_sensitive_groups", - "unhealthy", - "very_unhealthy", - ], translation_key="health_concern", icon="mdi:hospital", ), @@ -260,20 +257,12 @@ SENSOR_TYPES = ( key=TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, name="China MEP Primary Pollutant", value_map=PrimaryPollutantType, + translation_key="primary_pollutant", ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_CHINA_HEALTH_CONCERN, name="China MEP Health Concern", value_map=HealthConcernType, - device_class=SensorDeviceClass.ENUM, - options=[ - "good", - "hazardous", - "moderate", - "unhealthy_for_sensitive_groups", - "unhealthy", - "very_unhealthy", - ], translation_key="health_concern", icon="mdi:hospital", ), @@ -281,8 +270,6 @@ SENSOR_TYPES = ( key=TMRW_ATTR_POLLEN_TREE, name="Tree Pollen Index", value_map=PollenIndex, - device_class=SensorDeviceClass.ENUM, - options=["high", "low", "medium", "none", "very_high", "very_low"], translation_key="pollen_index", icon="mdi:flower-pollen", ), @@ -290,8 +277,6 @@ SENSOR_TYPES = ( key=TMRW_ATTR_POLLEN_WEED, name="Weed Pollen Index", value_map=PollenIndex, - device_class=SensorDeviceClass.ENUM, - options=["high", "low", "medium", "none", "very_high", "very_low"], translation_key="pollen_index", icon="mdi:flower-pollen", ), @@ -299,8 +284,6 @@ SENSOR_TYPES = ( key=TMRW_ATTR_POLLEN_GRASS, name="Grass Pollen Index", value_map=PollenIndex, - device_class=SensorDeviceClass.ENUM, - options=["high", "low", "medium", "none", "very_high", "very_low"], translation_key="pollen_index", icon="mdi:flower-pollen", ), @@ -309,6 +292,18 @@ SENSOR_TYPES = ( name="Fire Index", icon="mdi:fire", ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_UV_INDEX, + name="UV Index", + icon="mdi:sun-wireless", + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_UV_HEALTH_CONCERN, + name="UV Radiation Health Concern", + value_map=UVDescription, + translation_key="uv_index", + icon="mdi:sun-wireless", + ), ) diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json index 1057477b0ac..a104570f5c8 100644 --- a/homeassistant/components/tomorrowio/strings.json +++ b/homeassistant/components/tomorrowio/strings.json @@ -61,6 +61,25 @@ "freezing_rain": "Freezing Rain", "ice_pellets": "Ice Pellets" } + }, + "primary_pollutant": { + "state": { + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "o3": "[%key:component::sensor::entity_component::ozone::name%]", + "no2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + } + }, + "uv_index": { + "state": { + "low": "Low", + "moderate": "Moderate", + "high": "High", + "very_high": "Very high", + "extreme": "Extreme" + } } } } diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index d92ac401f92..86b84ec3ca6 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -224,6 +224,7 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): temp = values.get(TMRW_ATTR_TEMPERATURE_HIGH) temp_low = None + wind_direction = values.get(TMRW_ATTR_WIND_DIRECTION) wind_speed = values.get(TMRW_ATTR_WIND_SPEED) diff --git a/homeassistant/components/toon/services.yaml b/homeassistant/components/toon/services.yaml index d01cf32994b..1b75dd4957a 100644 --- a/homeassistant/components/toon/services.yaml +++ b/homeassistant/components/toon/services.yaml @@ -1,10 +1,6 @@ update: - name: Update - description: Update all entities with fresh data from Toon fields: display: - name: Display - description: Toon display to update. advanced: true example: eneco-001-123456 selector: diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json index 60d5ed3312c..620a7f51113 100644 --- a/homeassistant/components/toon/strings.json +++ b/homeassistant/components/toon/strings.json @@ -20,5 +20,17 @@ "no_agreements": "This account has no Toon displays.", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" } + }, + "services": { + "update": { + "name": "Update", + "description": "Updates all entities with fresh data from Toon.", + "fields": { + "display": { + "name": "Display", + "description": "Toon display to update." + } + } + } } } diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index a81e7518132..183919f05f2 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -3,7 +3,6 @@ "name": "Total Connect", "codeowners": ["@austinmroczek"], "config_flow": true, - "dependencies": [], "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], diff --git a/homeassistant/components/totalconnect/services.yaml b/homeassistant/components/totalconnect/services.yaml index 0e8f8f8e217..3ab4faf0c30 100644 --- a/homeassistant/components/totalconnect/services.yaml +++ b/homeassistant/components/totalconnect/services.yaml @@ -1,14 +1,10 @@ arm_away_instant: - name: Arm Away Instant - description: Arm Away with zero entry delay. target: entity: integration: totalconnect domain: alarm_control_panel arm_home_instant: - name: Arm Home Instant - description: Arm Home with zero entry delay. target: entity: integration: totalconnect diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json index 346ea7ef403..922962c9866 100644 --- a/homeassistant/components/totalconnect/strings.json +++ b/homeassistant/components/totalconnect/strings.json @@ -39,5 +39,15 @@ } } } + }, + "services": { + "arm_away_instant": { + "name": "Arm away instant", + "description": "Arms Away with zero entry delay." + }, + "arm_home_instant": { + "name": "Arm home instant", + "description": "Arms Home with zero entry delay." + } } } diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 01e124dea1a..4bf076a59bc 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -32,13 +32,14 @@ def async_refresh_after( class CoordinatedTPLinkEntity(CoordinatorEntity[TPLinkDataUpdateCoordinator]): """Common base class for all coordinated tplink entities.""" + _attr_has_entity_name = True + def __init__( self, device: SmartDevice, coordinator: TPLinkDataUpdateCoordinator ) -> None: """Initialize the switch.""" super().__init__(coordinator) self.device: SmartDevice = device - self._attr_name = self.device.alias self._attr_unique_id = self.device.device_id @property diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index e4f91f282f6..db7e6ff355e 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -162,6 +162,7 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): """Representation of a TPLink Smart Bulb.""" _attr_supported_features = LightEntityFeature.TRANSITION + _attr_name = None device: SmartBulb diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index eaa1acc11bf..c33106d13cc 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -9,21 +9,33 @@ "registered_devices": true }, { - "hostname": "es*", + "hostname": "e[sp]*", "macaddress": "54AF97*" }, { - "hostname": "ep*", + "hostname": "e[sp]*", "macaddress": "E848B8*" }, { - "hostname": "ep*", + "hostname": "e[sp]*", "macaddress": "1C61B4*" }, { - "hostname": "ep*", + "hostname": "e[sp]*", "macaddress": "003192*" }, + { + "hostname": "hs*", + "macaddress": "B4B024*" + }, + { + "hostname": "hs*", + "macaddress": "9C5322*" + }, + { + "hostname": "k[lps]*", + "macaddress": "9C5322*" + }, { "hostname": "hs*", "macaddress": "1C3BF3*" @@ -69,73 +81,85 @@ "macaddress": "B09575*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "60A4B7*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "005F67*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "1027F5*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "B0A7B9*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "403F8C*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "C0C9E3*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "909A4A*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "E848B8*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "003192*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "1C3BF3*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "50C7BF*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "68FF7B*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "98DAC4*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "B09575*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "C006C3*" }, { - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "6C5AB0*" + }, + { + "hostname": "k[lps]*", + "macaddress": "54AF97*" + }, + { + "hostname": "k[lps]*", + "macaddress": "AC15A2*" + }, + { + "hostname": "k[lps]*", + "macaddress": "788C5B*" } ], "documentation": "https://www.home-assistant.io/integrations/tplink", "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.5.2"] + "requirements": ["python-kasa[speedups]==0.5.3"] } diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 7471ed8982b..ba4949434f7 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -46,6 +46,7 @@ class TPLinkSensorEntityDescription(SensorEntityDescription): ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( TPLinkSensorEntityDescription( key=ATTR_CURRENT_POWER_W, + translation_key="current_consumption", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -55,6 +56,7 @@ ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( ), TPLinkSensorEntityDescription( key=ATTR_TOTAL_ENERGY_KWH, + translation_key="total_consumption", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -64,6 +66,7 @@ ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( ), TPLinkSensorEntityDescription( key=ATTR_TODAY_ENERGY_KWH, + translation_key="today_consumption", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -75,7 +78,6 @@ ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, - name="Voltage", emeter_attr="voltage", precision=1, ), @@ -84,7 +86,6 @@ ENERGY_SENSORS: tuple[TPLinkSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - name="Current", emeter_attr="current", precision=2, ), @@ -155,14 +156,6 @@ class SmartPlugSensor(CoordinatedTPLinkEntity, SensorEntity): f"{legacy_device_id(self.device)}_{self.entity_description.key}" ) - @property - def name(self) -> str: - """Return the name of the Smart Plug. - - Overridden to include the description. - """ - return f"{self.device.alias} {self.entity_description.name}" - @property def native_value(self) -> float | None: """Return the sensors state.""" diff --git a/homeassistant/components/tplink/services.yaml b/homeassistant/components/tplink/services.yaml index 128c5c3a493..1850df9a060 100644 --- a/homeassistant/components/tplink/services.yaml +++ b/homeassistant/components/tplink/services.yaml @@ -1,12 +1,10 @@ sequence_effect: - description: Set a sequence effect target: entity: integration: tplink domain: light fields: sequence: - description: List of HSV sequences (Max 16) example: | - [340, 20, 50] - [20, 50, 50] @@ -15,14 +13,12 @@ sequence_effect: selector: object: segments: - description: List of Segments (0 for all) example: 0, 2, 4, 6, 8 default: 0 required: false selector: object: brightness: - description: Initial brightness example: 80 default: 100 required: false @@ -33,7 +29,6 @@ sequence_effect: max: 100 unit_of_measurement: "%" duration: - description: Duration example: 0 default: 0 required: false @@ -44,7 +39,6 @@ sequence_effect: max: 5000 unit_of_measurement: "ms" repeat_times: - description: Repetitions (0 for continuous) example: 0 default: 0 required: false @@ -54,7 +48,6 @@ sequence_effect: step: 1 max: 10 transition: - description: Transition example: 2000 default: 0 required: false @@ -65,7 +58,6 @@ sequence_effect: max: 6000 unit_of_measurement: "ms" spread: - description: Speed of spread example: 1 default: 0 required: false @@ -75,7 +67,6 @@ sequence_effect: step: 1 max: 16 direction: - description: Direction example: 1 default: 4 required: false @@ -85,20 +76,17 @@ sequence_effect: step: 1 max: 4 random_effect: - description: Set a random effect target: entity: integration: tplink domain: light fields: init_states: - description: Initial HSV sequence example: [199, 99, 96] required: true selector: object: backgrounds: - description: List of HSV sequences (Max 16) example: | - [199, 89, 50] - [160, 50, 50] @@ -107,14 +95,12 @@ random_effect: selector: object: segments: - description: List of segments (0 for all) example: 0, 2, 4, 6, 8 default: 0 required: false selector: object: brightness: - description: Initial brightness example: 90 default: 100 required: false @@ -125,7 +111,6 @@ random_effect: max: 100 unit_of_measurement: "%" duration: - description: Duration example: 0 default: 0 required: false @@ -136,7 +121,6 @@ random_effect: max: 5000 unit_of_measurement: "ms" transition: - description: Transition example: 2000 default: 0 required: false @@ -147,7 +131,6 @@ random_effect: max: 6000 unit_of_measurement: "ms" fadeoff: - description: Fade off example: 2000 default: 0 required: false @@ -158,31 +141,26 @@ random_effect: max: 3000 unit_of_measurement: "ms" hue_range: - description: Range of hue example: 340, 360 required: false selector: object: saturation_range: - description: Range of saturation example: 40, 95 required: false selector: object: brightness_range: - description: Range of brightness example: 90, 100 required: false selector: object: transition_range: - description: Range of transition example: 2000, 6000 required: false selector: object: random_seed: - description: Random seed example: 80 default: 100 required: false diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index afc595a3adc..750d422cd0d 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -10,7 +10,7 @@ }, "pick_device": { "data": { - "device": "Device" + "device": "[%key:common::config_flow::data::device%]" } }, "discovery_confirm": { @@ -24,5 +24,117 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "sensor": { + "current_consumption": { + "name": "Current consumption" + }, + "total_consumption": { + "name": "Total consumption" + }, + "today_consumption": { + "name": "Today's consumption" + } + }, + "switch": { + "led": { + "name": "LED" + } + } + }, + "services": { + "sequence_effect": { + "name": "Sequence effect", + "description": "Sets a sequence effect.", + "fields": { + "sequence": { + "name": "Sequence", + "description": "List of HSV sequences (Max 16)." + }, + "segments": { + "name": "Segments", + "description": "List of Segments (0 for all)." + }, + "brightness": { + "name": "Brightness", + "description": "Initial brightness." + }, + "duration": { + "name": "Duration", + "description": "Duration." + }, + "repeat_times": { + "name": "Repetitions", + "description": "Repetitions (0 for continuous)." + }, + "transition": { + "name": "Transition", + "description": "Transition." + }, + "spread": { + "name": "Spread", + "description": "Speed of spread." + }, + "direction": { + "name": "Direction", + "description": "Direction." + } + } + }, + "random_effect": { + "name": "Random effect", + "description": "Sets a random effect.", + "fields": { + "init_states": { + "name": "Initial states", + "description": "Initial HSV sequence." + }, + "backgrounds": { + "name": "Backgrounds", + "description": "[%key:component::tplink::services::sequence_effect::fields::sequence::description%]" + }, + "segments": { + "name": "Segments", + "description": "List of segments (0 for all)." + }, + "brightness": { + "name": "Brightness", + "description": "[%key:component::tplink::services::sequence_effect::fields::brightness::description%]" + }, + "duration": { + "name": "Duration", + "description": "[%key:component::tplink::services::sequence_effect::fields::duration::description%]" + }, + "transition": { + "name": "Transition", + "description": "[%key:component::tplink::services::sequence_effect::fields::transition::description%]" + }, + "fadeoff": { + "name": "Fade off", + "description": "Fade off." + }, + "hue_range": { + "name": "Hue range", + "description": "Range of hue." + }, + "saturation_range": { + "name": "Saturation range", + "description": "Range of saturation." + }, + "brightness_range": { + "name": "Brightness range", + "description": "Range of brightness." + }, + "transition_range": { + "name": "Transition range", + "description": "Range of transition." + }, + "random_seed": { + "name": "Random seed", + "description": "Random seed." + } + } + } } } diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index aa0616447cc..6c843246663 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -35,7 +35,7 @@ async def async_setup_entry( # Historically we only add the children if the device is a strip _LOGGER.debug("Initializing strip with %s sockets", len(device.children)) for child in device.children: - entities.append(SmartPlugSwitch(child, coordinator)) + entities.append(SmartPlugSwitchChild(device, coordinator, child)) elif device.is_plug: entities.append(SmartPlugSwitch(device, coordinator)) @@ -49,6 +49,7 @@ class SmartPlugLedSwitch(CoordinatedTPLinkEntity, SwitchEntity): device: SmartPlug + _attr_translation_key = "led" _attr_entity_category = EntityCategory.CONFIG def __init__( @@ -57,7 +58,6 @@ class SmartPlugLedSwitch(CoordinatedTPLinkEntity, SwitchEntity): """Initialize the LED switch.""" super().__init__(device, coordinator) - self._attr_name = f"{device.alias} LED" self._attr_unique_id = f"{self.device.mac}_led" @property @@ -84,6 +84,8 @@ class SmartPlugLedSwitch(CoordinatedTPLinkEntity, SwitchEntity): class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): """Representation of a TPLink Smart Plug switch.""" + _attr_name = None + def __init__( self, device: SmartDevice, @@ -103,3 +105,29 @@ class SmartPlugSwitch(CoordinatedTPLinkEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.device.turn_off() + + +class SmartPlugSwitchChild(SmartPlugSwitch): + """Representation of an individual plug of a TPLink Smart Plug strip.""" + + def __init__( + self, + device: SmartDevice, + coordinator: TPLinkDataUpdateCoordinator, + plug: SmartDevice, + ) -> None: + """Initialize the switch.""" + super().__init__(device, coordinator) + self._plug = plug + self._attr_unique_id = legacy_device_id(plug) + self._attr_name = plug.alias + + @async_refresh_after + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._plug.turn_on() + + @async_refresh_after + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._plug.turn_off() diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 9ed7922fa19..ad31f20e3cf 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -39,6 +39,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.restore_state import RestoreEntity @@ -364,8 +365,11 @@ class TraccarScanner: class TraccarEntity(TrackerEntity, RestoreEntity): """Represent a tracked device.""" + _attr_has_entity_name = True + _attr_name = None + def __init__(self, device, latitude, longitude, battery, accuracy, attributes): - """Set up Geofency entity.""" + """Set up Traccar entity.""" self._accuracy = accuracy self._attributes = attributes self._name = device @@ -400,20 +404,18 @@ class TraccarEntity(TrackerEntity, RestoreEntity): """Return the gps accuracy of the device.""" return self._accuracy - @property - def name(self): - """Return the name of the device.""" - return self._name - @property def unique_id(self): """Return the unique ID.""" return self._unique_id @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info.""" - return {"name": self._name, "identifiers": {(DOMAIN, self._unique_id)}} + return DeviceInfo( + name=self._name, + identifiers={(DOMAIN, self._unique_id)}, + ) @property def source_type(self) -> SourceType: @@ -464,7 +466,7 @@ class TraccarEntity(TrackerEntity, RestoreEntity): self, device, latitude, longitude, battery, accuracy, attributes ): """Mark the device as seen.""" - if device != self.name: + if device != self._name: return self._latitude = latitude diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py index 96fc718c67d..351b39f61e7 100644 --- a/homeassistant/components/tractive/__init__.py +++ b/homeassistant/components/tractive/__init__.py @@ -24,10 +24,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( ATTR_BUZZER, + ATTR_CALORIES, ATTR_DAILY_GOAL, ATTR_LED, ATTR_LIVE_TRACKING, ATTR_MINUTES_ACTIVE, + ATTR_MINUTES_DAY_SLEEP, + ATTR_MINUTES_NIGHT_SLEEP, + ATTR_MINUTES_REST, ATTR_TRACKER_STATE, CLIENT, CLIENT_ID, @@ -38,6 +42,7 @@ from .const import ( TRACKER_ACTIVITY_STATUS_UPDATED, TRACKER_HARDWARE_STATUS_UPDATED, TRACKER_POSITION_UPDATED, + TRACKER_WELLNESS_STATUS_UPDATED, ) PLATFORMS = [ @@ -202,6 +207,9 @@ class TractiveClient: if event["message"] == "activity_update": self._send_activity_update(event) continue + if event["message"] == "wellness_overview": + self._send_wellness_update(event) + continue if ( "hardware" in event and self._last_hw_time != event["hardware"]["time"] @@ -264,6 +272,17 @@ class TractiveClient: TRACKER_ACTIVITY_STATUS_UPDATED, event["pet_id"], payload ) + def _send_wellness_update(self, event: dict[str, Any]) -> None: + payload = { + ATTR_CALORIES: event["activity"]["calories"], + ATTR_MINUTES_DAY_SLEEP: event["sleep"]["minutes_day_sleep"], + ATTR_MINUTES_NIGHT_SLEEP: event["sleep"]["minutes_night_sleep"], + ATTR_MINUTES_REST: event["activity"]["minutes_rest"], + } + self._dispatch_tracker_event( + TRACKER_WELLNESS_STATUS_UPDATED, event["pet_id"], payload + ) + def _send_position_update(self, event: dict[str, Any]) -> None: payload = { "latitude": event["position"]["latlong"][0], diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py index 4b376941344..d7968f15bf8 100644 --- a/homeassistant/components/tractive/binary_sensor.py +++ b/homeassistant/components/tractive/binary_sensor.py @@ -24,14 +24,10 @@ from .const import ( ) from .entity import TractiveEntity -TRACKERS_WITH_BUILTIN_BATTERY = ("TRNJA4", "TRAXL1") - class TractiveBinarySensor(TractiveEntity, BinarySensorEntity): """Tractive sensor.""" - _attr_has_entity_name = True - def __init__( self, user_id: str, item: Trackables, description: BinarySensorEntityDescription ) -> None: @@ -92,7 +88,7 @@ async def async_setup_entry( entities = [ TractiveBinarySensor(client.user_id, item, SENSOR_TYPE) for item in trackables - if item.tracker_details["model_number"] in TRACKERS_WITH_BUILTIN_BATTERY + if item.tracker_details.get("charging_state") is not None ] async_add_entities(entities) diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py index a87e22c505d..81936ae5d80 100644 --- a/homeassistant/components/tractive/const.py +++ b/homeassistant/components/tractive/const.py @@ -6,11 +6,15 @@ DOMAIN = "tractive" RECONNECT_INTERVAL = timedelta(seconds=10) -ATTR_DAILY_GOAL = "daily_goal" ATTR_BUZZER = "buzzer" +ATTR_CALORIES = "calories" +ATTR_DAILY_GOAL = "daily_goal" ATTR_LED = "led" ATTR_LIVE_TRACKING = "live_tracking" ATTR_MINUTES_ACTIVE = "minutes_active" +ATTR_MINUTES_DAY_SLEEP = "minutes_day_sleep" +ATTR_MINUTES_NIGHT_SLEEP = "minutes_night_sleep" +ATTR_MINUTES_REST = "minutes_rest" ATTR_TRACKER_STATE = "tracker_state" # This client ID was issued by Tractive specifically for Home Assistant. @@ -23,5 +27,6 @@ TRACKABLES = "trackables" TRACKER_HARDWARE_STATUS_UPDATED = f"{DOMAIN}_tracker_hardware_status_updated" TRACKER_POSITION_UPDATED = f"{DOMAIN}_tracker_position_updated" TRACKER_ACTIVITY_STATUS_UPDATED = f"{DOMAIN}_tracker_activity_updated" +TRACKER_WELLNESS_STATUS_UPDATED = f"{DOMAIN}_tracker_wellness_updated" SERVER_UNAVAILABLE = f"{DOMAIN}_server_unavailable" diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 038461494d6..e9739819734 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -36,7 +36,6 @@ async def async_setup_entry( class TractiveDeviceTracker(TractiveEntity, TrackerEntity): """Tractive device tracker.""" - _attr_has_entity_name = True _attr_icon = "mdi:paw" _attr_translation_key = "tracker" diff --git a/homeassistant/components/tractive/entity.py b/homeassistant/components/tractive/entity.py index def321d928f..712f8eda75a 100644 --- a/homeassistant/components/tractive/entity.py +++ b/homeassistant/components/tractive/entity.py @@ -11,6 +11,8 @@ from .const import DOMAIN class TractiveEntity(Entity): """Tractive entity class.""" + _attr_has_entity_name = True + def __init__( self, user_id: str, trackable: dict[str, Any], tracker_details: dict[str, Any] ) -> None: diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index 9c0f8f307ed..493b627f9b4 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -22,8 +23,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import Trackables from .const import ( + ATTR_CALORIES, ATTR_DAILY_GOAL, ATTR_MINUTES_ACTIVE, + ATTR_MINUTES_DAY_SLEEP, + ATTR_MINUTES_NIGHT_SLEEP, + ATTR_MINUTES_REST, ATTR_TRACKER_STATE, CLIENT, DOMAIN, @@ -31,6 +36,7 @@ from .const import ( TRACKABLES, TRACKER_ACTIVITY_STATUS_UPDATED, TRACKER_HARDWARE_STATUS_UPDATED, + TRACKER_WELLNESS_STATUS_UPDATED, ) from .entity import TractiveEntity @@ -52,8 +58,6 @@ class TractiveSensorEntityDescription( class TractiveSensor(TractiveEntity, SensorEntity): """Tractive sensor.""" - _attr_has_entity_name = True - def __init__( self, user_id: str, @@ -109,8 +113,8 @@ class TractiveActivitySensor(TractiveSensor): """Tractive active sensor.""" @callback - def handle_activity_status_update(self, event: dict[str, Any]) -> None: - """Handle activity status update.""" + def handle_status_update(self, event: dict[str, Any]) -> None: + """Handle status update.""" self._attr_native_value = event[self.entity_description.key] self._attr_available = True self.async_write_ha_state() @@ -122,7 +126,30 @@ class TractiveActivitySensor(TractiveSensor): async_dispatcher_connect( self.hass, f"{TRACKER_ACTIVITY_STATUS_UPDATED}-{self._trackable['_id']}", - self.handle_activity_status_update, + self.handle_status_update, + ) + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{SERVER_UNAVAILABLE}-{self._user_id}", + self.handle_server_unavailable, + ) + ) + + +class TractiveWellnessSensor(TractiveActivitySensor): + """Tractive wellness sensor.""" + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{TRACKER_WELLNESS_STATUS_UPDATED}-{self._trackable['_id']}", + self.handle_status_update, ) ) @@ -145,18 +172,42 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), TractiveSensorEntityDescription( - # Currently, only state operational and not_reporting are used - # More states are available by polling the data key=ATTR_TRACKER_STATE, translation_key="tracker_state", entity_class=TractiveHardwareSensor, + icon="mdi:radar", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=[ + "not_reporting", + "operational", + "system_shutdown_user", + "system_startup", + ], ), TractiveSensorEntityDescription( key=ATTR_MINUTES_ACTIVE, - translation_key="minutes_active", + translation_key="activity_time", icon="mdi:clock-time-eight-outline", native_unit_of_measurement=UnitOfTime.MINUTES, entity_class=TractiveActivitySensor, + state_class=SensorStateClass.TOTAL, + ), + TractiveSensorEntityDescription( + key=ATTR_MINUTES_REST, + translation_key="rest_time", + icon="mdi:clock-time-eight-outline", + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_class=TractiveWellnessSensor, + state_class=SensorStateClass.TOTAL, + ), + TractiveSensorEntityDescription( + key=ATTR_CALORIES, + translation_key="calories", + icon="mdi:fire", + native_unit_of_measurement="kcal", + entity_class=TractiveWellnessSensor, + state_class=SensorStateClass.TOTAL, ), TractiveSensorEntityDescription( key=ATTR_DAILY_GOAL, @@ -165,6 +216,22 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTime.MINUTES, entity_class=TractiveActivitySensor, ), + TractiveSensorEntityDescription( + key=ATTR_MINUTES_DAY_SLEEP, + translation_key="minutes_day_sleep", + icon="mdi:sleep", + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_class=TractiveWellnessSensor, + state_class=SensorStateClass.TOTAL, + ), + TractiveSensorEntityDescription( + key=ATTR_MINUTES_NIGHT_SLEEP, + translation_key="minutes_night_sleep", + icon="mdi:sleep", + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_class=TractiveWellnessSensor, + state_class=SensorStateClass.TOTAL, + ), ) diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json index d5aee51ed61..4053d2658f5 100644 --- a/homeassistant/components/tractive/strings.json +++ b/homeassistant/components/tractive/strings.json @@ -30,11 +30,23 @@ } }, "sensor": { + "calories": { + "name": "Calories burned" + }, "daily_goal": { "name": "Daily goal" }, - "minutes_active": { - "name": "Minutes active" + "activity_time": { + "name": "Activity time" + }, + "minutes_day_sleep": { + "name": "Day sleep" + }, + "minutes_night_sleep": { + "name": "Night sleep" + }, + "rest_time": { + "name": "Rest time" }, "tracker_battery_level": { "name": "Tracker battery" diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 7ae480d4f98..6d8274df253 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -88,7 +88,6 @@ async def async_setup_entry( class TractiveSwitch(TractiveEntity, SwitchEntity): """Tractive switch.""" - _attr_has_entity_name = True entity_description: TractiveSwitchEntityDescription def __init__( diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index 5a84ad5719c..c7154c19f15 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -37,6 +37,8 @@ def handle_error( class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]): """Base Tradfri device.""" + _attr_has_entity_name = True + def __init__( self, device_coordinator: TradfriDeviceDataUpdateCoordinator, @@ -52,7 +54,6 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]): self._device_id = self._device.id self._api = handle_error(api) - self._attr_name = self._device.name self._attr_unique_id = f"{self._gateway_id}-{self._device.id}" diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py index 976a48906fc..c51918b4a4f 100644 --- a/homeassistant/components/tradfri/cover.py +++ b/homeassistant/components/tradfri/cover.py @@ -40,6 +40,8 @@ async def async_setup_entry( class TradfriCover(TradfriBaseEntity, CoverEntity): """The platform class required by Home Assistant.""" + _attr_name = None + def __init__( self, device_coordinator: TradfriDeviceDataUpdateCoordinator, diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index d6bb91a4979..a26dfa1d9a0 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -54,6 +54,7 @@ async def async_setup_entry( class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): """The platform class required by Home Assistant.""" + _attr_name = None _attr_supported_features = FanEntityFeature.PRESET_MODE | FanEntityFeature.SET_SPEED def __init__( diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index 32160c6a130..df35301b373 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -49,6 +49,7 @@ async def async_setup_entry( class TradfriLight(TradfriBaseEntity, LightEntity): """The platform class required by Home Assistant.""" + _attr_name = None _attr_supported_features = LightEntityFeature.TRANSITION def __init__( diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 1b3839ce2d7..383eec8a8fb 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -24,7 +24,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import UNDEFINED from .base_class import TradfriBaseEntity from .const import ( @@ -65,7 +64,7 @@ def _get_air_quality(device: Device) -> int | None: def _get_filter_time_left(device: Device) -> int: - """Fetch the filter's remaining life (in hours).""" + """Fetch the filter's remaining lifetime (in hours).""" assert device.air_purifier_control is not None return round( cast( @@ -89,7 +88,7 @@ SENSOR_DESCRIPTIONS_BATTERY: tuple[TradfriSensorEntityDescription, ...] = ( SENSOR_DESCRIPTIONS_FAN: tuple[TradfriSensorEntityDescription, ...] = ( TradfriSensorEntityDescription( key="aqi", - name="air quality", + translation_key="aqi", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, icon="mdi:air-filter", @@ -97,7 +96,7 @@ SENSOR_DESCRIPTIONS_FAN: tuple[TradfriSensorEntityDescription, ...] = ( ), TradfriSensorEntityDescription( key="filter_life_remaining", - name="filter time left", + translation_key="filter_life_remaining", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTime.HOURS, icon="mdi:clock-outline", @@ -203,9 +202,6 @@ class TradfriSensor(TradfriBaseEntity, SensorEntity): self._attr_unique_id = f"{self._attr_unique_id}-{description.key}" - if description.name is not UNDEFINED: - self._attr_name = f"{self._attr_name}: {description.name}" - self._refresh() # Set initial state def _refresh(self) -> None: diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json index 34d7e89929a..0a9a86bd23a 100644 --- a/homeassistant/components/tradfri/strings.json +++ b/homeassistant/components/tradfri/strings.json @@ -20,5 +20,15 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" } + }, + "entity": { + "sensor": { + "aqi": { + "name": "Air quality" + }, + "filter_life_remaining": { + "name": "Filter time left" + } + } } } diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index e0e2467ca4b..2f6f1996157 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -40,6 +40,8 @@ async def async_setup_entry( class TradfriSwitch(TradfriBaseEntity, SwitchEntity): """The platform class required by Home Assistant.""" + _attr_name = None + def __init__( self, device_coordinator: TradfriDeviceDataUpdateCoordinator, diff --git a/homeassistant/components/trafikverket_ferry/sensor.py b/homeassistant/components/trafikverket_ferry/sensor.py index b02c673f698..366c193f8fe 100644 --- a/homeassistant/components/trafikverket_ferry/sensor.py +++ b/homeassistant/components/trafikverket_ferry/sensor.py @@ -51,7 +51,7 @@ class TrafikverketSensorEntityDescription( SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="departure_time", - name="Departure time", + translation_key="departure_time", icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: as_utc(data["departure_time"]), @@ -59,21 +59,21 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( ), TrafikverketSensorEntityDescription( key="departure_from", - name="Departure from", + translation_key="departure_from", icon="mdi:ferry", value_fn=lambda data: cast(str, data["departure_from"]), info_fn=lambda data: cast(list[str], data["departure_information"]), ), TrafikverketSensorEntityDescription( key="departure_to", - name="Departure to", + translation_key="departure_to", icon="mdi:ferry", value_fn=lambda data: cast(str, data["departure_to"]), info_fn=lambda data: cast(list[str], data["departure_information"]), ), TrafikverketSensorEntityDescription( key="departure_modified", - name="Departure modified", + translation_key="departure_modified", icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: as_utc(data["departure_modified"]), @@ -82,7 +82,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( ), TrafikverketSensorEntityDescription( key="departure_time_next", - name="Departure time next", + translation_key="departure_time_next", icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: as_utc(data["departure_time_next"]), @@ -91,7 +91,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( ), TrafikverketSensorEntityDescription( key="departure_time_next_next", - name="Departure time next after", + translation_key="departure_time_next_next", icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: as_utc(data["departure_time_next_next"]), diff --git a/homeassistant/components/trafikverket_ferry/strings.json b/homeassistant/components/trafikverket_ferry/strings.json index 86ce87c92e4..d98d60f4643 100644 --- a/homeassistant/components/trafikverket_ferry/strings.json +++ b/homeassistant/components/trafikverket_ferry/strings.json @@ -30,13 +30,35 @@ "selector": { "weekday": { "options": { - "mon": "Monday", - "tue": "Tuesday", - "wed": "Wednesday", - "thu": "Thursday", - "fri": "Friday", - "sat": "Saturday", - "sun": "Sunday" + "mon": "[%key:common::time::monday%]", + "tue": "[%key:common::time::tuesday%]", + "wed": "[%key:common::time::wednesday%]", + "thu": "[%key:common::time::thursday%]", + "fri": "[%key:common::time::friday%]", + "sat": "[%key:common::time::saturday%]", + "sun": "[%key:common::time::sunday%]" + } + } + }, + "entity": { + "sensor": { + "departure_time": { + "name": "Departure time" + }, + "departure_from": { + "name": "Departure from" + }, + "departure_to": { + "name": "Departure to" + }, + "departure_modified": { + "name": "Departure modified" + }, + "departure_time_next": { + "name": "Departure time next" + }, + "departure_time_next_next": { + "name": "Departure time next after" } } } diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 8047cf2046d..dd35d058ed5 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -15,6 +15,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_FROM, CONF_TO, DOMAIN, PLATFORMS +from .coordinator import TVDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -34,11 +35,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f" {entry.data[CONF_TO]}. Error: {error} " ) from error - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - CONF_TO: to_station, - CONF_FROM: from_station, - "train_api": train_api, - } + coordinator = TVDataUpdateCoordinator(hass, entry, to_station, from_station) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py new file mode 100644 index 00000000000..fba6eb93dd9 --- /dev/null +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -0,0 +1,149 @@ +"""DataUpdateCoordinator for the Trafikverket Train integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime, time, timedelta +import logging + +from pytrafikverket import TrafikverketTrain +from pytrafikverket.trafikverket_train import StationInfo, TrainStop + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_WEEKDAY, WEEKDAYS +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import CONF_TIME, DOMAIN + + +@dataclass +class TrainData: + """Dataclass for Trafikverket Train data.""" + + departure_time: datetime | None + departure_state: str + cancelled: bool + delayed_time: int | None + planned_time: datetime | None + estimated_time: datetime | None + actual_time: datetime | None + other_info: str | None + deviation: str | None + + +_LOGGER = logging.getLogger(__name__) +TIME_BETWEEN_UPDATES = timedelta(minutes=5) + + +def _next_weekday(fromdate: date, weekday: int) -> date: + """Return the date of the next time a specific weekday happen.""" + days_ahead = weekday - fromdate.weekday() + if days_ahead <= 0: + days_ahead += 7 + return fromdate + timedelta(days_ahead) + + +def _next_departuredate(departure: list[str]) -> date: + """Calculate the next departuredate from an array input of short days.""" + today_date = date.today() + today_weekday = date.weekday(today_date) + if WEEKDAYS[today_weekday] in departure: + return today_date + for day in departure: + next_departure = WEEKDAYS.index(day) + if next_departure > today_weekday: + return _next_weekday(today_date, next_departure) + return _next_weekday(today_date, WEEKDAYS.index(departure[0])) + + +def _get_as_utc(date_value: datetime | None) -> datetime | None: + """Return utc datetime or None.""" + if date_value: + return dt_util.as_utc(date_value) + return None + + +def _get_as_joined(information: list[str] | None) -> str | None: + """Return joined information or None.""" + if information: + return ", ".join(information) + return None + + +class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): + """A Trafikverket Data Update Coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + to_station: StationInfo, + from_station: StationInfo, + ) -> None: + """Initialize the Trafikverket coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=TIME_BETWEEN_UPDATES, + ) + self._train_api = TrafikverketTrain( + async_get_clientsession(hass), entry.data[CONF_API_KEY] + ) + self.from_station: StationInfo = from_station + self.to_station: StationInfo = to_station + self._time: time | None = dt_util.parse_time(entry.data[CONF_TIME]) + self._weekdays: list[str] = entry.data[CONF_WEEKDAY] + + async def _async_update_data(self) -> TrainData: + """Fetch data from Trafikverket.""" + + when = dt_util.now() + state: TrainStop | None = None + if self._time: + departure_day = _next_departuredate(self._weekdays) + when = datetime.combine( + departure_day, + self._time, + dt_util.get_time_zone(self.hass.config.time_zone), + ) + try: + if self._time: + state = await self._train_api.async_get_train_stop( + self.from_station, self.to_station, when + ) + else: + state = await self._train_api.async_get_next_train_stop( + self.from_station, self.to_station, when + ) + except ValueError as error: + if "Invalid authentication" in error.args[0]: + raise ConfigEntryAuthFailed from error + raise UpdateFailed( + f"Train departure {when} encountered a problem: {error}" + ) from error + + departure_time = state.advertised_time_at_location + if state.estimated_time_at_location: + departure_time = state.estimated_time_at_location + elif state.time_at_location: + departure_time = state.time_at_location + + delay_time = state.get_delay_time() + + states = TrainData( + departure_time=_get_as_utc(departure_time), + departure_state=state.get_state().value, + cancelled=state.canceled, + delayed_time=delay_time.seconds if delay_time else None, + planned_time=_get_as_utc(state.advertised_time_at_location), + estimated_time=_get_as_utc(state.estimated_time_at_location), + actual_time=_get_as_utc(state.time_at_location), + other_info=_get_as_joined(state.other_information), + deviation=_get_as_joined(state.deviations), + ) + + return states diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index 4ea6ff48dc1..f57850e51b8 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -1,31 +1,25 @@ """Train information for departures and delays, provided by Trafikverket.""" from __future__ import annotations -from datetime import date, datetime, time, timedelta -import logging -from typing import TYPE_CHECKING, Any +from datetime import time, timedelta +from typing import TYPE_CHECKING -from pytrafikverket import TrafikverketTrain -from pytrafikverket.exceptions import ( - MultipleTrainAnnouncementFound, - NoTrainAnnouncementFound, -) -from pytrafikverket.trafikverket_train import StationInfo, TrainStop +from pytrafikverket.trafikverket_train import StationInfo from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_WEEKDAY, WEEKDAYS -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_NAME, CONF_WEEKDAY +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN +from .const import CONF_TIME, DOMAIN +from .coordinator import TVDataUpdateCoordinator from .util import create_unique_id -_LOGGER = logging.getLogger(__name__) - ATTR_DEPARTURE_STATE = "departure_state" ATTR_CANCELED = "canceled" ATTR_DELAY_TIME = "number_of_minutes_delayed" @@ -44,16 +38,17 @@ async def async_setup_entry( ) -> None: """Set up the Trafikverket sensor entry.""" - train_api = hass.data[DOMAIN][entry.entry_id]["train_api"] - to_station = hass.data[DOMAIN][entry.entry_id][CONF_TO] - from_station = hass.data[DOMAIN][entry.entry_id][CONF_FROM] + coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + to_station = coordinator.to_station + from_station = coordinator.from_station get_time: str | None = entry.data.get(CONF_TIME) train_time = dt_util.parse_time(get_time) if get_time else None async_add_entities( [ TrainSensor( - train_api, + coordinator, entry.data[CONF_NAME], from_station, to_station, @@ -66,42 +61,17 @@ async def async_setup_entry( ) -def next_weekday(fromdate: date, weekday: int) -> date: - """Return the date of the next time a specific weekday happen.""" - days_ahead = weekday - fromdate.weekday() - if days_ahead <= 0: - days_ahead += 7 - return fromdate + timedelta(days_ahead) - - -def next_departuredate(departure: list[str]) -> date: - """Calculate the next departuredate from an array input of short days.""" - today_date = date.today() - today_weekday = date.weekday(today_date) - if WEEKDAYS[today_weekday] in departure: - return today_date - for day in departure: - next_departure = WEEKDAYS.index(day) - if next_departure > today_weekday: - return next_weekday(today_date, next_departure) - return next_weekday(today_date, WEEKDAYS.index(departure[0])) - - -def _to_iso_format(traintime: datetime) -> str: - """Return isoformatted utc time.""" - return dt_util.as_utc(traintime).isoformat() - - -class TrainSensor(SensorEntity): +class TrainSensor(CoordinatorEntity[TVDataUpdateCoordinator], SensorEntity): """Contains data about a train depature.""" _attr_icon = ICON _attr_device_class = SensorDeviceClass.TIMESTAMP _attr_has_entity_name = True + _attr_name = None def __init__( self, - train_api: TrafikverketTrain, + coordinator: TVDataUpdateCoordinator, name: str, from_station: StationInfo, to_station: StationInfo, @@ -110,11 +80,7 @@ class TrainSensor(SensorEntity): entry_id: str, ) -> None: """Initialize the sensor.""" - self._train_api = train_api - self._from_station = from_station - self._to_station = to_station - self._weekday = weekday - self._time = departuretime + super().__init__(coordinator) self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, entry_id)}, @@ -128,80 +94,28 @@ class TrainSensor(SensorEntity): self._attr_unique_id = create_unique_id( from_station.name, to_station.name, departuretime, weekday ) + self._update_attr() - async def async_update(self) -> None: + @callback + def _handle_coordinator_update(self) -> None: + self._update_attr() + return super()._handle_coordinator_update() + + @callback + def _update_attr(self) -> None: """Retrieve latest state.""" - when = dt_util.now() - _state: TrainStop | None = None - if self._time: - departure_day = next_departuredate(self._weekday) - when = datetime.combine( - departure_day, - self._time, - dt_util.get_time_zone(self.hass.config.time_zone), - ) - try: - if self._time: - _LOGGER.debug("%s, %s, %s", self._from_station, self._to_station, when) - _state = await self._train_api.async_get_train_stop( - self._from_station, self._to_station, when - ) - else: - _state = await self._train_api.async_get_next_train_stop( - self._from_station, self._to_station, when - ) - except (NoTrainAnnouncementFound, MultipleTrainAnnouncementFound) as error: - _LOGGER.error("Departure %s encountered a problem: %s", when, error) - if not _state: - self._attr_available = False - self._attr_native_value = None - self._attr_extra_state_attributes = {} - return + data = self.coordinator.data - self._attr_available = True + self._attr_native_value = data.departure_time - # The original datetime doesn't provide a timezone so therefore attaching it here. - if TYPE_CHECKING: - assert _state.advertised_time_at_location - self._attr_native_value = dt_util.as_utc(_state.advertised_time_at_location) - if _state.time_at_location: - self._attr_native_value = dt_util.as_utc(_state.time_at_location) - if _state.estimated_time_at_location: - self._attr_native_value = dt_util.as_utc(_state.estimated_time_at_location) - - self._update_attributes(_state) - - def _update_attributes(self, state: TrainStop) -> None: - """Return extra state attributes.""" - - attributes: dict[str, Any] = { - ATTR_DEPARTURE_STATE: state.get_state().value, - ATTR_CANCELED: state.canceled, - ATTR_DELAY_TIME: None, - ATTR_PLANNED_TIME: None, - ATTR_ESTIMATED_TIME: None, - ATTR_ACTUAL_TIME: None, - ATTR_OTHER_INFORMATION: None, - ATTR_DEVIATIONS: None, + self._attr_extra_state_attributes = { + ATTR_DEPARTURE_STATE: data.departure_state, + ATTR_CANCELED: data.cancelled, + ATTR_DELAY_TIME: data.delayed_time, + ATTR_PLANNED_TIME: data.planned_time, + ATTR_ESTIMATED_TIME: data.estimated_time, + ATTR_ACTUAL_TIME: data.actual_time, + ATTR_OTHER_INFORMATION: data.other_info, + ATTR_DEVIATIONS: data.deviation, } - - if delay_in_minutes := state.get_delay_time(): - attributes[ATTR_DELAY_TIME] = delay_in_minutes.total_seconds() / 60 - - if advert_time := state.advertised_time_at_location: - attributes[ATTR_PLANNED_TIME] = _to_iso_format(advert_time) - - if est_time := state.estimated_time_at_location: - attributes[ATTR_ESTIMATED_TIME] = _to_iso_format(est_time) - - if time_location := state.time_at_location: - attributes[ATTR_ACTUAL_TIME] = _to_iso_format(time_location) - - if other_info := state.other_information: - attributes[ATTR_OTHER_INFORMATION] = ", ".join(other_info) - - if deviation := state.deviations: - attributes[ATTR_DEVIATIONS] = ", ".join(deviation) - - self._attr_extra_state_attributes = attributes diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index 6c67d881153..0089f6db8fc 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -32,13 +32,13 @@ "selector": { "weekday": { "options": { - "mon": "Monday", - "tue": "Tuesday", - "wed": "Wednesday", - "thu": "Thursday", - "fri": "Friday", - "sat": "Saturday", - "sun": "Sunday" + "mon": "[%key:common::time::monday%]", + "tue": "[%key:common::time::tuesday%]", + "wed": "[%key:common::time::wednesday%]", + "thu": "[%key:common::time::thursday%]", + "fri": "[%key:common::time::friday%]", + "sat": "[%key:common::time::saturday%]", + "sun": "[%key:common::time::sunday%]" } } } diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 8523ded1fff..f34eae3cf1f 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -87,32 +87,33 @@ class TrafikverketSensorEntityDescription( SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="air_temp", + translation_key="air_temperature", api_key="air_temp", - name="Air temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TrafikverketSensorEntityDescription( key="road_temp", + translation_key="road_temperature", api_key="road_temp", - name="Road temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TrafikverketSensorEntityDescription( key="precipitation", + translation_key="precipitation", api_key="precipitationtype_translated", name="Precipitation type", icon="mdi:weather-snowy-rainy", entity_registry_enabled_default=False, - translation_key="precipitation", options=PRECIPITATION_TYPE, device_class=SensorDeviceClass.ENUM, ), TrafikverketSensorEntityDescription( key="wind_direction", + translation_key="wind_direction", api_key="winddirection", name="Wind direction", native_unit_of_measurement=DEGREE, @@ -121,25 +122,24 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( ), TrafikverketSensorEntityDescription( key="wind_direction_text", + translation_key="wind_direction_text", api_key="winddirectiontext_translated", name="Wind direction text", icon="mdi:flag-triangle", - translation_key="wind_direction_text", options=WIND_DIRECTIONS, device_class=SensorDeviceClass.ENUM, ), TrafikverketSensorEntityDescription( key="wind_speed", api_key="windforce", - name="Wind speed", native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), TrafikverketSensorEntityDescription( key="wind_speed_max", + translation_key="wind_speed_max", api_key="windforcemax", - name="Wind speed max", native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.WIND_SPEED, icon="mdi:weather-windy-variant", @@ -149,9 +149,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="humidity", api_key="humidity", - name="Humidity", native_unit_of_measurement=PERCENTAGE, - icon="mdi:water-percent", device_class=SensorDeviceClass.HUMIDITY, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, @@ -159,25 +157,23 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( TrafikverketSensorEntityDescription( key="precipitation_amount", api_key="precipitation_amount", - name="Precipitation amount", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, state_class=SensorStateClass.MEASUREMENT, ), TrafikverketSensorEntityDescription( key="precipitation_amountname", + translation_key="precipitation_amountname", api_key="precipitation_amountname_translated", - name="Precipitation name", icon="mdi:weather-pouring", entity_registry_enabled_default=False, - translation_key="precipitation_amountname", options=PRECIPITATION_AMOUNTNAME, device_class=SensorDeviceClass.ENUM, ), TrafikverketSensorEntityDescription( key="measure_time", + translation_key="measure_time", api_key="measure_time", - name="Measure Time", icon="mdi:clock", entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, diff --git a/homeassistant/components/trafikverket_weatherstation/strings.json b/homeassistant/components/trafikverket_weatherstation/strings.json index 3680fae6d8c..9ff1b077f33 100644 --- a/homeassistant/components/trafikverket_weatherstation/strings.json +++ b/homeassistant/components/trafikverket_weatherstation/strings.json @@ -20,7 +20,29 @@ }, "entity": { "sensor": { + "air_temperature": { + "name": "Air temperature" + }, + "road_temperature": { + "name": "Road temperature" + }, + "precipitation": { + "name": "Precipitation type", + "state": { + "drizzle": "Drizzle", + "hail": "Hail", + "none": "None", + "rain": "Rain", + "snow": "Snow", + "rain_snow_mixed": "Rain and snow mixed", + "freezing_rain": "Freezing rain" + } + }, + "wind_direction": { + "name": "Wind direction" + }, "wind_direction_text": { + "name": "Wind direction text", "state": { "east": "East", "north_east": "North east", @@ -36,7 +58,11 @@ "west": "West" } }, + "wind_speed_max": { + "name": "Wind speed max" + }, "precipitation_amountname": { + "name": "Precipitation name", "state": { "error": "Error", "mild_rain": "Mild rain", @@ -53,16 +79,8 @@ "unknown": "Unknown" } }, - "precipitation": { - "state": { - "drizzle": "Drizzle", - "hail": "Hail", - "none": "None", - "rain": "Rain", - "snow": "Snow", - "rain_snow_mixed": "Rain and snow mixed", - "freezing_rain": "Freezing rain" - } + "measure_time": { + "name": "Measure time" } } } diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 3cee556044f..833c1910d4e 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -38,21 +38,53 @@ async def async_setup_entry( name = config_entry.data[CONF_NAME] dev = [ - TransmissionSpeedSensor(tm_client, name, "Down speed", "download"), - TransmissionSpeedSensor(tm_client, name, "Up speed", "upload"), - TransmissionStatusSensor(tm_client, name, "Status", "status"), - TransmissionTorrentsSensor( - tm_client, name, "Active torrents", "active_torrents" + TransmissionSpeedSensor( + tm_client, + name, + "download_speed", + "download", + ), + TransmissionSpeedSensor( + tm_client, + name, + "upload_speed", + "upload", + ), + TransmissionStatusSensor( + tm_client, + name, + "transmission_status", + "status", ), TransmissionTorrentsSensor( - tm_client, name, "Paused torrents", "paused_torrents" - ), - TransmissionTorrentsSensor(tm_client, name, "Total torrents", "total_torrents"), - TransmissionTorrentsSensor( - tm_client, name, "Completed torrents", "completed_torrents" + tm_client, + name, + "active_torrents", + "active_torrents", ), TransmissionTorrentsSensor( - tm_client, name, "Started torrents", "started_torrents" + tm_client, + name, + "paused_torrents", + "paused_torrents", + ), + TransmissionTorrentsSensor( + tm_client, + name, + "total_torrents", + "total_torrents", + ), + TransmissionTorrentsSensor( + tm_client, + name, + "completed_torrents", + "completed_torrents", + ), + TransmissionTorrentsSensor( + tm_client, + name, + "started_torrents", + "started_torrents", ), ] @@ -65,10 +97,10 @@ class TransmissionSensor(SensorEntity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, tm_client, client_name, sensor_name, key): + def __init__(self, tm_client, client_name, sensor_translation_key, key): """Initialize the sensor.""" self._tm_client: TransmissionClient = tm_client - self._attr_name = sensor_name + self._attr_translation_key = sensor_translation_key self._key = key self._state = None self._attr_unique_id = f"{tm_client.config_entry.entry_id}-{key}" @@ -128,7 +160,6 @@ class TransmissionStatusSensor(TransmissionSensor): _attr_device_class = SensorDeviceClass.ENUM _attr_options = [STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING] - _attr_translation_key = "transmission_status" def update(self) -> None: """Get the latest data from Transmission and updates the state.""" diff --git a/homeassistant/components/transmission/services.yaml b/homeassistant/components/transmission/services.yaml index 34a88528411..2d61bda442f 100644 --- a/homeassistant/components/transmission/services.yaml +++ b/homeassistant/components/transmission/services.yaml @@ -1,75 +1,49 @@ add_torrent: - name: Add torrent - description: Add a new torrent to download (URL, magnet link or Base64 encoded). fields: entry_id: - name: Transmission entry - description: Config entry id selector: config_entry: integration: transmission torrent: - name: Torrent - description: URL, magnet link or Base64 encoded file. required: true example: http://releases.ubuntu.com/19.04/ubuntu-19.04-desktop-amd64.iso.torrent selector: text: remove_torrent: - name: Remove torrent - description: Remove a torrent fields: entry_id: - name: Transmission entry - description: Config entry id selector: config_entry: integration: transmission id: - name: ID - description: ID of a torrent required: true example: 123 selector: text: delete_data: - name: Delete data - description: Delete torrent data default: false selector: boolean: start_torrent: - name: Start torrent - description: Start a torrent fields: entry_id: - name: Transmission entry - description: Config entry id selector: config_entry: integration: transmission id: - name: ID - description: ID of a torrent example: 123 selector: text: stop_torrent: - name: Stop torrent - description: Stop a torrent fields: entry_id: - name: Transmission entry - description: Config entry id selector: config_entry: integration: transmission id: - name: ID - description: ID of a torrent required: true example: 123 selector: diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index e2c144d5423..aaab4d2e2d7 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -43,13 +43,97 @@ }, "entity": { "sensor": { + "download_speed": { + "name": "Download speed" + }, + "upload_speed": { + "name": "Upload speed" + }, "transmission_status": { + "name": "Status", "state": { - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "up_down": "Up/Down", "seeding": "Seeding", "downloading": "Downloading" } + }, + "active_torrents": { + "name": "Active torrents" + }, + "paused_torrents": { + "name": "Paused torrents" + }, + "total_torrents": { + "name": "Total torrents" + }, + "completed_torrents": { + "name": "Completed torrents" + }, + "started_torrents": { + "name": "Started torrents" + } + } + }, + "services": { + "add_torrent": { + "name": "Add torrent", + "description": "Adds a new torrent to download (URL, magnet link or Base64 encoded).", + "fields": { + "entry_id": { + "name": "Transmission entry", + "description": "Config entry id." + }, + "torrent": { + "name": "Torrent", + "description": "URL, magnet link or Base64 encoded file." + } + } + }, + "remove_torrent": { + "name": "Remove torrent", + "description": "Removes a torrent.", + "fields": { + "entry_id": { + "name": "[%key:component::transmission::services::add_torrent::fields::entry_id::name%]", + "description": "[%key:component::transmission::services::add_torrent::fields::entry_id::description%]" + }, + "id": { + "name": "ID", + "description": "ID of a torrent." + }, + "delete_data": { + "name": "Delete data", + "description": "Delete torrent data." + } + } + }, + "start_torrent": { + "name": "Start torrent", + "description": "Starts a torrent.", + "fields": { + "entry_id": { + "name": "[%key:component::transmission::services::add_torrent::fields::entry_id::name%]", + "description": "[%key:component::transmission::services::add_torrent::fields::entry_id::description%]" + }, + "id": { + "name": "ID", + "description": "[%key:component::transmission::services::remove_torrent::fields::id::description%]" + } + } + }, + "stop_torrent": { + "name": "Stop torrent", + "description": "Stops a torrent.", + "fields": { + "entry_id": { + "name": "[%key:component::transmission::services::add_torrent::fields::entry_id::name%]", + "description": "[%key:component::transmission::services::add_torrent::fields::entry_id::description%]" + }, + "id": { + "name": "ID", + "description": "[%key:component::transmission::services::remove_torrent::fields::id::description%]" + } } } } diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py index 83fa590429f..520b1a5626b 100644 --- a/homeassistant/components/transport_nsw/sensor.py +++ b/homeassistant/components/transport_nsw/sensor.py @@ -6,7 +6,12 @@ from datetime import timedelta from TransportNSW import TransportNSW import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.const import ATTR_MODE, CONF_API_KEY, CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -70,6 +75,8 @@ class TransportNSWSensor(SensorEntity): """Implementation of an Transport NSW sensor.""" _attr_attribution = "Data provided by Transport NSW" + _attr_device_class = SensorDeviceClass.DURATION + _attr_state_class = SensorStateClass.MEASUREMENT def __init__(self, data, stop_id, name): """Initialize the sensor.""" @@ -121,6 +128,11 @@ class TransportNSWSensor(SensorEntity): self._icon = ICONS[self._times[ATTR_MODE]] +def _get_value(value): + """Replace the API response 'n/a' value with None.""" + return None if (value is None or value == "n/a") else value + + class PublicTransportData: """The Class for handling the data retrieval.""" @@ -132,10 +144,10 @@ class PublicTransportData: self._api_key = api_key self.info = { ATTR_ROUTE: self._route, - ATTR_DUE_IN: "n/a", - ATTR_DELAY: "n/a", - ATTR_REAL_TIME: "n/a", - ATTR_DESTINATION: "n/a", + ATTR_DUE_IN: None, + ATTR_DELAY: None, + ATTR_REAL_TIME: None, + ATTR_DESTINATION: None, ATTR_MODE: None, } self.tnsw = TransportNSW() @@ -146,10 +158,10 @@ class PublicTransportData: self._stop_id, self._route, self._destination, self._api_key ) self.info = { - ATTR_ROUTE: _data["route"], - ATTR_DUE_IN: _data["due"], - ATTR_DELAY: _data["delay"], - ATTR_REAL_TIME: _data["real_time"], - ATTR_DESTINATION: _data["destination"], - ATTR_MODE: _data["mode"], + ATTR_ROUTE: _get_value(_data["route"]), + ATTR_DUE_IN: _get_value(_data["due"]), + ATTR_DELAY: _get_value(_data["delay"]), + ATTR_REAL_TIME: _get_value(_data["real_time"]), + ATTR_DESTINATION: _get_value(_data["destination"]), + ATTR_MODE: _get_value(_data["mode"]), } diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index e43032a580f..020f7903060 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -29,9 +29,12 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) from homeassistant.helpers.reload import async_setup_reload_service -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from homeassistant.util.dt import utcnow from . import PLATFORMS @@ -174,9 +177,11 @@ class SensorTrend(BinarySensorEntity): """Complete device setup after being added to hass.""" @callback - def trend_sensor_state_listener(event): + def trend_sensor_state_listener( + event: EventType[EventStateChangedData], + ) -> None: """Handle state changes on the observed device.""" - if (new_state := event.data.get("new_state")) is None: + if (new_state := event.data["new_state"]) is None: return try: if self._attribute: @@ -184,7 +189,7 @@ class SensorTrend(BinarySensorEntity): else: state = new_state.state if state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): - sample = (new_state.last_updated.timestamp(), float(state)) + sample = (new_state.last_updated.timestamp(), float(state)) # type: ignore[arg-type] self.samples.append(sample) self.async_schedule_update_ha_state(True) except (ValueError, TypeError) as ex: diff --git a/homeassistant/components/trend/services.yaml b/homeassistant/components/trend/services.yaml index 1d29e08dccf..c983a105c93 100644 --- a/homeassistant/components/trend/services.yaml +++ b/homeassistant/components/trend/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload all trend entities. diff --git a/homeassistant/components/trend/strings.json b/homeassistant/components/trend/strings.json new file mode 100644 index 00000000000..6af231bb4c5 --- /dev/null +++ b/homeassistant/components/trend/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads trend sensors from the YAML-configuration." + } + } +} diff --git a/homeassistant/components/tts/services.yaml b/homeassistant/components/tts/services.yaml index 99e0bcca4d4..03b176eaab3 100644 --- a/homeassistant/components/tts/services.yaml +++ b/homeassistant/components/tts/services.yaml @@ -1,88 +1,58 @@ # Describes the format for available TTS services say: - name: Say a TTS message - description: Say something using text-to-speech on a media player. fields: entity_id: - name: Entity - description: Name(s) of media player entities. required: true selector: entity: domain: media_player message: - name: Message - description: Text to speak on devices. example: "My name is hanna" required: true selector: text: cache: - name: Cache - description: Control file cache of this message. default: false selector: boolean: language: - name: Language - description: Language to use for speech generation. example: "ru" selector: text: options: - name: Options - description: - A dictionary containing platform-specific options. Optional depending on - the platform. advanced: true example: platform specific selector: object: speak: - name: Speak - description: Speak something using text-to-speech on a media player. target: entity: domain: tts fields: media_player_entity_id: - name: Media Player Entity - description: Name(s) of media player entities. required: true selector: entity: domain: media_player message: - name: Message - description: Text to speak on devices. example: "My name is hanna" required: true selector: text: cache: - name: Cache - description: Control file cache of this message. default: true selector: boolean: language: - name: Language - description: Language to use for speech generation. example: "ru" selector: text: options: - name: Options - description: - A dictionary containing platform-specific options. Optional depending on - the platform. advanced: true example: platform specific selector: object: clear_cache: - name: Clear TTS cache - description: Remove all text-to-speech cache files and RAM cache. diff --git a/homeassistant/components/tts/strings.json b/homeassistant/components/tts/strings.json new file mode 100644 index 00000000000..2f0208ef8b5 --- /dev/null +++ b/homeassistant/components/tts/strings.json @@ -0,0 +1,60 @@ +{ + "services": { + "say": { + "name": "Say a TTS message", + "description": "Says something using text-to-speech on a media player.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Media players to play the message." + }, + "message": { + "name": "Message", + "description": "The text you want to convert into speech so that you can listen to it on your device." + }, + "cache": { + "name": "Cache", + "description": "Stores this message locally so that when the text is requested again, the output can be produced more quickly." + }, + "language": { + "name": "Language", + "description": "Language to use for speech generation." + }, + "options": { + "name": "Options", + "description": "A dictionary containing integration-specific options." + } + } + }, + "speak": { + "name": "Speak", + "description": "Speaks something using text-to-speech on a media player.", + "fields": { + "media_player_entity_id": { + "name": "Media player entity", + "description": "Media players to play the message." + }, + "message": { + "name": "[%key:component::tts::services::say::fields::message::name%]", + "description": "[%key:component::tts::services::say::fields::message::description%]" + }, + "cache": { + "name": "[%key:component::tts::services::say::fields::cache::name%]", + "description": "[%key:component::tts::services::say::fields::cache::description%]" + }, + "language": { + "name": "[%key:component::tts::services::say::fields::language::name%]", + "description": "[%key:component::tts::services::say::fields::language::description%]" + }, + "options": { + "name": "[%key:component::tts::services::say::fields::options::name%]", + "description": "[%key:component::tts::services::say::fields::options::description%]" + } + } + }, + "clear_cache": { + "name": "Clear TTS cache", + "description": "Removes all cached text-to-speech files and purges the memory." + } + } +} diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index c2c9c207c02..cd92e62b864 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -1,9 +1,10 @@ """Support for Tuya Alarm.""" from __future__ import annotations +from enum import StrEnum + from tuya_iot import TuyaDevice, TuyaDeviceManager -from homeassistant.backports.enum import StrEnum from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityDescription, diff --git a/homeassistant/components/tuya/base.py b/homeassistant/components/tuya/base.py index 2658f50edad..998e5a55e63 100644 --- a/homeassistant/components/tuya/base.py +++ b/homeassistant/components/tuya/base.py @@ -5,10 +5,9 @@ import base64 from dataclasses import dataclass import json import struct -from typing import Any, Literal, overload +from typing import Any, Literal, Self, overload from tuya_iot import TuyaDevice, TuyaDeviceManager -from typing_extensions import Self from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 25b2df41478..c57a37365ed 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -51,68 +51,64 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "dgnbj": ( TuyaBinarySensorEntityDescription( key=DPCode.GAS_SENSOR_STATE, - name="Gas", icon="mdi:gas-cylinder", - device_class=BinarySensorDeviceClass.SAFETY, + device_class=BinarySensorDeviceClass.GAS, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.CH4_SENSOR_STATE, - name="Methane", + translation_key="methane", device_class=BinarySensorDeviceClass.GAS, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.VOC_STATE, - name="Volatile organic compound", + translation_key="voc", device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.PM25_STATE, - name="Particulate matter 2.5 µm", + translation_key="pm25", device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.CO_STATE, - name="Carbon monoxide", + translation_key="carbon_monoxide", icon="mdi:molecule-co", device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.CO2_STATE, + translation_key="carbon_dioxide", icon="mdi:molecule-co2", - name="Carbon dioxide", device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.CH2O_STATE, - name="Formaldehyde", + translation_key="formaldehyde", device_class=BinarySensorDeviceClass.SAFETY, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.DOORCONTACT_STATE, - name="Door", device_class=BinarySensorDeviceClass.DOOR, ), TuyaBinarySensorEntityDescription( key=DPCode.WATERSENSOR_STATE, - name="Water leak", device_class=BinarySensorDeviceClass.MOISTURE, on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.PRESSURE_STATE, - name="Pressure", + translation_key="pressure", on_value="alarm", ), TuyaBinarySensorEntityDescription( key=DPCode.SMOKE_SENSOR_STATE, - name="Smoke", icon="mdi:smoke-detector", device_class=BinarySensorDeviceClass.SMOKE, on_value="alarm", @@ -149,7 +145,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "cwwsq": ( TuyaBinarySensorEntityDescription( key=DPCode.FEED_STATE, - name="Feeding", + translation_key="feeding", icon="mdi:information", on_value="feeding", ), @@ -215,7 +211,6 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "ldcg": ( TuyaBinarySensorEntityDescription( key=DPCode.TEMPER_ALARM, - name="Tamper", device_class=BinarySensorDeviceClass.TAMPER, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -290,7 +285,6 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { "wkf": ( TuyaBinarySensorEntityDescription( key=DPCode.WINDOW_STATE, - name="Window", device_class=BinarySensorDeviceClass.WINDOW, on_value="opened", ), @@ -318,7 +312,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { TuyaBinarySensorEntityDescription( key=DPCode.SMOKE_SENSOR_STATE, device_class=BinarySensorDeviceClass.SMOKE, - on_value="1", + on_value={"1", "alarm"}, ), TAMPER_BINARY_SENSOR, ), @@ -328,21 +322,20 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { TuyaBinarySensorEntityDescription( key=f"{DPCode.SHOCK_STATE}_vibration", dpcode=DPCode.SHOCK_STATE, - name="Vibration", device_class=BinarySensorDeviceClass.VIBRATION, on_value="vibration", ), TuyaBinarySensorEntityDescription( key=f"{DPCode.SHOCK_STATE}_drop", dpcode=DPCode.SHOCK_STATE, - name="Drop", + translation_key="drop", icon="mdi:icon=package-down", on_value="drop", ), TuyaBinarySensorEntityDescription( key=f"{DPCode.SHOCK_STATE}_tilt", dpcode=DPCode.SHOCK_STATE, - name="Tilt", + translation_key="tilt", icon="mdi:spirit-level", on_value="tilt", ), diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index 64d405ee5ad..4c73b70c29a 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -22,31 +22,31 @@ BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { "sd": ( ButtonEntityDescription( key=DPCode.RESET_DUSTER_CLOTH, - name="Reset duster cloth", + translation_key="reset_duster_cloth", icon="mdi:restart", entity_category=EntityCategory.CONFIG, ), ButtonEntityDescription( key=DPCode.RESET_EDGE_BRUSH, - name="Reset edge brush", + translation_key="reset_edge_brush", icon="mdi:restart", entity_category=EntityCategory.CONFIG, ), ButtonEntityDescription( key=DPCode.RESET_FILTER, - name="Reset filter", + translation_key="reset_filter", icon="mdi:air-filter", entity_category=EntityCategory.CONFIG, ), ButtonEntityDescription( key=DPCode.RESET_MAP, - name="Reset map", + translation_key="reset_map", icon="mdi:map-marker-remove", entity_category=EntityCategory.CONFIG, ), ButtonEntityDescription( key=DPCode.RESET_ROLL_BRUSH, - name="Reset roll brush", + translation_key="reset_roll_brush", icon="mdi:restart", entity_category=EntityCategory.CONFIG, ), @@ -56,7 +56,7 @@ BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { "hxd": ( ButtonEntityDescription( key=DPCode.SWITCH_USB6, - name="Snooze", + translation_key="snooze", icon="mdi:sleep", ), ), diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 20dc724deb9..acf9f8bbd2c 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -3,11 +3,11 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass, field +from enum import StrEnum import logging from tuya_iot import TuyaCloudOpenAPIEndpoint -from homeassistant.backports.enum import StrEnum from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 5bb9c794ca4..3505bbf9f22 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -44,7 +44,6 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { "cl": ( TuyaCoverEntityDescription( key=DPCode.CONTROL, - name="Curtain", current_state=DPCode.SITUATION_SET, current_position=(DPCode.PERCENT_CONTROL, DPCode.PERCENT_STATE), set_position=DPCode.PERCENT_CONTROL, @@ -52,21 +51,20 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { ), TuyaCoverEntityDescription( key=DPCode.CONTROL_2, - name="Curtain 2", + translation_key="curtain_2", current_position=DPCode.PERCENT_STATE_2, set_position=DPCode.PERCENT_CONTROL_2, device_class=CoverDeviceClass.CURTAIN, ), TuyaCoverEntityDescription( key=DPCode.CONTROL_3, - name="Curtain 3", + translation_key="curtain_3", current_position=DPCode.PERCENT_STATE_3, set_position=DPCode.PERCENT_CONTROL_3, device_class=CoverDeviceClass.CURTAIN, ), TuyaCoverEntityDescription( key=DPCode.MACH_OPERATE, - name="Curtain", current_position=DPCode.POSITION, set_position=DPCode.POSITION, device_class=CoverDeviceClass.CURTAIN, @@ -78,7 +76,6 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { # It is used by the Kogan Smart Blinds Driver TuyaCoverEntityDescription( key=DPCode.SWITCH_1, - name="Blind", current_position=DPCode.PERCENT_CONTROL, set_position=DPCode.PERCENT_CONTROL, device_class=CoverDeviceClass.BLIND, @@ -89,21 +86,21 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { "ckmkzq": ( TuyaCoverEntityDescription( key=DPCode.SWITCH_1, - name="Door", + translation_key="door", current_state=DPCode.DOORCONTACT_STATE, current_state_inverse=True, device_class=CoverDeviceClass.GARAGE, ), TuyaCoverEntityDescription( key=DPCode.SWITCH_2, - name="Door 2", + translation_key="door_2", current_state=DPCode.DOORCONTACT_STATE_2, current_state_inverse=True, device_class=CoverDeviceClass.GARAGE, ), TuyaCoverEntityDescription( key=DPCode.SWITCH_3, - name="Door 3", + translation_key="door_3", current_state=DPCode.DOORCONTACT_STATE_3, current_state_inverse=True, device_class=CoverDeviceClass.GARAGE, @@ -114,14 +111,13 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { "clkg": ( TuyaCoverEntityDescription( key=DPCode.CONTROL, - name="Curtain", current_position=DPCode.PERCENT_CONTROL, set_position=DPCode.PERCENT_CONTROL, device_class=CoverDeviceClass.CURTAIN, ), TuyaCoverEntityDescription( key=DPCode.CONTROL_2, - name="Curtain 2", + translation_key="curtain_2", current_position=DPCode.PERCENT_CONTROL_2, set_position=DPCode.PERCENT_CONTROL_2, device_class=CoverDeviceClass.CURTAIN, diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 3ab4c3568c4..b4396f617cd 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -70,7 +70,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { "clkg": ( TuyaLightEntityDescription( key=DPCode.SWITCH_BACKLIGHT, - name="Backlight", + translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), @@ -114,7 +114,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { # Based on multiple reports: manufacturer customized Dimmer 2 switches TuyaLightEntityDescription( key=DPCode.SWITCH_1, - name="Light", + translation_key="light", brightness=DPCode.BRIGHT_VALUE_1, ), ), @@ -175,7 +175,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { "kg": ( TuyaLightEntityDescription( key=DPCode.SWITCH_BACKLIGHT, - name="Backlight", + translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), @@ -184,7 +184,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { "kj": ( TuyaLightEntityDescription( key=DPCode.LIGHT, - name="Backlight", + translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), @@ -193,7 +193,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { "kt": ( TuyaLightEntityDescription( key=DPCode.LIGHT, - name="Backlight", + translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), @@ -226,7 +226,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { "qn": ( TuyaLightEntityDescription( key=DPCode.LIGHT, - name="Backlight", + translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), @@ -249,21 +249,21 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { "tgkg": ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED_1, - name="Light", + translation_key="light", brightness=DPCode.BRIGHT_VALUE_1, brightness_max=DPCode.BRIGHTNESS_MAX_1, brightness_min=DPCode.BRIGHTNESS_MIN_1, ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_2, - name="Light 2", + translation_key="light_2", brightness=DPCode.BRIGHT_VALUE_2, brightness_max=DPCode.BRIGHTNESS_MAX_2, brightness_min=DPCode.BRIGHTNESS_MIN_2, ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_3, - name="Light 3", + translation_key="light_3", brightness=DPCode.BRIGHT_VALUE_3, brightness_max=DPCode.BRIGHTNESS_MAX_3, brightness_min=DPCode.BRIGHTNESS_MIN_3, @@ -274,19 +274,19 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { "tgq": ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, - name="Light", + translation_key="light", brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), brightness_max=DPCode.BRIGHTNESS_MAX_1, brightness_min=DPCode.BRIGHTNESS_MIN_1, ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_1, - name="Light", + translation_key="light", brightness=DPCode.BRIGHT_VALUE_1, ), TuyaLightEntityDescription( key=DPCode.SWITCH_LED_2, - name="Light 2", + translation_key="light_2", brightness=DPCode.BRIGHT_VALUE_2, ), ), @@ -295,7 +295,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { "hxd": ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, - name="Light", + translation_key="light", brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), brightness_max=DPCode.BRIGHTNESS_MAX_1, brightness_min=DPCode.BRIGHTNESS_MIN_1, @@ -326,7 +326,7 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { ), TuyaLightEntityDescription( key=DPCode.SWITCH_NIGHT_LIGHT, - name="Night light", + translation_key="night_light", ), ), # Remote Control @@ -350,6 +350,11 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness=DPCode.BRIGHT_VALUE, color_temp=DPCode.TEMP_VALUE, ), + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + translation_key="light_2", + brightness=DPCode.BRIGHT_VALUE_1, + ), ), } diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index f4f827980bb..5e7bdcc260a 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -9,7 +9,7 @@ from homeassistant.components.number import ( NumberEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -27,7 +27,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "dgnbj": ( NumberEntityDescription( key=DPCode.ALARM_TIME, - name="Time", + translation_key="time", entity_category=EntityCategory.CONFIG, ), ), @@ -36,35 +36,35 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "bh": ( NumberEntityDescription( key=DPCode.TEMP_SET, - name="Temperature", + translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.TEMP_SET_F, - name="Temperature", + translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.TEMP_BOILING_C, - name="Temperature after boiling", + translation_key="temperature_after_boiling", device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.TEMP_BOILING_F, - name="Temperature after boiling", + translation_key="temperature_after_boiling", device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.WARM_TIME, - name="Heat preservation time", + translation_key="heat_preservation_time", icon="mdi:timer", entity_category=EntityCategory.CONFIG, ), @@ -74,12 +74,12 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "cwwsq": ( NumberEntityDescription( key=DPCode.MANUAL_FEED, - name="Feed", + translation_key="feed", icon="mdi:bowl", ), NumberEntityDescription( key=DPCode.VOICE_TIMES, - name="Voice times", + translation_key="voice_times", icon="mdi:microphone", ), ), @@ -88,18 +88,18 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "hps": ( NumberEntityDescription( key=DPCode.SENSITIVITY, - name="Sensitivity", + translation_key="sensitivity", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.NEAR_DETECTION, - name="Near detection", + translation_key="near_detection", icon="mdi:signal-distance-variant", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.FAR_DETECTION, - name="Far detection", + translation_key="far_detection", icon="mdi:signal-distance-variant", entity_category=EntityCategory.CONFIG, ), @@ -109,26 +109,26 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "kfj": ( NumberEntityDescription( key=DPCode.WATER_SET, - name="Water level", + translation_key="water_level", icon="mdi:cup-water", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.TEMP_SET, - name="Temperature", + translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.WARM_TIME, - name="Heat preservation time", + translation_key="heat_preservation_time", icon="mdi:timer", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.POWDER_SET, - name="Powder", + translation_key="powder", entity_category=EntityCategory.CONFIG, ), ), @@ -137,20 +137,20 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "mzj": ( NumberEntityDescription( key=DPCode.COOK_TEMPERATURE, - name="Cook temperature", + translation_key="cook_temperature", icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.COOK_TIME, - name="Cook time", + translation_key="cook_time", icon="mdi:timer", native_unit_of_measurement=UnitOfTime.MINUTES, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.CLOUD_RECIPE_NUMBER, - name="Cloud recipe", + translation_key="cloud_recipe", entity_category=EntityCategory.CONFIG, ), ), @@ -159,7 +159,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "sd": ( NumberEntityDescription( key=DPCode.VOLUME_SET, - name="Volume", + translation_key="volume", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, ), @@ -169,7 +169,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "sgbj": ( NumberEntityDescription( key=DPCode.ALARM_TIME, - name="Time", + translation_key="time", entity_category=EntityCategory.CONFIG, ), ), @@ -178,7 +178,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "sp": ( NumberEntityDescription( key=DPCode.BASIC_DEVICE_VOLUME, - name="Volume", + translation_key="volume", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, ), @@ -188,37 +188,37 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "tgkg": ( NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_1, - name="Minimum brightness", + translation_key="minimum_brightness", icon="mdi:lightbulb-outline", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_1, - name="Maximum brightness", + translation_key="maximum_brightness", icon="mdi:lightbulb-on-outline", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_2, - name="Minimum brightness 2", + translation_key="minimum_brightness_2", icon="mdi:lightbulb-outline", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_2, - name="Maximum brightness 2", + translation_key="maximum_brightness_2", icon="mdi:lightbulb-on-outline", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_3, - name="Minimum brightness 3", + translation_key="minimum_brightness_3", icon="mdi:lightbulb-outline", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_3, - name="Maximum brightness 3", + translation_key="maximum_brightness_3", icon="mdi:lightbulb-on-outline", entity_category=EntityCategory.CONFIG, ), @@ -228,25 +228,25 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "tgq": ( NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_1, - name="Minimum brightness", + translation_key="minimum_brightness", icon="mdi:lightbulb-outline", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_1, - name="Maximum brightness", + translation_key="maximum_brightness", icon="mdi:lightbulb-on-outline", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_2, - name="Minimum brightness 2", + translation_key="minimum_brightness_2", icon="mdi:lightbulb-outline", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.BRIGHTNESS_MAX_2, - name="Maximum brightness 2", + translation_key="maximum_brightness_2", icon="mdi:lightbulb-on-outline", entity_category=EntityCategory.CONFIG, ), @@ -256,7 +256,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "zd": ( NumberEntityDescription( key=DPCode.SENSITIVITY, - name="Sensitivity", + translation_key="sensitivity", entity_category=EntityCategory.CONFIG, ), ), @@ -264,19 +264,21 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "szjqr": ( NumberEntityDescription( key=DPCode.ARM_DOWN_PERCENT, - name="Move down %", + translation_key="move_down", icon="mdi:arrow-down-bold", + native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.ARM_UP_PERCENT, - name="Move up %", + translation_key="move_up", icon="mdi:arrow-up-bold", + native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.CLICK_SUSTAIN_TIME, - name="Down delay", + translation_key="down_delay", icon="mdi:timer", entity_category=EntityCategory.CONFIG, ), @@ -286,7 +288,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "fs": ( NumberEntityDescription( key=DPCode.TEMP, - name="Temperature", + translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer-lines", ), @@ -296,13 +298,13 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { "jsq": ( NumberEntityDescription( key=DPCode.TEMP_SET, - name="Temperature", + translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer-lines", ), NumberEntityDescription( key=DPCode.TEMP_SET_F, - name="Temperature", + translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer-lines", ), diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index dadc64f9846..90cf4266ae6 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -30,6 +30,8 @@ class TuyaSceneEntity(Scene): """Tuya Scene Remote.""" _should_poll = False + _attr_has_entity_name = True + _attr_name = None def __init__(self, home_manager: TuyaHomeManager, scene: TuyaScene) -> None: """Init Tuya Scene.""" @@ -38,11 +40,6 @@ class TuyaSceneEntity(Scene): self.home_manager = home_manager self.scene = scene - @property - def name(self) -> str | None: - """Return Tuya scene name.""" - return self.scene.name - @property def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index b84737f7360..3cc8c72f555 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -23,7 +23,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "dgnbj": ( SelectEntityDescription( key=DPCode.ALARM_VOLUME, - name="Volume", + translation_key="volume", entity_category=EntityCategory.CONFIG, ), ), @@ -32,23 +32,23 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "kfj": ( SelectEntityDescription( key=DPCode.CUP_NUMBER, - name="Cups", + translation_key="cups", icon="mdi:numeric", ), SelectEntityDescription( key=DPCode.CONCENTRATION_SET, - name="Concentration", + translation_key="concentration", icon="mdi:altimeter", entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.MATERIAL, - name="Material", + translation_key="material", entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.MODE, - name="Mode", + translation_key="mode", icon="mdi:coffee", ), ), @@ -57,13 +57,11 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "kg": ( SelectEntityDescription( key=DPCode.RELAY_STATUS, - name="Power on behavior", entity_category=EntityCategory.CONFIG, translation_key="relay_status", ), SelectEntityDescription( key=DPCode.LIGHT_MODE, - name="Indicator light mode", entity_category=EntityCategory.CONFIG, translation_key="light_mode", ), @@ -73,7 +71,7 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "qn": ( SelectEntityDescription( key=DPCode.LEVEL, - name="Temperature level", + translation_key="temperature_level", icon="mdi:thermometer-lines", ), ), @@ -82,12 +80,12 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "sgbj": ( SelectEntityDescription( key=DPCode.ALARM_VOLUME, - name="Volume", + translation_key="volume", entity_category=EntityCategory.CONFIG, ), SelectEntityDescription( key=DPCode.BRIGHT_STATE, - name="Brightness", + translation_key="brightness", entity_category=EntityCategory.CONFIG, ), ), @@ -96,41 +94,35 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "sp": ( SelectEntityDescription( key=DPCode.IPC_WORK_MODE, - name="IPC mode", entity_category=EntityCategory.CONFIG, translation_key="ipc_work_mode", ), SelectEntityDescription( key=DPCode.DECIBEL_SENSITIVITY, - name="Sound detection densitivity", icon="mdi:volume-vibrate", entity_category=EntityCategory.CONFIG, translation_key="decibel_sensitivity", ), SelectEntityDescription( key=DPCode.RECORD_MODE, - name="Record mode", icon="mdi:record-rec", entity_category=EntityCategory.CONFIG, translation_key="record_mode", ), SelectEntityDescription( key=DPCode.BASIC_NIGHTVISION, - name="Night vision", icon="mdi:theme-light-dark", entity_category=EntityCategory.CONFIG, translation_key="basic_nightvision", ), SelectEntityDescription( key=DPCode.BASIC_ANTI_FLICKER, - name="Anti-flicker", icon="mdi:image-outline", entity_category=EntityCategory.CONFIG, translation_key="basic_anti_flicker", ), SelectEntityDescription( key=DPCode.MOTION_SENSITIVITY, - name="Motion detection sensitivity", icon="mdi:motion-sensor", entity_category=EntityCategory.CONFIG, translation_key="motion_sensitivity", @@ -141,13 +133,11 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "tdq": ( SelectEntityDescription( key=DPCode.RELAY_STATUS, - name="Power on behavior", entity_category=EntityCategory.CONFIG, translation_key="relay_status", ), SelectEntityDescription( key=DPCode.LIGHT_MODE, - name="Indicator light mode", entity_category=EntityCategory.CONFIG, translation_key="light_mode", ), @@ -157,33 +147,28 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "tgkg": ( SelectEntityDescription( key=DPCode.RELAY_STATUS, - name="Power on behavior", entity_category=EntityCategory.CONFIG, translation_key="relay_status", ), SelectEntityDescription( key=DPCode.LIGHT_MODE, - name="Indicator light mode", entity_category=EntityCategory.CONFIG, translation_key="light_mode", ), SelectEntityDescription( key=DPCode.LED_TYPE_1, - name="Light source type", entity_category=EntityCategory.CONFIG, translation_key="led_type", ), SelectEntityDescription( key=DPCode.LED_TYPE_2, - name="Light 2 source type", entity_category=EntityCategory.CONFIG, - translation_key="led_type", + translation_key="led_type_2", ), SelectEntityDescription( key=DPCode.LED_TYPE_3, - name="Light 3 source type", entity_category=EntityCategory.CONFIG, - translation_key="led_type", + translation_key="led_type_3", ), ), # Dimmer @@ -191,22 +176,19 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "tgq": ( SelectEntityDescription( key=DPCode.LED_TYPE_1, - name="Light source type", entity_category=EntityCategory.CONFIG, translation_key="led_type", ), SelectEntityDescription( key=DPCode.LED_TYPE_2, - name="Light 2 source type", entity_category=EntityCategory.CONFIG, - translation_key="led_type", + translation_key="led_type_2", ), ), # Fingerbot "szjqr": ( SelectEntityDescription( key=DPCode.MODE, - name="Mode", entity_category=EntityCategory.CONFIG, translation_key="fingerbot_mode", ), @@ -216,21 +198,18 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "sd": ( SelectEntityDescription( key=DPCode.CISTERN, - name="Water tank adjustment", entity_category=EntityCategory.CONFIG, icon="mdi:water-opacity", translation_key="vacuum_cistern", ), SelectEntityDescription( key=DPCode.COLLECTION_MODE, - name="Dust collection mode", entity_category=EntityCategory.CONFIG, icon="mdi:air-filter", translation_key="vacuum_collection", ), SelectEntityDescription( key=DPCode.MODE, - name="Mode", entity_category=EntityCategory.CONFIG, icon="mdi:layers-outline", translation_key="vacuum_mode", @@ -241,28 +220,24 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "fs": ( SelectEntityDescription( key=DPCode.FAN_VERTICAL, - name="Vertical swing flap angle", entity_category=EntityCategory.CONFIG, icon="mdi:format-vertical-align-center", - translation_key="fan_angle", + translation_key="vertical_fan_angle", ), SelectEntityDescription( key=DPCode.FAN_HORIZONTAL, - name="Horizontal swing flap angle", entity_category=EntityCategory.CONFIG, icon="mdi:format-horizontal-align-center", - translation_key="fan_angle", + translation_key="horizontal_fan_angle", ), SelectEntityDescription( key=DPCode.COUNTDOWN, - name="Countdown", entity_category=EntityCategory.CONFIG, icon="mdi:timer-cog-outline", translation_key="countdown", ), SelectEntityDescription( key=DPCode.COUNTDOWN_SET, - name="Countdown", entity_category=EntityCategory.CONFIG, icon="mdi:timer-cog-outline", translation_key="countdown", @@ -273,14 +248,12 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "cl": ( SelectEntityDescription( key=DPCode.CONTROL_BACK_MODE, - name="Motor mode", entity_category=EntityCategory.CONFIG, icon="mdi:swap-horizontal", translation_key="curtain_motor_mode", ), SelectEntityDescription( key=DPCode.MODE, - name="Mode", entity_category=EntityCategory.CONFIG, translation_key="curtain_mode", ), @@ -290,35 +263,30 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "jsq": ( SelectEntityDescription( key=DPCode.SPRAY_MODE, - name="Spray mode", entity_category=EntityCategory.CONFIG, icon="mdi:spray", translation_key="humidifier_spray_mode", ), SelectEntityDescription( key=DPCode.LEVEL, - name="Spraying level", entity_category=EntityCategory.CONFIG, icon="mdi:spray", translation_key="humidifier_level", ), SelectEntityDescription( key=DPCode.MOODLIGHTING, - name="Moodlighting", entity_category=EntityCategory.CONFIG, icon="mdi:lightbulb-multiple", translation_key="humidifier_moodlighting", ), SelectEntityDescription( key=DPCode.COUNTDOWN, - name="Countdown", entity_category=EntityCategory.CONFIG, icon="mdi:timer-cog-outline", translation_key="countdown", ), SelectEntityDescription( key=DPCode.COUNTDOWN_SET, - name="Countdown", entity_category=EntityCategory.CONFIG, icon="mdi:timer-cog-outline", translation_key="countdown", @@ -329,14 +297,12 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "kj": ( SelectEntityDescription( key=DPCode.COUNTDOWN, - name="Countdown", entity_category=EntityCategory.CONFIG, icon="mdi:timer-cog-outline", translation_key="countdown", ), SelectEntityDescription( key=DPCode.COUNTDOWN_SET, - name="Countdown", entity_category=EntityCategory.CONFIG, icon="mdi:timer-cog-outline", translation_key="countdown", @@ -347,14 +313,13 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { "cs": ( SelectEntityDescription( key=DPCode.COUNTDOWN_SET, - name="Countdown", entity_category=EntityCategory.CONFIG, icon="mdi:timer-cog-outline", translation_key="countdown", ), SelectEntityDescription( key=DPCode.DEHUMIDITY_SET_ENUM, - name="Target humidity", + translation_key="target_humidity", entity_category=EntityCategory.CONFIG, icon="mdi:water-percent", ), diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index afa40f27afd..9f055a6262e 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -49,7 +49,7 @@ class TuyaSensorEntityDescription(SensorEntityDescription): BATTERY_SENSORS: tuple[TuyaSensorEntityDescription, ...] = ( TuyaSensorEntityDescription( key=DPCode.BATTERY_PERCENTAGE, - name="Battery", + translation_key="battery", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, @@ -57,20 +57,20 @@ BATTERY_SENSORS: tuple[TuyaSensorEntityDescription, ...] = ( ), TuyaSensorEntityDescription( key=DPCode.BATTERY_STATE, - name="Battery state", + translation_key="battery_state", icon="mdi:battery", entity_category=EntityCategory.DIAGNOSTIC, ), TuyaSensorEntityDescription( key=DPCode.BATTERY_VALUE, - name="Battery", + translation_key="battery", device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.VA_BATTERY, - name="Battery", + translation_key="battery", device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -87,73 +87,74 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "dgnbj": ( TuyaSensorEntityDescription( key=DPCode.GAS_SENSOR_VALUE, - name="Gas", + translation_key="gas", icon="mdi:gas-cylinder", state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.CH4_SENSOR_VALUE, + translation_key="gas", name="Methane", state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.VOC_VALUE, - name="Volatile organic compound", + translation_key="voc", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.PM25_VALUE, - name="Particulate matter 2.5 µm", + translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.CO_VALUE, - name="Carbon monoxide", + translation_key="carbon_monoxide", icon="mdi:molecule-co", device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, - name="Carbon dioxide", + translation_key="carbon_dioxide", icon="mdi:molecule-co2", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, - name="Formaldehyde", + translation_key="formaldehyde", state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.BRIGHT_STATE, - name="Luminosity", + translation_key="luminosity", icon="mdi:brightness-6", ), TuyaSensorEntityDescription( key=DPCode.BRIGHT_VALUE, - name="Luminosity", + translation_key="illuminance", icon="mdi:brightness-6", device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.SMOKE_SENSOR_VALUE, - name="Smoke amount", + translation_key="smoke_amount", icon="mdi:smoke-detector", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -165,19 +166,19 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "bh": ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, - name="Current temperature", + translation_key="current_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT_F, - name="Current temperature", + translation_key="current_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.STATUS, - name="Status", + translation_key="status", ), ), # CO2 Detector @@ -185,19 +186,19 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "co2bj": ( TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, - name="Carbon dioxide", + translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, ), @@ -209,13 +210,13 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "wkcz": ( TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), @@ -225,7 +226,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "cobj": ( TuyaSensorEntityDescription( key=DPCode.CO_VALUE, - name="Carbon monoxide", + translation_key="carbon_monoxide", device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, ), @@ -236,7 +237,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "cwwsq": ( TuyaSensorEntityDescription( key=DPCode.FEED_REPORT, - name="Last amount", + translation_key="last_amount", icon="mdi:counter", state_class=SensorStateClass.MEASUREMENT, ), @@ -246,36 +247,36 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "hjjcy": ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, - name="Carbon dioxide", + translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, - name="Formaldehyde", + translation_key="formaldehyde", state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.VOC_VALUE, - name="Volatile organic compound", + translation_key="voc", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.PM25_VALUE, - name="Particulate matter 2.5 µm", + translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, ), @@ -285,37 +286,37 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "jqbj": ( TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, - name="Carbon dioxide", + translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.VOC_VALUE, - name="Volatile organic compound", + translation_key="voc", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.PM25_VALUE, - name="Particulate matter 2.5 µm", + translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.VA_HUMIDITY, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.VA_TEMPERATURE, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, - name="Formaldehyde", + translation_key="formaldehyde", state_class=SensorStateClass.MEASUREMENT, ), *BATTERY_SENSORS, @@ -325,7 +326,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "jwbj": ( TuyaSensorEntityDescription( key=DPCode.CH4_SENSOR_VALUE, - name="Methane", + translation_key="methane", state_class=SensorStateClass.MEASUREMENT, ), *BATTERY_SENSORS, @@ -335,21 +336,21 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "kg": ( TuyaSensorEntityDescription( key=DPCode.CUR_CURRENT, - name="Current", + translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( key=DPCode.CUR_POWER, - name="Power", + translation_key="power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( key=DPCode.CUR_VOLTAGE, - name="Voltage", + translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -360,21 +361,21 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "tdq": ( TuyaSensorEntityDescription( key=DPCode.CUR_CURRENT, - name="Current", + translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( key=DPCode.CUR_POWER, - name="Power", + translation_key="power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( key=DPCode.CUR_VOLTAGE, - name="Voltage", + translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -385,30 +386,30 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "ldcg": ( TuyaSensorEntityDescription( key=DPCode.BRIGHT_STATE, - name="Luminosity", + translation_key="luminosity", icon="mdi:brightness-6", ), TuyaSensorEntityDescription( key=DPCode.BRIGHT_VALUE, - name="Luminosity", + translation_key="illuminance", device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, - name="Carbon dioxide", + translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, ), @@ -425,18 +426,17 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "mzj": ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, - name="Current temperature", + translation_key="current_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.STATUS, - name="Status", - translation_key="status", + translation_key="sous_vide_status", ), TuyaSensorEntityDescription( key=DPCode.REMAIN_TIME, - name="Remaining time", + translation_key="remaining_time", native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:timer", ), @@ -449,48 +449,48 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "pm2.5": ( TuyaSensorEntityDescription( key=DPCode.PM25_VALUE, - name="Particulate matter 2.5 µm", + translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, - name="Formaldehyde", + translation_key="formaldehyde", state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.VOC_VALUE, - name="Volatile organic compound", + translation_key="voc", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, - name="Carbon dioxide", + translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.PM1, - name="Particulate matter 1.0 µm", + translation_key="pm1", device_class=SensorDeviceClass.PM1, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.PM10, - name="Particulate matter 10.0 µm", + translation_key="pm10", device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, ), @@ -501,7 +501,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "qn": ( TuyaSensorEntityDescription( key=DPCode.WORK_POWER, - name="Power", + translation_key="power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), @@ -528,19 +528,19 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "sp": ( TuyaSensorEntityDescription( key=DPCode.SENSOR_TEMPERATURE, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.SENSOR_HUMIDITY, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.WIRELESS_ELECTRICITY, - name="Battery", + translation_key="battery", device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -556,36 +556,36 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "voc": ( TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, - name="Carbon dioxide", + translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.PM25_VALUE, - name="Particulate matter 2.5 µm", + translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, - name="Formaldehyde", + translation_key="formaldehyde", state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.VOC_VALUE, - name="Volatile organic compound", + translation_key="voc", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, state_class=SensorStateClass.MEASUREMENT, ), @@ -599,31 +599,31 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "wsdcg": ( TuyaSensorEntityDescription( key=DPCode.VA_TEMPERATURE, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.VA_HUMIDITY, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.BRIGHT_VALUE, - name="Luminosity", + translation_key="illuminance", device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ), @@ -645,7 +645,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "ywbj": ( TuyaSensorEntityDescription( key=DPCode.SMOKE_SENSOR_VALUE, - name="Smoke amount", + translation_key="smoke_amount", icon="mdi:smoke-detector", entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -660,13 +660,13 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "zndb": ( TuyaSensorEntityDescription( key=DPCode.FORWARD_ENERGY_TOTAL, - name="Total energy", + translation_key="total_energy", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), TuyaSensorEntityDescription( key=DPCode.PHASE_A, - name="Phase A current", + translation_key="phase_a_current", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, @@ -674,7 +674,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_A, - name="Phase A power", + translation_key="phase_a_power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -682,7 +682,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_A, - name="Phase A voltage", + translation_key="phase_a_voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -690,7 +690,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_B, - name="Phase B current", + translation_key="phase_b_current", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, @@ -698,7 +698,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_B, - name="Phase B power", + translation_key="phase_b_power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -706,7 +706,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_B, - name="Phase B voltage", + translation_key="phase_b_voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -714,7 +714,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_C, - name="Phase C current", + translation_key="phase_c_current", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, @@ -722,7 +722,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_C, - name="Phase C power", + translation_key="phase_c_power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -730,7 +730,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_C, - name="Phase C voltage", + translation_key="phase_c_voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -742,13 +742,13 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "dlq": ( TuyaSensorEntityDescription( key=DPCode.TOTAL_FORWARD_ENERGY, - name="Total energy", + translation_key="total_energy", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), TuyaSensorEntityDescription( key=DPCode.PHASE_A, - name="Phase A current", + translation_key="phase_a_current", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, @@ -756,7 +756,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_A, - name="Phase A power", + translation_key="phase_a_power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -764,7 +764,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_A, - name="Phase A voltage", + translation_key="phase_a_voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -772,7 +772,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_B, - name="Phase B current", + translation_key="phase_b_current", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, @@ -780,7 +780,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_B, - name="Phase B power", + translation_key="phase_b_power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -788,7 +788,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_B, - name="Phase B voltage", + translation_key="phase_b_voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -796,7 +796,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_C, - name="Phase C current", + translation_key="phase_c_current", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, @@ -804,7 +804,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_C, - name="Phase C power", + translation_key="phase_c_power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.KILO_WATT, @@ -812,7 +812,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), TuyaSensorEntityDescription( key=DPCode.PHASE_C, - name="Phase C voltage", + translation_key="phase_c_voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -824,55 +824,55 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "sd": ( TuyaSensorEntityDescription( key=DPCode.CLEAN_AREA, - name="Cleaning area", + translation_key="cleaning_area", icon="mdi:texture-box", state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.CLEAN_TIME, - name="Cleaning time", + translation_key="cleaning_time", icon="mdi:progress-clock", state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.TOTAL_CLEAN_AREA, - name="Total cleaning area", + translation_key="total_cleaning_area", icon="mdi:texture-box", state_class=SensorStateClass.TOTAL_INCREASING, ), TuyaSensorEntityDescription( key=DPCode.TOTAL_CLEAN_TIME, - name="Total cleaning time", + translation_key="total_cleaning_time", icon="mdi:history", state_class=SensorStateClass.TOTAL_INCREASING, ), TuyaSensorEntityDescription( key=DPCode.TOTAL_CLEAN_COUNT, - name="Total cleaning times", + translation_key="total_cleaning_times", icon="mdi:counter", state_class=SensorStateClass.TOTAL_INCREASING, ), TuyaSensorEntityDescription( key=DPCode.DUSTER_CLOTH, - name="Duster cloth life", + translation_key="duster_cloth_life", icon="mdi:ticket-percent-outline", state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.EDGE_BRUSH, - name="Side brush life", + translation_key="side_brush_life", icon="mdi:ticket-percent-outline", state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.FILTER_LIFE, - name="Filter life", + translation_key="filter_life", icon="mdi:ticket-percent-outline", state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.ROLL_BRUSH, - name="Rolling brush life", + translation_key="rolling_brush_life", icon="mdi:ticket-percent-outline", state_class=SensorStateClass.MEASUREMENT, ), @@ -882,7 +882,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "cl": ( TuyaSensorEntityDescription( key=DPCode.TIME_TOTAL, - name="Last operation duration", + translation_key="last_operation_duration", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:progress-clock", ), @@ -892,25 +892,25 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "jsq": ( TuyaSensorEntityDescription( key=DPCode.HUMIDITY_CURRENT, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT_F, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.LEVEL_CURRENT, - name="Water level", + translation_key="water_level", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:waves-arrow-up", ), @@ -920,60 +920,59 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "kj": ( TuyaSensorEntityDescription( key=DPCode.FILTER, - name="Filter utilization", + translation_key="filter_utilization", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:ticket-percent-outline", ), TuyaSensorEntityDescription( key=DPCode.PM25, - name="Particulate matter 2.5 µm", + translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, icon="mdi:molecule", ), TuyaSensorEntityDescription( key=DPCode.TEMP, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.HUMIDITY, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.TVOC, - name="Total volatile organic compound", + translation_key="total_volatile_organic_compound", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.ECO2, - name="Concentration of carbon dioxide", + translation_key="concentration_carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.TOTAL_TIME, - name="Total operating time", + translation_key="total_operating_time", icon="mdi:history", state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), TuyaSensorEntityDescription( key=DPCode.TOTAL_PM, - name="Total absorption of particles", + translation_key="total_absorption_particles", icon="mdi:texture-box", state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), TuyaSensorEntityDescription( key=DPCode.AIR_QUALITY, - name="Air quality", - icon="mdi:air-filter", translation_key="air_quality", + icon="mdi:air-filter", ), ), # Fan @@ -981,42 +980,83 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { "fs": ( TuyaSensorEntityDescription( key=DPCode.TEMP_CURRENT, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), ), # eMylo Smart WiFi IR Remote + # Air Conditioner Mate (Smart IR Socket) "wnykq": ( TuyaSensorEntityDescription( key=DPCode.VA_TEMPERATURE, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.VA_HUMIDITY, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), ), # Dehumidifier # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e "cs": ( TuyaSensorEntityDescription( key=DPCode.TEMP_INDOOR, - name="Temperature", + translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( key=DPCode.HUMIDITY_INDOOR, - name="Humidity", + translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), ), + # Soil sensor (Plant monitor) + "zwjcy": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), } # Socket (duplicate of `kg`) diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 534ff1dc9ec..db16015ba56 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -18,15 +18,201 @@ } }, "entity": { + "binary_sensor": { + "methane": { + "name": "Methane" + }, + "voc": { + "name": "VOCs" + }, + "pm25": { + "name": "PM2.5" + }, + "carbon_monoxide": { + "name": "Carbon monoxide" + }, + "carbon_dioxide": { + "name": "Carbon dioxide" + }, + "formaldehyde": { + "name": "Formaldehyde" + }, + "pressure": { + "name": "Pressure" + }, + "feeding": { + "name": "Feeding" + }, + "drop": { + "name": "Drop" + }, + "tilt": { + "name": "Tilt" + } + }, + "button": { + "reset_duster_cloth": { + "name": "Reset duster cloth" + }, + "reset_edge_brush": { + "name": "Reset edge brush" + }, + "reset_filter": { + "name": "Reset filter" + }, + "reset_map": { + "name": "Reset map" + }, + "reset_roll_brush": { + "name": "Reset roll brush" + }, + "snooze": { + "name": "Snooze" + } + }, + "cover": { + "curtain_2": { + "name": "Curtain 2" + }, + "curtain_3": { + "name": "Curtain 3" + }, + "door": { + "name": "[%key:component::cover::entity_component::door::name%]" + }, + "door_2": { + "name": "Door 2" + }, + "door_3": { + "name": "Door 3" + } + }, + "light": { + "backlight": { + "name": "Backlight" + }, + "light": { + "name": "[%key:component::light::title%]" + }, + "light_2": { + "name": "Light 2" + }, + "light_3": { + "name": "Light 3" + }, + "night_light": { + "name": "Night light" + } + }, + "number": { + "temperature": { + "name": "[%key:component::number::entity_component::temperature::name%]" + }, + "time": { + "name": "Time" + }, + "temperature_after_boiling": { + "name": "Temperature after boiling" + }, + "heat_preservation_time": { + "name": "Heat preservation time" + }, + "feed": { + "name": "Feed" + }, + "voice_times": { + "name": "Voice times" + }, + "sensitivity": { + "name": "Sensitivity" + }, + "near_detection": { + "name": "Near detection" + }, + "far_detection": { + "name": "Far detection" + }, + "water_level": { + "name": "Water level" + }, + "powder": { + "name": "Powder" + }, + "cook_temperature": { + "name": "Cook temperature" + }, + "cook_time": { + "name": "Cook time" + }, + "cloud_recipe": { + "name": "Cloud recipe" + }, + "volume": { + "name": "Volume" + }, + "minimum_brightness": { + "name": "Minimum brightness" + }, + "maximum_brightness": { + "name": "Maximum brightness" + }, + "minimum_brightness_2": { + "name": "Minimum brightness 2" + }, + "maximum_brightness_2": { + "name": "Maximum brightness 2" + }, + "minimum_brightness_3": { + "name": "Minimum brightness 3" + }, + "maximum_brightness_3": { + "name": "Maximum brightness 3" + }, + "move_down": { + "name": "Move down" + }, + "move_up": { + "name": "Move up" + }, + "down_delay": { + "name": "Down delay" + } + }, "select": { + "volume": { + "name": "[%key:component::tuya::entity::number::volume::name%]" + }, + "cups": { + "name": "Cups" + }, + "concentration": { + "name": "Concentration" + }, + "material": { + "name": "Material" + }, + "mode": { + "name": "Mode" + }, + "temperature_level": { + "name": "Temperature level" + }, + "brightness": { + "name": "Brightness" + }, + "target_humidity": { + "name": "Target humidity" + }, "basic_anti_flicker": { + "name": "Anti-flicker", "state": { - "0": "Disabled", + "0": "[%key:common::state::disabled%]", "1": "50 Hz", "2": "60 Hz" } }, "basic_nightvision": { + "name": "Night vision", "state": { "0": "Automatic", "1": "[%key:common::state::off%]", @@ -34,25 +220,45 @@ } }, "decibel_sensitivity": { + "name": "Sound detection sensitivity", "state": { "0": "Low sensitivity", "1": "High sensitivity" } }, "ipc_work_mode": { + "name": "IPC mode", "state": { "0": "Low power mode", "1": "Continuous working mode" } }, "led_type": { + "name": "Light source type", "state": { "halogen": "Halogen", "incandescent": "Incandescent", "led": "LED" } }, + "led_type_2": { + "name": "Light 2 source type", + "state": { + "halogen": "[%key:component::tuya::entity::select::led_type::state::halogen%]", + "incandescent": "[%key:component::tuya::entity::select::led_type::state::incandescent%]", + "led": "[%key:component::tuya::entity::select::led_type::state::led%]" + } + }, + "led_type_3": { + "name": "Light 3 source type", + "state": { + "halogen": "[%key:component::tuya::entity::select::led_type::state::halogen%]", + "incandescent": "[%key:component::tuya::entity::select::led_type::state::incandescent%]", + "led": "[%key:component::tuya::entity::select::led_type::state::led%]" + } + }, "light_mode": { + "name": "Indicator light mode", "state": { "none": "[%key:common::state::off%]", "pos": "Indicate switch location", @@ -60,6 +266,7 @@ } }, "motion_sensitivity": { + "name": "Motion detection sensitivity", "state": { "0": "Low sensitivity", "1": "Medium sensitivity", @@ -67,15 +274,17 @@ } }, "record_mode": { + "name": "Record mode", "state": { "1": "Record events only", "2": "Continuous recording" } }, "relay_status": { + "name": "Power on behavior", "state": { "last": "Remember last state", - "memory": "Remember last state", + "memory": "[%key:component::tuya::entity::select::relay_status::state::last%]", "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", "power_off": "[%key:common::state::off%]", @@ -83,20 +292,23 @@ } }, "fingerbot_mode": { + "name": "Mode", "state": { "click": "Push", "switch": "Switch" } }, "vacuum_cistern": { + "name": "Water tank adjustment", "state": { "low": "Low", "middle": "Middle", "high": "High", - "closed": "Closed" + "closed": "[%key:common::state::closed%]" } }, "vacuum_collection": { + "name": "Dust collection mode", "state": { "small": "Small", "middle": "Middle", @@ -104,29 +316,39 @@ } }, "vacuum_mode": { + "name": "Mode", "state": { - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "random": "Random", "smart": "Smart", - "wall_follow": "Follow Wall", + "wall_follow": "Follow wall", "mop": "Mop", "spiral": "Spiral", - "left_spiral": "Spiral Left", - "right_spiral": "Spiral Right", + "left_spiral": "Spiral left", + "right_spiral": "Spiral right", "bow": "Bow", - "left_bow": "Bow Left", - "right_bow": "Bow Right", - "partial_bow": "Bow Partially", + "left_bow": "Bow left", + "right_bow": "Bow right", + "partial_bow": "Bow partially", "chargego": "Return to dock", "single": "Single", "zone": "Zone", "pose": "Pose", "point": "Point", "part": "Part", - "pick_zone": "Pick Zone" + "pick_zone": "Pick zone" } }, - "fan_angle": { + "vertical_fan_angle": { + "name": "Vertical swing flap angle", + "state": { + "30": "30°", + "60": "60°", + "90": "90°" + } + }, + "horizontal_fan_angle": { + "name": "Horizontal swing flap angle", "state": { "30": "30°", "60": "60°", @@ -134,18 +356,21 @@ } }, "curtain_mode": { + "name": "Mode", "state": { "morning": "Morning", "night": "Night" } }, "curtain_motor_mode": { + "name": "Motor mode", "state": { "forward": "Forward", "back": "Back" } }, "countdown": { + "name": "Countdown", "state": { "cancel": "Cancel", "1h": "1 hour", @@ -157,6 +382,7 @@ } }, "humidifier_spray_mode": { + "name": "Spray mode", "state": { "auto": "Auto", "health": "Health", @@ -166,6 +392,7 @@ } }, "humidifier_level": { + "name": "Spraying level", "state": { "level_1": "Level 1", "level_2": "Level 2", @@ -180,6 +407,7 @@ } }, "humidifier_moodlighting": { + "name": "Moodlighting", "state": { "1": "Mood 1", "2": "Mood 2", @@ -190,7 +418,155 @@ } }, "sensor": { + "battery": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + }, + "voc": { + "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]" + }, + "carbon_monoxide": { + "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" + }, + "carbon_dioxide": { + "name": "[%key:component::sensor::entity_component::carbon_dioxide::name%]" + }, + "illuminance": { + "name": "[%key:component::sensor::entity_component::illuminance::name%]" + }, + "temperature": { + "name": "[%key:component::sensor::entity_component::temperature::name%]" + }, + "humidity": { + "name": "[%key:component::sensor::entity_component::humidity::name%]" + }, + "pm25": { + "name": "[%key:component::sensor::entity_component::pm25::name%]" + }, + "pm1": { + "name": "[%key:component::sensor::entity_component::pm1::name%]" + }, + "pm10": { + "name": "[%key:component::sensor::entity_component::pm10::name%]" + }, + "current": { + "name": "[%key:component::sensor::entity_component::current::name%]" + }, + "power": { + "name": "[%key:component::sensor::entity_component::power::name%]" + }, + "voltage": { + "name": "[%key:component::sensor::entity_component::voltage::name%]" + }, + "battery_state": { + "name": "Battery state" + }, + "gas": { + "name": "Gas" + }, + "formaldehyde": { + "name": "[%key:component::tuya::entity::binary_sensor::formaldehyde::name%]" + }, + "luminosity": { + "name": "Luminosity" + }, + "smoke_amount": { + "name": "Smoke amount" + }, + "current_temperature": { + "name": "Current temperature" + }, "status": { + "name": "Status" + }, + "last_amount": { + "name": "Last amount" + }, + "remaining_time": { + "name": "Remaining time" + }, + "methane": { + "name": "[%key:component::tuya::entity::binary_sensor::methane::name%]" + }, + "total_energy": { + "name": "Total energy" + }, + "phase_a_current": { + "name": "Phase A current" + }, + "phase_a_power": { + "name": "Phase A power" + }, + "phase_a_voltage": { + "name": "Phase A voltage" + }, + "phase_b_current": { + "name": "Phase B current" + }, + "phase_b_power": { + "name": "Phase B power" + }, + "phase_b_voltage": { + "name": "Phase B voltage" + }, + "phase_c_current": { + "name": "Phase C current" + }, + "phase_c_power": { + "name": "Phase C power" + }, + "phase_c_voltage": { + "name": "Phase C voltage" + }, + "cleaning_area": { + "name": "Cleaning area" + }, + "cleaning_time": { + "name": "Cleaning time" + }, + "total_cleaning_area": { + "name": "Total cleaning area" + }, + "total_cleaning_time": { + "name": "Total cleaning time" + }, + "total_cleaning_times": { + "name": "Total cleaning times" + }, + "duster_cloth_life": { + "name": "Duster cloth lifetime" + }, + "side_brush_life": { + "name": "Side brush lifetime" + }, + "filter_life": { + "name": "Filter lifetime" + }, + "rolling_brush_life": { + "name": "Rolling brush lifetime" + }, + "last_operation_duration": { + "name": "Last operation duration" + }, + "water_level": { + "name": "Water level" + }, + "filter_utilization": { + "name": "Filter utilization" + }, + "total_volatile_organic_compound": { + "name": "Total volatile organic compound" + }, + "concentration_carbon_dioxide": { + "name": "Concentration of carbon dioxide" + }, + "total_operating_time": { + "name": "Total operating time" + }, + "total_absorption_particles": { + "name": "Total absorption of particles" + }, + "sous_vide_status": { + "name": "Status", "state": { "boiling_temp": "Boiling temperature", "cooling": "Cooling", @@ -199,11 +575,12 @@ "reserve_1": "Reserve 1", "reserve_2": "Reserve 2", "reserve_3": "Reserve 3", - "standby": "Standby", + "standby": "[%key:common::state::standby%]", "warm": "Heat preservation" } }, "air_quality": { + "name": "Air quality", "state": { "great": "Great", "mild": "Mild", @@ -211,6 +588,236 @@ "severe": "Severe" } } + }, + "switch": { + "start": { + "name": "Start" + }, + "heat_preservation": { + "name": "Heat preservation" + }, + "disinfection": { + "name": "Disinfection" + }, + "water": { + "name": "Water" + }, + "slow_feed": { + "name": "Slow feed" + }, + "filter_reset": { + "name": "Filter reset" + }, + "water_pump_reset": { + "name": "Water pump reset" + }, + "power": { + "name": "Power" + }, + "reset_of_water_usage_days": { + "name": "Reset of water usage days" + }, + "uv_sterilization": { + "name": "UV sterilization" + }, + "plug": { + "name": "Plug" + }, + "child_lock": { + "name": "Child lock" + }, + "switch": { + "name": "Switch" + }, + "socket": { + "name": "Socket" + }, + "radio": { + "name": "Radio" + }, + "alarm_1": { + "name": "Alarm 1" + }, + "alarm_2": { + "name": "Alarm 2" + }, + "alarm_3": { + "name": "Alarm 3" + }, + "alarm_4": { + "name": "Alarm 4" + }, + "sleep_aid": { + "name": "Sleep aid" + }, + "switch_1": { + "name": "Switch 1" + }, + "switch_2": { + "name": "Switch 2" + }, + "switch_3": { + "name": "Switch 3" + }, + "switch_4": { + "name": "Switch 4" + }, + "switch_5": { + "name": "Switch 5" + }, + "switch_6": { + "name": "Switch 6" + }, + "switch_7": { + "name": "Switch 7" + }, + "switch_8": { + "name": "Switch 8" + }, + "usb_1": { + "name": "USB 1" + }, + "usb_2": { + "name": "USB 2" + }, + "usb_3": { + "name": "USB 3" + }, + "usb_4": { + "name": "USB 4" + }, + "usb_5": { + "name": "USB 5" + }, + "usb_6": { + "name": "USB 6" + }, + "socket_1": { + "name": "Socket 1" + }, + "socket_2": { + "name": "Socket 2" + }, + "socket_3": { + "name": "Socket 3" + }, + "socket_4": { + "name": "Socket 4" + }, + "socket_5": { + "name": "Socket 5" + }, + "socket_6": { + "name": "Socket 6" + }, + "ionizer": { + "name": "Ionizer" + }, + "filter_cartridge_reset": { + "name": "Filter cartridge reset" + }, + "humidification": { + "name": "Humidification" + }, + "do_not_disturb": { + "name": "Do not disturb" + }, + "mute_voice": { + "name": "Mute voice" + }, + "mute": { + "name": "Mute" + }, + "battery_lock": { + "name": "Battery lock" + }, + "cry_detection": { + "name": "Cry detection" + }, + "sound_detection": { + "name": "Sound detection" + }, + "video_recording": { + "name": "Video recording" + }, + "motion_recording": { + "name": "Motion recording" + }, + "privacy_mode": { + "name": "Privacy mode" + }, + "flip": { + "name": "Flip" + }, + "time_watermark": { + "name": "Time watermark" + }, + "wide_dynamic_range": { + "name": "Wide dynamic range" + }, + "motion_tracking": { + "name": "Motion tracking" + }, + "motion_alarm": { + "name": "Motion alarm" + }, + "energy_saving": { + "name": "Energy saving" + }, + "open_window_detection": { + "name": "Open window detection" + }, + "spray": { + "name": "Spray" + }, + "voice": { + "name": "Voice" + }, + "anion": { + "name": "Anion" + }, + "oxygen_bar": { + "name": "Oxygen bar" + }, + "natural_wind": { + "name": "Natural wind" + }, + "sound": { + "name": "Sound" + }, + "reverse": { + "name": "Reverse" + }, + "sleep": { + "name": "Sleep" + }, + "sterilization": { + "name": "Sterilization" + } + } + }, + "issues": { + "service_deprecation_turn_off": { + "title": "Tuya vacuum support for vacuum.turn_off is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::tuya::issues::service_deprecation_turn_off::title%]", + "description": "Tuya vacuum support for the vacuum.turn_off service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call vacuum.stop and select submit below to mark this issue as resolved." + } + } + } + }, + "service_deprecation_turn_on": { + "title": "Tuya vacuum support for vacuum.turn_on is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::tuya::issues::service_deprecation_turn_on::title%]", + "description": "Tuya vacuum support for the vacuum.turn_on service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call vacuum.start and select submit below to mark this issue as resolved." + } + } + } } } } diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index a7245913e73..676991fe167 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -29,12 +29,12 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "bh": ( SwitchEntityDescription( key=DPCode.START, - name="Start", + translation_key="start", icon="mdi:kettle-steam", ), SwitchEntityDescription( key=DPCode.WARM, - name="Heat preservation", + translation_key="heat_preservation", entity_category=EntityCategory.CONFIG, ), ), @@ -43,12 +43,12 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "cn": ( SwitchEntityDescription( key=DPCode.DISINFECTION, - name="Disinfection", + translation_key="disinfection", icon="mdi:bacteria", ), SwitchEntityDescription( key=DPCode.WATER, - name="Water", + translation_key="water", icon="mdi:water", ), ), @@ -57,7 +57,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "cwwsq": ( SwitchEntityDescription( key=DPCode.SLOW_FEED, - name="Slow feed", + translation_key="slow_feed", icon="mdi:speedometer-slow", entity_category=EntityCategory.CONFIG, ), @@ -67,29 +67,29 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "cwysj": ( SwitchEntityDescription( key=DPCode.FILTER_RESET, - name="Filter reset", + translation_key="filter_reset", icon="mdi:filter", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.PUMP_RESET, - name="Water pump reset", + translation_key="water_pump_reset", icon="mdi:pump", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH, - name="Power", + translation_key="power", ), SwitchEntityDescription( key=DPCode.WATER_RESET, - name="Reset of water usage days", + translation_key="reset_of_water_usage_days", icon="mdi:water-sync", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.UV, - name="UV sterilization", + translation_key="uv_sterilization", icon="mdi:lightbulb", entity_category=EntityCategory.CONFIG, ), @@ -102,20 +102,20 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { # switch to control the plug. SwitchEntityDescription( key=DPCode.SWITCH, - name="Plug", + translation_key="plug", ), ), # Cirquit Breaker "dlq": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child lock", + translation_key="asd", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH, - name="Switch", + translation_key="switch", ), ), # Wake Up Light II @@ -123,36 +123,36 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "hxd": ( SwitchEntityDescription( key=DPCode.SWITCH_1, - name="Radio", + translation_key="radio", icon="mdi:radio", ), SwitchEntityDescription( key=DPCode.SWITCH_2, - name="Alarm 1", + translation_key="alarm_1", icon="mdi:alarm", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_3, - name="Alarm 2", + translation_key="alarm_2", icon="mdi:alarm", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_4, - name="Alarm 3", + translation_key="alarm_3", icon="mdi:alarm", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_5, - name="Alarm 4", + translation_key="alarm_4", icon="mdi:alarm", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_6, - name="Sleep aid", + translation_key="sleep_aid", icon="mdi:power-sleep", ), ), @@ -162,12 +162,12 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "wkcz": ( SwitchEntityDescription( key=DPCode.SWITCH_1, - name="Switch 1", + translation_key="switch_1", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_2, - name="Switch 2", + translation_key="switch_2", device_class=SwitchDeviceClass.OUTLET, ), ), @@ -176,77 +176,77 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "kg": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child lock", + translation_key="child_lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_1, - name="Switch 1", + translation_key="switch_1", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_2, - name="Switch 2", + translation_key="switch_2", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_3, - name="Switch 3", + translation_key="switch_3", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_4, - name="Switch 4", + translation_key="switch_4", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_5, - name="Switch 5", + translation_key="switch_5", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_6, - name="Switch 6", + translation_key="switch_6", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_7, - name="Switch 7", + translation_key="switch_7", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_8, - name="Switch 8", + translation_key="switch_8", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_USB1, - name="USB 1", + translation_key="usb_1", ), SwitchEntityDescription( key=DPCode.SWITCH_USB2, - name="USB 2", + translation_key="usb_2", ), SwitchEntityDescription( key=DPCode.SWITCH_USB3, - name="USB 3", + translation_key="usb_3", ), SwitchEntityDescription( key=DPCode.SWITCH_USB4, - name="USB 4", + translation_key="usb_4", ), SwitchEntityDescription( key=DPCode.SWITCH_USB5, - name="USB 5", + translation_key="usb_5", ), SwitchEntityDescription( key=DPCode.SWITCH_USB6, - name="USB 6", + translation_key="usb_6", ), SwitchEntityDescription( key=DPCode.SWITCH, - name="Switch", + translation_key="switch", device_class=SwitchDeviceClass.OUTLET, ), ), @@ -255,35 +255,35 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "kj": ( SwitchEntityDescription( key=DPCode.ANION, - name="Ionizer", + translation_key="ionizer", icon="mdi:minus-circle-outline", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.FILTER_RESET, - name="Filter cartridge reset", + translation_key="filter_cartridge_reset", icon="mdi:filter", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.LOCK, - name="Child lock", + translation_key="child_lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH, - name="Power", + translation_key="power", ), SwitchEntityDescription( key=DPCode.WET, - name="Humidification", + translation_key="humidification", icon="mdi:water-percent", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.UV, - name="UV sterilization", + translation_key="uv_sterilization", icon="mdi:minus-circle-outline", entity_category=EntityCategory.CONFIG, ), @@ -293,13 +293,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "kt": ( SwitchEntityDescription( key=DPCode.ANION, - name="Ionizer", + translation_key="ionizer", icon="mdi:minus-circle-outline", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.LOCK, - name="Child lock", + translation_key="child_lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -309,13 +309,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "mzj": ( SwitchEntityDescription( key=DPCode.SWITCH, - name="Switch", + translation_key="switch", icon="mdi:power", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.START, - name="Start", + translation_key="start", icon="mdi:pot-steam", entity_category=EntityCategory.CONFIG, ), @@ -325,67 +325,67 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "pc": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child lock", + translation_key="child_lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SWITCH_1, - name="Socket 1", + translation_key="socket_1", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_2, - name="Socket 2", + translation_key="socket_2", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_3, - name="Socket 3", + translation_key="socket_3", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_4, - name="Socket 4", + translation_key="socket_4", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_5, - name="Socket 5", + translation_key="socket_5", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_6, - name="Socket 6", + translation_key="socket_6", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_USB1, - name="USB 1", + translation_key="usb_1", ), SwitchEntityDescription( key=DPCode.SWITCH_USB2, - name="USB 2", + translation_key="usb_2", ), SwitchEntityDescription( key=DPCode.SWITCH_USB3, - name="USB 3", + translation_key="usb_3", ), SwitchEntityDescription( key=DPCode.SWITCH_USB4, - name="USB 4", + translation_key="usb_4", ), SwitchEntityDescription( key=DPCode.SWITCH_USB5, - name="USB 5", + translation_key="usb_5", ), SwitchEntityDescription( key=DPCode.SWITCH_USB6, - name="USB 6", + translation_key="usb_6", ), SwitchEntityDescription( key=DPCode.SWITCH, - name="Socket", + translation_key="socket", device_class=SwitchDeviceClass.OUTLET, ), ), @@ -395,7 +395,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "qjdcz": ( SwitchEntityDescription( key=DPCode.SWITCH_1, - name="Switch", + translation_key="switch", ), ), # Heater @@ -403,13 +403,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "qn": ( SwitchEntityDescription( key=DPCode.ANION, - name="Ionizer", + translation_key="ionizer", icon="mdi:minus-circle-outline", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.LOCK, - name="Child lock", + translation_key="child_lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -419,13 +419,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "sd": ( SwitchEntityDescription( key=DPCode.SWITCH_DISTURB, - name="Do not disturb", + translation_key="do_not_disturb", icon="mdi:minus-circle", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.VOICE_SWITCH, - name="Mute voice", + translation_key="mute_voice", icon="mdi:account-voice", entity_category=EntityCategory.CONFIG, ), @@ -435,7 +435,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "sgbj": ( SwitchEntityDescription( key=DPCode.MUFFLING, - name="Mute", + translation_key="mute", entity_category=EntityCategory.CONFIG, ), ), @@ -444,68 +444,68 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "sp": ( SwitchEntityDescription( key=DPCode.WIRELESS_BATTERYLOCK, - name="Battery lock", + translation_key="battery_lock", icon="mdi:battery-lock", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.CRY_DETECTION_SWITCH, + translation_key="cry_detection", icon="mdi:emoticon-cry", - name="Cry detection", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.DECIBEL_SWITCH, + translation_key="sound_detection", icon="mdi:microphone-outline", - name="Sound detection", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.RECORD_SWITCH, + translation_key="video_recording", icon="mdi:record-rec", - name="Video recording", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_RECORD, + translation_key="motion_recording", icon="mdi:record-rec", - name="Motion recording", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.BASIC_PRIVATE, + translation_key="privacy_mode", icon="mdi:eye-off", - name="Privacy mode", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.BASIC_FLIP, + translation_key="flip", icon="mdi:flip-horizontal", - name="Flip", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.BASIC_OSD, + translation_key="time_watermark", icon="mdi:watermark", - name="Time watermark", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.BASIC_WDR, + translation_key="wide_dynamic_range", icon="mdi:watermark", - name="Wide dynamic range", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_TRACKING, + translation_key="motion_tracking", icon="mdi:motion-sensor", - name="Motion tracking", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.MOTION_SWITCH, + translation_key="motion_alarm", icon="mdi:motion-sensor", - name="Motion alarm", entity_category=EntityCategory.CONFIG, ), ), @@ -513,7 +513,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "szjqr": ( SwitchEntityDescription( key=DPCode.SWITCH, - name="Switch", + translation_key="switch", icon="mdi:cursor-pointer", ), ), @@ -522,27 +522,27 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "tdq": ( SwitchEntityDescription( key=DPCode.SWITCH_1, - name="Switch 1", + translation_key="switch_1", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_2, - name="Switch 2", + translation_key="switch_2", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_3, - name="Switch 3", + translation_key="switch_3", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.SWITCH_4, - name="Switch 4", + translation_key="switch_4", device_class=SwitchDeviceClass.OUTLET, ), SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child lock", + translation_key="child_lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -552,7 +552,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "tyndj": ( SwitchEntityDescription( key=DPCode.SWITCH_SAVE_ENERGY, - name="Energy saving", + translation_key="energy_saving", icon="mdi:leaf", entity_category=EntityCategory.CONFIG, ), @@ -562,23 +562,30 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "wkf": ( SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child lock", + translation_key="child_lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.WINDOW_CHECK, - name="Open window detection", + translation_key="open_window_detection", icon="mdi:window-open", entity_category=EntityCategory.CONFIG, ), ), + # Air Conditioner Mate (Smart IR Socket) + "wnykq": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + name=None, + ), + ), # SIREN: Siren (switch) with Temperature and humidity sensor # https://developer.tuya.com/en/docs/iot/f?id=Kavck4sr3o5ek "wsdcg": ( SwitchEntityDescription( key=DPCode.SWITCH, - name="Switch", + translation_key="switch", device_class=SwitchDeviceClass.OUTLET, ), ), @@ -587,7 +594,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "xdd": ( SwitchEntityDescription( key=DPCode.DO_NOT_DISTURB, - name="Do not disturb", + translation_key="do_not_disturb", icon="mdi:minus-circle-outline", entity_category=EntityCategory.CONFIG, ), @@ -597,16 +604,16 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "xxj": ( SwitchEntityDescription( key=DPCode.SWITCH, - name="Power", + translation_key="power", ), SwitchEntityDescription( key=DPCode.SWITCH_SPRAY, - name="Spray", + translation_key="spray", icon="mdi:spray", ), SwitchEntityDescription( key=DPCode.SWITCH_VOICE, - name="Voice", + translation_key="voice", icon="mdi:account-voice", entity_category=EntityCategory.CONFIG, ), @@ -616,7 +623,7 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "zndb": ( SwitchEntityDescription( key=DPCode.SWITCH, - name="Switch", + translation_key="switch", ), ), # Fan @@ -624,37 +631,37 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "fs": ( SwitchEntityDescription( key=DPCode.ANION, - name="Anion", + translation_key="anion", icon="mdi:atom", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.HUMIDIFIER, - name="Humidification", + translation_key="humidification", icon="mdi:air-humidifier", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.OXYGEN, - name="Oxygen bar", + translation_key="oxygen_bar", icon="mdi:molecule", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.FAN_COOL, - name="Natural wind", + translation_key="natural_wind", icon="mdi:weather-windy", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.FAN_BEEP, - name="Sound", + translation_key="sound", icon="mdi:minus-circle", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.CHILD_LOCK, - name="Child lock", + translation_key="child_lock", icon="mdi:account-lock", entity_category=EntityCategory.CONFIG, ), @@ -664,13 +671,13 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "cl": ( SwitchEntityDescription( key=DPCode.CONTROL_BACK, - name="Reverse", + translation_key="reverse", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.OPPOSITE, - name="Reverse", + translation_key="reverse", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, ), @@ -680,19 +687,19 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { "jsq": ( SwitchEntityDescription( key=DPCode.SWITCH_SOUND, - name="Voice", + translation_key="voice", icon="mdi:account-voice", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.SLEEP, - name="Sleep", + translation_key="sleep", icon="mdi:power-sleep", entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( key=DPCode.STERILIZATION, - name="Sterilization", + translation_key="sterilization", icon="mdi:minus-circle-outline", entity_category=EntityCategory.CONFIG, ), diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index a2961a55d78..b332be7de2d 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -15,6 +15,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, STATE_PAUSED from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -86,7 +87,9 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): self._attr_fan_speed_list = [] - self._attr_supported_features |= VacuumEntityFeature.SEND_COMMAND + self._attr_supported_features = ( + VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE + ) if self.find_dpcode(DPCode.PAUSE, prefer_function=True): self._attr_supported_features |= VacuumEntityFeature.PAUSE @@ -102,11 +105,6 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): if self.find_dpcode(DPCode.SEEK, prefer_function=True): self._attr_supported_features |= VacuumEntityFeature.LOCATE - if self.find_dpcode(DPCode.STATUS, prefer_function=True): - self._attr_supported_features |= ( - VacuumEntityFeature.STATE | VacuumEntityFeature.STATUS - ) - if self.find_dpcode(DPCode.POWER, prefer_function=True): self._attr_supported_features |= ( VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF @@ -156,10 +154,30 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" self._send_command([{"code": DPCode.POWER, "value": True}]) + ir.async_create_issue( + self.hass, + DOMAIN, + "service_deprecation_turn_on", + breaks_in_ha_version="2024.2.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation_turn_on", + ) def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" self._send_command([{"code": DPCode.POWER, "value": False}]) + ir.async_create_issue( + self.hass, + DOMAIN, + "service_deprecation_turn_off", + breaks_in_ha_version="2024.2.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation_turn_off", + ) def start(self, **kwargs: Any) -> None: """Start the device.""" diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index ab0a60c44ca..fba10a269f7 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, WASTE_TYPE_TO_DESCRIPTION +from .const import DOMAIN from .entity import TwenteMilieuEntity @@ -38,36 +38,36 @@ class TwenteMilieuSensorDescription( SENSORS: tuple[TwenteMilieuSensorDescription, ...] = ( TwenteMilieuSensorDescription( key="tree", + translation_key="christmas_tree_pickup", waste_type=WasteType.TREE, - name=WASTE_TYPE_TO_DESCRIPTION[WasteType.TREE], icon="mdi:pine-tree", device_class=SensorDeviceClass.DATE, ), TwenteMilieuSensorDescription( key="Non-recyclable", + translation_key="non_recyclable_waste_pickup", waste_type=WasteType.NON_RECYCLABLE, - name=WASTE_TYPE_TO_DESCRIPTION[WasteType.NON_RECYCLABLE], icon="mdi:delete-empty", device_class=SensorDeviceClass.DATE, ), TwenteMilieuSensorDescription( key="Organic", + translation_key="organic_waste_pickup", waste_type=WasteType.ORGANIC, - name=WASTE_TYPE_TO_DESCRIPTION[WasteType.ORGANIC], icon="mdi:delete-empty", device_class=SensorDeviceClass.DATE, ), TwenteMilieuSensorDescription( key="Paper", + translation_key="paper_waste_pickup", waste_type=WasteType.PAPER, - name=WASTE_TYPE_TO_DESCRIPTION[WasteType.PAPER], icon="mdi:delete-empty", device_class=SensorDeviceClass.DATE, ), TwenteMilieuSensorDescription( key="Plastic", + translation_key="packages_waste_pickup", waste_type=WasteType.PACKAGES, - name=WASTE_TYPE_TO_DESCRIPTION[WasteType.PACKAGES], icon="mdi:delete-empty", device_class=SensorDeviceClass.DATE, ), diff --git a/homeassistant/components/twentemilieu/strings.json b/homeassistant/components/twentemilieu/strings.json index d9b59b2d02c..7797167ea0b 100644 --- a/homeassistant/components/twentemilieu/strings.json +++ b/homeassistant/components/twentemilieu/strings.json @@ -17,5 +17,24 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" } + }, + "entity": { + "sensor": { + "non_recyclable_waste_pickup": { + "name": "Non-recyclable waste pickup" + }, + "organic_waste_pickup": { + "name": "Organic waste pickup" + }, + "packages_waste_pickup": { + "name": "Packages waste pickup" + }, + "paper_waste_pickup": { + "name": "Paper waste pickup" + }, + "christmas_tree_pickup": { + "name": "Christmas tree pickup" + } + } } } diff --git a/homeassistant/components/ukraine_alarm/binary_sensor.py b/homeassistant/components/ukraine_alarm/binary_sensor.py index 3cfe79ef5fb..eb83fe490e7 100644 --- a/homeassistant/components/ukraine_alarm/binary_sensor.py +++ b/homeassistant/components/ukraine_alarm/binary_sensor.py @@ -30,36 +30,36 @@ from .const import ( BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key=ALERT_TYPE_UNKNOWN, - name="Unknown", + translation_key="unknown", device_class=BinarySensorDeviceClass.SAFETY, ), BinarySensorEntityDescription( key=ALERT_TYPE_AIR, - name="Air", + translation_key="air", device_class=BinarySensorDeviceClass.SAFETY, icon="mdi:cloud", ), BinarySensorEntityDescription( key=ALERT_TYPE_URBAN_FIGHTS, - name="Urban Fights", + translation_key="urban_fights", device_class=BinarySensorDeviceClass.SAFETY, icon="mdi:pistol", ), BinarySensorEntityDescription( key=ALERT_TYPE_ARTILLERY, - name="Artillery", + translation_key="artillery", device_class=BinarySensorDeviceClass.SAFETY, icon="mdi:tank", ), BinarySensorEntityDescription( key=ALERT_TYPE_CHEMICAL, - name="Chemical", + translation_key="chemical", device_class=BinarySensorDeviceClass.SAFETY, icon="mdi:chemical-weapon", ), BinarySensorEntityDescription( key=ALERT_TYPE_NUCLEAR, - name="Nuclear", + translation_key="nuclear", device_class=BinarySensorDeviceClass.SAFETY, icon="mdi:nuke", ), @@ -92,6 +92,7 @@ class UkraineAlarmSensor( """Class for a Ukraine Alarm binary sensor.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, @@ -105,7 +106,6 @@ class UkraineAlarmSensor( self.entity_description = description - self._attr_name = f"{name} {description.name}" self._attr_unique_id = f"{unique_id}-{description.key}".lower() self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, diff --git a/homeassistant/components/ukraine_alarm/strings.json b/homeassistant/components/ukraine_alarm/strings.json index 6831d66adb3..73a2657065e 100644 --- a/homeassistant/components/ukraine_alarm/strings.json +++ b/homeassistant/components/ukraine_alarm/strings.json @@ -28,5 +28,27 @@ "description": "If you want to monitor not only state and district, choose its specific community" } } + }, + "entity": { + "binary_sensor": { + "unknown": { + "name": "Unknown" + }, + "air": { + "name": "Air" + }, + "urban_fights": { + "name": "Urban fights" + }, + "artillery": { + "name": "Artillery" + }, + "chemical": { + "name": "Chemical" + }, + "nuclear": { + "name": "Nuclear" + } + } } } diff --git a/homeassistant/components/ultraloq/__init__.py b/homeassistant/components/ultraloq/__init__.py deleted file mode 100644 index b650c59a5de..00000000000 --- a/homeassistant/components/ultraloq/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Virtual integration: Ultraloq.""" diff --git a/homeassistant/components/ultraloq/manifest.json b/homeassistant/components/ultraloq/manifest.json deleted file mode 100644 index 4775ba6caa3..00000000000 --- a/homeassistant/components/ultraloq/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "domain": "ultraloq", - "name": "Ultraloq", - "integration_type": "virtual", - "iot_standards": ["zwave"] -} diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index d283b668995..12f2d49e416 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -308,7 +308,7 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): return await self.async_step_client_control() ssids = ( - set(self.controller.api.wlans) + {wlan.name for wlan in self.controller.api.wlans.values()} | { f"{wlan.name}{wlan.name_combine_suffix}" for wlan in self.controller.api.wlans.values() diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index b5cea06c719..e03bd50d483 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -9,6 +9,7 @@ DOMAIN = "unifi" PLATFORMS = [ Platform.DEVICE_TRACKER, + Platform.IMAGE, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 60507d5a8c6..6ac4e622736 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -345,7 +345,6 @@ class UniFiController: device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, - configuration_url=self.api.url, connections={(CONNECTION_NETWORK_MAC, self.mac)}, default_manufacturer=ATTR_MANUFACTURER, default_model="UniFi Network", diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 296857e1cfa..fcfe71a2858 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -171,6 +171,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = ( is_connected_fn=async_client_is_connected_fn, name_fn=lambda client: client.name or client.hostname, object_fn=lambda api, obj_id: api.clients[obj_id], + should_poll=False, supported_fn=lambda controller, obj_id: True, unique_id_fn=lambda controller, obj_id: f"{obj_id}-{controller.site}", ip_address_fn=lambda api, obj_id: api.clients[obj_id].ip, @@ -190,6 +191,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiTrackerEntityDescription, ...] = ( is_connected_fn=lambda ctrlr, obj_id: ctrlr.api.devices[obj_id].state == 1, name_fn=lambda device: device.name or device.model, object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, supported_fn=lambda controller, obj_id: True, unique_id_fn=lambda controller, obj_id: obj_id, ip_address_fn=lambda api, obj_id: api.devices[obj_id].ip, diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 18a132be6a8..54b9cb12157 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -18,11 +18,14 @@ from aiounifi.models.event import Event, EventKey from homeassistant.core import callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceEntryType, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription -from .const import ATTR_MANUFACTURER +from .const import ATTR_MANUFACTURER, DOMAIN if TYPE_CHECKING: from .controller import UniFiController @@ -58,6 +61,19 @@ def async_device_device_info_fn(api: aiounifi.Controller, obj_id: str) -> Device ) +@callback +def async_wlan_device_info_fn(api: aiounifi.Controller, obj_id: str) -> DeviceInfo: + """Create device registry entry for WLAN.""" + wlan = api.wlans[obj_id] + return DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, wlan.id)}, + manufacturer=ATTR_MANUFACTURER, + model="UniFi WLAN", + name=wlan.name, + ) + + @dataclass class UnifiDescription(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -70,6 +86,7 @@ class UnifiDescription(Generic[HandlerT, ApiItemT]): event_to_subscribe: tuple[EventKey, ...] | None name_fn: Callable[[ApiItemT], str | None] object_fn: Callable[[aiounifi.Controller, str], ApiItemT] + should_poll: bool supported_fn: Callable[[UniFiController, str], bool | None] unique_id_fn: Callable[[UniFiController, str], str] @@ -83,8 +100,6 @@ class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): """Representation of a UniFi entity.""" entity_description: UnifiEntityDescription[HandlerT, ApiItemT] - _attr_should_poll = False - _attr_unique_id: str def __init__( @@ -104,6 +119,7 @@ class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): self._attr_available = description.available_fn(controller, obj_id) self._attr_device_info = description.device_info_fn(controller.api, obj_id) + self._attr_should_poll = description.should_poll self._attr_unique_id = description.unique_id_fn(controller, obj_id) obj = description.object_fn(self.controller.api, obj_id) @@ -193,6 +209,10 @@ class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): else: await self.async_remove(force_remove=True) + async def async_update(self) -> None: + """Update state if polling is configured.""" + self.async_update_state(ItemEvent.CHANGED, self._obj_id) + @callback def async_initiate_state(self) -> None: """Initiate entity state. diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py new file mode 100644 index 00000000000..dc4fb93eded --- /dev/null +++ b/homeassistant/components/unifi/image.py @@ -0,0 +1,130 @@ +"""Image platform for UniFi Network integration. + +Support for QR code for guest WLANs. +""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic + +from aiounifi.interfaces.api_handlers import ItemEvent +from aiounifi.interfaces.wlans import Wlans +from aiounifi.models.api import ApiItemT +from aiounifi.models.wlan import Wlan + +from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.util.dt as dt_util + +from .const import DOMAIN as UNIFI_DOMAIN +from .controller import UniFiController +from .entity import ( + HandlerT, + UnifiEntity, + UnifiEntityDescription, + async_wlan_device_info_fn, +) + + +@callback +def async_wlan_qr_code_image_fn(controller: UniFiController, wlan: Wlan) -> bytes: + """Calculate receiving data transfer value.""" + return controller.api.wlans.generate_wlan_qr_code(wlan) + + +@dataclass +class UnifiImageEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): + """Validate and load entities from different UniFi handlers.""" + + image_fn: Callable[[UniFiController, ApiItemT], bytes] + value_fn: Callable[[ApiItemT], str] + + +@dataclass +class UnifiImageEntityDescription( + ImageEntityDescription, + UnifiEntityDescription[HandlerT, ApiItemT], + UnifiImageEntityDescriptionMixin[HandlerT, ApiItemT], +): + """Class describing UniFi image entity.""" + + +ENTITY_DESCRIPTIONS: tuple[UnifiImageEntityDescription, ...] = ( + UnifiImageEntityDescription[Wlans, Wlan]( + key="WLAN QR Code", + entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, + entity_registry_enabled_default=False, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.wlans, + available_fn=lambda controller, _: controller.available, + device_info_fn=async_wlan_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda _: "QR Code", + object_fn=lambda api, obj_id: api.wlans[obj_id], + should_poll=False, + supported_fn=lambda controller, obj_id: True, + unique_id_fn=lambda controller, obj_id: f"qr_code-{obj_id}", + image_fn=async_wlan_qr_code_image_fn, + value_fn=lambda obj: obj.x_passphrase, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up image platform for UniFi Network integration.""" + controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + if controller.site_role != "admin": + return + + controller.register_platform_add_entities( + UnifiImageEntity, ENTITY_DESCRIPTIONS, async_add_entities + ) + + +class UnifiImageEntity(UnifiEntity[HandlerT, ApiItemT], ImageEntity): + """Base representation of a UniFi image.""" + + entity_description: UnifiImageEntityDescription[HandlerT, ApiItemT] + _attr_content_type = "image/png" + + current_image: bytes | None = None + previous_value = "" + + def __init__( + self, + obj_id: str, + controller: UniFiController, + description: UnifiEntityDescription[HandlerT, ApiItemT], + ) -> None: + """Initiatlize UniFi Image entity.""" + super().__init__(obj_id, controller, description) + ImageEntity.__init__(self, controller.hass) + + def image(self) -> bytes | None: + """Return bytes of image.""" + if self.current_image is None: + description = self.entity_description + obj = description.object_fn(self.controller.api, self._obj_id) + self.current_image = description.image_fn(self.controller, obj) + return self.current_image + + @callback + def async_update_state(self, event: ItemEvent, obj_id: str) -> None: + """Update entity state.""" + description = self.entity_description + obj = description.object_fn(self.controller.api, self._obj_id) + if (value := description.value_fn(obj)) != self.previous_value: + self.previous_value = value + self.current_image = None + self._attr_image_last_updated = dt_util.utcnow() diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 9bfb01e5a88..c34d1035158 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==49"], + "requirements": ["aiounifi==50"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 3682fa0bf6c..8cdc0dcbb71 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -14,9 +14,11 @@ import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.ports import Ports +from aiounifi.interfaces.wlans import Wlans from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client from aiounifi.models.port import Port +from aiounifi.models.wlan import Wlan from homeassistant.components.sensor import ( SensorDeviceClass, @@ -39,6 +41,7 @@ from .entity import ( UnifiEntityDescription, async_device_available_fn, async_device_device_info_fn, + async_wlan_device_info_fn, ) @@ -68,6 +71,18 @@ def async_client_uptime_value_fn( return dt_util.utc_from_timestamp(float(client.uptime)) +@callback +def async_wlan_client_value_fn(controller: UniFiController, wlan: Wlan) -> int: + """Calculate the amount of clients connected to a wlan.""" + return len( + [ + client.mac + for client in controller.api.clients.values() + if client.essid == wlan.name + ] + ) + + @callback def async_client_device_info_fn(api: aiounifi.Controller, obj_id: str) -> DeviceInfo: """Create device registry entry for client.""" @@ -109,6 +124,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( event_to_subscribe=None, name_fn=lambda _: "RX", object_fn=lambda api, obj_id: api.clients[obj_id], + should_poll=False, supported_fn=lambda controller, _: controller.option_allow_bandwidth_sensors, unique_id_fn=lambda controller, obj_id: f"rx-{obj_id}", value_fn=async_client_rx_value_fn, @@ -126,6 +142,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( event_to_subscribe=None, name_fn=lambda _: "TX", object_fn=lambda api, obj_id: api.clients[obj_id], + should_poll=False, supported_fn=lambda controller, _: controller.option_allow_bandwidth_sensors, unique_id_fn=lambda controller, obj_id: f"tx-{obj_id}", value_fn=async_client_tx_value_fn, @@ -145,6 +162,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( event_to_subscribe=None, name_fn=lambda port: f"{port.name} PoE Power", object_fn=lambda api, obj_id: api.ports[obj_id], + should_poll=False, supported_fn=lambda controller, obj_id: controller.api.ports[obj_id].port_poe, unique_id_fn=lambda controller, obj_id: f"poe_power-{obj_id}", value_fn=lambda _, obj: obj.poe_power if obj.poe_mode != "off" else "0", @@ -163,10 +181,28 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( event_to_subscribe=None, name_fn=lambda client: "Uptime", object_fn=lambda api, obj_id: api.clients[obj_id], + should_poll=False, supported_fn=lambda controller, _: controller.option_allow_uptime_sensors, unique_id_fn=lambda controller, obj_id: f"uptime-{obj_id}", value_fn=async_client_uptime_value_fn, ), + UnifiSensorEntityDescription[Wlans, Wlan]( + key="WLAN clients", + entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, + allowed_fn=lambda controller, _: True, + api_handler_fn=lambda api: api.wlans, + available_fn=lambda controller, obj_id: controller.available, + device_info_fn=async_wlan_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda client: None, + object_fn=lambda api, obj_id: api.wlans[obj_id], + should_poll=True, + supported_fn=lambda controller, _: True, + unique_id_fn=lambda controller, obj_id: f"wlan_clients-{obj_id}", + value_fn=async_wlan_client_value_fn, + ), ) diff --git a/homeassistant/components/unifi/services.yaml b/homeassistant/components/unifi/services.yaml index c6a4de3072a..fd69b8eb708 100644 --- a/homeassistant/components/unifi/services.yaml +++ b/homeassistant/components/unifi/services.yaml @@ -1,15 +1,9 @@ reconnect_client: - name: Reconnect wireless client - description: Try to get wireless client to reconnect to UniFi Network fields: device_id: - name: Device - description: Try reconnect client to wireless network required: true selector: device: integration: unifi remove_clients: - name: Remove clients from the UniFi Network - description: Clean up clients that has only been associated with the controller for a short period of time. diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index 02b64f3c50e..e441d4695ed 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -68,5 +68,21 @@ "title": "UniFi Network options 3/3" } } + }, + "services": { + "reconnect_client": { + "name": "Reconnect wireless client", + "description": "Tries to get wireless client to reconnect to UniFi Network.", + "fields": { + "device_id": { + "name": "[%key:common::config_flow::data::device%]", + "description": "Try reconnect client to wireless network." + } + } + }, + "remove_clients": { + "name": "Remove clients from the UniFi Network", + "description": "Cleans up clients that has only been associated with the controller for a short period of time." + } } } diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 846c6d12234..64e3ec2455c 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -3,6 +3,7 @@ Support for controlling power supply of clients which are powered over Ethernet (POE). Support for controlling network access of clients selected in option flow. Support for controlling deep packet inspection (DPI) restriction groups. +Support for controlling WLAN availability. """ from __future__ import annotations @@ -17,6 +18,7 @@ from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.ports import Ports +from aiounifi.interfaces.wlans import Wlans from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client, ClientBlockRequest from aiounifi.models.device import ( @@ -28,6 +30,7 @@ from aiounifi.models.dpi_restriction_group import DPIRestrictionGroup from aiounifi.models.event import Event, EventKey from aiounifi.models.outlet import Outlet from aiounifi.models.port import Port +from aiounifi.models.wlan import Wlan, WlanEnableRequest from homeassistant.components.switch import ( DOMAIN, @@ -54,6 +57,7 @@ from .entity import ( UnifiEntityDescription, async_device_available_fn, async_device_device_info_fn, + async_wlan_device_info_fn, ) CLIENT_BLOCKED = (EventKey.WIRED_CLIENT_BLOCKED, EventKey.WIRELESS_CLIENT_BLOCKED) @@ -137,6 +141,13 @@ async def async_poe_port_control_fn( await api.request(DeviceSetPoePortModeRequest.create(device, int(index), state)) +async def async_wlan_control_fn( + api: aiounifi.Controller, obj_id: str, target: bool +) -> None: + """Control outlet relay.""" + await api.request(WlanEnableRequest.create(obj_id, target)) + + @dataclass class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -175,6 +186,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( name_fn=lambda client: None, object_fn=lambda api, obj_id: api.clients[obj_id], only_event_for_state_change=True, + should_poll=False, supported_fn=lambda controller, obj_id: True, unique_id_fn=lambda controller, obj_id: f"block-{obj_id}", ), @@ -193,6 +205,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( is_on_fn=async_dpi_group_is_on_fn, name_fn=lambda group: group.name, object_fn=lambda api, obj_id: api.dpi_groups[obj_id], + should_poll=False, supported_fn=lambda c, obj_id: bool(c.api.dpi_groups[obj_id].dpiapp_ids), unique_id_fn=lambda controller, obj_id: obj_id, ), @@ -210,6 +223,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( is_on_fn=lambda controller, outlet: outlet.relay_state, name_fn=lambda outlet: outlet.name, object_fn=lambda api, obj_id: api.outlets[obj_id], + should_poll=False, supported_fn=lambda c, obj_id: c.api.outlets[obj_id].has_relay, unique_id_fn=lambda controller, obj_id: f"{obj_id.split('_', 1)[0]}-outlet-{obj_id.split('_', 1)[1]}", ), @@ -230,9 +244,30 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( is_on_fn=lambda controller, port: port.poe_mode != "off", name_fn=lambda port: f"{port.name} PoE", object_fn=lambda api, obj_id: api.ports[obj_id], + should_poll=False, supported_fn=lambda controller, obj_id: controller.api.ports[obj_id].port_poe, unique_id_fn=lambda controller, obj_id: f"{obj_id.split('_', 1)[0]}-poe-{obj_id.split('_', 1)[1]}", ), + UnifiSwitchEntityDescription[Wlans, Wlan]( + key="WLAN control", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + icon="mdi:wifi-check", + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.wlans, + available_fn=lambda controller, _: controller.available, + control_fn=async_wlan_control_fn, + device_info_fn=async_wlan_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + is_on_fn=lambda controller, wlan: wlan.enabled, + name_fn=lambda wlan: None, + object_fn=lambda api, obj_id: api.wlans[obj_id], + should_poll=False, + supported_fn=lambda controller, obj_id: True, + unique_id_fn=lambda controller, obj_id: f"wlan-{obj_id}", + ), ) diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index ea02b144a2f..661a9016bdc 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -74,6 +74,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiUpdateEntityDescription, ...] = ( event_to_subscribe=None, name_fn=lambda device: None, object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, state_fn=lambda api, device: device.state == 4, supported_fn=lambda controller, obj_id: True, unique_id_fn=lambda controller, obj_id: f"device_update-{obj_id}", diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index fe4399c4c6d..668fe479e1f 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -556,12 +556,13 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) - - self._attr_is_on = self.entity_description.get_ufp_value(self.device) + entity_description = self.entity_description + updated_device = self.device + self._attr_is_on = entity_description.get_ufp_value(updated_device) # UP Sense can be any of the 3 contact sensor device classes - if self.entity_description.key == _KEY_DOOR and isinstance(self.device, Sensor): - self.entity_description.device_class = MOUNT_DEVICE_CLASS_MAP.get( - self.device.mount_type, BinarySensorDeviceClass.DOOR + if entity_description.key == _KEY_DOOR and isinstance(updated_device, Sensor): + entity_description.device_class = MOUNT_DEVICE_CLASS_MAP.get( + updated_device.mount_type, BinarySensorDeviceClass.DOOR ) @@ -615,7 +616,7 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) - is_on = self.entity_description.get_is_on(device) + is_on = self.entity_description.get_is_on(self._event) self._attr_is_on: bool | None = is_on if not is_on: self._event = None diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 8c620402e77..3306743b707 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -183,7 +183,8 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity): super()._async_update_device_from_protect(device) if self.entity_description.key == KEY_ADOPT: - self._attr_available = self.device.can_adopt and self.device.can_create( + device = self.device + self._attr_available = device.can_adopt and device.can_create( self.data.api.bootstrap.auth_user ) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index a4da77fe50b..481d51ec529 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -115,7 +115,7 @@ async def async_setup_entry( async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: if not isinstance(device, UFPCamera): - return + return # type: ignore[unreachable] entities = _async_camera_entities(data, ufp_device=device) async_add_entities(entities) @@ -151,23 +151,25 @@ class ProtectCamera(ProtectDeviceEntity, Camera): self._disable_stream = disable_stream self._last_image: bytes | None = None super().__init__(data, camera) + device = self.device if self._secure: - self._attr_unique_id = f"{self.device.mac}_{self.channel.id}" - self._attr_name = f"{self.device.display_name} {self.channel.name}" + self._attr_unique_id = f"{device.mac}_{channel.id}" + self._attr_name = f"{device.display_name} {channel.name}" else: - self._attr_unique_id = f"{self.device.mac}_{self.channel.id}_insecure" - self._attr_name = f"{self.device.display_name} {self.channel.name} Insecure" + self._attr_unique_id = f"{device.mac}_{channel.id}_insecure" + self._attr_name = f"{device.display_name} {channel.name} Insecure" # only the default (first) channel is enabled by default self._attr_entity_registry_enabled_default = is_default and secure @callback def _async_set_stream_source(self) -> None: disable_stream = self._disable_stream - if not self.channel.is_rtsp_enabled: + channel = self.channel + + if not channel.is_rtsp_enabled: disable_stream = False - channel = self.channel rtsp_url = channel.rtsps_url if self._secure else channel.rtsp_url # _async_set_stream_source called by __init__ @@ -182,27 +184,30 @@ class ProtectCamera(ProtectDeviceEntity, Camera): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) - self.channel = self.device.channels[self.channel.id] - motion_enabled = self.device.recording_settings.enable_motion_detection + updated_device = self.device + channel = updated_device.channels[self.channel.id] + self.channel = channel + motion_enabled = updated_device.recording_settings.enable_motion_detection self._attr_motion_detection_enabled = ( motion_enabled if motion_enabled is not None else True ) self._attr_is_recording = ( - self.device.state == StateType.CONNECTED and self.device.is_recording + updated_device.state == StateType.CONNECTED and updated_device.is_recording ) is_connected = ( - self.data.last_update_success and self.device.state == StateType.CONNECTED + self.data.last_update_success + and updated_device.state == StateType.CONNECTED ) # some cameras have detachable lens that could cause the camera to be offline - self._attr_available = is_connected and self.device.is_video_ready + self._attr_available = is_connected and updated_device.is_video_ready self._async_set_stream_source() self._attr_extra_state_attributes = { - ATTR_WIDTH: self.channel.width, - ATTR_HEIGHT: self.channel.height, - ATTR_FPS: self.channel.fps, - ATTR_BITRATE: self.channel.bitrate, - ATTR_CHANNEL_ID: self.channel.id, + ATTR_WIDTH: channel.width, + ATTR_HEIGHT: channel.height, + ATTR_FPS: channel.fps, + ATTR_BITRATE: channel.bitrate, + ATTR_CHANNEL_ID: channel.id, } async def async_camera_image( diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 88c500f18fd..73d05f1be1d 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -178,7 +178,7 @@ class ProtectData: def _async_remove_device(self, device: ProtectAdoptableDeviceModel) -> None: registry = dr.async_get(self._hass) device_entry = registry.async_get_device( - identifiers=set(), connections={(dr.CONNECTION_NETWORK_MAC, device.mac)} + connections={(dr.CONNECTION_NETWORK_MAC, device.mac)} ) if device_entry: _LOGGER.debug("Device removed: %s", device.id) @@ -227,7 +227,7 @@ class ProtectData: self._async_update_device(obj, message.changed_data) # trigger updates for camera that the event references - elif isinstance(obj, Event): + elif isinstance(obj, Event): # type: ignore[unreachable] if obj.type in SMART_EVENTS: if obj.camera is not None: if obj.end is None: diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 15bd17554ad..a8a4c78465d 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Sequence import logging -from typing import Any +from typing import TYPE_CHECKING, Any from pyunifiprotect.data import ( NVR, @@ -57,7 +57,8 @@ def _async_device_entities( else data.get_by_types({model_type}, ignore_unadopted=False) ) for device in devices: - assert isinstance(device, (Camera, Light, Sensor, Viewer, Doorlock, Chime)) + if TYPE_CHECKING: + assert isinstance(device, (Camera, Light, Sensor, Viewer, Doorlock, Chime)) if not device.is_adopted_by_us: for description in unadopted_descs: entities.append( @@ -237,7 +238,8 @@ class ProtectDeviceEntity(Entity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: """Update Entity object from Protect device.""" - assert isinstance(device, ProtectAdoptableDeviceModel) + if TYPE_CHECKING: + assert isinstance(device, ProtectAdoptableDeviceModel) if last_update_success := self.data.last_update_success: self.device = device @@ -272,7 +274,7 @@ class ProtectNVREntity(ProtectDeviceEntity): """Base class for unifi protect entities.""" # separate subclass on purpose - device: NVR # type: ignore[assignment] + device: NVR def __init__( self, @@ -281,7 +283,7 @@ class ProtectNVREntity(ProtectDeviceEntity): description: EntityDescription | None = None, ) -> None: """Initialize the entity.""" - super().__init__(entry, device, description) # type: ignore[arg-type] + super().__init__(entry, device, description) @callback def _async_set_device_info(self) -> None: @@ -297,10 +299,12 @@ class ProtectNVREntity(ProtectDeviceEntity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: - if self.data.last_update_success: - self.device = self.data.api.bootstrap.nvr + data = self.data + last_update_success = data.last_update_success + if last_update_success: + self.device = data.api.bootstrap.nvr - self._attr_available = self.data.last_update_success + self._attr_available = last_update_success class EventEntityMixin(ProtectDeviceEntity): @@ -317,24 +321,15 @@ class EventEntityMixin(ProtectDeviceEntity): super().__init__(*args, **kwarg) self._event: Event | None = None - @callback - def _async_event_extra_attrs(self) -> dict[str, Any]: - attrs: dict[str, Any] = {} - - if self._event is None: - return attrs - - attrs[ATTR_EVENT_ID] = self._event.id - attrs[ATTR_EVENT_SCORE] = self._event.score - return attrs - @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + event = self.entity_description.get_event_obj(device) + if event is not None: + self._attr_extra_state_attributes = { + ATTR_EVENT_ID: event.id, + ATTR_EVENT_SCORE: event.score, + } + else: + self._attr_extra_state_attributes = {} + self._event = event super()._async_update_device_from_protect(device) - self._event = self.entity_description.get_event_obj(device) - - attrs = self.extra_state_attributes or {} - self._attr_extra_state_attributes = { - **attrs, - **self._async_event_extra_attrs(), - } diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 500b4b4703e..38ce73828c2 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -73,9 +73,10 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) - self._attr_is_on = self.device.is_light_on + updated_device = self.device + self._attr_is_on = updated_device.is_light_on self._attr_brightness = unifi_brightness_to_hass( - self.device.light_device_settings.led_level + updated_device.light_device_settings.led_level ) async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index 4fa9ebf4001..791a5e958ea 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -73,18 +73,19 @@ class ProtectLock(ProtectDeviceEntity, LockEntity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) + lock_status = self.device.lock_status self._attr_is_locked = False self._attr_is_locking = False self._attr_is_unlocking = False self._attr_is_jammed = False - if self.device.lock_status == LockStatusType.CLOSED: + if lock_status == LockStatusType.CLOSED: self._attr_is_locked = True - elif self.device.lock_status == LockStatusType.CLOSING: + elif lock_status == LockStatusType.CLOSING: self._attr_is_locking = True - elif self.device.lock_status == LockStatusType.OPENING: + elif lock_status == LockStatusType.OPENING: self._attr_is_unlocking = True - elif self.device.lock_status in ( + elif lock_status in ( LockStatusType.FAILED_WHILE_CLOSING, LockStatusType.FAILED_WHILE_OPENING, LockStatusType.JAMMED_WHILE_CLOSING, @@ -92,7 +93,7 @@ class ProtectLock(ProtectDeviceEntity, LockEntity): ): self._attr_is_jammed = True # lock is not fully initialized yet - elif self.device.lock_status != LockStatusType.OPEN: + elif lock_status != LockStatusType.OPEN: self._attr_available = False async def async_unlock(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index cfa90664f36..5f2f58ce98a 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.10.3", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.10.6", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index 4704c42762e..c3f4e58e247 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -98,21 +98,22 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) - self._attr_volume_level = float(self.device.speaker_settings.volume / 100) + updated_device = self.device + self._attr_volume_level = float(updated_device.speaker_settings.volume / 100) if ( - self.device.talkback_stream is not None - and self.device.talkback_stream.is_running + updated_device.talkback_stream is not None + and updated_device.talkback_stream.is_running ): self._attr_state = MediaPlayerState.PLAYING else: self._attr_state = MediaPlayerState.IDLE is_connected = self.data.last_update_success and ( - self.device.state == StateType.CONNECTED - or (not self.device.is_adopted_by_us and self.device.can_adopt) + updated_device.state == StateType.CONNECTED + or (not updated_device.is_adopted_by_us and updated_device.can_adopt) ) - self._attr_available = is_connected and self.device.feature_flags.has_speaker + self._attr_available = is_connected and updated_device.feature_flags.has_speaker async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index 8c688231628..c250a021340 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -3,10 +3,9 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass -from datetime import timedelta from enum import Enum import logging -from typing import Any, Generic, TypeVar, cast +from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast from pyunifiprotect.data import NVR, Event, ProtectAdoptableDeviceModel @@ -20,6 +19,15 @@ _LOGGER = logging.getLogger(__name__) T = TypeVar("T", bound=ProtectAdoptableDeviceModel | NVR) +def split_tuple(value: tuple[str, ...] | str | None) -> tuple[str, ...] | None: + """Split string to tuple.""" + if value is None: + return None + if TYPE_CHECKING: + assert isinstance(value, str) + return tuple(value.split(".")) + + class PermRequired(int, Enum): """Type of permission level required for entity.""" @@ -32,18 +40,34 @@ class PermRequired(int, Enum): class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): """Mixin for required keys.""" - ufp_required_field: str | None = None - ufp_value: str | None = None + # `ufp_required_field`, `ufp_value`, and `ufp_enabled` are defined as + # a `str` in the dataclass, but `__post_init__` converts it to a + # `tuple[str, ...]` to avoid doing it at run time in `get_nested_attr` + # which is usually called millions of times per day. + ufp_required_field: tuple[str, ...] | str | None = None + ufp_value: tuple[str, ...] | str | None = None ufp_value_fn: Callable[[T], Any] | None = None - ufp_enabled: str | None = None + ufp_enabled: tuple[str, ...] | str | None = None ufp_perm: PermRequired | None = None + def __post_init__(self) -> None: + """Pre-convert strings to tuples for faster get_nested_attr.""" + self.ufp_required_field = split_tuple(self.ufp_required_field) + self.ufp_value = split_tuple(self.ufp_value) + self.ufp_enabled = split_tuple(self.ufp_enabled) + def get_ufp_value(self, obj: T) -> Any: """Return value from UniFi Protect device.""" - if self.ufp_value is not None: - return get_nested_attr(obj, self.ufp_value) - if self.ufp_value_fn is not None: - return self.ufp_value_fn(obj) + if (ufp_value := self.ufp_value) is not None: + if TYPE_CHECKING: + # `ufp_value` is defined as a `str` in the dataclass, but + # `__post_init__` converts it to a `tuple[str, ...]` to avoid + # doing it at run time in `get_nested_attr` which is usually called + # millions of times per day. This tells mypy that it's a tuple. + assert isinstance(ufp_value, tuple) + return get_nested_attr(obj, ufp_value) + if (ufp_value_fn := self.ufp_value_fn) is not None: + return ufp_value_fn(obj) # reminder for future that one is required raise RuntimeError( # pragma: no cover @@ -52,16 +76,27 @@ class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): def get_ufp_enabled(self, obj: T) -> bool: """Return value from UniFi Protect device.""" - if self.ufp_enabled is not None: - return bool(get_nested_attr(obj, self.ufp_enabled)) + if (ufp_enabled := self.ufp_enabled) is not None: + if TYPE_CHECKING: + # `ufp_enabled` is defined as a `str` in the dataclass, but + # `__post_init__` converts it to a `tuple[str, ...]` to avoid + # doing it at run time in `get_nested_attr` which is usually called + # millions of times per day. This tells mypy that it's a tuple. + assert isinstance(ufp_enabled, tuple) + return bool(get_nested_attr(obj, ufp_enabled)) return True def has_required(self, obj: T) -> bool: """Return if has required field.""" - - if self.ufp_required_field is None: + if (ufp_required_field := self.ufp_required_field) is None: return True - return bool(get_nested_attr(obj, self.ufp_required_field)) + if TYPE_CHECKING: + # `ufp_required_field` is defined as a `str` in the dataclass, but + # `__post_init__` converts it to a `tuple[str, ...]` to avoid + # doing it at run time in `get_nested_attr` which is usually called + # millions of times per day. This tells mypy that it's a tuple. + assert isinstance(ufp_required_field, tuple) + return bool(get_nested_attr(obj, ufp_required_field)) @dataclass @@ -74,13 +109,11 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): """Return value from UniFi Protect device.""" if self.ufp_event_obj is not None: - return cast(Event, get_nested_attr(obj, self.ufp_event_obj)) + return cast(Event, getattr(obj, self.ufp_event_obj, None)) return None - def get_is_on(self, obj: T) -> bool: + def get_is_on(self, event: Event | None) -> bool: """Return value if event is active.""" - - event = self.get_event_obj(obj) if event is None: return False @@ -88,17 +121,7 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): value = now > event.start if value and event.end is not None and now > event.end: value = False - # only log if the recent ended recently - if event.end + timedelta(seconds=10) < now: - _LOGGER.debug( - "%s (%s): end ended at %s", - self.name, - obj.mac, - event.end.isoformat(), - ) - if value: - _LOGGER.debug("%s (%s): value is on", self.name, obj.mac) return value diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 753563023f4..26a03fb7967 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -356,15 +356,15 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) - + entity_description = self.entity_description # entities with categories are not exposed for voice # and safe to update dynamically if ( - self.entity_description.entity_category is not None - and self.entity_description.ufp_options_fn is not None + entity_description.entity_category is not None + and entity_description.ufp_options_fn is not None ): _LOGGER.debug( - "Updating dynamic select options for %s", self.entity_description.name + "Updating dynamic select options for %s", entity_description.name ) self._async_set_options() diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index dec6f10a57f..d842b13b015 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -710,15 +710,6 @@ class ProtectDeviceSensor(ProtectDeviceEntity, SensorEntity): entity_description: ProtectSensorEntityDescription - def __init__( - self, - data: ProtectData, - device: ProtectAdoptableDeviceModel, - description: ProtectSensorEntityDescription, - ) -> None: - """Initialize an UniFi Protect sensor.""" - super().__init__(data, device, description) - @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) @@ -730,15 +721,6 @@ class ProtectNVRSensor(ProtectNVREntity, SensorEntity): entity_description: ProtectSensorEntityDescription - def __init__( - self, - data: ProtectData, - device: NVR, - description: ProtectSensorEntityDescription, - ) -> None: - """Initialize an UniFi Protect sensor.""" - super().__init__(data, device, description) - @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) @@ -750,32 +732,22 @@ class ProtectEventSensor(EventEntityMixin, SensorEntity): entity_description: ProtectSensorEventEntityDescription - def __init__( - self, - data: ProtectData, - device: ProtectAdoptableDeviceModel, - description: ProtectSensorEventEntityDescription, - ) -> None: - """Initialize an UniFi Protect sensor.""" - super().__init__(data, device, description) - @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: # do not call ProtectDeviceSensor method since we want event to get value here EventEntityMixin._async_update_device_from_protect(self, device) - is_on = self.entity_description.get_is_on(device) + event = self._event + entity_description = self.entity_description + is_on = entity_description.get_is_on(event) is_license_plate = ( - self.entity_description.ufp_event_obj == "last_license_plate_detect_event" + entity_description.ufp_event_obj == "last_license_plate_detect_event" ) if ( not is_on - or self._event is None + or event is None or ( is_license_plate - and ( - self._event.metadata is None - or self._event.metadata.license_plate is None - ) + and (event.metadata is None or event.metadata.license_plate is None) ) ): self._attr_native_value = OBJECT_TYPE_NONE @@ -785,6 +757,6 @@ class ProtectEventSensor(EventEntityMixin, SensorEntity): if is_license_plate: # type verified above - self._attr_native_value = self._event.metadata.license_plate.name # type: ignore[union-attr] + self._attr_native_value = event.metadata.license_plate.name # type: ignore[union-attr] else: - self._attr_native_value = self._event.smart_detect_types[0].value + self._attr_native_value = event.smart_detect_types[0].value diff --git a/homeassistant/components/unifiprotect/services.yaml b/homeassistant/components/unifiprotect/services.yaml index 9f9031d6543..6998f540471 100644 --- a/homeassistant/components/unifiprotect/services.yaml +++ b/homeassistant/components/unifiprotect/services.yaml @@ -1,65 +1,42 @@ add_doorbell_text: - name: Add Custom Doorbell Text - description: Adds a new custom message for Doorbells. fields: device_id: - name: UniFi Protect NVR - description: Any device from the UniFi Protect instance you want to change. In case you have multiple Protect Instances. required: true selector: device: integration: unifiprotect message: - name: Custom Message - description: New custom message to add for Doorbells. Must be less than 30 characters. example: Come In required: true selector: text: remove_doorbell_text: - name: Remove Custom Doorbell Text - description: Removes an existing message for Doorbells. fields: device_id: - name: UniFi Protect NVR - description: Any device from the UniFi Protect instance you want to change. In case you have multiple Protect Instances. required: true selector: device: integration: unifiprotect message: - name: Custom Message - description: Existing custom message to remove for Doorbells. example: Go Away! required: true selector: text: set_default_doorbell_text: - name: Set Default Doorbell Text - description: Sets the default doorbell message. This will be the message that is automatically selected when a message "expires". fields: device_id: - name: UniFi Protect NVR - description: Any device from the UniFi Protect instance you want to change. In case you have multiple Protect Instances. required: true selector: device: integration: unifiprotect message: - name: Default Message - description: The default message for your Doorbell. Must be less than 30 characters. example: Welcome! required: true selector: text: set_chime_paired_doorbells: - name: Set Chime Paired Doorbells - description: > - Use to set the paired doorbell(s) with a smart chime. fields: device_id: - name: Chime - description: The Chimes to link to the doorbells to required: true selector: device: @@ -67,8 +44,6 @@ set_chime_paired_doorbells: entity: device_class: unifiprotect__chime_button doorbells: - name: Doorbells - description: The Doorbells to link to the chime example: "binary_sensor.front_doorbell_doorbell" required: false selector: diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index f8d578e1ca4..73ac6e08c17 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -66,7 +66,7 @@ "description": "You are using v{version} of UniFi Protect which is an Early Access version. [Early Access versions are not supported by Home Assistant](https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access) and it is recommended to go back to a stable release as soon as possible.\n\nBy submitting this form you have either [downgraded UniFi Protect](https://www.home-assistant.io/integrations/unifiprotect#downgrading-unifi-protect) or you agree to run an unsupported version of UniFi Protect." }, "confirm": { - "title": "v{version} is an Early Access version", + "title": "[%key:component::unifiprotect::issues::ea_warning::fix_flow::step::start::title%]", "description": "Are you sure you want to run unsupported versions of UniFi Protect? This may cause your Home Assistant integration to break." } } @@ -75,21 +75,6 @@ "ea_setup_failed": { "title": "Setup error using Early Access version", "description": "You are using v{version} of UniFi Protect which is an Early Access version. An unrecoverable error occurred while trying to load the integration. Please [downgrade to a stable version](https://www.home-assistant.io/integrations/unifiprotect#downgrading-unifi-protect) of UniFi Protect to continue using the integration.\n\nError: {error}" - }, - "deprecate_smart_sensor": { - "title": "Smart Detection Sensor Deprecated", - "description": "The unified \"Detected Object\" sensor for smart detections is now deprecated. It has been replaced with individual smart detection binary sensors for each smart detection type.\n\nBelow are the detected automations or scripts that use one or more of the deprecated entities:\n{items}\nThe above list may be incomplete and it does not include any template usages inside of dashboards. Please update any templates, automations or scripts accordingly." - }, - "deprecated_service_set_doorbell_message": { - "title": "set_doorbell_message is Deprecated", - "fix_flow": { - "step": { - "confirm": { - "title": "set_doorbell_message is Deprecated", - "description": "The `unifiprotect.set_doorbell_message` service is deprecated in favor of the new Doorbell Text entity added to each Doorbell device. It will be removed in v2023.3.0. Please update to use the [`text.set_value` service]({link})." - } - } - } } }, "entity": { @@ -100,5 +85,63 @@ } } } + }, + "services": { + "add_doorbell_text": { + "name": "Add custom doorbell text", + "description": "Adds a new custom message for doorbells.", + "fields": { + "device_id": { + "name": "UniFi Protect NVR", + "description": "Any device from the UniFi Protect instance you want to change. In case you have multiple Protect Instances." + }, + "message": { + "name": "Custom message", + "description": "New custom message to add for doorbells. Must be less than 30 characters." + } + } + }, + "remove_doorbell_text": { + "name": "Remove custom doorbell text", + "description": "Removes an existing message for doorbells.", + "fields": { + "device_id": { + "name": "[%key:component::unifiprotect::services::add_doorbell_text::fields::device_id::name%]", + "description": "[%key:component::unifiprotect::services::add_doorbell_text::fields::device_id::description%]" + }, + "message": { + "name": "[%key:component::unifiprotect::services::add_doorbell_text::fields::message::name%]", + "description": "Existing custom message to remove for doorbells." + } + } + }, + "set_default_doorbell_text": { + "name": "Set default doorbell text", + "description": "Sets the default doorbell message. This will be the message that is automatically selected when a message \"expires\".", + "fields": { + "device_id": { + "name": "[%key:component::unifiprotect::services::add_doorbell_text::fields::device_id::name%]", + "description": "[%key:component::unifiprotect::services::add_doorbell_text::fields::device_id::description%]" + }, + "message": { + "name": "Default message", + "description": "The default message for your doorbell. Must be less than 30 characters." + } + } + }, + "set_chime_paired_doorbells": { + "name": "Set chime paired doorbells", + "description": "Use to set the paired doorbell(s) with a smart chime.", + "fields": { + "device_id": { + "name": "Chime", + "description": "The chimes to link to the doorbells to." + }, + "doorbells": { + "name": "Doorbells", + "description": "The doorbells to link to the chime." + } + } + } } } diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index e0c56cfd5fc..3e2b5e1b19e 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -41,13 +41,13 @@ from .const import ( _SENTINEL = object() -def get_nested_attr(obj: Any, attr: str) -> Any: +def get_nested_attr(obj: Any, attrs: tuple[str, ...]) -> Any: """Fetch a nested attribute.""" - if "." not in attr: - value = getattr(obj, attr, None) + if len(attrs) == 1: + value = getattr(obj, attrs[0], None) else: value = obj - for key in attr.split("."): + for key in attrs: if (value := getattr(value, key, _SENTINEL)) is _SENTINEL: return None diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 68ce8e9b96c..c221a10284a 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -85,13 +85,15 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( + EventStateChangedData, TrackTemplate, + TrackTemplateResult, async_track_state_change_event, async_track_template_result, ) from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.service import async_call_from_config -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType ATTR_ACTIVE_CHILD = "active_child" @@ -183,13 +185,18 @@ class UniversalMediaPlayer(MediaPlayerEntity): """Subscribe to children and template state changes.""" @callback - def _async_on_dependency_update(event): + def _async_on_dependency_update( + event: EventType[EventStateChangedData], + ) -> None: """Update ha state when dependencies update.""" self.async_set_context(event.context) self.async_schedule_update_ha_state(True) @callback - def _async_on_template_update(event, updates): + def _async_on_template_update( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: """Update state when template state changes.""" for data in updates: template = data.template diff --git a/homeassistant/components/universal/services.yaml b/homeassistant/components/universal/services.yaml index e0af28bf3a6..c983a105c93 100644 --- a/homeassistant/components/universal/services.yaml +++ b/homeassistant/components/universal/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload all universal entities diff --git a/homeassistant/components/universal/strings.json b/homeassistant/components/universal/strings.json new file mode 100644 index 00000000000..a265a7c204c --- /dev/null +++ b/homeassistant/components/universal/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads universal media players from the YAML-configuration." + } + } +} diff --git a/homeassistant/components/upb/services.yaml b/homeassistant/components/upb/services.yaml index af8eb81d9b0..cf415705d72 100644 --- a/homeassistant/components/upb/services.yaml +++ b/homeassistant/components/upb/services.yaml @@ -1,29 +1,21 @@ light_fade_start: - name: Start light fade - description: Start fading a light either up or down from current brightness. target: entity: integration: upb domain: light fields: brightness: - name: Brightness - description: Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness. selector: number: min: 0 max: 255 brightness_pct: - name: Brightness percentage - description: Number indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness. selector: number: min: 0 max: 100 unit_of_measurement: "%" rate: - name: Rate - description: Rate for light to transition to new brightness default: -1 selector: number: @@ -33,24 +25,18 @@ light_fade_start: unit_of_measurement: seconds light_fade_stop: - name: Stop light fade - description: Stop a light fade. target: entity: integration: upb domain: light light_blink: - name: Blink light - description: Blink a light target: entity: integration: upb domain: light fields: rate: - name: Rate - description: Amount of time that the link flashes on. default: 0.5 selector: number: @@ -60,39 +46,29 @@ light_blink: unit_of_measurement: seconds link_deactivate: - name: Deactivate link - description: Deactivate a UPB scene. target: entity: integration: upb domain: light link_goto: - name: Go to link - description: Set scene to brightness. target: entity: integration: upb domain: scene fields: brightness: - name: Brightness - description: Number indicating brightness, where 0 turns the scene off, 1 is the minimum brightness and 255 is the maximum brightness. selector: number: min: 0 max: 255 brightness_pct: - name: Brightness percentage - description: Number indicating percentage of full brightness, where 0 turns the scene off, 1 is the minimum brightness and 100 is the maximum brightness. selector: number: min: 0 max: 100 unit_of_measurement: "%" rate: - name: Rate - description: Amount of time for scene to transition to new brightness selector: number: min: -1 @@ -101,31 +77,23 @@ link_goto: unit_of_measurement: seconds link_fade_start: - name: Start link fade - description: Start fading a link either up or down from current brightness. target: entity: integration: upb domain: scene fields: brightness: - name: Brightness - description: Number indicating brightness, where 0 turns the scene off, 1 is the minimum brightness and 255 is the maximum brightness. selector: number: min: 0 max: 255 brightness_pct: - name: Brightness percentage - description: Number indicating percentage of full brightness, where 0 turns the scene off, 1 is the minimum brightness and 100 is the maximum brightness. selector: number: min: 0 max: 100 unit_of_measurement: "%" rate: - name: Rate - description: Amount of time for scene to transition to new brightness selector: number: min: -1 @@ -134,24 +102,18 @@ link_fade_start: unit_of_measurement: seconds link_fade_stop: - name: Stop link fade - description: Stop a link fade. target: entity: integration: upb domain: scene link_blink: - name: Blink link - description: Blink a link. target: entity: integration: upb domain: scene fields: blink_rate: - name: Blink rate - description: Amount of time that the link flashes on. default: 0.5 selector: number: diff --git a/homeassistant/components/upb/strings.json b/homeassistant/components/upb/strings.json index 9b2cc0a1b12..7e4590d35a2 100644 --- a/homeassistant/components/upb/strings.json +++ b/homeassistant/components/upb/strings.json @@ -19,5 +19,93 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "services": { + "light_fade_start": { + "name": "Start light fade", + "description": "Starts fading a light either up or down from current brightness.", + "fields": { + "brightness": { + "name": "Brightness", + "description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness." + }, + "brightness_pct": { + "name": "Brightness percentage", + "description": "Number indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness." + }, + "rate": { + "name": "Rate", + "description": "Rate for light to transition to new brightness." + } + } + }, + "light_fade_stop": { + "name": "Stop light fade", + "description": "Stops a light fade." + }, + "light_blink": { + "name": "Blink light", + "description": "Blinks a light.", + "fields": { + "rate": { + "name": "[%key:component::upb::services::light_fade_start::fields::rate::name%]", + "description": "Amount of time that the link flashes on." + } + } + }, + "link_deactivate": { + "name": "Deactivate link", + "description": "Deactivates a UPB scene." + }, + "link_goto": { + "name": "Go to link", + "description": "Set scene to brightness.", + "fields": { + "brightness": { + "name": "Brightness", + "description": "Number indicating brightness, where 0 turns the scene off, 1 is the minimum brightness and 255 is the maximum brightness." + }, + "brightness_pct": { + "name": "[%key:component::upb::services::light_fade_start::fields::brightness_pct::name%]", + "description": "Number indicating percentage of full brightness, where 0 turns the scene off, 1 is the minimum brightness and 100 is the maximum brightness." + }, + "rate": { + "name": "[%key:component::upb::services::light_fade_start::fields::rate::name%]", + "description": "Amount of time for scene to transition to new brightness." + } + } + }, + "link_fade_start": { + "name": "Start link fade", + "description": "Starts fading a link either up or down from current brightness.", + "fields": { + "brightness": { + "name": "Brightness", + "description": "[%key:component::upb::services::link_goto::fields::brightness::description%]" + }, + "brightness_pct": { + "name": "[%key:component::upb::services::light_fade_start::fields::brightness_pct::name%]", + "description": "[%key:component::upb::services::link_goto::fields::brightness_pct::description%]" + }, + "rate": { + "name": "[%key:component::upb::services::light_fade_start::fields::rate::name%]", + "description": "[%key:component::upb::services::link_goto::fields::rate::description%]" + } + } + }, + "link_fade_stop": { + "name": "Stop link fade", + "description": "Stops a link fade." + }, + "link_blink": { + "name": "Blink link", + "description": "Blinks a link.", + "fields": { + "blink_rate": { + "name": "Blink rate", + "description": "[%key:component::upb::services::light_blink::fields::rate::description%]" + } + } + } } } diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index e0244034865..b9d01629536 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -3,13 +3,14 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +from enum import StrEnum +from functools import lru_cache import logging from typing import Any, Final, final from awesomeversion import AwesomeVersion, AwesomeVersionCompareException import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory @@ -182,6 +183,12 @@ class UpdateEntityDescription(EntityDescription): entity_category: EntityCategory | None = EntityCategory.CONFIG +@lru_cache(maxsize=256) +def _version_is_newer(latest_version: str, installed_version: str) -> bool: + """Return True if version is newer.""" + return AwesomeVersion(latest_version) > installed_version + + class UpdateEntity(RestoreEntity): """Representation of an update entity.""" @@ -355,7 +362,7 @@ class UpdateEntity(RestoreEntity): return STATE_OFF try: - newer = AwesomeVersion(latest_version) > installed_version + newer = _version_is_newer(latest_version, installed_version) return STATE_ON if newer else STATE_OFF except AwesomeVersionCompareException: # Can't compare versions, already tried exact match @@ -375,25 +382,25 @@ class UpdateEntity(RestoreEntity): else: in_progress = self.__in_progress + installed_version = self.installed_version + latest_version = self.latest_version + skipped_version = self.__skipped_version # Clear skipped version in case it matches the current installed # version or the latest version diverged. - if ( - self.installed_version is not None - and self.__skipped_version == self.installed_version - ) or ( - self.latest_version is not None - and self.__skipped_version != self.latest_version + if (installed_version is not None and skipped_version == installed_version) or ( + latest_version is not None and skipped_version != latest_version ): + skipped_version = None self.__skipped_version = None return { ATTR_AUTO_UPDATE: self.auto_update, - ATTR_INSTALLED_VERSION: self.installed_version, + ATTR_INSTALLED_VERSION: installed_version, ATTR_IN_PROGRESS: in_progress, - ATTR_LATEST_VERSION: self.latest_version, + ATTR_LATEST_VERSION: latest_version, ATTR_RELEASE_SUMMARY: release_summary, ATTR_RELEASE_URL: self.release_url, - ATTR_SKIPPED_VERSION: self.__skipped_version, + ATTR_SKIPPED_VERSION: skipped_version, ATTR_TITLE: self.title, } diff --git a/homeassistant/components/update/services.yaml b/homeassistant/components/update/services.yaml index 9b16dbd2713..036af10150a 100644 --- a/homeassistant/components/update/services.yaml +++ b/homeassistant/components/update/services.yaml @@ -1,34 +1,24 @@ install: - name: Install update - description: Install an update for this device or service target: entity: domain: update fields: version: - name: Version - description: Version to install, if omitted, the latest version will be installed. required: false example: "1.0.0" selector: text: backup: - name: Backup - description: Backup before installing the update, if supported by the integration. required: false selector: boolean: skip: - name: Skip update - description: Mark currently available update as skipped. target: entity: domain: update clear_skipped: - name: Clear skipped update - description: Removes the skipped version marker from an update. target: entity: domain: update diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index 518b8605aa7..1d238d3dd51 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -15,10 +15,28 @@ "name": "Firmware" } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "install": { + "name": "Install update", + "description": "Installs an update for this device or service.", + "fields": { + "version": { + "name": "Version", + "description": "The version to install. If omitted, the latest version will be installed." + }, + "backup": { + "name": "Backup", + "description": "If supported by the integration, this creates a backup before starting the update ." + } + } + }, + "skip": { + "name": "Skip update", + "description": "Marks currently available update as skipped." + }, + "clear_skipped": { + "name": "Clear skipped update", + "description": "Removes the skipped version marker from an update." } } } diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py index 030b0aa322e..0ab8962077b 100644 --- a/homeassistant/components/upnp/binary_sensor.py +++ b/homeassistant/components/upnp/binary_sensor.py @@ -28,7 +28,7 @@ class UpnpBinarySensorEntityDescription( SENSOR_DESCRIPTIONS: tuple[UpnpBinarySensorEntityDescription, ...] = ( UpnpBinarySensorEntityDescription( key=WAN_STATUS, - name="wan status", + translation_key="wan_status", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/upnp/entity.py b/homeassistant/components/upnp/entity.py index cd39609d9d5..a3d7709a5d5 100644 --- a/homeassistant/components/upnp/entity.py +++ b/homeassistant/components/upnp/entity.py @@ -25,6 +25,7 @@ class UpnpEntity(CoordinatorEntity[UpnpDataUpdateCoordinator]): """Base class for UPnP/IGD entities.""" entity_description: UpnpEntityDescription + _attr_has_entity_name = True def __init__( self, @@ -35,7 +36,6 @@ class UpnpEntity(CoordinatorEntity[UpnpDataUpdateCoordinator]): super().__init__(coordinator) self._device = coordinator.device self.entity_description = entity_description - self._attr_name = f"{coordinator.device.name} {entity_description.name}" self._attr_unique_id = f"{coordinator.device.original_udn}_{entity_description.unique_id or entity_description.key}" self._attr_device_info = DeviceInfo( connections=coordinator.device_entry.connections, diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 8112726607e..4b4f0358bb9 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.33.2", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.34.1", "getmac==0.8.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 6f0fe340f30..46d748f6939 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -49,7 +49,7 @@ class UpnpSensorEntityDescription(UpnpEntityDescription, SensorEntityDescription SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( UpnpSensorEntityDescription( key=BYTES_RECEIVED, - name=f"{UnitOfInformation.BYTES} received", + translation_key="data_received", icon="mdi:server-network", device_class=SensorDeviceClass.DATA_SIZE, native_unit_of_measurement=UnitOfInformation.BYTES, @@ -59,7 +59,7 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( ), UpnpSensorEntityDescription( key=BYTES_SENT, - name=f"{UnitOfInformation.BYTES} sent", + translation_key="data_sent", icon="mdi:server-network", device_class=SensorDeviceClass.DATA_SIZE, native_unit_of_measurement=UnitOfInformation.BYTES, @@ -69,7 +69,7 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( ), UpnpSensorEntityDescription( key=PACKETS_RECEIVED, - name=f"{DATA_PACKETS} received", + translation_key="packets_received", icon="mdi:server-network", native_unit_of_measurement=DATA_PACKETS, entity_registry_enabled_default=False, @@ -78,7 +78,7 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( ), UpnpSensorEntityDescription( key=PACKETS_SENT, - name=f"{DATA_PACKETS} sent", + translation_key="packets_sent", icon="mdi:server-network", native_unit_of_measurement=DATA_PACKETS, entity_registry_enabled_default=False, @@ -87,13 +87,13 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( ), UpnpSensorEntityDescription( key=ROUTER_IP, - name="External IP", + translation_key="external_ip", icon="mdi:server-network", entity_category=EntityCategory.DIAGNOSTIC, ), UpnpSensorEntityDescription( key=ROUTER_UPTIME, - name="Uptime", + translation_key="uptime", icon="mdi:server-network", native_unit_of_measurement=UnitOfTime.SECONDS, entity_registry_enabled_default=False, @@ -102,16 +102,16 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( ), UpnpSensorEntityDescription( key=WAN_STATUS, - name="wan status", + translation_key="wan_status", icon="mdi:server-network", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), UpnpSensorEntityDescription( key=BYTES_RECEIVED, + translation_key="download_speed", value_key=KIBIBYTES_PER_SEC_RECEIVED, unique_id="KiB/sec_received", - name=f"{UnitOfDataRate.KIBIBYTES_PER_SECOND} received", icon="mdi:server-network", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, @@ -120,9 +120,9 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( ), UpnpSensorEntityDescription( key=BYTES_SENT, + translation_key="upload_speed", value_key=KIBIBYTES_PER_SEC_SENT, unique_id="KiB/sec_sent", - name=f"{UnitOfDataRate.KIBIBYTES_PER_SECOND} sent", icon="mdi:server-network", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, @@ -131,9 +131,9 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( ), UpnpSensorEntityDescription( key=PACKETS_RECEIVED, + translation_key="packet_download_speed", value_key=PACKETS_PER_SEC_RECEIVED, unique_id="packets/sec_received", - name=f"{DATA_RATE_PACKETS_PER_SECOND} received", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, entity_registry_enabled_default=False, @@ -142,9 +142,9 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = ( ), UpnpSensorEntityDescription( key=PACKETS_SENT, + translation_key="packet_upload_speed", value_key=PACKETS_PER_SEC_SENT, unique_id="packets/sec_sent", - name=f"{DATA_RATE_PACKETS_PER_SECOND} sent", icon="mdi:server-network", native_unit_of_measurement=DATA_RATE_PACKETS_PER_SECOND, entity_registry_enabled_default=False, diff --git a/homeassistant/components/upnp/strings.json b/homeassistant/components/upnp/strings.json index 45d0c7de1c8..7ce1798c351 100644 --- a/homeassistant/components/upnp/strings.json +++ b/homeassistant/components/upnp/strings.json @@ -7,7 +7,7 @@ }, "user": { "data": { - "unique_id": "Device" + "unique_id": "[%key:common::config_flow::data::device%]" } } }, @@ -25,5 +25,47 @@ } } } + }, + "entity": { + "binary_sensor": { + "wan_status": { + "name": "[%key:component::upnp::entity::sensor::wan_status::name%]" + } + }, + "sensor": { + "data_received": { + "name": "Data received" + }, + "data_sent": { + "name": "Data sent" + }, + "packets_received": { + "name": "Packets received" + }, + "packets_sent": { + "name": "Packets sent" + }, + "external_ip": { + "name": "External IP" + }, + "uptime": { + "name": "Uptime" + }, + "packet_download_speed": { + "name": "Packet download speed" + }, + "packet_upload_speed": { + "name": "Packet upload speed" + }, + "download_speed": { + "name": "Download speed" + }, + "upload_speed": { + "name": "Upload speed" + }, + "wan_status": { + "name": "WAN status" + } + } } } diff --git a/homeassistant/components/uptime/strings.json b/homeassistant/components/uptime/strings.json index 3d374015acb..9ceb91de9ba 100644 --- a/homeassistant/components/uptime/strings.json +++ b/homeassistant/components/uptime/strings.json @@ -9,11 +9,5 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } - }, - "issues": { - "removed_yaml": { - "title": "The Uptime YAML configuration has been removed", - "description": "Configuring Uptime using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index 359e4c6831a..3cb119837d7 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -100,7 +100,7 @@ class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMon if stale_monitors := current_monitors - new_monitors: for monitor_id in stale_monitors: if device := self._device_registry.async_get_device( - {(DOMAIN, monitor_id)} + identifiers={(DOMAIN, monitor_id)} ): self._device_registry.async_remove_device(device.id) diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index 248212a8345..a4aeeb3151b 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -27,7 +27,6 @@ async def async_setup_entry( coordinator, BinarySensorEntityDescription( key=str(monitor.id), - name=monitor.friendly_name, device_class=BinarySensorDeviceClass.CONNECTIVITY, ), monitor=monitor, diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py index 7991525c2a0..d5caf36fa18 100644 --- a/homeassistant/components/uptimerobot/entity.py +++ b/homeassistant/components/uptimerobot/entity.py @@ -15,6 +15,8 @@ class UptimeRobotEntity(CoordinatorEntity[UptimeRobotDataUpdateCoordinator]): """Base UptimeRobot entity.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + _attr_name = None def __init__( self, diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index 219dd304dbd..f9d4097fe40 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -46,7 +46,6 @@ async def async_setup_entry( coordinator, SensorEntityDescription( key=str(monitor.id), - name=monitor.friendly_name, entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, options=["down", "not_checked_yet", "pause", "seems_down", "up"], diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json index 8fccc3cb9e9..588dc3ebf5c 100644 --- a/homeassistant/components/uptimerobot/strings.json +++ b/homeassistant/components/uptimerobot/strings.json @@ -35,7 +35,7 @@ "state": { "down": "Down", "not_checked_yet": "Not checked yet", - "pause": "Pause", + "pause": "[%key:common::action::pause%]", "seems_down": "Seems down", "up": "Up" } diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index 619f72ae47f..397d2085357 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -29,7 +29,6 @@ async def async_setup_entry( coordinator, SwitchEntityDescription( key=str(monitor.id), - name=f"{monitor.friendly_name} Active", device_class=SwitchDeviceClass.SWITCH, ), monitor=monitor, diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index f52b78b5a52..7301158d6c6 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -5,10 +5,9 @@ from dataclasses import dataclass from datetime import datetime, timedelta from decimal import Decimal, DecimalException, InvalidOperation import logging -from typing import Any +from typing import Any, Self from croniter import croniter -from typing_extensions import Self import voluptuous as vol from homeassistant.components.sensor import ( @@ -27,7 +26,7 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfEnergy, ) -from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import ( device_registry as dr, entity_platform, @@ -37,12 +36,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( + EventStateChangedData, async_track_point_in_time, async_track_state_change_event, ) from homeassistant.helpers.start import async_at_started from homeassistant.helpers.template import is_number -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from homeassistant.util import slugify import homeassistant.util.dt as dt_util @@ -452,7 +452,7 @@ class UtilityMeterSensor(RestoreSensor): return None @callback - def async_reading(self, event: Event): + def async_reading(self, event: EventType[EventStateChangedData]) -> None: """Handle the sensor state changes.""" if ( source_state := self.hass.states.get(self._sensor_source_id) @@ -463,8 +463,10 @@ class UtilityMeterSensor(RestoreSensor): self._attr_available = True - old_state: State | None = event.data.get("old_state") - new_state: State = event.data.get("new_state") # type: ignore[assignment] # a state change event always has a new state + old_state = event.data["old_state"] + new_state = event.data["new_state"] + if new_state is None: + return # First check if the new_state is valid (see discussion in PR #88446) if (new_state_val := self._validate_state(new_state)) is None: @@ -493,14 +495,14 @@ class UtilityMeterSensor(RestoreSensor): self.async_write_ha_state() @callback - def async_tariff_change(self, event): + def async_tariff_change(self, event: EventType[EventStateChangedData]) -> None: """Handle tariff changes.""" - if (new_state := event.data.get("new_state")) is None: + if (new_state := event.data["new_state"]) is None: return self._change_status(new_state.state) - def _change_status(self, tariff): + def _change_status(self, tariff: str) -> None: if self._tariff == tariff: self._collecting = async_track_state_change_event( self.hass, [self._sensor_source_id], self.async_reading diff --git a/homeassistant/components/utility_meter/services.yaml b/homeassistant/components/utility_meter/services.yaml index 4252f796199..918c51cee39 100644 --- a/homeassistant/components/utility_meter/services.yaml +++ b/homeassistant/components/utility_meter/services.yaml @@ -1,23 +1,17 @@ # Describes the format for available switch services reset: - name: Reset - description: Resets all counters of a utility meter. target: entity: domain: select calibrate: - name: Calibrate - description: Calibrates a utility meter sensor. target: entity: domain: sensor integration: utility_meter fields: value: - name: Value - description: Value to which set the meter example: "100" required: true selector: diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json index 1eeacbae800..f38989b536e 100644 --- a/homeassistant/components/utility_meter/strings.json +++ b/homeassistant/components/utility_meter/strings.json @@ -8,7 +8,7 @@ "data": { "cycle": "Meter reset cycle", "delta_values": "Delta values", - "name": "Name", + "name": "[%key:common::config_flow::data::name%]", "periodically_resetting": "Periodically resetting", "net_consumption": "Net consumption", "offset": "Meter reset offset", @@ -52,5 +52,21 @@ "yearly": "Yearly" } } + }, + "services": { + "reset": { + "name": "Reset", + "description": "Resets all counters of a utility meter." + }, + "calibrate": { + "name": "Calibrate", + "description": "Calibrates a utility meter sensor.", + "fields": { + "value": { + "name": "Value", + "description": "Value to which set the meter." + } + } + } } } diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index cf82836cbec..8285e1d76d1 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -1,6 +1,7 @@ """Support for vacuum cleaner robots (botvacs).""" from __future__ import annotations +import asyncio from collections.abc import Mapping from dataclasses import dataclass from datetime import timedelta @@ -22,8 +23,8 @@ from homeassistant.const import ( # noqa: F401 # STATE_PAUSED/IDLE are API STATE_ON, STATE_PAUSED, ) -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -36,6 +37,7 @@ from homeassistant.helpers.entity import ( ToggleEntityDescription, ) from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -77,19 +79,19 @@ DEFAULT_NAME = "Vacuum cleaner robot" class VacuumEntityFeature(IntFlag): """Supported features of the vacuum entity.""" - TURN_ON = 1 - TURN_OFF = 2 + TURN_ON = 1 # Deprecated, not supported by StateVacuumEntity + TURN_OFF = 2 # Deprecated, not supported by StateVacuumEntity PAUSE = 4 STOP = 8 RETURN_HOME = 16 FAN_SPEED = 32 BATTERY = 64 - STATUS = 128 + STATUS = 128 # Deprecated, not supported by StateVacuumEntity SEND_COMMAND = 256 LOCATE = 512 CLEAN_SPOT = 1024 MAP = 2048 - STATE = 4096 + STATE = 4096 # Must be set by vacuum platforms derived from StateVacuumEntity START = 8192 @@ -127,24 +129,73 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) - component.async_register_entity_service(SERVICE_TURN_ON, {}, "async_turn_on") - component.async_register_entity_service(SERVICE_TURN_OFF, {}, "async_turn_off") - component.async_register_entity_service(SERVICE_TOGGLE, {}, "async_toggle") component.async_register_entity_service( - SERVICE_START_PAUSE, {}, "async_start_pause" + SERVICE_TURN_ON, + {}, + "async_turn_on", + [VacuumEntityFeature.TURN_ON], ) - component.async_register_entity_service(SERVICE_START, {}, "async_start") - component.async_register_entity_service(SERVICE_PAUSE, {}, "async_pause") component.async_register_entity_service( - SERVICE_RETURN_TO_BASE, {}, "async_return_to_base" + SERVICE_TURN_OFF, + {}, + "async_turn_off", + [VacuumEntityFeature.TURN_OFF], + ) + component.async_register_entity_service( + SERVICE_TOGGLE, + {}, + "async_toggle", + [VacuumEntityFeature.TURN_OFF | VacuumEntityFeature.TURN_ON], + ) + # start_pause is a legacy service, only supported by VacuumEntity, and only needs + # VacuumEntityFeature.PAUSE + component.async_register_entity_service( + SERVICE_START_PAUSE, + {}, + "async_start_pause", + [VacuumEntityFeature.PAUSE], + ) + component.async_register_entity_service( + SERVICE_START, + {}, + "async_start", + [VacuumEntityFeature.START], + ) + component.async_register_entity_service( + SERVICE_PAUSE, + {}, + "async_pause", + [VacuumEntityFeature.PAUSE], + ) + component.async_register_entity_service( + SERVICE_RETURN_TO_BASE, + {}, + "async_return_to_base", + [VacuumEntityFeature.RETURN_HOME], + ) + component.async_register_entity_service( + SERVICE_CLEAN_SPOT, + {}, + "async_clean_spot", + [VacuumEntityFeature.CLEAN_SPOT], + ) + component.async_register_entity_service( + SERVICE_LOCATE, + {}, + "async_locate", + [VacuumEntityFeature.LOCATE], + ) + component.async_register_entity_service( + SERVICE_STOP, + {}, + "async_stop", + [VacuumEntityFeature.STOP], ) - component.async_register_entity_service(SERVICE_CLEAN_SPOT, {}, "async_clean_spot") - component.async_register_entity_service(SERVICE_LOCATE, {}, "async_locate") - component.async_register_entity_service(SERVICE_STOP, {}, "async_stop") component.async_register_entity_service( SERVICE_SET_FAN_SPEED, {vol.Required(ATTR_FAN_SPEED): cv.string}, "async_set_fan_speed", + [VacuumEntityFeature.FAN_SPEED], ) component.async_register_entity_service( SERVICE_SEND_COMMAND, @@ -153,6 +204,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.Optional(ATTR_PARAMS): vol.Any(dict, cv.ensure_list), }, "async_send_command", + [VacuumEntityFeature.SEND_COMMAND], ) return True @@ -317,6 +369,45 @@ class VacuumEntityDescription(ToggleEntityDescription): class VacuumEntity(_BaseVacuum, ToggleEntity): """Representation of a vacuum cleaner robot.""" + @callback + def add_to_platform_start( + self, + hass: HomeAssistant, + platform: EntityPlatform, + parallel_updates: asyncio.Semaphore | None, + ) -> None: + """Start adding an entity to a platform.""" + super().add_to_platform_start(hass, platform, parallel_updates) + # Don't report core integrations known to still use the deprecated base class; + # we don't worry about demo and mqtt has it's own deprecation warnings. + if self.platform.platform_name in ("demo", "mqtt"): + return + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_vacuum_base_class_{self.platform.platform_name}", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + is_persistent=False, + issue_domain=self.platform.platform_name, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_vacuum_base_class", + translation_placeholders={ + "platform": self.platform.platform_name, + }, + ) + _LOGGER.warning( + ( + "%s::%s is extending the deprecated base class VacuumEntity instead of " + "StateVacuumEntity, this is not valid and will be unsupported " + "from Home Assistant 2024.2. Please report it to the author of the '%s'" + " custom integration" + ), + self.platform.platform_name, + self.__class__.__name__, + self.platform.platform_name, + ) + entity_description: VacuumEntityDescription _attr_status: str | None = None @@ -379,12 +470,6 @@ class VacuumEntity(_BaseVacuum, ToggleEntity): """ await self.hass.async_add_executor_job(partial(self.start_pause, **kwargs)) - async def async_pause(self) -> None: - """Not supported.""" - - async def async_start(self) -> None: - """Not supported.""" - @dataclass class StateVacuumEntityDescription(EntityDescription): @@ -432,12 +517,3 @@ class StateVacuumEntity(_BaseVacuum): This method must be run in the event loop. """ await self.hass.async_add_executor_job(self.pause) - - async def async_turn_on(self, **kwargs: Any) -> None: - """Not supported.""" - - async def async_turn_off(self, **kwargs: Any) -> None: - """Not supported.""" - - async def async_toggle(self, **kwargs: Any) -> None: - """Not supported.""" diff --git a/homeassistant/components/vacuum/reproduce_state.py b/homeassistant/components/vacuum/reproduce_state.py index fbcc97445c8..4d0d6b4b12c 100644 --- a/homeassistant/components/vacuum/reproduce_state.py +++ b/homeassistant/components/vacuum/reproduce_state.py @@ -37,8 +37,8 @@ VALID_STATES_STATE = { STATE_CLEANING, STATE_DOCKED, STATE_IDLE, - STATE_RETURNING, STATE_PAUSED, + STATE_RETURNING, } diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index 26c8d745b27..aab35b42077 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -1,101 +1,87 @@ # Describes the format for available vacuum services turn_on: - name: Turn on - description: Start a new cleaning task. target: entity: domain: vacuum + supported_features: + - vacuum.VacuumEntityFeature.TURN_ON turn_off: - name: Turn off - description: Stop the current cleaning task and return to home. target: entity: domain: vacuum + supported_features: + - vacuum.VacuumEntityFeature.TURN_OFF stop: - name: Stop - description: Stop the current cleaning task. target: entity: domain: vacuum + supported_features: + - vacuum.VacuumEntityFeature.STOP locate: - name: Locate - description: Locate the vacuum cleaner robot. target: entity: domain: vacuum + supported_features: + - vacuum.VacuumEntityFeature.LOCATE start_pause: - name: Start/Pause - description: Start, pause, or resume the cleaning task. target: entity: domain: vacuum + supported_features: + - vacuum.VacuumEntityFeature.PAUSE start: - name: Start - description: Start or resume the cleaning task. target: entity: domain: vacuum + supported_features: + - vacuum.VacuumEntityFeature.START pause: - name: Pause - description: Pause the cleaning task. target: entity: domain: vacuum + supported_features: + - vacuum.VacuumEntityFeature.PAUSE return_to_base: - name: Return to base - description: Tell the vacuum cleaner to return to its dock. target: entity: domain: vacuum + supported_features: + - vacuum.VacuumEntityFeature.RETURN_HOME clean_spot: - name: Clean spot - description: Tell the vacuum cleaner to do a spot clean-up. target: entity: domain: vacuum send_command: - name: Send command - description: Send a raw command to the vacuum cleaner. target: entity: domain: vacuum fields: command: - name: Command - description: Command to execute. required: true example: "set_dnd_timer" selector: text: params: - name: Parameters - description: Parameters for the command. example: '{ "key": "value" }' selector: object: set_fan_speed: - name: Set fan speed - description: Set the fan speed of the vacuum cleaner. target: entity: domain: vacuum fields: fan_speed: - name: Fan speed - description: - Platform dependent vacuum cleaner fan speed, with speed steps, like - 'medium' or by percentage, between 0 and 100. required: true example: "low" selector: diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index e0db3ba4e47..73e50af5caa 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -30,9 +30,71 @@ } }, "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "deprecated_vacuum_base_class": { + "title": "The {platform} custom integration is using deprecated vacuum feature", + "description": "The custom integration `{platform}` is extending the deprecated base class `VacuumEntity` instead of `StateVacuumEntity`.\n\nPlease report it to the author of the `{platform}` custom integration.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." + } + }, + "services": { + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Starts a new cleaning task." + }, + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Stops the current cleaning task and returns to its dock." + }, + "stop": { + "name": "[%key:common::action::stop%]", + "description": "Stops the current cleaning task." + }, + "locate": { + "name": "Locate", + "description": "Locates the vacuum cleaner robot." + }, + "start_pause": { + "name": "Start/pause", + "description": "Starts, pauses, or resumes the cleaning task." + }, + "start": { + "name": "[%key:common::action::start%]", + "description": "Starts or resumes the cleaning task." + }, + "pause": { + "name": "[%key:common::action::pause%]", + "description": "Pauses the cleaning task." + }, + "return_to_base": { + "name": "Return to dock", + "description": "Tells the vacuum cleaner to return to its dock." + }, + "clean_spot": { + "name": "Clean spot", + "description": "Tells the vacuum cleaner to do a spot clean-up." + }, + "send_command": { + "name": "Send command", + "description": "Sends a command to the vacuum cleaner.", + "fields": { + "command": { + "name": "Command", + "description": "Command to execute. The commands are integration-specific." + }, + "params": { + "name": "Parameters", + "description": "Parameters for the command. The parameters are integration-specific." + } + } + }, + "set_fan_speed": { + "name": "Set fan speed", + "description": "Sets the fan speed of the vacuum cleaner.", + "fields": { + "fan_speed": { + "name": "Fan speed", + "description": "Fan speed. The value depends on the integration. Some integrations have speed steps, like 'medium'. Some use a percentage, between 0 and 100." + } + } } } } diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 6f8d00eb48c..473b9fa07d1 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -304,6 +304,8 @@ class ValloxServiceHandler: class ValloxEntity(CoordinatorEntity[ValloxDataUpdateCoordinator]): """Representation of a Vallox entity.""" + _attr_has_entity_name = True + def __init__(self, name: str, coordinator: ValloxDataUpdateCoordinator) -> None: """Initialize a Vallox entity.""" super().__init__(coordinator) diff --git a/homeassistant/components/vallox/binary_sensor.py b/homeassistant/components/vallox/binary_sensor.py index 2d40c43836d..05085c24424 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -21,7 +21,6 @@ class ValloxBinarySensorEntity(ValloxEntity, BinarySensorEntity): entity_description: ValloxBinarySensorEntityDescription _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_has_entity_name = True def __init__( self, @@ -59,7 +58,7 @@ class ValloxBinarySensorEntityDescription( BINARY_SENSOR_ENTITIES: tuple[ValloxBinarySensorEntityDescription, ...] = ( ValloxBinarySensorEntityDescription( key="post_heater", - name="Post heater", + translation_key="post_heater", icon="mdi:radiator", metric_key="A_CYC_IO_HEATER", ), diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index b43dabbba80..2f420096c74 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -83,7 +83,6 @@ async def async_setup_entry( class ValloxFanEntity(ValloxEntity, FanEntity): """Representation of the fan.""" - _attr_has_entity_name = True _attr_name = None _attr_supported_features = FanEntityFeature.PRESET_MODE | FanEntityFeature.SET_SPEED diff --git a/homeassistant/components/vallox/number.py b/homeassistant/components/vallox/number.py index 36145f85bc7..ce43ca9c3fb 100644 --- a/homeassistant/components/vallox/number.py +++ b/homeassistant/components/vallox/number.py @@ -23,7 +23,6 @@ class ValloxNumberEntity(ValloxEntity, NumberEntity): """Representation of a Vallox number entity.""" entity_description: ValloxNumberEntityDescription - _attr_has_entity_name = True _attr_entity_category = EntityCategory.CONFIG def __init__( @@ -76,7 +75,7 @@ class ValloxNumberEntityDescription(NumberEntityDescription, ValloxMetricMixin): NUMBER_ENTITIES: tuple[ValloxNumberEntityDescription, ...] = ( ValloxNumberEntityDescription( key="supply_air_target_home", - name="Supply air temperature (Home)", + translation_key="supply_air_target_home", metric_key="A_CYC_HOME_AIR_TEMP_TARGET", device_class=NumberDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -87,7 +86,7 @@ NUMBER_ENTITIES: tuple[ValloxNumberEntityDescription, ...] = ( ), ValloxNumberEntityDescription( key="supply_air_target_away", - name="Supply air temperature (Away)", + translation_key="supply_air_target_away", metric_key="A_CYC_AWAY_AIR_TEMP_TARGET", device_class=NumberDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -98,7 +97,7 @@ NUMBER_ENTITIES: tuple[ValloxNumberEntityDescription, ...] = ( ), ValloxNumberEntityDescription( key="supply_air_target_boost", - name="Supply air temperature (Boost)", + translation_key="supply_air_target_boost", metric_key="A_CYC_BOOST_AIR_TEMP_TARGET", device_class=NumberDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index a4f6563798d..ee0e1e43204 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -38,7 +38,6 @@ class ValloxSensorEntity(ValloxEntity, SensorEntity): entity_description: ValloxSensorEntityDescription _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_has_entity_name = True def __init__( self, @@ -138,13 +137,13 @@ class ValloxSensorEntityDescription(SensorEntityDescription): SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( ValloxSensorEntityDescription( key="current_profile", - name="Current profile", + translation_key="current_profile", icon="mdi:gauge", entity_type=ValloxProfileSensor, ), ValloxSensorEntityDescription( key="fan_speed", - name="Fan speed", + translation_key="fan_speed", metric_key="A_CYC_FAN_SPEED", icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, @@ -153,7 +152,7 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( ), ValloxSensorEntityDescription( key="extract_fan_speed", - name="Extract fan speed", + translation_key="extract_fan_speed", metric_key="A_CYC_EXTR_FAN_SPEED", icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, @@ -163,7 +162,7 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( ), ValloxSensorEntityDescription( key="supply_fan_speed", - name="Supply fan speed", + translation_key="supply_fan_speed", metric_key="A_CYC_SUPP_FAN_SPEED", icon="mdi:fan", state_class=SensorStateClass.MEASUREMENT, @@ -173,20 +172,20 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( ), ValloxSensorEntityDescription( key="remaining_time_for_filter", - name="Remaining time for filter", + translation_key="remaining_time_for_filter", device_class=SensorDeviceClass.TIMESTAMP, entity_type=ValloxFilterRemainingSensor, ), ValloxSensorEntityDescription( key="cell_state", - name="Cell state", + translation_key="cell_state", icon="mdi:swap-horizontal-bold", metric_key="A_CYC_CELL_STATE", entity_type=ValloxCellStateSensor, ), ValloxSensorEntityDescription( key="extract_air", - name="Extract air", + translation_key="extract_air", metric_key="A_CYC_TEMP_EXTRACT_AIR", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -194,7 +193,7 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( ), ValloxSensorEntityDescription( key="exhaust_air", - name="Exhaust air", + translation_key="exhaust_air", metric_key="A_CYC_TEMP_EXHAUST_AIR", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -202,7 +201,7 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( ), ValloxSensorEntityDescription( key="outdoor_air", - name="Outdoor air", + translation_key="outdoor_air", metric_key="A_CYC_TEMP_OUTDOOR_AIR", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -210,7 +209,7 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( ), ValloxSensorEntityDescription( key="supply_air", - name="Supply air", + translation_key="supply_air", metric_key="A_CYC_TEMP_SUPPLY_AIR", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -218,7 +217,7 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( ), ValloxSensorEntityDescription( key="supply_cell_air", - name="Supply cell air", + translation_key="supply_cell_air", metric_key="A_CYC_TEMP_SUPPLY_CELL_AIR", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -226,7 +225,7 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( ), ValloxSensorEntityDescription( key="optional_air", - name="Optional air", + translation_key="optional_air", metric_key="A_CYC_TEMP_OPTIONAL", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -235,7 +234,6 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( ), ValloxSensorEntityDescription( key="humidity", - name="Humidity", metric_key="A_CYC_RH_VALUE", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, @@ -243,7 +241,7 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( ), ValloxSensorEntityDescription( key="efficiency", - name="Efficiency", + translation_key="efficiency", metric_key="A_CYC_EXTRACT_EFFICIENCY", icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, @@ -253,7 +251,6 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = ( ), ValloxSensorEntityDescription( key="co2", - name="CO2", metric_key="A_CYC_CO2_VALUE", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/vallox/services.yaml b/homeassistant/components/vallox/services.yaml index 15ce6c88b55..e6bd3edad11 100644 --- a/homeassistant/components/vallox/services.yaml +++ b/homeassistant/components/vallox/services.yaml @@ -1,10 +1,6 @@ set_profile_fan_speed_home: - name: Set profile fan speed home - description: Set the fan speed of the Home profile. fields: fan_speed: - name: Fan speed - description: Fan speed. required: true selector: number: @@ -13,12 +9,8 @@ set_profile_fan_speed_home: unit_of_measurement: "%" set_profile_fan_speed_away: - name: Set profile fan speed away - description: Set the fan speed of the Away profile. fields: fan_speed: - name: Fan speed - description: Fan speed. required: true selector: number: @@ -27,12 +19,8 @@ set_profile_fan_speed_away: unit_of_measurement: "%" set_profile_fan_speed_boost: - name: Set profile fan speed boost - description: Set the fan speed of the Boost profile. fields: fan_speed: - name: Fan speed - description: Fan speed. required: true selector: number: diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json index cada5a7febd..acc6a31f158 100644 --- a/homeassistant/components/vallox/strings.json +++ b/homeassistant/components/vallox/strings.json @@ -18,5 +18,101 @@ "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "binary_sensor": { + "post_heater": { + "name": "Post heater" + } + }, + "number": { + "supply_air_target_home": { + "name": "Supply air temperature (Home)" + }, + "supply_air_target_away": { + "name": "Supply air temperature (Away)" + }, + "supply_air_target_boost": { + "name": "Supply air temperature (Boost)" + } + }, + "sensor": { + "current_profile": { + "name": "Current profile" + }, + "fan_speed": { + "name": "Fan speed" + }, + "extract_fan_speed": { + "name": "Extract fan speed" + }, + "supply_fan_speed": { + "name": "Supply fan speed" + }, + "remaining_time_for_filter": { + "name": "Remaining time for filter" + }, + "cell_state": { + "name": "Cell state" + }, + "extract_air": { + "name": "Extract air" + }, + "exhaust_air": { + "name": "Exhaust air" + }, + "outdoor_air": { + "name": "Outdoor air" + }, + "supply_air": { + "name": "Supply air" + }, + "supply_cell_air": { + "name": "Supply cell air" + }, + "optional_air": { + "name": "Optional air" + }, + "efficiency": { + "name": "Efficiency" + } + }, + "switch": { + "bypass_locked": { + "name": "Bypass locked" + } + } + }, + "services": { + "set_profile_fan_speed_home": { + "name": "Set profile fan speed home", + "description": "Sets the fan speed of the Home profile.", + "fields": { + "fan_speed": { + "name": "Fan speed", + "description": "Fan speed." + } + } + }, + "set_profile_fan_speed_away": { + "name": "Set profile fan speed away", + "description": "Sets the fan speed of the Away profile.", + "fields": { + "fan_speed": { + "name": "Fan speed", + "description": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::description%]" + } + } + }, + "set_profile_fan_speed_boost": { + "name": "Set profile fan speed boost", + "description": "Sets the fan speed of the Boost profile.", + "fields": { + "fan_speed": { + "name": "Fan speed", + "description": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::description%]" + } + } + } } } diff --git a/homeassistant/components/vallox/switch.py b/homeassistant/components/vallox/switch.py index 7e8cb4e39c5..194659d40cd 100644 --- a/homeassistant/components/vallox/switch.py +++ b/homeassistant/components/vallox/switch.py @@ -21,7 +21,6 @@ class ValloxSwitchEntity(ValloxEntity, SwitchEntity): entity_description: ValloxSwitchEntityDescription _attr_entity_category = EntityCategory.CONFIG - _attr_has_entity_name = True def __init__( self, @@ -79,7 +78,7 @@ class ValloxSwitchEntityDescription(SwitchEntityDescription, ValloxMetricKeyMixi SWITCH_ENTITIES: tuple[ValloxSwitchEntityDescription, ...] = ( ValloxSwitchEntityDescription( key="bypass_locked", - name="Bypass locked", + translation_key="bypass_locked", icon="mdi:arrow-horizontal-lock", metric_key="A_CYC_BYPASS_LOCKED", ), diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml index 32cda00f708..e3ecc3556f0 100644 --- a/homeassistant/components/velbus/services.yaml +++ b/homeassistant/components/velbus/services.yaml @@ -1,10 +1,6 @@ sync_clock: - name: Sync clock - description: Sync the velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink fields: interface: - name: Interface - description: The velbus interface to send the command to, this will be the same value as used during configuration required: true example: "192.168.1.5:27015" default: "" @@ -12,12 +8,8 @@ sync_clock: text: scan: - name: Scan - description: Scan the velbus modules, this will be need if you see unknown module warnings in the logs, or when you added new modules fields: interface: - name: Interface - description: The velbus interface to send the command to, this will be the same value as used during configuration required: true example: "192.168.1.5:27015" default: "" @@ -25,22 +17,14 @@ scan: text: clear_cache: - name: Clear cache - description: Clears the velbuscache and then starts a new scan fields: interface: - name: Interface - description: The velbus interface to send the command to, this will be the same value as used during configuration required: true example: "192.168.1.5:27015" default: "" selector: text: address: - name: Address - description: > - The module address in decimal format, if this is provided we only clear this module, if nothing is provided we clear the whole cache directory (all modules) - The decimal addresses are displayed in front of the modules listed at the integration page. required: false selector: number: @@ -48,34 +32,20 @@ clear_cache: max: 254 set_memo_text: - name: Set memo text - description: > - Set the memo text to the display of modules like VMBGPO, VMBGPOD - Be sure the page(s) of the module is configured to display the memo text. fields: interface: - name: Interface - description: The velbus interface to send the command to, this will be the same value as used during configuration required: true example: "192.168.1.5:27015" default: "" selector: text: address: - name: Address - description: > - The module address in decimal format. - The decimal addresses are displayed in front of the modules listed at the integration page. required: true selector: number: min: 1 max: 254 memo_text: - name: Memo text - description: > - The actual text to be displayed. - Text is limited to 64 characters. example: "Do not forget trash" default: "" selector: diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 6eb44d8cb0c..948c079444d 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -16,5 +16,59 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "services": { + "sync_clock": { + "name": "Sync clock", + "description": "Syncs the velbus modules clock to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", + "fields": { + "interface": { + "name": "Interface", + "description": "The velbus interface to send the command to, this will be the same value as used during configuration." + } + } + }, + "scan": { + "name": "Scan", + "description": "Scans the velbus modules, this will be need if you see unknown module warnings in the logs, or when you added new modules.", + "fields": { + "interface": { + "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" + } + } + }, + "clear_cache": { + "name": "Clear cache", + "description": "Clears the velbuscache and then starts a new scan.", + "fields": { + "interface": { + "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" + }, + "address": { + "name": "Address", + "description": "The module address in decimal format, if this is provided we only clear this module, if nothing is provided we clear the whole cache directory (all modules) The decimal addresses are displayed in front of the modules listed at the integration page.\n." + } + } + }, + "set_memo_text": { + "name": "Set memo text", + "description": "Sets the memo text to the display of modules like VMBGPO, VMBGPOD Be sure the page(s) of the module is configured to display the memo text.\n.", + "fields": { + "interface": { + "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", + "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" + }, + "address": { + "name": "Address", + "description": "The module address in decimal format. The decimal addresses are displayed in front of the modules listed at the integration page.\n." + }, + "memo_text": { + "name": "Memo text", + "description": "The actual text to be displayed. Text is limited to 64 characters.\n." + } + } + } } } diff --git a/homeassistant/components/velux/services.yaml b/homeassistant/components/velux/services.yaml index 46aee795890..7aee1694061 100644 --- a/homeassistant/components/velux/services.yaml +++ b/homeassistant/components/velux/services.yaml @@ -1,5 +1,3 @@ # Velux Integration services reboot_gateway: - name: Reboot gateway - description: Reboots the KLF200 Gateway. diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json new file mode 100644 index 00000000000..6a7e8c6e1ec --- /dev/null +++ b/homeassistant/components/velux/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reboot_gateway": { + "name": "Reboot gateway", + "description": "Reboots the KLF200 Gateway." + } + } +} diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index 48760a8bfc0..4b2d2955832 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import update_coordinator +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import _LOGGER, DOMAIN, VENSTAR_SLEEP, VENSTAR_TIMEOUT @@ -143,12 +144,12 @@ class VenstarEntity(CoordinatorEntity[VenstarDataUpdateCoordinator]): self.async_write_ha_state() @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device information for this entity.""" - return { - "identifiers": {(DOMAIN, self._config.entry_id)}, - "name": self._client.name, - "manufacturer": "Venstar", - "model": f"{self._client.model}-{self._client.get_type()}", - "sw_version": self._client.get_api_ver(), - } + return DeviceInfo( + identifiers={(DOMAIN, self._config.entry_id)}, + name=self._client.name, + manufacturer="Venstar", + model=f"{self._client.model}-{self._client.get_type()}", + sw_version=self._client.get_api_ver(), + ) diff --git a/homeassistant/components/vera/strings.json b/homeassistant/components/vera/strings.json index 4e51177910c..3bfb58f8104 100644 --- a/homeassistant/components/vera/strings.json +++ b/homeassistant/components/vera/strings.json @@ -23,8 +23,8 @@ "title": "Vera controller options", "description": "See the vera documentation for details on optional parameters: https://www.home-assistant.io/integrations/vera/. Note: Any changes here will need a restart to the home assistant server. To clear values, provide a space.", "data": { - "lights": "Vera switch device ids to treat as lights in Home Assistant.", - "exclude": "Vera device ids to exclude from Home Assistant." + "lights": "[%key:component::vera::config::step::user::data::lights%]", + "exclude": "[%key:component::vera::config::step::user::data::exclude%]" } } } diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py index 1f890a22a64..90ad926aeb7 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -47,6 +47,7 @@ class VerisureSmartcam(CoordinatorEntity[VerisureDataUpdateCoordinator], Camera) """Representation of a Verisure camera.""" _attr_has_entity_name = True + _attr_name = None def __init__( self, diff --git a/homeassistant/components/verisure/coordinator.py b/homeassistant/components/verisure/coordinator.py index 47fbde3ef20..bc3b68922b0 100644 --- a/homeassistant/components/verisure/coordinator.py +++ b/homeassistant/components/verisure/coordinator.py @@ -7,7 +7,6 @@ from time import sleep from verisure import ( Error as VerisureError, LoginError as VerisureLoginError, - ResponseError as VerisureResponseError, Session as Verisure, ) @@ -50,7 +49,7 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): except VerisureLoginError as ex: LOGGER.error("Could not log in to verisure, %s", ex) raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex - except VerisureResponseError as ex: + except VerisureError as ex: LOGGER.error("Could not log in to verisure, %s", ex) return False @@ -65,11 +64,9 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): try: await self.hass.async_add_executor_job(self.verisure.update_cookie) except VerisureLoginError as ex: - LOGGER.error("Credentials expired for Verisure, %s", ex) raise ConfigEntryAuthFailed("Credentials expired for Verisure") from ex - except VerisureResponseError as ex: - LOGGER.error("Could not log in to verisure, %s", ex) - raise ConfigEntryAuthFailed("Could not log in to verisure") from ex + except VerisureError as ex: + raise UpdateFailed("Unable to update cookie") from ex try: overview = await self.hass.async_add_executor_job( self.verisure.request, @@ -81,13 +78,6 @@ class VerisureDataUpdateCoordinator(DataUpdateCoordinator): self.verisure.smart_lock(), self.verisure.smartplugs(), ) - except VerisureResponseError as err: - LOGGER.debug("Cookie expired or service unavailable, %s", err) - overview = self._overview - try: - await self.hass.async_add_executor_job(self.verisure.update_cookie) - except VerisureResponseError as ex: - raise ConfigEntryAuthFailed("Credentials for Verisure expired.") from ex except VerisureError as err: LOGGER.error("Could not read overview, %s", err) raise UpdateFailed("Could not read overview") from err diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 53646c1e435..6af64060ab5 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -60,6 +60,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt """Representation of a Verisure doorlock.""" _attr_has_entity_name = True + _attr_name = None def __init__( self, coordinator: VerisureDataUpdateCoordinator, serial_number: str diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 66dccdc07de..98440f67e4c 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["verisure"], - "requirements": ["vsure==2.6.1"] + "requirements": ["vsure==2.6.4"] } diff --git a/homeassistant/components/verisure/services.yaml b/homeassistant/components/verisure/services.yaml index 2a4e2a008be..ccfc7726bc6 100644 --- a/homeassistant/components/verisure/services.yaml +++ b/homeassistant/components/verisure/services.yaml @@ -1,22 +1,16 @@ capture_smartcam: - name: Capture SmartCam image - description: Capture a new image from a Verisure SmartCam target: entity: integration: verisure domain: camera enable_autolock: - name: Enable autolock - description: Enable autolock of a Verisure Lockguard Smartlock target: entity: integration: verisure domain: lock disable_autolock: - name: Disable autolock - description: Disable autolock of a Verisure Lockguard Smartlock target: entity: integration: verisure diff --git a/homeassistant/components/verisure/strings.json b/homeassistant/components/verisure/strings.json index 85b3f4015b5..f715529b36b 100644 --- a/homeassistant/components/verisure/strings.json +++ b/homeassistant/components/verisure/strings.json @@ -29,8 +29,8 @@ }, "reauth_mfa": { "data": { - "description": "Your account has 2-step verification enabled. Please enter the verification code Verisure sends to you.", - "code": "Verification Code" + "description": "[%key:component::verisure::config::step::mfa::data::description%]", + "code": "[%key:component::verisure::config::step::mfa::data::code%]" } } }, @@ -63,5 +63,19 @@ "name": "Ethernet status" } } + }, + "services": { + "capture_smartcam": { + "name": "Capture SmartCam image", + "description": "Captures a new image from a Verisure SmartCam." + }, + "enable_autolock": { + "name": "Enable autolock", + "description": "Enables autolock of a Verisure Lockguard Smartlock." + }, + "disable_autolock": { + "name": "Disable autolock", + "description": "Disables autolock of a Verisure Lockguard Smartlock." + } } } diff --git a/homeassistant/components/version/strings.json b/homeassistant/components/version/strings.json index 299ab753cb9..36da7072626 100644 --- a/homeassistant/components/version/strings.json +++ b/homeassistant/components/version/strings.json @@ -1,4 +1,5 @@ { + "title": "Version", "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 752a65ff051..8e6ad545bd0 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -51,11 +51,12 @@ async def async_process_devices(hass, manager): class VeSyncBaseEntity(Entity): """Base class for VeSync Entity Representations.""" + _attr_has_entity_name = True + def __init__(self, device: VeSyncBaseDevice) -> None: """Initialize the VeSync device.""" self.device = device self._attr_unique_id = self.base_unique_id - self._attr_name = self.base_name @property def base_unique_id(self): @@ -67,12 +68,6 @@ class VeSyncBaseEntity(Entity): return f"{self.device.cid}{str(self.device.sub_device_no)}" return self.device.cid - @property - def base_name(self) -> str: - """Return the name of the device.""" - # Same story here as `base_unique_id` above - return self.device.device_name - @property def available(self) -> bool: """Return True if device is available.""" @@ -83,9 +78,9 @@ class VeSyncBaseEntity(Entity): """Return device information.""" return DeviceInfo( identifiers={(DOMAIN, self.base_unique_id)}, - name=self.base_name, + name=self.device.device_name, model=self.device.device_type, - default_manufacturer="VeSync", + manufacturer="VeSync", sw_version=self.device.current_firm_version, ) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index f89224aaba8..a3bf027c28f 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -87,6 +87,7 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): """Representation of a VeSync fan.""" _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_name = None def __init__(self, fan): """Initialize the VeSync fan device.""" diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index 9129d060cdc..e6cc979e808 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -66,6 +66,8 @@ def _setup_entities(devices, async_add_entities): class VeSyncBaseLight(VeSyncDevice, LightEntity): """Base class for VeSync Light Devices Representations.""" + _attr_name = None + @property def brightness(self) -> int: """Get light brightness.""" diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index bc0db04dd47..f3612c2d011 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -79,7 +79,7 @@ PM25_SUPPORTED = ["Core300S", "Core400S", "Core600S"] SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( VeSyncSensorEntityDescription( key="filter-life", - name="Filter Life", + translation_key="filter_life", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -88,13 +88,12 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( ), VeSyncSensorEntityDescription( key="air-quality", - name="Air Quality", + translation_key="air_quality", value_fn=lambda device: device.details["air_quality"], exists_fn=lambda device: sku_supported(device, AIR_QUALITY_SUPPORTED), ), VeSyncSensorEntityDescription( key="pm25", - name="PM2.5", device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, @@ -103,7 +102,7 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( ), VeSyncSensorEntityDescription( key="power", - name="current power", + translation_key="current_power", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -113,7 +112,7 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( ), VeSyncSensorEntityDescription( key="energy", - name="energy use today", + translation_key="energy_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -123,7 +122,7 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( ), VeSyncSensorEntityDescription( key="energy-weekly", - name="energy use weekly", + translation_key="energy_week", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -133,7 +132,7 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( ), VeSyncSensorEntityDescription( key="energy-monthly", - name="energy use monthly", + translation_key="energy_month", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -143,7 +142,7 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( ), VeSyncSensorEntityDescription( key="energy-yearly", - name="energy use yearly", + translation_key="energy_year", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -153,7 +152,7 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( ), VeSyncSensorEntityDescription( key="voltage", - name="current voltage", + translation_key="current_voltage", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, @@ -207,7 +206,6 @@ class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity): """Initialize the VeSync outlet device.""" super().__init__(device) self.entity_description = description - self._attr_name = f"{super().name} {description.name}" self._attr_unique_id = f"{super().unique_id}-{description.key}" @property diff --git a/homeassistant/components/vesync/services.yaml b/homeassistant/components/vesync/services.yaml index da264ea3b5d..52ee0382dbe 100644 --- a/homeassistant/components/vesync/services.yaml +++ b/homeassistant/components/vesync/services.yaml @@ -1,3 +1 @@ update_devices: - name: Update devices - description: Add new VeSync devices to Home Assistant diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 8359691effe..5ff0aa58722 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -15,5 +15,39 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "entity": { + "sensor": { + "filter_life": { + "name": "Filter lifetime" + }, + "air_quality": { + "name": "Air quality" + }, + "current_power": { + "name": "Current power" + }, + "energy_today": { + "name": "Energy use today" + }, + "energy_week": { + "name": "Energy use weekly" + }, + "energy_month": { + "name": "Energy use monthly" + }, + "energy_year": { + "name": "Energy use yearly" + }, + "current_voltage": { + "name": "Current voltage" + } + } + }, + "services": { + "update_devices": { + "name": "Update devices", + "description": "Adds new VeSync devices to Home Assistant." + } } } diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index 93cb5c67a5d..e6101b2ba51 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -54,6 +54,8 @@ def _setup_entities(devices, async_add_entities): class VeSyncBaseSwitch(VeSyncDevice, SwitchEntity): """Base class for VeSync switch Device Representations.""" + _attr_name = None + def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" self.device.turn_on() diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index b177a4c524f..269695a668d 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -2,8 +2,10 @@ from __future__ import annotations from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass import logging +import os from PyViCare.PyViCare import PyViCare from PyViCare.PyViCareDevice import Device @@ -25,6 +27,7 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) +_TOKEN_FILENAME = "vicare_token.save" @dataclass() @@ -64,7 +67,7 @@ def vicare_login(hass, entry_data): entry_data[CONF_USERNAME], entry_data[CONF_PASSWORD], entry_data[CONF_CLIENT_ID], - hass.config.path(STORAGE_DIR, "vicare_token.save"), + hass.config.path(STORAGE_DIR, _TOKEN_FILENAME), ) return vicare_api @@ -93,4 +96,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + with suppress(FileNotFoundError): + await hass.async_add_executor_job( + os.remove, hass.config.path(STORAGE_DIR, _TOKEN_FILENAME) + ) + return unload_ok diff --git a/homeassistant/components/vicare/services.yaml b/homeassistant/components/vicare/services.yaml index 1fc1e61b6ee..b4df8a1bb0e 100644 --- a/homeassistant/components/vicare/services.yaml +++ b/homeassistant/components/vicare/services.yaml @@ -1,14 +1,10 @@ set_vicare_mode: - name: Set vicare mode - description: Set a ViCare mode. target: entity: integration: vicare domain: climate fields: vicare_mode: - name: Vicare Mode - description: ViCare mode. required: true selector: select: diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index d54956f3e10..0700d5d6f0e 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -19,5 +19,17 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "services": { + "set_vicare_mode": { + "name": "Set ViCare mode", + "description": "Set a ViCare mode.", + "fields": { + "vicare_mode": { + "name": "ViCare mode", + "description": "ViCare mode." + } + } + } } } diff --git a/homeassistant/components/vilfo/const.py b/homeassistant/components/vilfo/const.py index 5ed9bc3efdd..e562add4e0f 100644 --- a/homeassistant/components/vilfo/const.py +++ b/homeassistant/components/vilfo/const.py @@ -1,11 +1,6 @@ """Constants for the Vilfo Router integration.""" from __future__ import annotations -from dataclasses import dataclass - -from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription -from homeassistant.const import PERCENTAGE - DOMAIN = "vilfo" ATTR_API_DATA_FIELD_LOAD = "load" @@ -17,33 +12,3 @@ ROUTER_DEFAULT_HOST = "admin.vilfo.com" ROUTER_DEFAULT_MODEL = "Vilfo Router" ROUTER_DEFAULT_NAME = "Vilfo Router" ROUTER_MANUFACTURER = "Vilfo AB" - - -@dataclass -class VilfoRequiredKeysMixin: - """Mixin for required keys.""" - - api_key: str - - -@dataclass -class VilfoSensorEntityDescription(SensorEntityDescription, VilfoRequiredKeysMixin): - """Describes Vilfo sensor entity.""" - - -SENSOR_TYPES: tuple[VilfoSensorEntityDescription, ...] = ( - VilfoSensorEntityDescription( - key=ATTR_LOAD, - name="Load", - native_unit_of_measurement=PERCENTAGE, - icon="mdi:memory", - api_key=ATTR_API_DATA_FIELD_LOAD, - ), - VilfoSensorEntityDescription( - key=ATTR_BOOT_TIME, - name="Boot time", - icon="mdi:timer-outline", - api_key=ATTR_API_DATA_FIELD_BOOT_TIME, - device_class=SensorDeviceClass.TIMESTAMP, - ), -) diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py index b6339cea0d6..7bdba371f49 100644 --- a/homeassistant/components/vilfo/sensor.py +++ b/homeassistant/components/vilfo/sensor.py @@ -1,16 +1,55 @@ """Support for Vilfo Router sensors.""" -from homeassistant.components.sensor import SensorEntity +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + ATTR_API_DATA_FIELD_BOOT_TIME, + ATTR_API_DATA_FIELD_LOAD, + ATTR_BOOT_TIME, + ATTR_LOAD, DOMAIN, ROUTER_DEFAULT_MODEL, ROUTER_DEFAULT_NAME, ROUTER_MANUFACTURER, - SENSOR_TYPES, - VilfoSensorEntityDescription, +) + + +@dataclass +class VilfoRequiredKeysMixin: + """Mixin for required keys.""" + + api_key: str + + +@dataclass +class VilfoSensorEntityDescription(SensorEntityDescription, VilfoRequiredKeysMixin): + """Describes Vilfo sensor entity.""" + + +SENSOR_TYPES: tuple[VilfoSensorEntityDescription, ...] = ( + VilfoSensorEntityDescription( + key=ATTR_LOAD, + name="Load", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + api_key=ATTR_API_DATA_FIELD_LOAD, + ), + VilfoSensorEntityDescription( + key=ATTR_BOOT_TIME, + name="Boot time", + icon="mdi:timer-outline", + api_key=ATTR_API_DATA_FIELD_BOOT_TIME, + device_class=SensorDeviceClass.TIMESTAMP, + ), ) diff --git a/homeassistant/components/vizio/services.yaml b/homeassistant/components/vizio/services.yaml index 7a2ea859b7d..2f5da4659f0 100644 --- a/homeassistant/components/vizio/services.yaml +++ b/homeassistant/components/vizio/services.yaml @@ -1,32 +1,20 @@ update_setting: - name: Update setting - description: Update the value of a setting on a Vizio media player device. target: entity: integration: vizio domain: media_player fields: setting_type: - name: Setting type - description: - The type of setting to be changed. Available types are listed in the - 'setting_types' property. required: true example: "audio" selector: text: setting_name: - name: Setting name - description: - The name of the setting to be changed. Available settings for a given - setting_type are listed in the '_settings' property. required: true example: "eq" selector: text: new_value: - name: New value - description: The new value for the setting. required: true example: "Music" selector: diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 665e03b531a..0ff64eeda53 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -23,7 +23,7 @@ "description": "Your VIZIO SmartCast Device is now connected to Home Assistant." }, "pairing_complete_import": { - "title": "Pairing Complete", + "title": "[%key:component::vizio::config::step::pairing_complete::title%]", "description": "Your VIZIO SmartCast Device is now connected to Home Assistant.\n\nYour access token is '**{access_token}**'." } }, @@ -50,5 +50,25 @@ } } } + }, + "services": { + "update_setting": { + "name": "Update setting", + "description": "Updates the value of a setting on a Vizio media player device.", + "fields": { + "setting_type": { + "name": "Setting type", + "description": "The type of setting to be changed. Available types are listed in the 'setting_types' property." + }, + "setting_name": { + "name": "Setting name", + "description": "The name of the setting to be changed. Available settings for a given setting_type are listed in the '[setting_type]_settings' property." + }, + "new_value": { + "name": "New value", + "description": "The new value for the setting." + } + } + } } } diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index 80b9d75303b..14728c05e53 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -70,6 +70,8 @@ def catch_vlc_errors( class VlcDevice(MediaPlayerEntity): """Representation of a vlc player.""" + _attr_has_entity_name = True + _attr_name = None _attr_media_content_type = MediaType.MUSIC _attr_supported_features = ( MediaPlayerEntityFeature.CLEAR_PLAYLIST @@ -91,7 +93,6 @@ class VlcDevice(MediaPlayerEntity): ) -> None: """Initialize the vlc device.""" self._config_entry = config_entry - self._name = name self._volume: float | None = None self._muted: bool | None = None self._media_position_updated_at: datetime | None = None @@ -183,11 +184,6 @@ class VlcDevice(MediaPlayerEntity): if self._media_title and (pos := self._media_title.find("?authSig=")) != -1: self._media_title = self._media_title[:pos] - @property - def name(self) -> str: - """Return the name of the device.""" - return self._name - @property def available(self) -> bool: """Return True if entity is available.""" diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 1d586198c5a..880d02cfeae 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -49,6 +49,8 @@ async def async_setup_entry( class Volumio(MediaPlayerEntity): """Volumio Player Object.""" + _attr_has_entity_name = True + _attr_name = None _attr_media_content_type = MediaType.MUSIC _attr_supported_features = ( MediaPlayerEntityFeature.PAUSE @@ -89,11 +91,6 @@ class Volumio(MediaPlayerEntity): """Return the unique id for the entity.""" return self._uid - @property - def name(self): - """Return the name of the entity.""" - return self._name - @property def device_info(self) -> DeviceInfo: """Return device info for this device.""" @@ -101,7 +98,7 @@ class Volumio(MediaPlayerEntity): identifiers={(DOMAIN, self.unique_id)}, manufacturer="Volumio", model=self._info["hardware"], - name=self.name, + name=self._name, sw_version=self._info["systemversion"], ) diff --git a/homeassistant/components/vulcan/calendar.py b/homeassistant/components/vulcan/calendar.py index d9182bb9905..791ae9ee7c4 100644 --- a/homeassistant/components/vulcan/calendar.py +++ b/homeassistant/components/vulcan/calendar.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import date, datetime, timedelta import logging +from zoneinfo import ZoneInfo from aiohttp import ClientConnectorError from vulcan import UnauthorizedCertificateException @@ -16,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.entity import DeviceInfo, generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN @@ -64,19 +65,19 @@ class VulcanCalendarEntity(CalendarEntity): self._unique_id = f"vulcan_calendar_{self.student_info['id']}" self._attr_name = f"Vulcan calendar - {self.student_info['full_name']}" self._attr_unique_id = f"vulcan_calendar_{self.student_info['id']}" - self._attr_device_info = { - "identifiers": {(DOMAIN, f"calendar_{self.student_info['id']}")}, - "entry_type": DeviceEntryType.SERVICE, - "name": f"{self.student_info['full_name']}: Calendar", - "model": ( + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"calendar_{self.student_info['id']}")}, + entry_type=DeviceEntryType.SERVICE, + name=f"{self.student_info['full_name']}: Calendar", + model=( f"{self.student_info['full_name']} -" f" {self.student_info['class']} {self.student_info['school']}" ), - "manufacturer": "Uonet +", - "configuration_url": ( + manufacturer="Uonet +", + configuration_url=( f"https://uonetplus.vulcan.net.pl/{self.student_info['symbol']}" ), - } + ) @property def event(self) -> CalendarEvent | None: @@ -107,8 +108,12 @@ class VulcanCalendarEntity(CalendarEntity): event_list = [] for item in events: event = CalendarEvent( - start=datetime.combine(item["date"], item["time"].from_), - end=datetime.combine(item["date"], item["time"].to), + start=datetime.combine(item["date"], item["time"].from_).astimezone( + ZoneInfo("Europe/Warsaw") + ), + end=datetime.combine(item["date"], item["time"].to).astimezone( + ZoneInfo("Europe/Warsaw") + ), summary=item["lesson"], location=item["room"], description=item["teacher"], @@ -156,8 +161,12 @@ class VulcanCalendarEntity(CalendarEntity): ), ) self._event = CalendarEvent( - start=datetime.combine(new_event["date"], new_event["time"].from_), - end=datetime.combine(new_event["date"], new_event["time"].to), + start=datetime.combine( + new_event["date"], new_event["time"].from_ + ).astimezone(ZoneInfo("Europe/Warsaw")), + end=datetime.combine(new_event["date"], new_event["time"].to).astimezone( + ZoneInfo("Europe/Warsaw") + ), summary=new_event["lesson"], location=new_event["room"], description=new_event["teacher"], diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json index bb9e1d4d848..b2b270e3422 100644 --- a/homeassistant/components/vulcan/strings.json +++ b/homeassistant/components/vulcan/strings.json @@ -25,11 +25,11 @@ } }, "reauth_confirm": { - "description": "Login to your Vulcan Account using mobile app registration page.", + "description": "[%key:component::vulcan::config::step::auth::description%]", "data": { "token": "Token", - "region": "Symbol", - "pin": "Pin" + "region": "[%key:component::vulcan::config::step::auth::data::region%]", + "pin": "[%key:component::vulcan::config::step::auth::data::pin%]" } }, "select_student": { diff --git a/homeassistant/components/wake_on_lan/services.yaml b/homeassistant/components/wake_on_lan/services.yaml index ea374a88b8f..48d3df5c4f9 100644 --- a/homeassistant/components/wake_on_lan/services.yaml +++ b/homeassistant/components/wake_on_lan/services.yaml @@ -1,23 +1,15 @@ send_magic_packet: - name: Send magic packet - description: Send a 'magic packet' to wake up a device with 'Wake-On-LAN' capabilities. fields: mac: - name: MAC address - description: MAC address of the device to wake up. required: true example: "aa:bb:cc:dd:ee:ff" selector: text: broadcast_address: - name: Broadcast address - description: Broadcast IP where to send the magic packet. example: 192.168.255.255 selector: text: broadcast_port: - name: Broadcast port - description: Port where to send the magic packet. default: 9 selector: number: diff --git a/homeassistant/components/wake_on_lan/strings.json b/homeassistant/components/wake_on_lan/strings.json new file mode 100644 index 00000000000..8395bc7503a --- /dev/null +++ b/homeassistant/components/wake_on_lan/strings.json @@ -0,0 +1,22 @@ +{ + "services": { + "send_magic_packet": { + "name": "Send magic packet", + "description": "Sends a 'magic packet' to wake up a device with 'Wake-On-LAN' capabilities.", + "fields": { + "mac": { + "name": "MAC address", + "description": "MAC address of the device to wake up." + }, + "broadcast_address": { + "name": "Broadcast address", + "description": "Broadcast IP where to send the magic packet." + }, + "broadcast_port": { + "name": "Broadcast port", + "description": "Port where to send the magic packet." + } + } + } + } +} diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index a6f92541a10..9bab8232dab 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -1,5 +1,5 @@ """Constants for the Wallbox integration.""" -from homeassistant.backports.enum import StrEnum +from enum import StrEnum DOMAIN = "wallbox" diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 3cd9378cfca..b31d1306c55 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -61,6 +61,7 @@ class WaterHeaterEntityFeature(IntFlag): TARGET_TEMPERATURE = 1 OPERATION_MODE = 2 AWAY_MODE = 4 + ON_OFF = 8 # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. @@ -116,6 +117,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await component.async_setup(config) + component.async_register_entity_service( + SERVICE_TURN_ON, {}, "async_turn_on", [WaterHeaterEntityFeature.ON_OFF] + ) + component.async_register_entity_service( + SERVICE_TURN_OFF, {}, "async_turn_off", [WaterHeaterEntityFeature.ON_OFF] + ) component.async_register_entity_service( SERVICE_SET_AWAY_MODE, SET_AWAY_MODE_SCHEMA, async_service_away_mode ) @@ -294,6 +301,22 @@ class WaterHeaterEntity(Entity): ft.partial(self.set_temperature, **kwargs) ) + def turn_on(self, **kwargs: Any) -> None: + """Turn the water heater on.""" + raise NotImplementedError() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the water heater on.""" + await self.hass.async_add_executor_job(ft.partial(self.turn_on, **kwargs)) + + def turn_off(self, **kwargs: Any) -> None: + """Turn the water heater off.""" + raise NotImplementedError() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the water heater off.""" + await self.hass.async_add_executor_job(ft.partial(self.turn_off, **kwargs)) + def set_operation_mode(self, operation_mode: str) -> None: """Set new target operation mode.""" raise NotImplementedError() diff --git a/homeassistant/components/water_heater/services.yaml b/homeassistant/components/water_heater/services.yaml index a3b372f219e..b60cfdd8c48 100644 --- a/homeassistant/components/water_heater/services.yaml +++ b/homeassistant/components/water_heater/services.yaml @@ -1,29 +1,21 @@ # Describes the format for available water_heater services set_away_mode: - name: Set away mode - description: Turn away mode on/off for water_heater device. target: entity: domain: water_heater fields: away_mode: - name: Away mode - description: New value of away mode. required: true selector: boolean: set_temperature: - name: Set temperature - description: Set target temperature of water_heater device. target: entity: domain: water_heater fields: temperature: - name: Temperature - description: New target temperature for water heater. required: true selector: number: @@ -32,23 +24,27 @@ set_temperature: step: 0.5 unit_of_measurement: "°" operation_mode: - name: Operation mode - description: New value of operation mode. example: eco selector: text: set_operation_mode: - name: Set operation mode - description: Set operation mode for water_heater device. target: entity: domain: water_heater fields: operation_mode: - name: Operation mode - description: New value of operation mode. required: true example: eco selector: text: + +turn_on: + target: + entity: + domain: water_heater + +turn_off: + target: + entity: + domain: water_heater diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index b0784279667..5ddb61d28b0 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -1,8 +1,8 @@ { "device_automation": { "action_type": { - "turn_on": "Turn on {entity_name}", - "turn_off": "Turn off {entity_name}" + "turn_on": "[%key:common::device_automation::action_type::turn_on%]", + "turn_off": "[%key:common::device_automation::action_type::turn_off%]" } }, "entity_component": { @@ -19,10 +19,48 @@ } } }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" + "services": { + "set_away_mode": { + "name": "Set away mode", + "description": "Turns away mode on/off.", + "fields": { + "away_mode": { + "name": "Away mode", + "description": "New value of away mode." + } + } + }, + "set_temperature": { + "name": "Set temperature", + "description": "Sets the target temperature.", + "fields": { + "temperature": { + "name": "Temperature", + "description": "New target temperature for the water heater." + }, + "operation_mode": { + "name": "Operation mode", + "description": "New value of the operation mode. For a list of possible modes, refer to the integration documentation." + } + } + }, + "set_operation_mode": { + "name": "Set operation mode", + "description": "Sets the operation mode.", + "fields": { + "operation_mode": { + "name": "[%key:component::water_heater::services::set_temperature::fields::operation_mode::name%]", + "description": "[%key:component::water_heater::services::set_temperature::fields::operation_mode::description%]" + } + } + }, + "turn_on": { + "name": "[%key:common::action::turn_on%]", + "description": "Turns water heater on." + }, + "turn_off": { + "name": "[%key:common::action::turn_off%]", + "description": "Turns water heater off." } } } diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index c4a6a0ad777..f0c32f2d8cc 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -1,14 +1,13 @@ """Weather component that handles meteorological data for your location.""" from __future__ import annotations +from collections.abc import Callable, Iterable from contextlib import suppress from dataclasses import dataclass from datetime import timedelta import inspect import logging -from typing import Any, Final, TypedDict, final - -from typing_extensions import Required +from typing import Any, Final, Literal, Required, TypedDict, final from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -19,7 +18,7 @@ from homeassistant.const import ( UnitOfSpeed, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -29,7 +28,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from .const import ( +from .const import ( # noqa: F401 ATTR_WEATHER_APPARENT_TEMPERATURE, ATTR_WEATHER_CLOUD_COVERAGE, ATTR_WEATHER_DEW_POINT, @@ -40,6 +39,7 @@ from .const import ( ATTR_WEATHER_PRESSURE_UNIT, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_UV_INDEX, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_VISIBILITY_UNIT, ATTR_WEATHER_WIND_BEARING, @@ -49,6 +49,7 @@ from .const import ( DOMAIN, UNIT_CONVERSIONS, VALID_UNITS, + WeatherEntityFeature, ) from .websocket_api import async_setup as async_setup_ws_api @@ -71,6 +72,7 @@ ATTR_CONDITION_SUNNY = "sunny" ATTR_CONDITION_WINDY = "windy" ATTR_CONDITION_WINDY_VARIANT = "windy-variant" ATTR_FORECAST = "forecast" +ATTR_FORECAST_IS_DAYTIME: Final = "is_daytime" ATTR_FORECAST_CONDITION: Final = "condition" ATTR_FORECAST_HUMIDITY: Final = "humidity" ATTR_FORECAST_NATIVE_PRECIPITATION: Final = "native_precipitation" @@ -93,6 +95,7 @@ ATTR_FORECAST_WIND_SPEED: Final = "wind_speed" ATTR_FORECAST_NATIVE_DEW_POINT: Final = "native_dew_point" ATTR_FORECAST_DEW_POINT: Final = "dew_point" ATTR_FORECAST_CLOUD_COVERAGE: Final = "cloud_coverage" +ATTR_FORECAST_UV_INDEX: Final = "uv_index" ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -146,6 +149,8 @@ class Forecast(TypedDict, total=False): native_wind_speed: float | None wind_speed: None native_dew_point: float | None + uv_index: float | None + is_daytime: bool | None # Mandatory to use with forecast_twice_daily async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -180,10 +185,13 @@ class WeatherEntity(Entity): entity_description: WeatherEntityDescription _attr_condition: str | None + # _attr_forecast is deprecated, implement async_forecast_daily, + # async_forecast_hourly or async_forecast_twice daily instead _attr_forecast: list[Forecast] | None = None _attr_humidity: float | None = None _attr_ozone: float | None = None _attr_cloud_coverage: int | None = None + _attr_uv_index: float | None = None _attr_precision: float _attr_pressure: None = ( None # Provide backwards compatibility. Use _attr_native_pressure @@ -228,6 +236,11 @@ class WeatherEntity(Entity): _attr_native_wind_speed_unit: str | None = None _attr_native_dew_point: float | None = None + _forecast_listeners: dict[ + Literal["daily", "hourly", "twice_daily"], + list[Callable[[list[dict[str, Any]] | None], None]], + ] + _weather_option_temperature_unit: str | None = None _weather_option_pressure_unit: str | None = None _weather_option_visibility_unit: str | None = None @@ -287,8 +300,9 @@ class WeatherEntity(Entity): ) async def async_internal_added_to_hass(self) -> None: - """Call when the sensor entity is added to hass.""" + """Call when the weather entity is added to hass.""" await super().async_internal_added_to_hass() + self._forecast_listeners = {"daily": [], "hourly": [], "twice_daily": []} if not self.registry_entry: return self.async_registry_entry_updated() @@ -503,6 +517,11 @@ class WeatherEntity(Entity): """Return the Cloud coverage in %.""" return self._attr_cloud_coverage + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return self._attr_uv_index + @final @property def visibility(self) -> float | None: @@ -562,9 +581,24 @@ class WeatherEntity(Entity): @property def forecast(self) -> list[Forecast] | None: - """Return the forecast in native units.""" + """Return the forecast in native units. + + Should not be overridden by integrations. Kept for backwards compatibility. + """ return self._attr_forecast + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + raise NotImplementedError + + async def async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the daily forecast in native units.""" + raise NotImplementedError + + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + raise NotImplementedError + @property def native_precipitation_unit(self) -> str | None: """Return the native unit of measurement for accumulated precipitation.""" @@ -680,6 +714,9 @@ class WeatherEntity(Entity): if (cloud_coverage := self.cloud_coverage) is not None: data[ATTR_WEATHER_CLOUD_COVERAGE] = cloud_coverage + if (uv_index := self.uv_index) is not None: + data[ATTR_WEATHER_UV_INDEX] = uv_index + if (pressure := self.native_pressure) is not None: from_unit = self.native_pressure_unit or self._default_pressure_unit to_unit = self._pressure_unit @@ -744,197 +781,197 @@ class WeatherEntity(Entity): data[ATTR_WEATHER_VISIBILITY_UNIT] = self._visibility_unit data[ATTR_WEATHER_PRECIPITATION_UNIT] = self._precipitation_unit - if self.forecast is not None: - forecast: list[dict[str, Any]] = [] - for existing_forecast_entry in self.forecast: - forecast_entry: dict[str, Any] = dict(existing_forecast_entry) - - temperature = forecast_entry.pop( - ATTR_FORECAST_NATIVE_TEMP, forecast_entry.get(ATTR_FORECAST_TEMP) - ) - - from_temp_unit = ( - self.native_temperature_unit or self._default_temperature_unit - ) - to_temp_unit = self._temperature_unit - - if temperature is None: - forecast_entry[ATTR_FORECAST_TEMP] = None - else: - with suppress(TypeError, ValueError): - temperature_f = float(temperature) - value_temp = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( - temperature_f, - from_temp_unit, - to_temp_unit, - ) - forecast_entry[ATTR_FORECAST_TEMP] = round_temperature( - value_temp, precision - ) - - if ( - forecast_apparent_temp := forecast_entry.pop( - ATTR_FORECAST_NATIVE_APPARENT_TEMP, - forecast_entry.get(ATTR_FORECAST_NATIVE_APPARENT_TEMP), - ) - ) is not None: - with suppress(TypeError, ValueError): - forecast_apparent_temp = float(forecast_apparent_temp) - value_apparent_temp = UNIT_CONVERSIONS[ - ATTR_WEATHER_TEMPERATURE_UNIT - ]( - forecast_apparent_temp, - from_temp_unit, - to_temp_unit, - ) - - forecast_entry[ATTR_FORECAST_APPARENT_TEMP] = round_temperature( - value_apparent_temp, precision - ) - - if ( - forecast_temp_low := forecast_entry.pop( - ATTR_FORECAST_NATIVE_TEMP_LOW, - forecast_entry.get(ATTR_FORECAST_TEMP_LOW), - ) - ) is not None: - with suppress(TypeError, ValueError): - forecast_temp_low_f = float(forecast_temp_low) - value_temp_low = UNIT_CONVERSIONS[ - ATTR_WEATHER_TEMPERATURE_UNIT - ]( - forecast_temp_low_f, - from_temp_unit, - to_temp_unit, - ) - - forecast_entry[ATTR_FORECAST_TEMP_LOW] = round_temperature( - value_temp_low, precision - ) - - if ( - forecast_dew_point := forecast_entry.pop( - ATTR_FORECAST_NATIVE_DEW_POINT, - None, - ) - ) is not None: - with suppress(TypeError, ValueError): - forecast_dew_point_f = float(forecast_dew_point) - value_dew_point = UNIT_CONVERSIONS[ - ATTR_WEATHER_TEMPERATURE_UNIT - ]( - forecast_dew_point_f, - from_temp_unit, - to_temp_unit, - ) - - forecast_entry[ATTR_FORECAST_DEW_POINT] = round_temperature( - value_dew_point, precision - ) - - if ( - forecast_pressure := forecast_entry.pop( - ATTR_FORECAST_NATIVE_PRESSURE, - forecast_entry.get(ATTR_FORECAST_PRESSURE), - ) - ) is not None: - from_pressure_unit = ( - self.native_pressure_unit or self._default_pressure_unit - ) - to_pressure_unit = self._pressure_unit - with suppress(TypeError, ValueError): - forecast_pressure_f = float(forecast_pressure) - forecast_entry[ATTR_FORECAST_PRESSURE] = round( - UNIT_CONVERSIONS[ATTR_WEATHER_PRESSURE_UNIT]( - forecast_pressure_f, - from_pressure_unit, - to_pressure_unit, - ), - ROUNDING_PRECISION, - ) - - if ( - forecast_wind_gust_speed := forecast_entry.pop( - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, - None, - ) - ) is not None: - from_wind_speed_unit = ( - self.native_wind_speed_unit or self._default_wind_speed_unit - ) - to_wind_speed_unit = self._wind_speed_unit - with suppress(TypeError, ValueError): - forecast_wind_gust_speed_f = float(forecast_wind_gust_speed) - forecast_entry[ATTR_FORECAST_WIND_GUST_SPEED] = round( - UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( - forecast_wind_gust_speed_f, - from_wind_speed_unit, - to_wind_speed_unit, - ), - ROUNDING_PRECISION, - ) - - if ( - forecast_wind_speed := forecast_entry.pop( - ATTR_FORECAST_NATIVE_WIND_SPEED, - forecast_entry.get(ATTR_FORECAST_WIND_SPEED), - ) - ) is not None: - from_wind_speed_unit = ( - self.native_wind_speed_unit or self._default_wind_speed_unit - ) - to_wind_speed_unit = self._wind_speed_unit - with suppress(TypeError, ValueError): - forecast_wind_speed_f = float(forecast_wind_speed) - forecast_entry[ATTR_FORECAST_WIND_SPEED] = round( - UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( - forecast_wind_speed_f, - from_wind_speed_unit, - to_wind_speed_unit, - ), - ROUNDING_PRECISION, - ) - - if ( - forecast_precipitation := forecast_entry.pop( - ATTR_FORECAST_NATIVE_PRECIPITATION, - forecast_entry.get(ATTR_FORECAST_PRECIPITATION), - ) - ) is not None: - from_precipitation_unit = ( - self.native_precipitation_unit - or self._default_precipitation_unit - ) - to_precipitation_unit = self._precipitation_unit - with suppress(TypeError, ValueError): - forecast_precipitation_f = float(forecast_precipitation) - forecast_entry[ATTR_FORECAST_PRECIPITATION] = round( - UNIT_CONVERSIONS[ATTR_WEATHER_PRECIPITATION_UNIT]( - forecast_precipitation_f, - from_precipitation_unit, - to_precipitation_unit, - ), - ROUNDING_PRECISION, - ) - - if ( - forecast_humidity := forecast_entry.pop( - ATTR_FORECAST_HUMIDITY, - None, - ) - ) is not None: - with suppress(TypeError, ValueError): - forecast_humidity_f = float(forecast_humidity) - forecast_entry[ATTR_FORECAST_HUMIDITY] = round( - forecast_humidity_f - ) - - forecast.append(forecast_entry) - - data[ATTR_FORECAST] = forecast + if self.forecast: + data[ATTR_FORECAST] = self._convert_forecast(self.forecast) return data + @final + def _convert_forecast( + self, native_forecast_list: list[Forecast] + ) -> list[dict[str, Any]]: + """Convert a forecast in native units to the unit configured by the user.""" + converted_forecast_list: list[dict[str, Any]] = [] + precision = self.precision + + from_temp_unit = self.native_temperature_unit or self._default_temperature_unit + to_temp_unit = self._temperature_unit + + for _forecast_entry in native_forecast_list: + forecast_entry: dict[str, Any] = dict(_forecast_entry) + + temperature = forecast_entry.pop( + ATTR_FORECAST_NATIVE_TEMP, forecast_entry.get(ATTR_FORECAST_TEMP) + ) + + if temperature is None: + forecast_entry[ATTR_FORECAST_TEMP] = None + else: + with suppress(TypeError, ValueError): + temperature_f = float(temperature) + value_temp = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( + temperature_f, + from_temp_unit, + to_temp_unit, + ) + forecast_entry[ATTR_FORECAST_TEMP] = round_temperature( + value_temp, precision + ) + + if ( + forecast_apparent_temp := forecast_entry.pop( + ATTR_FORECAST_NATIVE_APPARENT_TEMP, + forecast_entry.get(ATTR_FORECAST_NATIVE_APPARENT_TEMP), + ) + ) is not None: + with suppress(TypeError, ValueError): + forecast_apparent_temp = float(forecast_apparent_temp) + value_apparent_temp = UNIT_CONVERSIONS[ + ATTR_WEATHER_TEMPERATURE_UNIT + ]( + forecast_apparent_temp, + from_temp_unit, + to_temp_unit, + ) + + forecast_entry[ATTR_FORECAST_APPARENT_TEMP] = round_temperature( + value_apparent_temp, precision + ) + + if ( + forecast_temp_low := forecast_entry.pop( + ATTR_FORECAST_NATIVE_TEMP_LOW, + forecast_entry.get(ATTR_FORECAST_TEMP_LOW), + ) + ) is not None: + with suppress(TypeError, ValueError): + forecast_temp_low_f = float(forecast_temp_low) + value_temp_low = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( + forecast_temp_low_f, + from_temp_unit, + to_temp_unit, + ) + + forecast_entry[ATTR_FORECAST_TEMP_LOW] = round_temperature( + value_temp_low, precision + ) + + if ( + forecast_dew_point := forecast_entry.pop( + ATTR_FORECAST_NATIVE_DEW_POINT, + None, + ) + ) is not None: + with suppress(TypeError, ValueError): + forecast_dew_point_f = float(forecast_dew_point) + value_dew_point = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( + forecast_dew_point_f, + from_temp_unit, + to_temp_unit, + ) + + forecast_entry[ATTR_FORECAST_DEW_POINT] = round_temperature( + value_dew_point, precision + ) + + if ( + forecast_pressure := forecast_entry.pop( + ATTR_FORECAST_NATIVE_PRESSURE, + forecast_entry.get(ATTR_FORECAST_PRESSURE), + ) + ) is not None: + from_pressure_unit = ( + self.native_pressure_unit or self._default_pressure_unit + ) + to_pressure_unit = self._pressure_unit + with suppress(TypeError, ValueError): + forecast_pressure_f = float(forecast_pressure) + forecast_entry[ATTR_FORECAST_PRESSURE] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_PRESSURE_UNIT]( + forecast_pressure_f, + from_pressure_unit, + to_pressure_unit, + ), + ROUNDING_PRECISION, + ) + + if ( + forecast_wind_gust_speed := forecast_entry.pop( + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + None, + ) + ) is not None: + from_wind_speed_unit = ( + self.native_wind_speed_unit or self._default_wind_speed_unit + ) + to_wind_speed_unit = self._wind_speed_unit + with suppress(TypeError, ValueError): + forecast_wind_gust_speed_f = float(forecast_wind_gust_speed) + forecast_entry[ATTR_FORECAST_WIND_GUST_SPEED] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( + forecast_wind_gust_speed_f, + from_wind_speed_unit, + to_wind_speed_unit, + ), + ROUNDING_PRECISION, + ) + + if ( + forecast_wind_speed := forecast_entry.pop( + ATTR_FORECAST_NATIVE_WIND_SPEED, + forecast_entry.get(ATTR_FORECAST_WIND_SPEED), + ) + ) is not None: + from_wind_speed_unit = ( + self.native_wind_speed_unit or self._default_wind_speed_unit + ) + to_wind_speed_unit = self._wind_speed_unit + with suppress(TypeError, ValueError): + forecast_wind_speed_f = float(forecast_wind_speed) + forecast_entry[ATTR_FORECAST_WIND_SPEED] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( + forecast_wind_speed_f, + from_wind_speed_unit, + to_wind_speed_unit, + ), + ROUNDING_PRECISION, + ) + + if ( + forecast_precipitation := forecast_entry.pop( + ATTR_FORECAST_NATIVE_PRECIPITATION, + forecast_entry.get(ATTR_FORECAST_PRECIPITATION), + ) + ) is not None: + from_precipitation_unit = ( + self.native_precipitation_unit or self._default_precipitation_unit + ) + to_precipitation_unit = self._precipitation_unit + with suppress(TypeError, ValueError): + forecast_precipitation_f = float(forecast_precipitation) + forecast_entry[ATTR_FORECAST_PRECIPITATION] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_PRECIPITATION_UNIT]( + forecast_precipitation_f, + from_precipitation_unit, + to_precipitation_unit, + ), + ROUNDING_PRECISION, + ) + + if ( + forecast_humidity := forecast_entry.pop( + ATTR_FORECAST_HUMIDITY, + None, + ) + ) is not None: + with suppress(TypeError, ValueError): + forecast_humidity_f = float(forecast_humidity) + forecast_entry[ATTR_FORECAST_HUMIDITY] = round(forecast_humidity_f) + + converted_forecast_list.append(forecast_entry) + + return converted_forecast_list + @property @final def state(self) -> str | None: @@ -986,3 +1023,53 @@ class WeatherEntity(Entity): ) ) and custom_unit_visibility in VALID_UNITS[ATTR_WEATHER_VISIBILITY_UNIT]: self._weather_option_visibility_unit = custom_unit_visibility + + @final + @callback + def async_subscribe_forecast( + self, + forecast_type: Literal["daily", "hourly", "twice_daily"], + forecast_listener: Callable[[list[dict[str, Any]] | None], None], + ) -> CALLBACK_TYPE: + """Subscribe to forecast updates. + + Called by websocket API. + """ + self._forecast_listeners[forecast_type].append(forecast_listener) + + @callback + def unsubscribe() -> None: + self._forecast_listeners[forecast_type].remove(forecast_listener) + + return unsubscribe + + @final + async def async_update_listeners( + self, forecast_types: Iterable[Literal["daily", "hourly", "twice_daily"]] | None + ) -> None: + """Push updated forecast to all listeners.""" + if forecast_types is None: + forecast_types = {"daily", "hourly", "twice_daily"} + for forecast_type in forecast_types: + if not self._forecast_listeners[forecast_type]: + continue + + native_forecast_list: list[Forecast] | None = await getattr( + self, f"async_forecast_{forecast_type}" + )() + + if native_forecast_list is None: + for listener in self._forecast_listeners[forecast_type]: + listener(None) + continue + + if forecast_type == "twice_daily": + for fc_twice_daily in native_forecast_list: + if fc_twice_daily.get(ATTR_FORECAST_IS_DAYTIME) is None: + raise ValueError( + "is_daytime mandatory attribute for forecast_twice_daily is missing" + ) + + converted_forecast_list = self._convert_forecast(native_forecast_list) + for listener in self._forecast_listeners[forecast_type]: + listener(converted_forecast_list) diff --git a/homeassistant/components/weather/const.py b/homeassistant/components/weather/const.py index b995ce2b729..c6da2c28c71 100644 --- a/homeassistant/components/weather/const.py +++ b/homeassistant/components/weather/const.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable +from enum import IntFlag from typing import Final from homeassistant.const import ( @@ -18,6 +19,15 @@ from homeassistant.util.unit_conversion import ( TemperatureConverter, ) + +class WeatherEntityFeature(IntFlag): + """Supported features of the update entity.""" + + FORECAST_DAILY = 1 + FORECAST_HOURLY = 2 + FORECAST_TWICE_DAILY = 4 + + ATTR_WEATHER_HUMIDITY = "humidity" ATTR_WEATHER_OZONE = "ozone" ATTR_WEATHER_DEW_POINT = "dew_point" @@ -34,6 +44,7 @@ ATTR_WEATHER_WIND_SPEED = "wind_speed" ATTR_WEATHER_WIND_SPEED_UNIT = "wind_speed_unit" ATTR_WEATHER_PRECIPITATION_UNIT = "precipitation_unit" ATTR_WEATHER_CLOUD_COVERAGE = "cloud_coverage" +ATTR_WEATHER_UV_INDEX = "uv_index" DOMAIN: Final = "weather" diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 53eca9c7f91..21029c77284 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -71,14 +71,11 @@ }, "wind_speed_unit": { "name": "Wind speed unit" + }, + "uv_index": { + "name": "UV index" } } } - }, - "issues": { - "platform_integration_no_support": { - "title": "[%key:common::issues::platform_integration_no_support_title%]", - "description": "[%key:common::issues::platform_integration_no_support_description%]" - } } } diff --git a/homeassistant/components/weather/websocket_api.py b/homeassistant/components/weather/websocket_api.py index 51f129fc4a2..f2be4dfec6d 100644 --- a/homeassistant/components/weather/websocket_api.py +++ b/homeassistant/components/weather/websocket_api.py @@ -1,20 +1,29 @@ """The weather websocket API.""" from __future__ import annotations -from typing import Any +from typing import Any, Literal import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent -from .const import VALID_UNITS +from .const import DOMAIN, VALID_UNITS, WeatherEntityFeature + +FORECAST_TYPE_TO_FLAG = { + "daily": WeatherEntityFeature.FORECAST_DAILY, + "hourly": WeatherEntityFeature.FORECAST_HOURLY, + "twice_daily": WeatherEntityFeature.FORECAST_TWICE_DAILY, +} @callback def async_setup(hass: HomeAssistant) -> None: """Set up the weather websocket API.""" websocket_api.async_register_command(hass, ws_convertible_units) + websocket_api.async_register_command(hass, ws_subscribe_forecast) @callback @@ -31,3 +40,62 @@ def ws_convertible_units( key: sorted(units, key=str.casefold) for key, units in VALID_UNITS.items() } connection.send_result(msg["id"], {"units": sorted_units}) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "weather/subscribe_forecast", + vol.Required("entity_id"): cv.entity_domain(DOMAIN), + vol.Required("forecast_type"): vol.In(["daily", "hourly", "twice_daily"]), + } +) +@websocket_api.async_response +async def ws_subscribe_forecast( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Subscribe to weather forecasts.""" + from . import WeatherEntity # pylint: disable=import-outside-toplevel + + component: EntityComponent[WeatherEntity] = hass.data[DOMAIN] + entity_id: str = msg["entity_id"] + forecast_type: Literal["daily", "hourly", "twice_daily"] = msg["forecast_type"] + + if not (entity := component.get_entity(msg["entity_id"])): + connection.send_error( + msg["id"], + "invalid_entity_id", + f"Weather entity not found: {entity_id}", + ) + return + + if ( + entity.supported_features is None + or not entity.supported_features & FORECAST_TYPE_TO_FLAG[forecast_type] + ): + connection.send_error( + msg["id"], + "forecast_not_supported", + f"The weather entity does not support forecast type: {forecast_type}", + ) + return + + @callback + def forecast_listener(forecast: list[dict[str, Any]] | None) -> None: + """Push a new forecast to websocket.""" + connection.send_message( + websocket_api.event_message( + msg["id"], + { + "type": forecast_type, + "forecast": forecast, + }, + ) + ) + + connection.subscriptions[msg["id"]] = entity.async_subscribe_forecast( + forecast_type, forecast_listener + ) + connection.send_message(websocket_api.result_message(msg["id"])) + + # Push an initial forecast update + await entity.async_update_listeners({forecast_type}) diff --git a/homeassistant/components/webostv/services.yaml b/homeassistant/components/webostv/services.yaml index 1985857d128..c3297dd8902 100644 --- a/homeassistant/components/webostv/services.yaml +++ b/homeassistant/components/webostv/services.yaml @@ -1,52 +1,33 @@ # Describes the format for available webostv services button: - name: Button - description: "Send a button press command." fields: entity_id: - name: Entity - description: Name(s) of the webostv entities where to run the API method. required: true selector: entity: integration: webostv domain: media_player button: - name: Button - description: >- - Name of the button to press. Known possible values are - LEFT, RIGHT, DOWN, UP, HOME, MENU, BACK, ENTER, DASH, INFO, ASTERISK, CC, EXIT, - MUTE, RED, GREEN, BLUE, YELLOW, VOLUMEUP, VOLUMEDOWN, CHANNELUP, CHANNELDOWN, - PLAY, PAUSE, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 required: true example: "LEFT" selector: text: command: - name: Command - description: "Send a command." fields: entity_id: - name: Entity - description: Name(s) of the webostv entities where to run the API method. required: true selector: entity: integration: webostv domain: media_player command: - name: Command - description: Endpoint of the command. required: true example: "system.launcher/open" selector: text: payload: - name: Payload - description: >- - An optional payload to provide to the endpoint in the format of key value pair(s). example: >- target: https://www.google.com advanced: true @@ -54,20 +35,14 @@ command: object: select_sound_output: - name: Select Sound Output - description: "Send the TV the command to change sound output." fields: entity_id: - name: Entity - description: Name(s) of the webostv entities to change sound output on. required: true selector: entity: integration: webostv domain: media_player sound_output: - name: Sound Output - description: Name of the sound output to switch to. required: true example: "external_speaker" selector: diff --git a/homeassistant/components/webostv/strings.json b/homeassistant/components/webostv/strings.json index c623effe22b..a5e7b73e59e 100644 --- a/homeassistant/components/webostv/strings.json +++ b/homeassistant/components/webostv/strings.json @@ -15,8 +15,8 @@ "description": "Click submit and accept the pairing request on your TV.\n\n![Image](/static/images/config_webos.png)" }, "reauth_confirm": { - "title": "webOS TV Pairing", - "description": "Click submit and accept the pairing request on your TV.\n\n![Image](/static/images/config_webos.png)" + "title": "[%key:component::webostv::config::step::pairing::title%]", + "description": "[%key:component::webostv::config::step::pairing::description%]" } }, "error": { @@ -48,5 +48,53 @@ "trigger_type": { "webostv.turn_on": "Device is requested to turn on" } + }, + "services": { + "button": { + "name": "Button", + "description": "Sends a button press command.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name(s) of the webostv entities where to run the API method." + }, + "button": { + "name": "Button", + "description": "Name of the button to press. Known possible values are LEFT, RIGHT, DOWN, UP, HOME, MENU, BACK, ENTER, DASH, INFO, ASTERISK, CC, EXIT, MUTE, RED, GREEN, BLUE, YELLOW, VOLUMEUP, VOLUMEDOWN, CHANNELUP, CHANNELDOWN, PLAY, PAUSE, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9." + } + } + }, + "command": { + "name": "Command", + "description": "Sends a command.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "[%key:component::webostv::services::button::fields::entity_id::description%]" + }, + "command": { + "name": "Command", + "description": "Endpoint of the command." + }, + "payload": { + "name": "Payload", + "description": "An optional payload to provide to the endpoint in the format of key value pair(s)." + } + } + }, + "select_sound_output": { + "name": "Select sound output", + "description": "Sends the TV the command to change sound output.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Name(s) of the webostv entities to change sound output on." + }, + "sound_output": { + "name": "Sound output", + "description": "Name of the sound output to switch to." + } + } + } } } diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index d0831f2e90e..9f8e8bfb6f8 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -57,7 +57,7 @@ class AuthPhase: self, logger: WebSocketAdapter, hass: HomeAssistant, - send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], + send_message: Callable[[str | dict[str, Any]], None], cancel_ws: CALLBACK_TYPE, request: Request, ) -> None: diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 143d0617d51..bbcbfa6ecb8 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -3,12 +3,13 @@ from __future__ import annotations from collections.abc import Callable import datetime as dt -from functools import lru_cache +from functools import lru_cache, partial import json from typing import Any, cast import voluptuous as vol +from homeassistant.auth.models import User from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_READ from homeassistant.const import ( EVENT_STATE_CHANGED, @@ -25,6 +26,7 @@ from homeassistant.exceptions import ( from homeassistant.helpers import config_validation as cv, entity, template from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import ( + EventStateChangedData, TrackTemplate, TrackTemplateResult, async_track_template_result, @@ -36,6 +38,7 @@ from homeassistant.helpers.json import ( json_dumps, ) from homeassistant.helpers.service import async_get_all_descriptions +from homeassistant.helpers.typing import EventType from homeassistant.loader import ( Integration, IntegrationNotFound, @@ -88,6 +91,32 @@ def pong_message(iden: int) -> dict[str, Any]: return {"id": iden, "type": "pong"} +def _forward_events_check_permissions( + send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], + user: User, + msg_id: int, + event: Event, +) -> None: + """Forward state changed events to websocket.""" + # We have to lookup the permissions again because the user might have + # changed since the subscription was created. + permissions = user.permissions + if not permissions.access_all_entities( + POLICY_READ + ) and not permissions.check_entity(event.data["entity_id"], POLICY_READ): + return + send_message(messages.cached_event_message(msg_id, event)) + + +def _forward_events_unconditional( + send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], + msg_id: int, + event: Event, +) -> None: + """Forward events to websocket.""" + send_message(messages.cached_event_message(msg_id, event)) + + @callback @decorators.websocket_command( { @@ -109,26 +138,18 @@ def handle_subscribe_events( raise Unauthorized if event_type == EVENT_STATE_CHANGED: - user = connection.user - - @callback - def forward_events(event: Event) -> None: - """Forward state changed events to websocket.""" - # We have to lookup the permissions again because the user might have - # changed since the subscription was created. - permissions = user.permissions - if not permissions.access_all_entities( - POLICY_READ - ) and not permissions.check_entity(event.data["entity_id"], POLICY_READ): - return - connection.send_message(messages.cached_event_message(msg["id"], event)) - + forward_events = callback( + partial( + _forward_events_check_permissions, + connection.send_message, + connection.user, + msg["id"], + ) + ) else: - - @callback - def forward_events(event: Event) -> None: - """Forward events to websocket.""" - connection.send_message(messages.cached_event_message(msg["id"], event)) + forward_events = callback( + partial(_forward_events_unconditional, connection.send_message, msg["id"]) + ) connection.subscriptions[msg["id"]] = hass.bus.async_listen( event_type, forward_events, run_immediately=True @@ -280,6 +301,27 @@ def _send_handle_get_states_response( connection.send_message(construct_result_message(msg_id, f"[{joined_states}]")) +def _forward_entity_changes( + send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], + entity_ids: set[str], + user: User, + msg_id: int, + event: Event, +) -> None: + """Forward entity state changed events to websocket.""" + entity_id = event.data["entity_id"] + if entity_ids and entity_id not in entity_ids: + return + # We have to lookup the permissions again because the user might have + # changed since the subscription was created. + permissions = user.permissions + if not permissions.access_all_entities( + POLICY_READ + ) and not permissions.check_entity(event.data["entity_id"], POLICY_READ): + return + send_message(messages.cached_state_diff_message(msg_id, event)) + + @callback @decorators.websocket_command( { @@ -292,29 +334,22 @@ def handle_subscribe_entities( ) -> None: """Handle subscribe entities command.""" entity_ids = set(msg.get("entity_ids", [])) - user = connection.user - - @callback - def forward_entity_changes(event: Event) -> None: - """Forward entity state changed events to websocket.""" - entity_id = event.data["entity_id"] - if entity_ids and entity_id not in entity_ids: - return - # We have to lookup the permissions again because the user might have - # changed since the subscription was created. - permissions = user.permissions - if not permissions.access_all_entities( - POLICY_READ - ) and not permissions.check_entity(event.data["entity_id"], POLICY_READ): - return - connection.send_message(messages.cached_state_diff_message(msg["id"], event)) - # We must never await between sending the states and listening for # state changed events or we will introduce a race condition # where some states are missed states = _async_get_allowed_states(hass, connection) connection.subscriptions[msg["id"]] = hass.bus.async_listen( - EVENT_STATE_CHANGED, forward_entity_changes, run_immediately=True + EVENT_STATE_CHANGED, + callback( + partial( + _forward_entity_changes, + connection.send_message, + entity_ids, + connection.user, + msg["id"], + ) + ), + run_immediately=True, ) connection.send_result(msg["id"]) @@ -502,7 +537,8 @@ async def handle_render_template( @callback def _template_listener( - event: Event | None, updates: list[TrackTemplateResult] + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], ) -> None: nonlocal info track_template_result = updates.pop() diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 319188dae21..f598906661c 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -33,11 +33,25 @@ BinaryHandler = Callable[[HomeAssistant, "ActiveConnection", bytes], None] class ActiveConnection: """Handle an active websocket client connection.""" + __slots__ = ( + "logger", + "hass", + "send_message", + "user", + "refresh_token_id", + "subscriptions", + "last_id", + "can_coalesce", + "supported_features", + "handlers", + "binary_handlers", + ) + def __init__( self, logger: WebSocketAdapter, hass: HomeAssistant, - send_message: Callable[[str | dict[str, Any] | Callable[[], str]], None], + send_message: Callable[[str | dict[str, Any]], None], user: User, refresh_token: RefreshToken, ) -> None: diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 6ac0e10a76c..fcaa13ff8de 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -63,6 +63,21 @@ class WebSocketAdapter(logging.LoggerAdapter): class WebSocketHandler: """Handle an active websocket client connection.""" + __slots__ = ( + "_hass", + "_request", + "_wsock", + "_handle_task", + "_writer_task", + "_closing", + "_authenticated", + "_logger", + "_peak_checker_unsub", + "_connection", + "_message_queue", + "_ready_future", + ) + def __init__(self, hass: HomeAssistant, request: web.Request) -> None: """Initialize an active connection.""" self._hass = hass @@ -80,7 +95,7 @@ class WebSocketHandler: # to where messages are queued. This allows the implementation # to use a deque and an asyncio.Future to avoid the overhead of # an asyncio.Queue. - self._message_queue: deque[str | Callable[[], str] | None] = deque() + self._message_queue: deque[str | None] = deque() self._ready_future: asyncio.Future[None] | None = None def __repr__(self) -> str: @@ -121,12 +136,11 @@ class WebSocketHandler: messages_remaining = len(message_queue) # A None message is used to signal the end of the connection - if (process := message_queue.popleft()) is None: + if (message := message_queue.popleft()) is None: return debug_enabled = is_enabled_for(logging_debug) messages_remaining -= 1 - message = process if isinstance(process, str) else process() if ( not messages_remaining @@ -141,9 +155,9 @@ class WebSocketHandler: messages: list[str] = [message] while messages_remaining: # A None message is used to signal the end of the connection - if (process := message_queue.popleft()) is None: + if (message := message_queue.popleft()) is None: return - messages.append(process if isinstance(process, str) else process()) + messages.append(message) messages_remaining -= 1 joined_messages = ",".join(messages) @@ -169,7 +183,7 @@ class WebSocketHandler: self._peak_checker_unsub = None @callback - def _send_message(self, message: str | dict[str, Any] | Callable[[], str]) -> None: + def _send_message(self, message: str | dict[str, Any]) -> None: """Send a message to the client. Closes connection if the client is not reading the messages. @@ -201,8 +215,9 @@ class WebSocketHandler: return message_queue.append(message) - if self._ready_future and not self._ready_future.done(): - self._ready_future.set_result(None) + ready_future = self._ready_future + if ready_future and not ready_future.done(): + ready_future.set_result(None) peak_checker_active = self._peak_checker_unsub is not None diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 3d85f984e9a..e5fd5626302 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -180,7 +180,10 @@ def _state_diff( if old_attributes.get(key) != value: additions.setdefault(COMPRESSED_STATE_ATTRIBUTES, {})[key] = value if removed := set(old_attributes).difference(new_attributes): - diff[STATE_DIFF_REMOVALS] = {COMPRESSED_STATE_ATTRIBUTES: removed} + # sets are not JSON serializable by default so we convert to list + # here if there are any values to avoid jumping into the json_encoder_default + # for every state diff with a removed attribute + diff[STATE_DIFF_REMOVALS] = {COMPRESSED_STATE_ATTRIBUTES: list(removed)} return {ENTITY_EVENT_CHANGE: {new_state.entity_id: diff}} diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 4488e881938..a58169aa6e5 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -1,9 +1,10 @@ """Support for WeMo device discovery.""" from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Callable, Coroutine, Sequence from datetime import datetime import logging +from typing import Any import pywemo import voluptuous as vol @@ -13,13 +14,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISCOVERY, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import gather_with_concurrency from .const import DOMAIN -from .wemo_device import async_register_device +from .models import WemoConfigEntryData, WemoData, async_wemo_data +from .wemo_device import DeviceCoordinator, async_register_device # Max number of devices to initialize at once. This limit is in place to # avoid tying up too many executor threads with WeMo device setup. @@ -42,6 +43,7 @@ WEMO_MODEL_DISPATCH = { _LOGGER = logging.getLogger(__name__) +DispatchCallback = Callable[[DeviceCoordinator], Coroutine[Any, Any, None]] HostPortTuple = tuple[str, int | None] @@ -81,11 +83,26 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up for WeMo devices.""" - hass.data[DOMAIN] = { - "config": config.get(DOMAIN, {}), - "registry": None, - "pending": {}, - } + # Keep track of WeMo device subscriptions for push updates + registry = pywemo.SubscriptionRegistry() + await hass.async_add_executor_job(registry.start) + + # Respond to discovery requests from WeMo devices. + discovery_responder = pywemo.ssdp.DiscoveryResponder(registry.port) + await hass.async_add_executor_job(discovery_responder.start) + + async def _on_hass_stop(_: Event) -> None: + await hass.async_add_executor_job(discovery_responder.stop) + await hass.async_add_executor_job(registry.stop) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_hass_stop) + + yaml_config = config.get(DOMAIN, {}) + hass.data[DOMAIN] = WemoData( + discovery_enabled=yaml_config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY), + static_config=yaml_config.get(CONF_STATIC, []), + registry=registry, + ) if DOMAIN in config: hass.async_create_task( @@ -99,45 +116,48 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a wemo config entry.""" - config = hass.data[DOMAIN].pop("config") - - # Keep track of WeMo device subscriptions for push updates - registry = hass.data[DOMAIN]["registry"] = pywemo.SubscriptionRegistry() - await hass.async_add_executor_job(registry.start) - - # Respond to discovery requests from WeMo devices. - discovery_responder = pywemo.ssdp.DiscoveryResponder(registry.port) - await hass.async_add_executor_job(discovery_responder.start) - - static_conf: Sequence[HostPortTuple] = config.get(CONF_STATIC, []) - wemo_dispatcher = WemoDispatcher(entry) - wemo_discovery = WemoDiscovery(hass, wemo_dispatcher, static_conf) - - async def async_stop_wemo(_: Event | None = None) -> None: - """Shutdown Wemo subscriptions and subscription thread on exit.""" - _LOGGER.debug("Shutting down WeMo event subscriptions") - await hass.async_add_executor_job(registry.stop) - await hass.async_add_executor_job(discovery_responder.stop) - wemo_discovery.async_stop_discovery() - - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_wemo) + wemo_data = async_wemo_data(hass) + dispatcher = WemoDispatcher(entry) + discovery = WemoDiscovery(hass, dispatcher, wemo_data.static_config) + wemo_data.config_entry_data = WemoConfigEntryData( + device_coordinators={}, + discovery=discovery, + dispatcher=dispatcher, ) - entry.async_on_unload(async_stop_wemo) # Need to do this at least once in case statistics are defined and discovery is disabled - await wemo_discovery.discover_statics() + await discovery.discover_statics() - if config.get(CONF_DISCOVERY, DEFAULT_DISCOVERY): - await wemo_discovery.async_discover_and_schedule() + if wemo_data.discovery_enabled: + await discovery.async_discover_and_schedule() return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a wemo config entry.""" - # This makes sure that `entry.async_on_unload` routines run correctly on unload - return True + _LOGGER.debug("Unloading WeMo") + wemo_data = async_wemo_data(hass) + + wemo_data.config_entry_data.discovery.async_stop_discovery() + + dispatcher = wemo_data.config_entry_data.dispatcher + if unload_ok := await dispatcher.async_unload_platforms(hass): + assert not wemo_data.config_entry_data.device_coordinators + wemo_data.config_entry_data = None # type: ignore[assignment] + return unload_ok + + +async def async_wemo_dispatcher_connect( + hass: HomeAssistant, + dispatch: DispatchCallback, +) -> None: + """Connect a wemo platform with the WemoDispatcher.""" + module = dispatch.__module__ # Example: "homeassistant.components.wemo.switch" + platform = Platform(module.rsplit(".", 1)[1]) + + dispatcher = async_wemo_data(hass).config_entry_data.dispatcher + await dispatcher.async_connect_platform(platform, dispatch) class WemoDispatcher: @@ -148,7 +168,8 @@ class WemoDispatcher: self._config_entry = config_entry self._added_serial_numbers: set[str] = set() self._failed_serial_numbers: set[str] = set() - self._loaded_platforms: set[Platform] = set() + self._dispatch_backlog: dict[Platform, list[DeviceCoordinator]] = {} + self._dispatch_callbacks: dict[Platform, DispatchCallback] = {} async def async_add_unique_device( self, hass: HomeAssistant, wemo: pywemo.WeMoDevice @@ -171,32 +192,47 @@ class WemoDispatcher: platforms.add(Platform.SENSOR) for platform in platforms: # Three cases: - # - First time we see platform, we need to load it and initialize the backlog + # - Platform is loaded, dispatch discovery # - Platform is being loaded, add to backlog - # - Platform is loaded, backlog is gone, dispatch discovery + # - First time we see platform, we need to load it and initialize the backlog - if platform not in self._loaded_platforms: - hass.data[DOMAIN]["pending"][platform] = [coordinator] - self._loaded_platforms.add(platform) + if platform in self._dispatch_callbacks: + await self._dispatch_callbacks[platform](coordinator) + elif platform in self._dispatch_backlog: + self._dispatch_backlog[platform].append(coordinator) + else: + self._dispatch_backlog[platform] = [coordinator] hass.async_create_task( hass.config_entries.async_forward_entry_setup( self._config_entry, platform ) ) - elif platform in hass.data[DOMAIN]["pending"]: - hass.data[DOMAIN]["pending"][platform].append(coordinator) - - else: - async_dispatcher_send( - hass, - f"{DOMAIN}.{platform}", - coordinator, - ) - self._added_serial_numbers.add(wemo.serial_number) self._failed_serial_numbers.discard(wemo.serial_number) + async def async_connect_platform( + self, platform: Platform, dispatch: DispatchCallback + ) -> None: + """Consider a platform as loaded and dispatch any backlog of discovered devices.""" + self._dispatch_callbacks[platform] = dispatch + + await gather_with_concurrency( + MAX_CONCURRENCY, + *( + dispatch(coordinator) + for coordinator in self._dispatch_backlog.pop(platform) + ), + ) + + async def async_unload_platforms(self, hass: HomeAssistant) -> bool: + """Forward the unloading of an entry to platforms.""" + platforms: set[Platform] = set(self._dispatch_backlog.keys()) + platforms.update(self._dispatch_callbacks.keys()) + return await hass.config_entries.async_unload_platforms( + self._config_entry, platforms + ) + class WemoDiscovery: """Use SSDP to discover WeMo devices.""" diff --git a/homeassistant/components/wemo/binary_sensor.py b/homeassistant/components/wemo/binary_sensor.py index ce7dfc2fa11..396a555e4f4 100644 --- a/homeassistant/components/wemo/binary_sensor.py +++ b/homeassistant/components/wemo/binary_sensor.py @@ -1,22 +1,20 @@ """Support for WeMo binary sensors.""" -import asyncio from pywemo import Insight, Maker, StandbyState from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN as WEMO_DOMAIN +from . import async_wemo_dispatcher_connect from .entity import WemoBinaryStateEntity, WemoEntity from .wemo_device import DeviceCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + _config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WeMo binary sensors.""" @@ -30,14 +28,7 @@ async def async_setup_entry( else: async_add_entities([WemoBinarySensor(coordinator)]) - async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.binary_sensor", _discovered_wemo) - - await asyncio.gather( - *( - _discovered_wemo(coordinator) - for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("binary_sensor") - ) - ) + await async_wemo_dispatcher_connect(hass, _discovered_wemo) class WemoBinarySensor(WemoBinaryStateEntity, BinarySensorEntity): diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 1d2c2c9252d..aaa85455c56 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -1,7 +1,6 @@ """Support for WeMo humidifier.""" from __future__ import annotations -import asyncio from datetime import timedelta import math from typing import Any @@ -13,7 +12,6 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( int_states_in_range, @@ -21,8 +19,8 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) +from . import async_wemo_dispatcher_connect from .const import ( - DOMAIN as WEMO_DOMAIN, SERVICE_RESET_FILTER_LIFE, SERVICE_SET_HUMIDITY, ) @@ -50,7 +48,7 @@ SET_HUMIDITY_SCHEMA = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + _config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WeMo binary sensors.""" @@ -59,14 +57,7 @@ async def async_setup_entry( """Handle a discovered Wemo device.""" async_add_entities([WemoHumidifier(coordinator)]) - async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.fan", _discovered_wemo) - - await asyncio.gather( - *( - _discovered_wemo(coordinator) - for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("fan") - ) - ) + await async_wemo_dispatcher_connect(hass, _discovered_wemo) platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 2767d44032c..fb01d117c08 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -1,7 +1,6 @@ """Support for Belkin WeMo lights.""" from __future__ import annotations -import asyncio from typing import Any, cast from pywemo import Bridge, BridgeLight, Dimmer @@ -18,11 +17,11 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util +from . import async_wemo_dispatcher_connect from .const import DOMAIN as WEMO_DOMAIN from .entity import WemoBinaryStateEntity, WemoEntity from .wemo_device import DeviceCoordinator @@ -45,14 +44,7 @@ async def async_setup_entry( else: async_add_entities([WemoDimmer(coordinator)]) - async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.light", _discovered_wemo) - - await asyncio.gather( - *( - _discovered_wemo(coordinator) - for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("light") - ) - ) + await async_wemo_dispatcher_connect(hass, _discovered_wemo) @callback diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index bb19d2e1655..cb189116eeb 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pywemo"], - "requirements": ["pywemo==1.1.0"], + "requirements": ["pywemo==1.2.1"], "ssdp": [ { "manufacturer": "Belkin International Inc." diff --git a/homeassistant/components/wemo/models.py b/homeassistant/components/wemo/models.py new file mode 100644 index 00000000000..ee12ccbf846 --- /dev/null +++ b/homeassistant/components/wemo/models.py @@ -0,0 +1,43 @@ +"""Common data structures and helpers for accessing them.""" + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +import pywemo + +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN + +if TYPE_CHECKING: # Avoid circular dependencies. + from . import HostPortTuple, WemoDiscovery, WemoDispatcher + from .wemo_device import DeviceCoordinator + + +@dataclass +class WemoConfigEntryData: + """Config entry state data.""" + + device_coordinators: dict[str, "DeviceCoordinator"] + discovery: "WemoDiscovery" + dispatcher: "WemoDispatcher" + + +@dataclass +class WemoData: + """Component state data.""" + + discovery_enabled: bool + static_config: Sequence["HostPortTuple"] + registry: pywemo.SubscriptionRegistry + # config_entry_data is set when the config entry is loaded and unset when it's + # unloaded. It's a programmer error if config_entry_data is accessed when the + # config entry is not loaded + config_entry_data: WemoConfigEntryData = None # type: ignore[assignment] + + +@callback +def async_wemo_data(hass: HomeAssistant) -> WemoData: + """Fetch WemoData with proper typing.""" + return cast(WemoData, hass.data[DOMAIN]) diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 15e396cc660..2547dc0ad0d 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -1,7 +1,6 @@ """Support for power sensors in WeMo Insight devices.""" from __future__ import annotations -import asyncio from collections.abc import Callable from dataclasses import dataclass from typing import cast @@ -15,11 +14,10 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN as WEMO_DOMAIN +from . import async_wemo_dispatcher_connect from .entity import WemoEntity from .wemo_device import DeviceCoordinator @@ -59,7 +57,7 @@ ATTRIBUTE_SENSORS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + _config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WeMo sensors.""" @@ -72,14 +70,7 @@ async def async_setup_entry( if hasattr(coordinator.wemo, description.key) ) - async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.sensor", _discovered_wemo) - - await asyncio.gather( - *( - _discovered_wemo(coordinator) - for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("sensor") - ) - ) + await async_wemo_dispatcher_connect(hass, _discovered_wemo) class AttributeSensor(WemoEntity, SensorEntity): diff --git a/homeassistant/components/wemo/services.yaml b/homeassistant/components/wemo/services.yaml index 58305798cf9..59f38ca77a0 100644 --- a/homeassistant/components/wemo/services.yaml +++ b/homeassistant/components/wemo/services.yaml @@ -1,14 +1,10 @@ set_humidity: - name: Set humidity - description: Set the target humidity of WeMo humidifier devices. target: entity: integration: wemo domain: fan fields: target_humidity: - name: Target humidity - description: Target humidity. required: true selector: number: @@ -18,8 +14,6 @@ set_humidity: unit_of_measurement: "%" reset_filter_life: - name: Reset filter life - description: Reset the WeMo Humidifier's filter life to 100%. target: entity: integration: wemo diff --git a/homeassistant/components/wemo/strings.json b/homeassistant/components/wemo/strings.json index b218f758985..9b112d9a388 100644 --- a/homeassistant/components/wemo/strings.json +++ b/homeassistant/components/wemo/strings.json @@ -28,5 +28,21 @@ "trigger_type": { "long_press": "Wemo button was pressed for 2 seconds" } + }, + "services": { + "set_humidity": { + "name": "Set humidity", + "description": "Sets the target humidity of WeMo humidifier devices.", + "fields": { + "target_humidity": { + "name": "Target humidity", + "description": "Target humidity." + } + } + }, + "reset_filter_life": { + "name": "Reset filter lifetime", + "description": "Resets the WeMo Humidifier's filter lifetime to 100%." + } } } diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index 6d5e6b678b4..508621ba415 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -1,7 +1,6 @@ """Support for WeMo switches.""" from __future__ import annotations -import asyncio from datetime import datetime, timedelta from typing import Any @@ -11,10 +10,9 @@ from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN as WEMO_DOMAIN +from . import async_wemo_dispatcher_connect from .entity import WemoBinaryStateEntity from .wemo_device import DeviceCoordinator @@ -36,7 +34,7 @@ MAKER_SWITCH_TOGGLE = "toggle" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + _config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up WeMo switches.""" @@ -45,14 +43,7 @@ async def async_setup_entry( """Handle a discovered Wemo device.""" async_add_entities([WemoSwitch(coordinator)]) - async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.switch", _discovered_wemo) - - await asyncio.gather( - *( - _discovered_wemo(coordinator) - for coordinator in hass.data[WEMO_DOMAIN]["pending"].pop("switch") - ) - ) + await async_wemo_dispatcher_connect(hass, _discovered_wemo) class WemoSwitch(WemoBinaryStateEntity, SwitchEntity): diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 65431fb7657..c85bc9fd473 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -9,7 +9,7 @@ from typing import Literal from pywemo import Insight, LongPressMixin, WeMoDevice from pywemo.exceptions import ActionException, PyWeMoException -from pywemo.subscribe import EVENT_TYPE_LONG_PRESS +from pywemo.subscribe import EVENT_TYPE_LONG_PRESS, SubscriptionRegistry from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -30,11 +30,12 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, WEMO_SUBSCRIPTION_EVENT +from .models import async_wemo_data _LOGGER = logging.getLogger(__name__) # Literal values must match options.error keys from strings.json. -ErrorStringKey = Literal["long_press_requires_subscription"] +ErrorStringKey = Literal["long_press_requires_subscription"] # noqa: F821 # Literal values must match options.step.init.data keys from strings.json. OptionsFieldKey = Literal["enable_subscription", "enable_long_press"] @@ -124,9 +125,21 @@ class DeviceCoordinator(DataUpdateCoordinator[None]): updated = self.wemo.subscription_update(event_type, params) self.hass.create_task(self._async_subscription_callback(updated)) + async def async_shutdown(self) -> None: + """Unregister push subscriptions and remove from coordinators dict.""" + await super().async_shutdown() + del _async_coordinators(self.hass)[self.device_id] + assert self.options # Always set by async_register_device. + if self.options.enable_subscription: + await self._async_set_enable_subscription(False) + # Check that the device is available (last_update_success) before disabling long + # press. That avoids long shutdown times for devices that are no longer connected. + if self.options.enable_long_press and self.last_update_success: + await self._async_set_enable_long_press(False) + async def _async_set_enable_subscription(self, enable_subscription: bool) -> None: """Turn on/off push updates from the device.""" - registry = self.hass.data[DOMAIN]["registry"] + registry = _async_registry(self.hass) if enable_subscription: registry.on(self.wemo, None, self.subscription_callback) await self.hass.async_add_executor_job(registry.register, self.wemo) @@ -199,8 +212,10 @@ class DeviceCoordinator(DataUpdateCoordinator[None]): # this case so the Sensor entities are properly populated. return True - registry = self.hass.data[DOMAIN]["registry"] - return not (registry.is_subscribed(self.wemo) and self.last_update_success) + return not ( + _async_registry(self.hass).is_subscribed(self.wemo) + and self.last_update_success + ) async def _async_update_data(self) -> None: """Update WeMo state.""" @@ -258,7 +273,7 @@ async def async_register_device( ) device = DeviceCoordinator(hass, wemo, entry.id) - hass.data[DOMAIN].setdefault("devices", {})[entry.id] = device + _async_coordinators(hass)[entry.id] = device config_entry.async_on_unload( config_entry.add_update_listener(device.async_set_options) @@ -271,5 +286,14 @@ async def async_register_device( @callback def async_get_coordinator(hass: HomeAssistant, device_id: str) -> DeviceCoordinator: """Return DeviceCoordinator for device_id.""" - coordinator: DeviceCoordinator = hass.data[DOMAIN]["devices"][device_id] - return coordinator + return _async_coordinators(hass)[device_id] + + +@callback +def _async_coordinators(hass: HomeAssistant) -> dict[str, DeviceCoordinator]: + return async_wemo_data(hass).config_entry_data.device_coordinators + + +@callback +def _async_registry(hass: HomeAssistant) -> SubscriptionRegistry: + return async_wemo_data(hass).registry diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 2b658387ef5..d1c5d6cf8f8 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -93,6 +93,7 @@ class AirConEntity(ClimateEntity): _attr_fan_modes = SUPPORTED_FAN_MODES _attr_has_entity_name = True + _attr_name = None _attr_hvac_modes = SUPPORTED_HVAC_MODES _attr_max_temp = SUPPORTED_MAX_TEMP _attr_min_temp = SUPPORTED_MIN_TEMP diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index 4b54f9746a0..4c3ce680323 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["whirlpool"], - "requirements": ["whirlpool-sixth-sense==0.18.3"] + "requirements": ["whirlpool-sixth-sense==0.18.4"] } diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index de415035c76..f761badfa2b 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -70,6 +70,7 @@ ICON_D = "mdi:tumble-dryer" ICON_W = "mdi:washing-machine" _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(minutes=5) def washer_state(washer: WasherDryer) -> str | None: @@ -105,7 +106,6 @@ class WhirlpoolSensorEntityDescription( SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( WhirlpoolSensorEntityDescription( key="state", - name="State", translation_key="whirlpool_machine", device_class=SensorDeviceClass.ENUM, options=( @@ -117,7 +117,6 @@ SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( ), WhirlpoolSensorEntityDescription( key="DispenseLevel", - name="Detergent Level", translation_key="whirlpool_tank", entity_registry_enabled_default=False, device_class=SensorDeviceClass.ENUM, @@ -131,7 +130,7 @@ SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( SENSOR_TIMER: tuple[SensorEntityDescription] = ( SensorEntityDescription( key="timeremaining", - name="End Time", + translation_key="end_time", device_class=SensorDeviceClass.TIMESTAMP, ), ) @@ -183,6 +182,7 @@ class WasherDryerClass(SensorEntity): """A class for the whirlpool/maytag washer account.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, @@ -205,7 +205,6 @@ class WasherDryerClass(SensorEntity): name=name.capitalize(), manufacturer="Whirlpool", ) - self._attr_has_entity_name = True self._attr_unique_id = f"{said}-{description.key}" async def async_added_to_hass(self) -> None: @@ -230,7 +229,8 @@ class WasherDryerClass(SensorEntity): class WasherDryerTimeClass(RestoreSensor): """A timestamp class for the whirlpool/maytag washer account.""" - _attr_should_poll = False + _attr_should_poll = True + _attr_has_entity_name = True def __init__( self, @@ -254,7 +254,6 @@ class WasherDryerTimeClass(RestoreSensor): name=name.capitalize(), manufacturer="Whirlpool", ) - self._attr_has_entity_name = True self._attr_unique_id = f"{said}-{description.key}" async def async_added_to_hass(self) -> None: @@ -274,6 +273,10 @@ class WasherDryerTimeClass(RestoreSensor): """Return True if entity is available.""" return self._wd.get_online() + async def async_update(self) -> None: + """Update status of Whirlpool.""" + await self._wd.fetch_data() + @callback def update_from_latest_data(self) -> None: """Calculate the time stamp for completion.""" diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index aff89019e4c..a24e42304d0 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -21,13 +21,14 @@ "entity": { "sensor": { "whirlpool_machine": { + "name": "State", "state": { "standby": "[%key:common::state::standby%]", "setting": "Setting", "delay_countdown": "Delay Countdown", "delay_paused": "Delay Paused", "smart_delay": "Smart Delay", - "smart_grid_pause": "Smart Delay", + "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::whirlpool_machine::state::smart_delay%]", "pause": "[%key:common::state::paused%]", "running_maincycle": "Running Maincycle", "running_postcycle": "Running Postcycle", @@ -51,6 +52,7 @@ } }, "whirlpool_tank": { + "name": "Detergent level", "state": { "unknown": "Unknown", "empty": "Empty", @@ -59,6 +61,9 @@ "100": "100%", "active": "[%key:common::state::active%]" } + }, + "end_time": { + "name": "End time" } } } diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index c3b115de60a..6333139e540 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -64,7 +64,7 @@ def _ensure_timezone(timestamp: datetime | None) -> datetime | None: SENSORS: tuple[WhoisSensorEntityDescription, ...] = ( WhoisSensorEntityDescription( key="admin", - name="Admin", + translation_key="admin", icon="mdi:account-star", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -72,35 +72,35 @@ SENSORS: tuple[WhoisSensorEntityDescription, ...] = ( ), WhoisSensorEntityDescription( key="creation_date", - name="Created", + translation_key="creation_date", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda domain: _ensure_timezone(domain.creation_date), ), WhoisSensorEntityDescription( key="days_until_expiration", - name="Days until expiration", + translation_key="days_until_expiration", icon="mdi:calendar-clock", native_unit_of_measurement=UnitOfTime.DAYS, value_fn=_days_until_expiration, ), WhoisSensorEntityDescription( key="expiration_date", - name="Expires", + translation_key="expiration_date", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda domain: _ensure_timezone(domain.expiration_date), ), WhoisSensorEntityDescription( key="last_updated", - name="Last updated", + translation_key="last_updated", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda domain: _ensure_timezone(domain.last_updated), ), WhoisSensorEntityDescription( key="owner", - name="Owner", + translation_key="owner", icon="mdi:account", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -108,7 +108,7 @@ SENSORS: tuple[WhoisSensorEntityDescription, ...] = ( ), WhoisSensorEntityDescription( key="registrant", - name="Registrant", + translation_key="registrant", icon="mdi:account-edit", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -116,7 +116,7 @@ SENSORS: tuple[WhoisSensorEntityDescription, ...] = ( ), WhoisSensorEntityDescription( key="registrar", - name="Registrar", + translation_key="registrar", icon="mdi:store", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -124,7 +124,7 @@ SENSORS: tuple[WhoisSensorEntityDescription, ...] = ( ), WhoisSensorEntityDescription( key="reseller", - name="Reseller", + translation_key="reseller", icon="mdi:store", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, diff --git a/homeassistant/components/whois/strings.json b/homeassistant/components/whois/strings.json index 553293962cd..c28c079784d 100644 --- a/homeassistant/components/whois/strings.json +++ b/homeassistant/components/whois/strings.json @@ -16,5 +16,36 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } + }, + "entity": { + "sensor": { + "admin": { + "name": "Admin" + }, + "creation_date": { + "name": "Created" + }, + "days_until_expiration": { + "name": "Days until expiration" + }, + "expiration_date": { + "name": "Expires" + }, + "last_updated": { + "name": "Last updated" + }, + "owner": { + "name": "Owner" + }, + "registrant": { + "name": "Registrant" + }, + "registrar": { + "name": "Registrar" + }, + "reseller": { + "name": "Reseller" + } + } } } diff --git a/homeassistant/components/wilight/__init__.py b/homeassistant/components/wilight/__init__.py index 326265b8b3f..58ba237ae68 100644 --- a/homeassistant/components/wilight/__init__.py +++ b/homeassistant/components/wilight/__init__.py @@ -56,6 +56,7 @@ class WiLightDevice(Entity): """ _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, api_device: PyWiLightDevice, index: str, item_name: str) -> None: """Initialize the device.""" @@ -65,7 +66,6 @@ class WiLightDevice(Entity): self._index = index self._status: dict[str, Any] = {} - self._attr_name = item_name self._attr_unique_id = f"{self._device_id}_{index}" self._attr_device_info = DeviceInfo( name=item_name, diff --git a/homeassistant/components/wilight/cover.py b/homeassistant/components/wilight/cover.py index cd0a3cc21ac..aa50b79f139 100644 --- a/homeassistant/components/wilight/cover.py +++ b/homeassistant/components/wilight/cover.py @@ -57,6 +57,8 @@ def hass_to_wilight_position(value: int) -> int: class WiLightCover(WiLightDevice, CoverEntity): """Representation of a WiLights cover.""" + _attr_name = None + @property def current_cover_position(self) -> int | None: """Return current position of cover. diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index 3d0c6d0ff39..ba9a108f636 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -54,6 +54,7 @@ async def async_setup_entry( class WiLightFan(WiLightDevice, FanEntity): """Representation of a WiLights fan.""" + _attr_name = None _attr_icon = "mdi:fan" _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION diff --git a/homeassistant/components/wilight/light.py b/homeassistant/components/wilight/light.py index 2509dc50737..b17eac36f09 100644 --- a/homeassistant/components/wilight/light.py +++ b/homeassistant/components/wilight/light.py @@ -53,6 +53,7 @@ async def async_setup_entry( class WiLightLightOnOff(WiLightDevice, LightEntity): """Representation of a WiLights light on-off.""" + _attr_name = None _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} @@ -73,6 +74,7 @@ class WiLightLightOnOff(WiLightDevice, LightEntity): class WiLightLightDimmer(WiLightDevice, LightEntity): """Representation of a WiLights light dimmer.""" + _attr_name = None _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @@ -124,6 +126,7 @@ def hass_to_wilight_saturation(value: float) -> int: class WiLightLightColor(WiLightDevice, LightEntity): """Representation of a WiLights light rgb.""" + _attr_name = None _attr_color_mode = ColorMode.HS _attr_supported_color_modes = {ColorMode.HS} diff --git a/homeassistant/components/wilight/services.yaml b/homeassistant/components/wilight/services.yaml index 07a545bd5d7..044a46784ef 100644 --- a/homeassistant/components/wilight/services.yaml +++ b/homeassistant/components/wilight/services.yaml @@ -1,24 +1,17 @@ set_watering_time: - description: Set watering time target: fields: watering_time: - description: Duration for this irrigation to be turned on example: 30 set_pause_time: - description: Set pause time target: fields: pause_time: - description: Duration for this irrigation to be paused example: 24 set_trigger: - description: Set trigger target: fields: trigger_index: - description: Index of Trigger from 1 to 4 example: "1" trigger: - description: Configuration of trigger example: "'12707001'" diff --git a/homeassistant/components/wilight/strings.json b/homeassistant/components/wilight/strings.json index 0449a900c29..ccba52d99e0 100644 --- a/homeassistant/components/wilight/strings.json +++ b/homeassistant/components/wilight/strings.json @@ -11,5 +11,51 @@ "not_supported_device": "This WiLight is currently not supported", "not_wilight_device": "This Device is not WiLight" } + }, + "entity": { + "switch": { + "watering": { + "name": "Watering" + }, + "pause": { + "name": "Pause" + } + } + }, + "services": { + "set_watering_time": { + "name": "Set watering time", + "description": "Sets time for watering.", + "fields": { + "watering_time": { + "name": "Duration", + "description": "Duration for this irrigation to be turned on." + } + } + }, + "set_pause_time": { + "name": "Set pause time", + "description": "Sets time to pause.", + "fields": { + "pause_time": { + "name": "Duration", + "description": "Duration for this irrigation to be paused." + } + } + }, + "set_trigger": { + "name": "Set trigger", + "description": "Sets the trigger to use.", + "fields": { + "trigger_index": { + "name": "Trigger index", + "description": "Index of Trigger from 1 to 4." + }, + "trigger": { + "name": "Trigger rules", + "description": "Configuration of trigger." + } + } + } } } diff --git a/homeassistant/components/wilight/switch.py b/homeassistant/components/wilight/switch.py index f2d74cce359..101162302ae 100644 --- a/homeassistant/components/wilight/switch.py +++ b/homeassistant/components/wilight/switch.py @@ -148,10 +148,7 @@ def hass_to_wilight_pause_time(value: int) -> int: class WiLightValveSwitch(WiLightDevice, SwitchEntity): """Representation of a WiLights Valve switch.""" - @property - def name(self) -> str: - """Return the name of the switch.""" - return f"{self._attr_name} {DESC_WATERING}" + _attr_translation_key = "watering" @property def is_on(self) -> bool: @@ -272,10 +269,7 @@ class WiLightValveSwitch(WiLightDevice, SwitchEntity): class WiLightValvePauseSwitch(WiLightDevice, SwitchEntity): """Representation of a WiLights Valve Pause switch.""" - @property - def name(self) -> str: - """Return the name of the switch.""" - return f"{self._attr_name} {DESC_PAUSE}" + _attr_translation_key = "pause" @property def is_on(self) -> bool: diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index 82c3a25590a..711c2987735 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -100,6 +100,7 @@ class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): super().__init__(api, tag) self._sensor_type = sensor_type self._name = f"{self._tag.name} {self.event.human_readable_name}" + self._attr_unique_id = f"{self.tag_id}_{self._sensor_type}" async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index e4505e59666..fd9a7898f92 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -100,6 +100,7 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): self._sensor_type = description.key self.entity_description = description self._name = self._tag.name + self._attr_unique_id = f"{self.tag_id}_{self._sensor_type}" # I want to see entity_id as: # sensor.wirelesstag_bedroom_temperature diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py index 26c7d9384a6..df0f72aca18 100644 --- a/homeassistant/components/wirelesstag/switch.py +++ b/homeassistant/components/wirelesstag/switch.py @@ -82,6 +82,7 @@ class WirelessTagSwitch(WirelessTagBaseSensor, SwitchEntity): super().__init__(api, tag) self.entity_description = description self._name = f"{self._tag.name} {description.name}" + self._attr_unique_id = f"{self.tag_id}_{description.key}" def turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 1b173e3a377..9282e3977c1 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass import datetime from datetime import timedelta -from enum import IntEnum +from enum import IntEnum, StrEnum from http import HTTPStatus import logging import re @@ -27,7 +27,6 @@ from withings_api.common import ( query_measure_groups, ) -from homeassistant.backports.enum import StrEnum from homeassistant.components import webhook from homeassistant.components.application_credentials import AuthImplementation from homeassistant.components.http import HomeAssistantView @@ -449,7 +448,7 @@ class DataManager: 0, 0, 0, - datetime.timezone.utc, + datetime.UTC, ) def get_sleep_summary() -> SleepGetSummaryResponse: diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 1193b6f612a..02d8977c604 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -1,5 +1,5 @@ """Constants used by the Withings component.""" -from homeassistant.backports.enum import StrEnum +from enum import StrEnum CONF_PROFILES = "profiles" CONF_USE_WEBHOOK = "use_webhook" diff --git a/homeassistant/components/wiz/binary_sensor.py b/homeassistant/components/wiz/binary_sensor.py index 538bd3a741d..6b3caf23a1c 100644 --- a/homeassistant/components/wiz/binary_sensor.py +++ b/homeassistant/components/wiz/binary_sensor.py @@ -66,7 +66,6 @@ class WizOccupancyEntity(WizEntity, BinarySensorEntity): """Representation of WiZ Occupancy sensor.""" _attr_device_class = BinarySensorDeviceClass.OCCUPANCY - _attr_name = "Occupancy" def __init__(self, wiz_data: WizData, name: str) -> None: """Initialize an WiZ device.""" diff --git a/homeassistant/components/wiz/number.py b/homeassistant/components/wiz/number.py index be1cf61ae09..f1212c75f25 100644 --- a/homeassistant/components/wiz/number.py +++ b/homeassistant/components/wiz/number.py @@ -49,11 +49,11 @@ async def _async_set_ratio(device: wizlight, ratio: int) -> None: NUMBERS: tuple[WizNumberEntityDescription, ...] = ( WizNumberEntityDescription( key="effect_speed", + translation_key="effect_speed", native_min_value=10, native_max_value=200, native_step=1, icon="mdi:speedometer", - name="Effect speed", value_fn=lambda device: cast(int | None, device.state.get_speed()), set_value_fn=_async_set_speed, required_feature="effect", @@ -61,11 +61,11 @@ NUMBERS: tuple[WizNumberEntityDescription, ...] = ( ), WizNumberEntityDescription( key="dual_head_ratio", + translation_key="dual_head_ratio", native_min_value=0, native_max_value=100, native_step=1, icon="mdi:floor-lamp-dual", - name="Dual head ratio", value_fn=lambda device: cast(int | None, device.state.get_ratio()), set_value_fn=_async_set_ratio, required_feature="dual_head", diff --git a/homeassistant/components/wiz/sensor.py b/homeassistant/components/wiz/sensor.py index e5346e00081..a66c37fabb5 100644 --- a/homeassistant/components/wiz/sensor.py +++ b/homeassistant/components/wiz/sensor.py @@ -23,7 +23,6 @@ from .models import WizData SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="rssi", - name="Signal strength", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -36,7 +35,6 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( POWER_SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="power", - name="Current power", state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, diff --git a/homeassistant/components/wiz/strings.json b/homeassistant/components/wiz/strings.json index 2efa3c9a1c3..b75e199fe33 100644 --- a/homeassistant/components/wiz/strings.json +++ b/homeassistant/components/wiz/strings.json @@ -13,7 +13,7 @@ }, "pick_device": { "data": { - "device": "Device" + "device": "[%key:common::config_flow::data::device%]" } } }, @@ -29,5 +29,15 @@ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "number": { + "effect_speed": { + "name": "Effect speed" + }, + "dual_head_ratio": { + "name": "Dual head ratio" + } + } } } diff --git a/homeassistant/components/wled/services.yaml b/homeassistant/components/wled/services.yaml index 9ca73fac0a3..40170fd54e9 100644 --- a/homeassistant/components/wled/services.yaml +++ b/homeassistant/components/wled/services.yaml @@ -1,55 +1,39 @@ effect: - name: Set effect - description: Control the effect settings of WLED. target: entity: integration: wled domain: light fields: effect: - name: Effect - description: Name or ID of the WLED light effect. example: "Rainbow" selector: text: intensity: - name: Effect intensity - description: Intensity of the effect. Number between 0 and 255. selector: number: min: 0 max: 255 palette: - name: Color palette - description: Name or ID of the WLED light palette. example: "Tiamat" selector: text: speed: - name: Effect speed - description: Speed of the effect. selector: number: min: 0 max: 255 reverse: - name: Reverse effect - description: Reverse the effect. Either true to reverse or false otherwise. default: false selector: boolean: preset: - name: Set preset (deprecated) - description: Set a preset for the WLED device. target: entity: integration: wled domain: light fields: preset: - name: Preset ID - description: ID of the WLED preset selector: number: min: -1 diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index eed62ab0499..9fc6573b112 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -41,5 +41,43 @@ } } } + }, + "services": { + "effect": { + "name": "Set effect", + "description": "Controls the effect settings of WLED.", + "fields": { + "effect": { + "name": "Effect", + "description": "Name or ID of the WLED light effect." + }, + "intensity": { + "name": "Effect intensity", + "description": "Intensity of the effect. Number between 0 and 255." + }, + "palette": { + "name": "Color palette", + "description": "Name or ID of the WLED light palette." + }, + "speed": { + "name": "Effect speed", + "description": "Speed of the effect." + }, + "reverse": { + "name": "Reverse effect", + "description": "Reverse the effect. Either true to reverse or false otherwise." + } + } + }, + "preset": { + "name": "Set preset (deprecated)", + "description": "Sets a preset for the WLED device.", + "fields": { + "preset": { + "name": "Preset ID", + "description": "ID of the WLED preset." + } + } + } } } diff --git a/homeassistant/components/wolflink/strings.json b/homeassistant/components/wolflink/strings.json index c8db962215f..b1c332984a1 100644 --- a/homeassistant/components/wolflink/strings.json +++ b/homeassistant/components/wolflink/strings.json @@ -18,7 +18,7 @@ }, "device": { "data": { - "device_name": "Device" + "device_name": "[%key:common::config_flow::data::device%]" }, "title": "Select WOLF device" } @@ -28,10 +28,10 @@ "sensor": { "state": { "state": { - "ein": "Enabled", + "ein": "[%key:common::state::enabled%]", "deaktiviert": "Inactive", - "aus": "Disabled", - "standby": "Standby", + "aus": "[%key:common::state::disabled%]", + "standby": "[%key:common::state::standby%]", "auto": "Auto", "permanent": "Permanent", "initialisierung": "Initialization", @@ -59,7 +59,7 @@ "spreizung_hoch": "dT too wide", "spreizung_kf": "Spread KF", "test": "Test", - "start": "Start", + "start": "[%key:common::action::start%]", "frost_heizkreis": "Heating circuit frost", "frost_warmwasser": "DHW frost", "schornsteinfeger": "Emissions test", diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 51560161faa..c80608ab1c2 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -90,12 +90,17 @@ async def async_setup_platform( """Set up the Workday sensor.""" async_create_issue( hass, - DOMAIN, - "deprecated_yaml", + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", breaks_in_ha_version="2023.11.0", is_fixable=False, + issue_domain=DOMAIN, severity=IssueSeverity.WARNING, translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Workday", + }, ) hass.async_create_task( @@ -120,15 +125,16 @@ async def async_setup_entry( sensor_name: str = entry.options[CONF_NAME] workdays: list[str] = entry.options[CONF_WORKDAYS] + cls: HolidayBase = getattr(holidays, country) year: int = (dt_util.now() + timedelta(days=days_offset)).year - obj_holidays: HolidayBase = getattr(holidays, country)(years=year) - if province: - try: - obj_holidays = getattr(holidays, country)(subdiv=province, years=year) - except NotImplementedError: - LOGGER.error("There is no subdivision %s in country %s", province, country) - return + if province and province not in cls.subdivisions: + LOGGER.error("There is no subdivision %s in country %s", province, country) + return + + obj_holidays = cls( + subdiv=province, years=year, language=cls.default_language + ) # type: ignore[operator] # Add custom holidays try: diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 7153dac1bcb..15e04ffca93 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -3,7 +3,8 @@ from __future__ import annotations from typing import Any -from holidays import country_holidays, list_supported_countries +import holidays +from holidays import HolidayBase, list_supported_countries import voluptuous as vol from homeassistant.config_entries import ( @@ -76,10 +77,12 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: if dt_util.parse_date(add_date) is None: raise AddDatesError("Incorrect date") + cls: HolidayBase = getattr(holidays, user_input[CONF_COUNTRY]) year: int = dt_util.now().year - obj_holidays = country_holidays( - user_input[CONF_COUNTRY], user_input.get(CONF_PROVINCE), year - ) + + obj_holidays = cls( + subdiv=user_input.get(CONF_PROVINCE), years=year, language=cls.default_language + ) # type: ignore[operator] for remove_date in user_input[CONF_REMOVE_HOLIDAYS]: if dt_util.parse_date(remove_date) is None: diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index e018eaa588e..698ef17902f 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -5,12 +5,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/workday", "iot_class": "local_polling", - "loggers": [ - "convertdate", - "hijri_converter", - "holidays", - "korean_lunar_calendar" - ], + "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.21.13"] + "requirements": ["holidays==0.28"] } diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index e6753b39dce..a217a7a36b1 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -1,4 +1,5 @@ { + "title": "Workday", "config": { "abort": { "incorrect_province": "Incorrect subdivision from yaml import", @@ -59,17 +60,11 @@ } }, "error": { - "add_holiday_error": "Incorrect format on date (YYYY-MM-DD)", - "remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found", + "add_holiday_error": "[%key:component::workday::config::error::add_holiday_error%]", + "remove_holiday_error": "[%key:component::workday::config::error::remove_holiday_error%]", "already_configured": "Service with this configuration already exist" } }, - "issues": { - "deprecated_yaml": { - "title": "The Workday YAML configuration is being removed", - "description": "Configuring Workday using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Workday YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } - }, "selector": { "province": { "options": { @@ -78,13 +73,13 @@ }, "days": { "options": { - "mon": "Monday", - "tue": "Tuesday", - "wed": "Wednesday", - "thu": "Thursday", - "fri": "Friday", - "sat": "Saturday", - "sun": "Sunday", + "mon": "[%key:common::time::monday%]", + "tue": "[%key:common::time::tuesday%]", + "wed": "[%key:common::time::wednesday%]", + "thu": "[%key:common::time::thursday%]", + "fri": "[%key:common::time::friday%]", + "sat": "[%key:common::time::saturday%]", + "sun": "[%key:common::time::sunday%]", "holiday": "Holidays" } } diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 8676365212a..33064d21097 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -7,11 +7,16 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN +from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService _LOGGER = logging.getLogger(__name__) +__all__ = [ + "ATTR_SPEAKER", + "DOMAIN", +] + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load Wyoming.""" diff --git a/homeassistant/components/wyoming/const.py b/homeassistant/components/wyoming/const.py index 26443cc11eb..fd73a6bd047 100644 --- a/homeassistant/components/wyoming/const.py +++ b/homeassistant/components/wyoming/const.py @@ -5,3 +5,6 @@ DOMAIN = "wyoming" SAMPLE_RATE = 16000 SAMPLE_WIDTH = 2 SAMPLE_CHANNELS = 1 + +# For multi-speaker voices, this is the name of the selected speaker. +ATTR_SPEAKER = "speaker" diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index 9ad8092bb8c..810092094d1 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wyoming", "iot_class": "local_push", - "requirements": ["wyoming==0.0.1"] + "requirements": ["wyoming==1.1.0"] } diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py index 3f5487881a3..e64a2f14667 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -2,7 +2,7 @@ from collections.abc import AsyncIterable import logging -from wyoming.asr import Transcript +from wyoming.asr import Transcribe, Transcript from wyoming.audio import AudioChunk, AudioStart, AudioStop from wyoming.client import AsyncTcpClient @@ -89,6 +89,10 @@ class WyomingSttProvider(stt.SpeechToTextEntity): """Process an audio stream to STT service.""" try: async with AsyncTcpClient(self.service.host, self.service.port) as client: + # Set transcription language + await client.write_event(Transcribe(language=metadata.language).event()) + + # Begin audio stream await client.write_event( AudioStart( rate=SAMPLE_RATE, @@ -106,6 +110,7 @@ class WyomingSttProvider(stt.SpeechToTextEntity): ) await client.write_event(chunk.event()) + # End audio stream await client.write_event(AudioStop().event()) while True: diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index 0fc7bf5e6c4..6510fd8c761 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -6,14 +6,14 @@ import wave from wyoming.audio import AudioChunk, AudioChunkConverter, AudioStop from wyoming.client import AsyncTcpClient -from wyoming.tts import Synthesize +from wyoming.tts import Synthesize, SynthesizeVoice from homeassistant.components import tts from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import ATTR_SPEAKER, DOMAIN from .data import WyomingService from .error import WyomingError @@ -57,10 +57,16 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): self._voices[language].append( tts.Voice( voice_id=voice.name, - name=voice.name, + name=voice.description or voice.name, ) ) + # Sort voices by name + for language in self._voices: + self._voices[language] = sorted( + self._voices[language], key=lambda v: v.name + ) + self._supported_languages: list[str] = list(voice_languages) self._attr_name = self._tts_service.name @@ -82,7 +88,7 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): @property def supported_options(self): """Return list of supported options like voice, emotion.""" - return [tts.ATTR_AUDIO_OUTPUT, tts.ATTR_VOICE] + return [tts.ATTR_AUDIO_OUTPUT, tts.ATTR_VOICE, ATTR_SPEAKER] @property def default_options(self): @@ -95,10 +101,18 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): return self._voices.get(language) async def async_get_tts_audio(self, message, language, options): - """Load TTS from UNIX socket.""" + """Load TTS from TCP socket.""" + voice_name: str | None = options.get(tts.ATTR_VOICE) + voice_speaker: str | None = options.get(ATTR_SPEAKER) + try: async with AsyncTcpClient(self.service.host, self.service.port) as client: - await client.write_event(Synthesize(message).event()) + voice: SynthesizeVoice | None = None + if voice_name is not None: + voice = SynthesizeVoice(name=voice_name, speaker=voice_speaker) + + synthesize = Synthesize(text=message, voice=voice) + await client.write_event(synthesize.event()) with io.BytesIO() as wav_io: wav_writer: wave.Wave_write | None = None diff --git a/homeassistant/components/xiaomi_aqara/manifest.json b/homeassistant/components/xiaomi_aqara/manifest.json index 6d84a5ffd0a..75d4b0b9a00 100644 --- a/homeassistant/components/xiaomi_aqara/manifest.json +++ b/homeassistant/components/xiaomi_aqara/manifest.json @@ -1,7 +1,6 @@ { "domain": "xiaomi_aqara", "name": "Xiaomi Gateway (Aqara)", - "after_dependencies": ["discovery"], "codeowners": ["@danielhiversen", "@syssi"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_aqara", diff --git a/homeassistant/components/xiaomi_aqara/services.yaml b/homeassistant/components/xiaomi_aqara/services.yaml index 75a9b9156c1..dcf79ebc215 100644 --- a/homeassistant/components/xiaomi_aqara/services.yaml +++ b/homeassistant/components/xiaomi_aqara/services.yaml @@ -1,70 +1,42 @@ add_device: - name: Add device - description: - Enables the join permission of the Xiaomi Aqara Gateway for 30 seconds. - A new device can be added afterwards by pressing the pairing button once. fields: gw_mac: - name: Gateway MAC - description: MAC address of the Xiaomi Aqara Gateway. required: true example: 34ce00880088 selector: text: play_ringtone: - name: play ringtone - description: - Play a specific ringtone. The version of the gateway firmware must - be 1.4.1_145 at least. fields: gw_mac: - name: Gateway MAC - description: MAC address of the Xiaomi Aqara Gateway. required: true example: 34ce00880088 selector: text: ringtone_id: - name: Ringtone ID - description: One of the allowed ringtone ids. required: true example: 8 selector: text: ringtone_vol: - name: Ringtone volume - description: The volume in percent. selector: number: min: 0 max: 100 remove_device: - name: Remove device - description: - Removes a specific device. The removal is required if a device shall - be paired with another gateway. fields: device_id: - name: Device ID - description: Hardware address of the device to remove. required: true example: 158d0000000000 selector: text: gw_mac: - name: Gateway MAC - description: MAC address of the Xiaomi Aqara Gateway. required: true example: 34ce00880088 selector: text: stop_ringtone: - name: Stop ringtone - description: Stops a playing ringtone immediately. fields: gw_mac: - name: Gateway MAC - description: MAC address of the Xiaomi Aqara Gateway. required: true example: 34ce00880088 selector: diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json index 63fb48542c9..a77b78c5a09 100644 --- a/homeassistant/components/xiaomi_aqara/strings.json +++ b/homeassistant/components/xiaomi_aqara/strings.json @@ -37,5 +37,59 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "not_xiaomi_aqara": "Not a Xiaomi Aqara Gateway, discovered device did not match known gateways" } + }, + "services": { + "add_device": { + "name": "Add device", + "description": "Enables the join permission of the Xiaomi Aqara Gateway for 30 seconds. A new device can be added afterwards by pressing the pairing button once.", + "fields": { + "gw_mac": { + "name": "Gateway MAC", + "description": "MAC address of the Xiaomi Aqara Gateway." + } + } + }, + "play_ringtone": { + "name": "Play ringtone", + "description": "Plays a specific ringtone. The version of the gateway firmware must be 1.4.1_145 at least.", + "fields": { + "gw_mac": { + "name": "[%key:component::xiaomi_aqara::services::add_device::fields::gw_mac::name%]", + "description": "[%key:component::xiaomi_aqara::services::add_device::fields::gw_mac::description%]" + }, + "ringtone_id": { + "name": "Ringtone ID", + "description": "One of the allowed ringtone ids." + }, + "ringtone_vol": { + "name": "Ringtone volume", + "description": "The volume in percent." + } + } + }, + "remove_device": { + "name": "Remove device", + "description": "Removes a specific device. The removal is required if a device shall be paired with another gateway.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "Hardware address of the device to remove." + }, + "gw_mac": { + "name": "[%key:component::xiaomi_aqara::services::add_device::fields::gw_mac::name%]", + "description": "[%key:component::xiaomi_aqara::services::add_device::fields::gw_mac::description%]" + } + } + }, + "stop_ringtone": { + "name": "Stop ringtone", + "description": "Stops a playing ringtone immediately.", + "fields": { + "gw_mac": { + "name": "[%key:component::xiaomi_aqara::services::add_device::fields::gw_mac::name%]", + "description": "[%key:component::xiaomi_aqara::services::add_device::fields::gw_mac::description%]" + } + } + } } } diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index 3930c50c70c..1810d52323c 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -12,15 +12,18 @@ from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, async_ble_device_from_address, ) -from homeassistant.components.bluetooth.active_update_processor import ( - ActiveBluetoothProcessorCoordinator, -) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry, async_get -from .const import DOMAIN, XIAOMI_BLE_EVENT, XiaomiBleEvent +from .const import ( + CONF_DISCOVERED_EVENT_CLASSES, + DOMAIN, + XIAOMI_BLE_EVENT, + XiaomiBleEvent, +) +from .coordinator import XiaomiActiveBluetoothProcessorCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -36,6 +39,10 @@ def process_service_info( ) -> SensorUpdate: """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data.""" update = data.update(service_info) + coordinator: XiaomiActiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + discovered_device_classes = coordinator.discovered_device_classes if update.events: address = service_info.device.address for device_key, event in update.events.items(): @@ -49,6 +56,16 @@ def process_service_info( sw_version=sensor_device_info.sw_version, hw_version=sensor_device_info.hw_version, ) + event_class = event.device_key.key + event_type = event.event_type + + if event_class not in discovered_device_classes: + discovered_device_classes.add(event_class) + hass.config_entries.async_update_entry( + entry, + data=entry.data + | {CONF_DISCOVERED_EVENT_CLASSES: list(discovered_device_classes)}, + ) hass.bus.async_fire( XIAOMI_BLE_EVENT, @@ -56,7 +73,8 @@ def process_service_info( XiaomiBleEvent( device_id=device.id, address=address, - event_type=event.event_type, + event_class=event_class, # ie 'button' + event_type=event_type, # ie 'press' event_properties=event.event_properties, ) ), @@ -121,7 +139,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_registry = async_get(hass) coordinator = hass.data.setdefault(DOMAIN, {})[ entry.entry_id - ] = ActiveBluetoothProcessorCoordinator( + ] = XiaomiActiveBluetoothProcessorCoordinator( hass, _LOGGER, address=address, @@ -130,6 +148,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry, data, service_info, device_registry ), needs_poll_method=_needs_poll, + device_data=data, + discovered_device_classes=set( + entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) + ), poll_method=_async_poll, # We will take advertisements from non-connectable devices # since we will trade the BLEDevice for a connectable one diff --git a/homeassistant/components/xiaomi_ble/binary_sensor.py b/homeassistant/components/xiaomi_ble/binary_sensor.py index 3d7bdfd0b48..f7c4c87014c 100644 --- a/homeassistant/components/xiaomi_ble/binary_sensor.py +++ b/homeassistant/components/xiaomi_ble/binary_sensor.py @@ -1,7 +1,6 @@ """Support for Xiaomi binary sensors.""" from __future__ import annotations -from xiaomi_ble import SLEEPY_DEVICE_MODELS from xiaomi_ble.parser import ( BinarySensorDeviceClass as XiaomiBinarySensorDeviceClass, ExtendedBinarySensorDeviceClass, @@ -15,17 +14,18 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) -from homeassistant.const import ATTR_MODEL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN +from .coordinator import ( + XiaomiActiveBluetoothProcessorCoordinator, + XiaomiPassiveBluetoothDataProcessor, +) from .device import device_key_to_bluetooth_entity_key BINARY_SENSOR_DESCRIPTIONS = { @@ -108,10 +108,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Xiaomi BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + coordinator: XiaomiActiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + processor = XiaomiPassiveBluetoothDataProcessor( + sensor_update_to_bluetooth_data_update + ) entry.async_on_unload( processor.async_add_entities_listener( XiaomiBluetoothSensorEntity, async_add_entities @@ -121,7 +123,7 @@ async def async_setup_entry( class XiaomiBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[bool | None]], + PassiveBluetoothProcessorEntity[XiaomiPassiveBluetoothDataProcessor], BinarySensorEntity, ): """Representation of a Xiaomi binary sensor.""" @@ -134,8 +136,7 @@ class XiaomiBluetoothSensorEntity( @property def available(self) -> bool: """Return True if entity is available.""" - if self.device_info and self.device_info[ATTR_MODEL] in SLEEPY_DEVICE_MODELS: - # These devices sleep for an indeterminate amount of time - # so there is no way to track their availability. - return True - return super().available + coordinator: XiaomiActiveBluetoothProcessorCoordinator = ( + self.processor.coordinator + ) + return coordinator.device_data.sleepy_device or super().available diff --git a/homeassistant/components/xiaomi_ble/config_flow.py b/homeassistant/components/xiaomi_ble/config_flow.py index d168835b394..9115fc5991b 100644 --- a/homeassistant/components/xiaomi_ble/config_flow.py +++ b/homeassistant/components/xiaomi_ble/config_flow.py @@ -123,7 +123,7 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): if len(bindkey) != 24: errors["bindkey"] = "expected_24_characters" else: - self._discovered_device.bindkey = bytes.fromhex(bindkey) + self._discovered_device.set_bindkey(bytes.fromhex(bindkey)) # If we got this far we already know supported will # return true so we don't bother checking that again @@ -157,7 +157,7 @@ class XiaomiConfigFlow(ConfigFlow, domain=DOMAIN): if len(bindkey) != 32: errors["bindkey"] = "expected_32_characters" else: - self._discovered_device.bindkey = bytes.fromhex(bindkey) + self._discovered_device.set_bindkey(bytes.fromhex(bindkey)) # If we got this far we already know supported will # return true so we don't bother checking that again diff --git a/homeassistant/components/xiaomi_ble/const.py b/homeassistant/components/xiaomi_ble/const.py index dda6c61d8aa..1566478bcea 100644 --- a/homeassistant/components/xiaomi_ble/const.py +++ b/homeassistant/components/xiaomi_ble/const.py @@ -6,6 +6,7 @@ from typing import Final, TypedDict DOMAIN = "xiaomi_ble" +CONF_DISCOVERED_EVENT_CLASSES: Final = "known_events" CONF_EVENT_PROPERTIES: Final = "event_properties" EVENT_PROPERTIES: Final = "event_properties" EVENT_TYPE: Final = "event_type" @@ -17,5 +18,6 @@ class XiaomiBleEvent(TypedDict): device_id: str address: str - event_type: str + event_class: str # ie 'button' + event_type: str # ie 'press' event_properties: dict[str, str | int | float | None] | None diff --git a/homeassistant/components/xiaomi_ble/coordinator.py b/homeassistant/components/xiaomi_ble/coordinator.py new file mode 100644 index 00000000000..2a4b35f6171 --- /dev/null +++ b/homeassistant/components/xiaomi_ble/coordinator.py @@ -0,0 +1,63 @@ +"""The Xiaomi BLE integration.""" +from collections.abc import Callable, Coroutine +from logging import Logger +from typing import Any + +from xiaomi_ble import XiaomiBluetoothDeviceData + +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfoBleak, +) +from homeassistant.components.bluetooth.active_update_processor import ( + ActiveBluetoothProcessorCoordinator, +) +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothDataProcessor, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer + + +class XiaomiActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator): + """Define a Xiaomi Bluetooth Active Update Processor Coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + *, + address: str, + mode: BluetoothScanningMode, + update_method: Callable[[BluetoothServiceInfoBleak], Any], + needs_poll_method: Callable[[BluetoothServiceInfoBleak, float | None], bool], + device_data: XiaomiBluetoothDeviceData, + discovered_device_classes: set[str], + poll_method: Callable[ + [BluetoothServiceInfoBleak], + Coroutine[Any, Any, Any], + ] + | None = None, + poll_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None, + connectable: bool = True, + ) -> None: + """Initialize the Xiaomi Bluetooth Active Update Processor Coordinator.""" + super().__init__( + hass=hass, + logger=logger, + address=address, + mode=mode, + update_method=update_method, + needs_poll_method=needs_poll_method, + poll_method=poll_method, + poll_debouncer=poll_debouncer, + connectable=connectable, + ) + self.discovered_device_classes = discovered_device_classes + self.device_data = device_data + + +class XiaomiPassiveBluetoothDataProcessor(PassiveBluetoothDataProcessor): + """Define a Xiaomi Bluetooth Passive Update Data Processor.""" + + coordinator: XiaomiActiveBluetoothProcessorCoordinator diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 69a95ea8a9c..e2b327c6823 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -2,6 +2,14 @@ "domain": "xiaomi_ble", "name": "Xiaomi BLE", "bluetooth": [ + { + "connectable": false, + "service_data_uuid": "0000181b-0000-1000-8000-00805f9b34fb" + }, + { + "connectable": false, + "service_data_uuid": "0000181d-0000-1000-8000-00805f9b34fb" + }, { "connectable": false, "service_data_uuid": "0000fd50-0000-1000-8000-00805f9b34fb" @@ -16,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.17.2"] + "requirements": ["xiaomi-ble==0.20.0"] } diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py index 81739db4d11..f0f0d7fa71e 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -5,9 +5,7 @@ from xiaomi_ble import DeviceClass, SensorUpdate, Units from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -24,6 +22,7 @@ from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, UnitOfElectricPotential, + UnitOfMass, UnitOfPressure, UnitOfTemperature, ) @@ -32,6 +31,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN +from .coordinator import ( + XiaomiActiveBluetoothProcessorCoordinator, + XiaomiPassiveBluetoothDataProcessor, +) from .device import device_key_to_bluetooth_entity_key SENSOR_DESCRIPTIONS = { @@ -68,6 +71,28 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=LIGHT_LUX, state_class=SensorStateClass.MEASUREMENT, ), + # Impedance sensor (ohm) + (DeviceClass.IMPEDANCE, Units.OHM): SensorEntityDescription( + key=f"{DeviceClass.IMPEDANCE}_{Units.OHM}", + icon="mdi:omega", + native_unit_of_measurement=Units.OHM, + state_class=SensorStateClass.MEASUREMENT, + ), + # Mass sensor (kg) + (DeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription( + key=f"{DeviceClass.MASS}_{Units.MASS_KILOGRAMS}", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + state_class=SensorStateClass.MEASUREMENT, + ), + # Mass non stabilized sensor (kg) + (DeviceClass.MASS_NON_STABILIZED, Units.MASS_KILOGRAMS): SensorEntityDescription( + key=f"{DeviceClass.MASS_NON_STABILIZED}_{Units.MASS_KILOGRAMS}", + device_class=SensorDeviceClass.WEIGHT, + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), (DeviceClass.MOISTURE, Units.PERCENTAGE): SensorEntityDescription( key=f"{DeviceClass.MOISTURE}_{Units.PERCENTAGE}", device_class=SensorDeviceClass.MOISTURE, @@ -147,10 +172,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Xiaomi BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + coordinator: XiaomiActiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + processor = XiaomiPassiveBluetoothDataProcessor( + sensor_update_to_bluetooth_data_update + ) entry.async_on_unload( processor.async_add_entities_listener( XiaomiBluetoothSensorEntity, async_add_entities @@ -160,7 +187,7 @@ async def async_setup_entry( class XiaomiBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[XiaomiPassiveBluetoothDataProcessor], SensorEntity, ): """Representation of a xiaomi ble sensor.""" @@ -169,3 +196,11 @@ class XiaomiBluetoothSensorEntity( def native_value(self) -> int | float | None: """Return the native value.""" return self.processor.entity_data.get(self.entity_key) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + coordinator: XiaomiActiveBluetoothProcessorCoordinator = ( + self.processor.coordinator + ) + return coordinator.device_data.sleepy_device or super().available diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index d1d703f9875..abda8703e02 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", "iot_class": "local_polling", "loggers": ["micloud", "miio"], - "requirements": ["construct==2.10.56", "micloud==0.5", "python-miio==0.5.12"], + "requirements": ["construct==2.10.68", "micloud==0.5", "python-miio==0.5.12"], "zeroconf": ["_miio._udp.local."] } diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 249774519d0..86c7905848a 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -42,6 +42,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -292,7 +293,7 @@ SENSOR_TYPES = { ), ATTR_FILTER_LIFE_REMAINING: XiaomiMiioSensorDescription( key=ATTR_FILTER_LIFE_REMAINING, - name="Filter life remaining", + name="Filter lifetime remaining", native_unit_of_measurement=PERCENTAGE, icon="mdi:air-filter", state_class=SensorStateClass.MEASUREMENT, @@ -310,7 +311,7 @@ SENSOR_TYPES = { ), ATTR_FILTER_LEFT_TIME: XiaomiMiioSensorDescription( key=ATTR_FILTER_LEFT_TIME, - name="Filter time left", + name="Filter lifetime left", native_unit_of_measurement=UnitOfTime.DAYS, icon="mdi:clock-outline", device_class=SensorDeviceClass.DURATION, @@ -319,7 +320,7 @@ SENSOR_TYPES = { ), ATTR_DUST_FILTER_LIFE_REMAINING: XiaomiMiioSensorDescription( key=ATTR_DUST_FILTER_LIFE_REMAINING, - name="Dust filter life remaining", + name="Dust filter lifetime remaining", native_unit_of_measurement=PERCENTAGE, icon="mdi:air-filter", state_class=SensorStateClass.MEASUREMENT, @@ -328,7 +329,7 @@ SENSOR_TYPES = { ), ATTR_DUST_FILTER_LIFE_REMAINING_DAYS: XiaomiMiioSensorDescription( key=ATTR_DUST_FILTER_LIFE_REMAINING_DAYS, - name="Dust filter life remaining days", + name="Dust filter lifetime remaining days", native_unit_of_measurement=UnitOfTime.DAYS, icon="mdi:clock-outline", device_class=SensorDeviceClass.DURATION, @@ -337,7 +338,7 @@ SENSOR_TYPES = { ), ATTR_UPPER_FILTER_LIFE_REMAINING: XiaomiMiioSensorDescription( key=ATTR_UPPER_FILTER_LIFE_REMAINING, - name="Upper filter life remaining", + name="Upper filter lifetime remaining", native_unit_of_measurement=PERCENTAGE, icon="mdi:air-filter", state_class=SensorStateClass.MEASUREMENT, @@ -346,7 +347,7 @@ SENSOR_TYPES = { ), ATTR_UPPER_FILTER_LIFE_REMAINING_DAYS: XiaomiMiioSensorDescription( key=ATTR_UPPER_FILTER_LIFE_REMAINING_DAYS, - name="Upper filter life remaining days", + name="Upper filter lifetime remaining days", native_unit_of_measurement=UnitOfTime.DAYS, icon="mdi:clock-outline", device_class=SensorDeviceClass.DURATION, @@ -997,7 +998,9 @@ class XiaomiGatewayIlluminanceSensor(SensorEntity): """Initialize the entity.""" self._attr_name = f"{gateway_name} {description.name}" self._attr_unique_id = f"{gateway_device_id}-{description.key}" - self._attr_device_info = {"identifiers": {(DOMAIN, gateway_device_id)}} + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, gateway_device_id)}, + ) self._gateway = gateway_device self.entity_description = description self._available = False diff --git a/homeassistant/components/xiaomi_miio/services.yaml b/homeassistant/components/xiaomi_miio/services.yaml index e1cf03ba4ee..0b3bd6435e4 100644 --- a/homeassistant/components/xiaomi_miio/services.yaml +++ b/homeassistant/components/xiaomi_miio/services.yaml @@ -1,27 +1,19 @@ fan_reset_filter: - name: Fan reset filter - description: Reset the filter lifetime and usage. fields: entity_id: - description: Name of the xiaomi miio entity. selector: entity: integration: xiaomi_miio domain: fan fan_set_extra_features: - name: Fan set extra features - description: Manipulates a storage register which advertises extra features. The Mi Home app evaluates the value. A feature called "turbo mode" is unlocked in the app on value 1. fields: entity_id: - description: Name of the xiaomi miio entity. selector: entity: integration: xiaomi_miio domain: fan features: - name: Features - description: Integer, known values are 0 (default) and 1 (turbo mode). required: true selector: number: @@ -29,18 +21,13 @@ fan_set_extra_features: max: 1 light_set_scene: - name: Light set scene - description: Set a fixed scene. fields: entity_id: - description: Name of the light entity. selector: entity: integration: xiaomi_miio domain: light scene: - name: Scene - description: Number of the fixed scene. required: true selector: number: @@ -48,108 +35,79 @@ light_set_scene: max: 6 light_set_delayed_turn_off: - name: Light set delayed turn off - description: Delayed turn off. fields: entity_id: - description: Name of the light entity. selector: entity: integration: xiaomi_miio domain: light time_period: - name: Time period - description: Time period for the delayed turn off. required: true example: "5, '0:05', {'minutes': 5}" selector: object: light_reminder_on: - name: Light reminder on - description: Enable the eye fatigue reminder/notification (EYECARE SMART LAMP 2 ONLY). fields: entity_id: - description: "Name of the entity to act on." selector: entity: integration: xiaomi_miio domain: light light_reminder_off: - name: Light reminder off - description: Disable the eye fatigue reminder/notification (EYECARE SMART LAMP 2 ONLY). fields: entity_id: - description: "Name of the entity to act on." selector: entity: integration: xiaomi_miio domain: light light_night_light_mode_on: - name: Night light mode on - description: Turn the eyecare mode on (EYECARE SMART LAMP 2 ONLY). fields: entity_id: - description: "Name of the entity to act on." selector: entity: integration: xiaomi_miio domain: light light_night_light_mode_off: - name: Night light mode off - description: Turn the eyecare mode fan_set_dry_off (EYECARE SMART LAMP 2 ONLY). fields: entity_id: - description: "Name of the entity to act on." selector: entity: integration: xiaomi_miio domain: light light_eyecare_mode_on: - name: Light eyecare mode on - description: Enable the eye fatigue reminder/notification (EYECARE SMART LAMP 2 ONLY). fields: entity_id: - description: "Name of the entity to act on." selector: entity: integration: xiaomi_miio domain: light light_eyecare_mode_off: - name: Light eyecare mode off - description: Disable the eye fatigue reminder/notification (EYECARE SMART LAMP 2 ONLY). fields: entity_id: - description: "Name of the entity to act on." selector: entity: integration: xiaomi_miio domain: light remote_learn_command: - name: Remote learn command - description: 'Learn an IR command, press "Call Service", point the remote at the IR device, and the learned command will be shown as a notification in Overview.' target: entity: integration: xiaomi_miio domain: remote fields: slot: - name: Slot - description: "Define the slot used to save the IR command." default: 1 selector: number: min: 1 max: 1000000 timeout: - name: Timeout - description: "Define the timeout, before which the command must be learned." default: 10 selector: number: @@ -158,56 +116,41 @@ remote_learn_command: unit_of_measurement: seconds remote_set_led_on: - name: Remote set LED on - description: "Turn on blue LED." target: entity: integration: xiaomi_miio domain: remote remote_set_led_off: - name: Remote set LED off - description: "Turn off blue LED." target: entity: integration: xiaomi_miio domain: remote switch_set_wifi_led_on: - name: Switch set Wi-fi LED on - description: Turn the wifi led on. fields: entity_id: - description: Name of the xiaomi miio entity. selector: entity: integration: xiaomi_miio domain: switch switch_set_wifi_led_off: - name: Switch set Wi-fi LED off - description: Turn the wifi led off. fields: entity_id: - description: Name of the xiaomi miio entity. selector: entity: integration: xiaomi_miio domain: switch switch_set_power_price: - name: Switch set power price - description: Set the power price. fields: entity_id: - description: Name of the xiaomi miio entity. selector: entity: integration: xiaomi_miio domain: switch mode: - name: Mode - description: Power price. required: true selector: number: @@ -215,18 +158,13 @@ switch_set_power_price: max: 999 switch_set_power_mode: - name: Switch set power mode - description: Set the power mode. fields: entity_id: - description: Name of the xiaomi miio entity. selector: entity: integration: xiaomi_miio domain: switch mode: - name: Mode - description: Power mode. required: true selector: select: @@ -235,48 +173,36 @@ switch_set_power_mode: - "normal" vacuum_remote_control_start: - name: Vacuum remote control start - description: Start remote control of the vacuum cleaner. You can then move it with `remote_control_move`, when done call `remote_control_stop`. target: entity: integration: xiaomi_miio domain: vacuum vacuum_remote_control_stop: - name: Vacuum remote control stop - description: Stop remote control mode of the vacuum cleaner. target: entity: integration: xiaomi_miio domain: vacuum vacuum_remote_control_move: - name: Vacuum remote control move - description: Remote control the vacuum cleaner, make sure you first set it in remote control mode with `remote_control_start`. target: entity: integration: xiaomi_miio domain: vacuum fields: velocity: - name: Velocity - description: Speed. selector: number: min: -0.29 max: 0.29 step: 0.01 rotation: - name: Rotation - description: Rotation, between -179 degrees and 179 degrees. selector: number: min: -179 max: 179 unit_of_measurement: "°" duration: - name: Duration - description: Duration of the movement. selector: number: min: 1 @@ -284,32 +210,24 @@ vacuum_remote_control_move: unit_of_measurement: seconds vacuum_remote_control_move_step: - name: Vacuum remote control move step - description: Remote control the vacuum cleaner, only makes one move and then stops. target: entity: integration: xiaomi_miio domain: vacuum fields: velocity: - name: Velocity - description: Speed. selector: number: min: -0.29 max: 0.29 step: 0.01 rotation: - name: Rotation - description: Rotation. selector: number: min: -179 max: 179 unit_of_measurement: "°" duration: - name: Duration - description: Duration of the movement. selector: number: min: 1 @@ -317,59 +235,43 @@ vacuum_remote_control_move_step: unit_of_measurement: seconds vacuum_clean_zone: - name: Vacuum clean zone - description: Start the cleaning operation in the selected areas for the number of repeats indicated. target: entity: integration: xiaomi_miio domain: vacuum fields: zone: - name: Zone - description: Array of zones. Each zone is an array of 4 integer values. example: "[[23510,25311,25110,26362]]" selector: object: repeats: - name: Repeats - description: Number of cleaning repeats for each zone. selector: number: min: 1 max: 3 vacuum_goto: - name: Vacuum go to - description: Go to the specified coordinates. target: entity: integration: xiaomi_miio domain: vacuum fields: x_coord: - name: X coordinate - description: x-coordinate. example: 27500 selector: text: y_coord: - name: Y coordinate - description: y-coordinate. example: 32000 selector: text: vacuum_clean_segment: - name: Vacuum clean segment - description: Start cleaning of the specified segment(s). target: entity: integration: xiaomi_miio domain: vacuum fields: segments: - name: Segments - description: Segments. example: "[1,2]" selector: object: diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index dfcb503182c..a9588855818 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -53,7 +53,7 @@ }, "options": { "error": { - "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country" + "cloud_credentials_incomplete": "[%key:component::xiaomi_miio::config::error::cloud_credentials_incomplete%]" }, "step": { "init": { @@ -69,7 +69,7 @@ "state": { "bright": "Bright", "dim": "Dim", - "off": "Off" + "off": "[%key:common::state::off%]" } }, "display_orientation": { @@ -94,5 +94,271 @@ } } } + }, + "services": { + "fan_reset_filter": { + "name": "Fan reset filter", + "description": "Resets the filter lifetime and usage.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Name of the xiaomi miio entity." + } + } + }, + "fan_set_extra_features": { + "name": "Fan set extra features", + "description": "Manipulates a storage register which advertises extra features. The Mi Home app evaluates the value. A feature called \"turbo mode\" is unlocked in the app on value 1.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "[%key:component::xiaomi_miio::services::fan_reset_filter::fields::entity_id::description%]" + }, + "features": { + "name": "Features", + "description": "Integer, known values are 0 (default) and 1 (turbo mode)." + } + } + }, + "light_set_scene": { + "name": "Light set scene", + "description": "Sets a fixed scene.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Name of the light entity." + }, + "scene": { + "name": "Scene", + "description": "Number of the fixed scene." + } + } + }, + "light_set_delayed_turn_off": { + "name": "Light set delayed turn off", + "description": "Delayed turn off.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "[%key:component::xiaomi_miio::services::light_set_scene::fields::entity_id::description%]" + }, + "time_period": { + "name": "Time period", + "description": "Time period for the delayed turn off." + } + } + }, + "light_reminder_on": { + "name": "Light reminder on", + "description": "Enables the eye fatigue reminder/notification (EYECARE SMART LAMP 2 ONLY).", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "Name of the entity to act on." + } + } + }, + "light_reminder_off": { + "name": "Light reminder off", + "description": "Disables the eye fatigue reminder/notification (EYECARE SMART LAMP 2 ONLY).", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "[%key:component::xiaomi_miio::services::light_reminder_on::fields::entity_id::description%]" + } + } + }, + "light_night_light_mode_on": { + "name": "Night light mode on", + "description": "Turns the eyecare mode on (EYECARE SMART LAMP 2 ONLY).", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "[%key:component::xiaomi_miio::services::light_reminder_on::fields::entity_id::description%]" + } + } + }, + "light_night_light_mode_off": { + "name": "Night light mode off", + "description": "Turns the eyecare mode fan_set_dry_off (EYECARE SMART LAMP 2 ONLY).", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "[%key:component::xiaomi_miio::services::light_reminder_on::fields::entity_id::description%]" + } + } + }, + "light_eyecare_mode_on": { + "name": "Light eyecare mode on", + "description": "[%key:component::xiaomi_miio::services::light_reminder_on::description%]", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "[%key:component::xiaomi_miio::services::light_reminder_on::fields::entity_id::description%]" + } + } + }, + "light_eyecare_mode_off": { + "name": "Light eyecare mode off", + "description": "[%key:component::xiaomi_miio::services::light_reminder_off::description%]", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "[%key:component::xiaomi_miio::services::light_reminder_on::fields::entity_id::description%]" + } + } + }, + "remote_learn_command": { + "name": "Remote learn command", + "description": "Learns an IR command, press \"Call Service\", point the remote at the IR device, and the learned command will be shown as a notification in Overview.", + "fields": { + "slot": { + "name": "Slot", + "description": "Define the slot used to save the IR command." + }, + "timeout": { + "name": "Timeout", + "description": "Define the timeout, before which the command must be learned." + } + } + }, + "remote_set_led_on": { + "name": "Remote set LED on", + "description": "Turns on blue LED." + }, + "remote_set_led_off": { + "name": "Remote set LED off", + "description": "Turns off blue LED." + }, + "switch_set_wifi_led_on": { + "name": "Switch set Wi-Fi LED on", + "description": "Turns the wifi led on.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "[%key:component::xiaomi_miio::services::fan_reset_filter::fields::entity_id::description%]" + } + } + }, + "switch_set_wifi_led_off": { + "name": "Switch set Wi-Fi LED off", + "description": "Turn the Wi-Fi led off.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "[%key:component::xiaomi_miio::services::fan_reset_filter::fields::entity_id::description%]" + } + } + }, + "switch_set_power_price": { + "name": "Switch set power price", + "description": "Sets the power price.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "[%key:component::xiaomi_miio::services::fan_reset_filter::fields::entity_id::description%]" + }, + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "Power price." + } + } + }, + "switch_set_power_mode": { + "name": "Switch set power mode", + "description": "Sets the power mode.", + "fields": { + "entity_id": { + "name": "Entity ID", + "description": "[%key:component::xiaomi_miio::services::fan_reset_filter::fields::entity_id::description%]" + }, + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "Power mode." + } + } + }, + "vacuum_remote_control_start": { + "name": "Vacuum remote control start", + "description": "Starts remote control of the vacuum cleaner. You can then move it with `remote_control_move`, when done call `remote_control_stop`." + }, + "vacuum_remote_control_stop": { + "name": "Vacuum remote control stop", + "description": "Stops remote control mode of the vacuum cleaner." + }, + "vacuum_remote_control_move": { + "name": "Vacuum remote control move", + "description": "Remote controls the vacuum cleaner, make sure you first set it in remote control mode with `remote_control_start`.", + "fields": { + "velocity": { + "name": "Velocity", + "description": "Speed." + }, + "rotation": { + "name": "Rotation", + "description": "Rotation, between -179 degrees and 179 degrees." + }, + "duration": { + "name": "Duration", + "description": "Duration of the movement." + } + } + }, + "vacuum_remote_control_move_step": { + "name": "Vacuum remote control move step", + "description": "Remote controls the vacuum cleaner, only makes one move and then stops.", + "fields": { + "velocity": { + "name": "[%key:component::xiaomi_miio::services::vacuum_remote_control_move::fields::velocity::name%]", + "description": "[%key:component::xiaomi_miio::services::vacuum_remote_control_move::fields::velocity::description%]" + }, + "rotation": { + "name": "[%key:component::xiaomi_miio::services::vacuum_remote_control_move::fields::rotation::name%]", + "description": "Rotation." + }, + "duration": { + "name": "Duration", + "description": "[%key:component::xiaomi_miio::services::vacuum_remote_control_move::fields::duration::description%]" + } + } + }, + "vacuum_clean_zone": { + "name": "Vacuum clean zone", + "description": "Starts the cleaning operation in the selected areas for the number of repeats indicated.", + "fields": { + "zone": { + "name": "Zone", + "description": "Array of zones. Each zone is an array of 4 integer values." + }, + "repeats": { + "name": "Repeats", + "description": "Number of cleaning repeats for each zone." + } + } + }, + "vacuum_goto": { + "name": "Vacuum go to", + "description": "Go to the specified coordinates.", + "fields": { + "x_coord": { + "name": "X coordinate", + "description": "X-coordinate." + }, + "y_coord": { + "name": "Y coordinate", + "description": "Y-coordinate." + } + } + }, + "vacuum_clean_segment": { + "name": "Vacuum clean segment", + "description": "Starts cleaning of the specified segment(s).", + "fields": { + "segments": { + "name": "Segments", + "description": "Segments." + } + } + } } } diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index 5928013e098..ec0c5d0702a 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -22,7 +22,7 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "name": "[%key:common::config_flow::data::name%]", - "area_id": "Area ID" + "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]" } } } diff --git a/homeassistant/components/yalexs_ble/binary_sensor.py b/homeassistant/components/yalexs_ble/binary_sensor.py index 32421f67fbb..8213baf33aa 100644 --- a/homeassistant/components/yalexs_ble/binary_sensor.py +++ b/homeassistant/components/yalexs_ble/binary_sensor.py @@ -32,7 +32,6 @@ class YaleXSBLEDoorSensor(YALEXSBLEEntity, BinarySensorEntity): """Yale XS BLE binary sensor.""" _attr_device_class = BinarySensorDeviceClass.DOOR - _attr_has_entity_name = True @callback def _async_update_state( diff --git a/homeassistant/components/yalexs_ble/entity.py b/homeassistant/components/yalexs_ble/entity.py index 18f1e28ece6..51f30b8a861 100644 --- a/homeassistant/components/yalexs_ble/entity.py +++ b/homeassistant/components/yalexs_ble/entity.py @@ -15,6 +15,7 @@ from .models import YaleXSBLEData class YALEXSBLEEntity(Entity): """Base class for yale xs ble entities.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__(self, data: YaleXSBLEData) -> None: diff --git a/homeassistant/components/yalexs_ble/lock.py b/homeassistant/components/yalexs_ble/lock.py index 0ecf0e7b697..d457784a038 100644 --- a/homeassistant/components/yalexs_ble/lock.py +++ b/homeassistant/components/yalexs_ble/lock.py @@ -28,7 +28,6 @@ async def async_setup_entry( class YaleXSBLELock(YALEXSBLEEntity, LockEntity): """A yale xs ble lock.""" - _attr_has_entity_name = True _attr_name = None @callback diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 4822b2d2704..3aefeea048a 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.1.18"] + "requirements": ["yalexs-ble==2.2.3"] } diff --git a/homeassistant/components/yalexs_ble/sensor.py b/homeassistant/components/yalexs_ble/sensor.py index 6304b791edd..9d702ff52eb 100644 --- a/homeassistant/components/yalexs_ble/sensor.py +++ b/homeassistant/components/yalexs_ble/sensor.py @@ -44,7 +44,6 @@ class YaleXSBLESensorEntityDescription( SENSORS: tuple[YaleXSBLESensorEntityDescription, ...] = ( YaleXSBLESensorEntityDescription( key="", # No key for the original RSSI sensor unique id - name="Signal strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -55,7 +54,6 @@ SENSORS: tuple[YaleXSBLESensorEntityDescription, ...] = ( ), YaleXSBLESensorEntityDescription( key="battery_level", - name="Battery level", device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, @@ -67,7 +65,7 @@ SENSORS: tuple[YaleXSBLESensorEntityDescription, ...] = ( ), YaleXSBLESensorEntityDescription( key="battery_voltage", - name="Battery Voltage", + translation_key="battery_voltage", device_class=SensorDeviceClass.VOLTAGE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index bd96e07f6ba..c79830be3a9 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -45,5 +45,12 @@ } } } + }, + "entity": { + "sensor": { + "battery_voltage": { + "name": "Battery voltage" + } + } } } diff --git a/homeassistant/components/yamaha/services.yaml b/homeassistant/components/yamaha/services.yaml index 8d25d5925c1..705f2996a3c 100644 --- a/homeassistant/components/yamaha/services.yaml +++ b/homeassistant/components/yamaha/services.yaml @@ -1,49 +1,35 @@ enable_output: - name: Enable output - description: Enable or disable an output port target: entity: integration: yamaha domain: media_player fields: port: - name: Port - description: Name of port to enable/disable. required: true example: "hdmi1" selector: text: enabled: - name: Enabled - description: Indicate if port should be enabled or not. required: true selector: boolean: menu_cursor: - name: Menu cursor - description: Control the cursor in a menu target: entity: integration: yamaha domain: media_player fields: cursor: - name: Cursor - description: Name of the cursor key to press ('up', 'down', 'left', 'right', 'select', 'return') example: down selector: text: select_scene: - name: Select scene - description: "Select a scene on the receiver" target: entity: integration: yamaha domain: media_player fields: scene: - name: Scene - description: Name of the scene. Standard for RX-V437 is 'BD/DVD Movie Viewing', 'TV Viewing', 'NET Audio Listening' or 'Radio Listening' required: true example: "TV Viewing" selector: diff --git a/homeassistant/components/yamaha/strings.json b/homeassistant/components/yamaha/strings.json new file mode 100644 index 00000000000..ecb69d9fc38 --- /dev/null +++ b/homeassistant/components/yamaha/strings.json @@ -0,0 +1,38 @@ +{ + "services": { + "enable_output": { + "name": "Enable output", + "description": "Enables or disables an output port.", + "fields": { + "port": { + "name": "[%key:common::config_flow::data::port%]", + "description": "Name of port to enable/disable." + }, + "enabled": { + "name": "[%key:common::state::enabled%]", + "description": "Indicate if port should be enabled or not." + } + } + }, + "menu_cursor": { + "name": "Menu cursor", + "description": "Controls the cursor in a menu.", + "fields": { + "cursor": { + "name": "Cursor", + "description": "Name of the cursor key to press ('up', 'down', 'left', 'right', 'select', 'return')." + } + } + }, + "select_scene": { + "name": "Select scene", + "description": "Selects a scene on the receiver.", + "fields": { + "scene": { + "name": "Scene", + "description": "Name of the scene. Standard for RX-V437 is 'BD/DVD Movie Viewing', 'TV Viewing', 'NET Audio Listening' or 'Radio Listening'." + } + } + } + } +} diff --git a/homeassistant/components/yamaha_musiccast/strings.json b/homeassistant/components/yamaha_musiccast/strings.json index 9905a8af74b..c4f28fc750b 100644 --- a/homeassistant/components/yamaha_musiccast/strings.json +++ b/homeassistant/components/yamaha_musiccast/strings.json @@ -29,7 +29,7 @@ }, "zone_sleep": { "state": { - "off": "Off", + "off": "[%key:common::state::off%]", "30_min": "30 Minutes", "60_min": "60 Minutes", "90_min": "90 Minutes", @@ -45,7 +45,7 @@ }, "zone_surr_decoder_type": { "state": { - "toggle": "Toggle", + "toggle": "[%key:common::action::toggle%]", "auto": "Auto", "dolby_pl": "Dolby ProLogic", "dolby_pl2x_movie": "Dolby ProLogic 2x Movie", @@ -61,7 +61,7 @@ "state": { "manual": "Manual", "auto": "Auto", - "bypass": "Bypass" + "bypass": "[%key:component::yamaha_musiccast::entity::select::zone_tone_control_mode::state::bypass%]" } }, "zone_link_audio_quality": { diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py index f78b4e1401d..88779e03b6c 100644 --- a/homeassistant/components/yeelight/binary_sensor.py +++ b/homeassistant/components/yeelight/binary_sensor.py @@ -28,6 +28,8 @@ async def async_setup_entry( class YeelightNightlightModeSensor(YeelightEntity, BinarySensorEntity): """Representation of a Yeelight nightlight mode sensor.""" + _attr_translation_key = "nightlight" + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" self.async_on_remove( @@ -44,11 +46,6 @@ class YeelightNightlightModeSensor(YeelightEntity, BinarySensorEntity): """Return a unique ID.""" return f"{self._unique_id}-nightlight_sensor" - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._device.name} nightlight" - @property def is_on(self): """Return true if nightlight mode is on.""" diff --git a/homeassistant/components/yeelight/entity.py b/homeassistant/components/yeelight/entity.py index 53211115dd6..9422ec9980d 100644 --- a/homeassistant/components/yeelight/entity.py +++ b/homeassistant/components/yeelight/entity.py @@ -12,6 +12,7 @@ class YeelightEntity(Entity): """Represents single Yeelight entity.""" _attr_should_poll = False + _attr_has_entity_name = True def __init__(self, device: YeelightDevice, entry: ConfigEntry) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 9ac457b79e9..35739b0f596 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -472,11 +472,6 @@ class YeelightGenericLight(YeelightEntity, LightEntity): self._color_temp = kelvin_to_mired(int(temp_in_k)) return self._color_temp - @property - def name(self) -> str: - """Return the name of the device if any.""" - return self.device.name - @property def is_on(self) -> bool: """Return true if device is on.""" @@ -892,6 +887,7 @@ class YeelightColorLightSupport(YeelightGenericLight): class YeelightWhiteTempLightSupport(YeelightGenericLight): """Representation of a White temp Yeelight light.""" + _attr_name = None _attr_color_mode = ColorMode.COLOR_TEMP _attr_supported_color_modes = {ColorMode.COLOR_TEMP} @@ -943,6 +939,8 @@ class YeelightColorLightWithNightlightSwitch( It represents case when nightlight switch is set to light. """ + _attr_name = None + @property def is_on(self) -> bool: """Return true if device is on.""" @@ -954,6 +952,8 @@ class YeelightWhiteTempWithoutNightlightSwitch( ): """White temp light, when nightlight switch is not set to light.""" + _attr_name = None + class YeelightWithNightLight( YeelightNightLightSupport, YeelightWhiteTempLightSupport, YeelightGenericLight @@ -963,6 +963,8 @@ class YeelightWithNightLight( It represents case when nightlight switch is set to light. """ + _attr_name = None + @property def is_on(self) -> bool: """Return true if device is on.""" @@ -975,6 +977,7 @@ class YeelightNightLightMode(YeelightGenericLight): _attr_color_mode = ColorMode.BRIGHTNESS _attr_icon = "mdi:weather-night" _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + _attr_translation_key = "nightlight" @property def unique_id(self) -> str: @@ -982,11 +985,6 @@ class YeelightNightLightMode(YeelightGenericLight): unique = super().unique_id return f"{unique}-nightlight" - @property - def name(self) -> str: - """Return the name of the device if any.""" - return f"{self.device.name} Nightlight" - @property def is_on(self) -> bool: """Return true if device is on.""" @@ -1030,6 +1028,8 @@ class YeelightWithAmbientWithoutNightlight(YeelightWhiteTempWithoutNightlightSwi And nightlight switch type is none. """ + _attr_name = None + @property def _power_property(self) -> str: return "main_power" @@ -1041,6 +1041,8 @@ class YeelightWithAmbientAndNightlight(YeelightWithNightLight): And nightlight switch type is set to light. """ + _attr_name = None + @property def _power_property(self) -> str: return "main_power" @@ -1049,6 +1051,8 @@ class YeelightWithAmbientAndNightlight(YeelightWithNightLight): class YeelightAmbientLight(YeelightColorLightWithoutNightlightSwitch): """Representation of a Yeelight ambient light.""" + _attr_translation_key = "ambilight" + PROPERTIES_MAPPING = {"color_mode": "bg_lmode"} def __init__(self, *args, **kwargs): @@ -1065,11 +1069,6 @@ class YeelightAmbientLight(YeelightColorLightWithoutNightlightSwitch): unique = super().unique_id return f"{unique}-ambilight" - @property - def name(self) -> str: - """Return the name of the device if any.""" - return f"{self.device.name} Ambilight" - @property def _brightness_property(self) -> str: return "bright" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index cf1bafe24fb..7f5a67f4220 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.11", "async-upnp-client==0.33.2"], + "requirements": ["yeelight==0.7.12", "async-upnp-client==0.34.1"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index dc4283b4a76..7c6bbd2d2ee 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -7,12 +7,12 @@ import contextlib from datetime import datetime from ipaddress import IPv4Address import logging +from typing import Self from urllib.parse import urlparse import async_timeout from async_upnp_client.search import SsdpSearchListener from async_upnp_client.utils import CaseInsensitiveDict -from typing_extensions import Self from homeassistant import config_entries from homeassistant.components import network, ssdp diff --git a/homeassistant/components/yeelight/services.yaml b/homeassistant/components/yeelight/services.yaml index d7850b34607..ccfd46ef680 100644 --- a/homeassistant/components/yeelight/services.yaml +++ b/homeassistant/components/yeelight/services.yaml @@ -1,85 +1,60 @@ set_mode: - name: Set mode - description: Set a operation mode. target: entity: integration: yeelight domain: light fields: mode: - name: Mode - description: Operation mode. required: true selector: select: options: - - label: "Color Flow" - value: "color_flow" - - label: "HSV" - value: "hsv" - - label: "Last" - value: "last" - - label: "Moonlight" - value: "moonlight" - - label: "Normal" - value: "normal" - - label: "RGB" - value: "rgb" + - "color_flow" + - "hsv" + - "last" + - "moonlight" + - "normal" + - "rgb" + translation_key: mode set_color_scene: - name: Set color scene - description: Changes the light to the specified RGB color and brightness. If the light is off, it will be turned on. target: entity: integration: yeelight domain: light fields: rgb_color: - name: RGB color - description: Color for the light in RGB-format. example: "[255, 100, 100]" selector: object: brightness: - name: Brightness - description: The brightness value to set. selector: number: min: 0 max: 100 unit_of_measurement: "%" set_hsv_scene: - name: Set HSV scene - description: Changes the light to the specified HSV color and brightness. If the light is off, it will be turned on. target: entity: integration: yeelight domain: light fields: hs_color: - name: Hue/sat color - description: Color for the light in hue/sat format. Hue is 0-359 and Sat is 0-100. example: "[300, 70]" selector: object: brightness: - name: Brightness - description: The brightness value to set. selector: number: min: 0 max: 100 unit_of_measurement: "%" set_color_temp_scene: - name: Set color temperature scene - description: Changes the light to the specified color temperature. If the light is off, it will be turned on. target: entity: integration: yeelight domain: light fields: kelvin: - name: Kelvin - description: Color temperature for the light in Kelvin. selector: number: min: 1700 @@ -87,118 +62,90 @@ set_color_temp_scene: step: 100 unit_of_measurement: K brightness: - name: Brightness - description: The brightness value to set. selector: number: min: 0 max: 100 unit_of_measurement: "%" set_color_flow_scene: - name: Set color flow scene - description: starts a color flow. If the light is off, it will be turned on. target: entity: integration: yeelight domain: light fields: count: - name: Count - description: The number of times to run this flow (0 to run forever). default: 0 selector: number: min: 0 max: 100 action: - name: Action - description: The action to take after the flow stops. default: "recover" selector: select: options: - - label: "Off" - value: "off" - - label: "Recover" - value: "recover" - - label: "Stay" - value: "stay" + - "off" + - "recover" + - "stay" + translation_key: action transitions: - name: Transitions - description: Array of transitions, for desired effect. Examples https://yeelight.readthedocs.io/en/stable/flow.html - example: '[{ "TemperatureTransition": [1900, 1000, 80] }, { "TemperatureTransition": [1900, 1000, 10] }]' + example: + '[{ "TemperatureTransition": [1900, 1000, 80] }, { "TemperatureTransition": + [1900, 1000, 10] }]' selector: object: set_auto_delay_off_scene: - name: Set auto delay off scene - description: Turns the light on to the specified brightness and sets a timer to turn it back off after the given number of minutes. If the light is off, Set a color scene, if light is off, it will be turned on. target: entity: integration: yeelight domain: light fields: minutes: - name: Minutes - description: The time to wait before automatically turning the light off. selector: number: min: 1 max: 60 unit_of_measurement: minutes brightness: - name: Brightness - description: The brightness value to set. selector: number: min: 0 max: 100 unit_of_measurement: "%" start_flow: - name: Start flow - description: Start a custom flow, using transitions from https://yeelight.readthedocs.io/en/stable/yeelight.html#flow-objects target: entity: integration: yeelight domain: light fields: count: - name: Count - description: The number of times to run this flow (0 to run forever). default: 0 selector: number: min: 0 max: 100 action: - name: Action - description: The action to take after the flow stops. default: "recover" selector: select: options: - - label: "Off" - value: "off" - - label: "Recover" - value: "recover" - - label: "Stay" - value: "stay" + - "off" + - "recover" + - "stay" + translation_key: action transitions: - name: Transitions - description: Array of transitions, for desired effect. Examples https://yeelight.readthedocs.io/en/stable/flow.html - example: '[{ "TemperatureTransition": [1900, 1000, 80] }, { "TemperatureTransition": [1900, 1000, 10] }]' + example: + '[{ "TemperatureTransition": [1900, 1000, 80] }, { "TemperatureTransition": + [1900, 1000, 10] }]' selector: object: set_music_mode: - name: Set music mode - description: Enable or disable music_mode target: entity: integration: yeelight domain: light fields: music_mode: - name: Music mode - description: Use true or false to enable / disable music_mode required: true selector: boolean: diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json index 0ecbd134b6a..ab22f42dae3 100644 --- a/homeassistant/components/yeelight/strings.json +++ b/homeassistant/components/yeelight/strings.json @@ -10,7 +10,7 @@ }, "pick_device": { "data": { - "device": "Device" + "device": "[%key:common::config_flow::data::device%]" } }, "discovery_confirm": { @@ -29,7 +29,7 @@ "step": { "init": { "data": { - "model": "Model", + "model": "[%key:common::generic::model%]", "transition": "Transition Time (ms)", "use_music_mode": "Enable Music Mode", "save_on_change": "Save Status On Change", @@ -37,5 +37,153 @@ } } } + }, + "entity": { + "binary_sensor": { + "nightlight": { + "name": "[%key:component::yeelight::entity::light::nightlight::name%]" + } + }, + "light": { + "nightlight": { + "name": "Nightlight" + }, + "ambilight": { + "name": "Ambilight" + } + } + }, + "services": { + "set_mode": { + "name": "Set mode", + "description": "Sets a operation mode.", + "fields": { + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "Operation mode." + } + } + }, + "set_color_scene": { + "name": "Set color scene", + "description": "Changes the light to the specified RGB color and brightness. If the light is off, it will be turned on.", + "fields": { + "rgb_color": { + "name": "RGB color", + "description": "Color for the light in RGB-format." + }, + "brightness": { + "name": "Brightness", + "description": "The brightness value to set." + } + } + }, + "set_hsv_scene": { + "name": "Set HSV scene", + "description": "Changes the light to the specified HSV color and brightness. If the light is off, it will be turned on.", + "fields": { + "hs_color": { + "name": "Hue/sat color", + "description": "Color for the light in hue/sat format. Hue is 0-359 and Sat is 0-100." + }, + "brightness": { + "name": "Brightness", + "description": "[%key:component::yeelight::services::set_color_scene::fields::brightness::description%]" + } + } + }, + "set_color_temp_scene": { + "name": "Set color temperature scene", + "description": "Changes the light to the specified color temperature. If the light is off, it will be turned on.", + "fields": { + "kelvin": { + "name": "Kelvin", + "description": "Color temperature for the light in Kelvin." + }, + "brightness": { + "name": "Brightness", + "description": "[%key:component::yeelight::services::set_color_scene::fields::brightness::description%]" + } + } + }, + "set_color_flow_scene": { + "name": "Set color flow scene", + "description": "Starts a color flow. If the light is off, it will be turned on.", + "fields": { + "count": { + "name": "Count", + "description": "The number of times to run this flow (0 to run forever)." + }, + "action": { + "name": "Action", + "description": "The action to take after the flow stops." + }, + "transitions": { + "name": "Transitions", + "description": "Array of transitions, for desired effect. Examples https://yeelight.readthedocs.io/en/stable/flow.html." + } + } + }, + "set_auto_delay_off_scene": { + "name": "Set auto delay off scene", + "description": "Turns the light on to the specified brightness and sets a timer to turn it back off after the given number of minutes. If the light is off, Set a color scene, if light is off, it will be turned on.", + "fields": { + "minutes": { + "name": "Minutes", + "description": "The time to wait before automatically turning the light off." + }, + "brightness": { + "name": "Brightness", + "description": "[%key:component::yeelight::services::set_color_scene::fields::brightness::description%]" + } + } + }, + "start_flow": { + "name": "Start flow", + "description": "Start a custom flow, using transitions from https://yeelight.readthedocs.io/en/stable/yeelight.html#flow-objects.", + "fields": { + "count": { + "name": "Count", + "description": "[%key:component::yeelight::services::set_color_flow_scene::fields::count::description%]" + }, + "action": { + "name": "Action", + "description": "[%key:component::yeelight::services::set_color_flow_scene::fields::action::description%]" + }, + "transitions": { + "name": "[%key:component::yeelight::services::set_color_flow_scene::fields::transitions::name%]", + "description": "[%key:component::yeelight::services::set_color_flow_scene::fields::transitions::description%]" + } + } + }, + "set_music_mode": { + "name": "Set music mode", + "description": "Enables or disables music_mode.", + "fields": { + "music_mode": { + "name": "Music mode", + "description": "Use true or false to enable / disable music_mode." + } + } + } + }, + "selector": { + "mode": { + "options": { + "color_flow": "Color Flow", + "hsv": "HSV", + "last": "Last", + "moonlight": "Moonlight", + "normal": "Normal", + "rgb": "RGB" + } + }, + "action": { + "options": { + "off": "[%key:common::state::off%]", + "recover": "Recover", + "stay": "Stay" + } + } } } diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index c10cc8158ea..c3633800685 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -121,8 +121,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from err device_coordinators = {} + + # revese mapping + device_pairing_mapping = {} for device in yolink_home.get_devices(): - device_coordinator = YoLinkCoordinator(hass, device) + if (parent_id := device.get_paired_device_id()) is not None: + device_pairing_mapping[parent_id] = device.device_id + + for device in yolink_home.get_devices(): + paried_device: YoLinkDevice | None = None + if ( + paried_device_id := device_pairing_mapping.get(device.device_id) + ) is not None: + paried_device = yolink_home.get_device(paried_device_id) + device_coordinator = YoLinkCoordinator(hass, device, paried_device) try: await device_coordinator.async_config_entry_first_refresh() except ConfigEntryNotReady: diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 5b9dacb9db7..38ea7d46537 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -51,42 +51,35 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( key="door_state", icon="mdi:door", device_class=BinarySensorDeviceClass.DOOR, - name="State", value=lambda value: value == "open" if value is not None else None, exists_fn=lambda device: device.device_type == ATTR_DEVICE_DOOR_SENSOR, ), YoLinkBinarySensorEntityDescription( key="motion_state", device_class=BinarySensorDeviceClass.MOTION, - name="Motion", value=lambda value: value == "alert" if value is not None else None, exists_fn=lambda device: device.device_type == ATTR_DEVICE_MOTION_SENSOR, ), YoLinkBinarySensorEntityDescription( key="leak_state", - name="Leak", - icon="mdi:water", device_class=BinarySensorDeviceClass.MOISTURE, value=lambda value: value == "alert" if value is not None else None, exists_fn=lambda device: device.device_type == ATTR_DEVICE_LEAK_SENSOR, ), YoLinkBinarySensorEntityDescription( key="vibration_state", - name="Vibration", device_class=BinarySensorDeviceClass.VIBRATION, value=lambda value: value == "alert" if value is not None else None, exists_fn=lambda device: device.device_type == ATTR_DEVICE_VIBRATION_SENSOR, ), YoLinkBinarySensorEntityDescription( key="co_detected", - name="Co Detected", device_class=BinarySensorDeviceClass.CO, value=lambda state: state.get("gasAlarm"), exists_fn=lambda device: device.device_type == ATTR_DEVICE_CO_SMOKE_SENSOR, ), YoLinkBinarySensorEntityDescription( key="smoke_detected", - name="Smoke Detected", device_class=BinarySensorDeviceClass.SMOKE, value=lambda state: state.get("smokeAlarm"), exists_fn=lambda device: device.device_type == ATTR_DEVICE_CO_SMOKE_SENSOR, @@ -135,9 +128,6 @@ class YoLinkBinarySensorEntity(YoLinkEntity, BinarySensorEntity): self._attr_unique_id = ( f"{coordinator.device.device_id} {self.entity_description.key}" ) - self._attr_name = ( - f"{coordinator.device.device_name} ({self.entity_description.name})" - ) @callback def update_entity_state(self, state: dict[str, Any]) -> None: diff --git a/homeassistant/components/yolink/climate.py b/homeassistant/components/yolink/climate.py index e9d11fb77d0..6e4495ee0b9 100644 --- a/homeassistant/components/yolink/climate.py +++ b/homeassistant/components/yolink/climate.py @@ -61,6 +61,8 @@ async def async_setup_entry( class YoLinkClimateEntity(YoLinkEntity, ClimateEntity): """YoLink Climate Entity.""" + _attr_name = None + def __init__( self, config_entry: ConfigEntry, @@ -69,7 +71,6 @@ class YoLinkClimateEntity(YoLinkEntity, ClimateEntity): """Init YoLink Thermostat.""" super().__init__(config_entry, coordinator) self._attr_unique_id = f"{coordinator.device.device_id}_climate" - self._attr_name = f"{coordinator.device.device_name} (Thermostat)" self._attr_temperature_unit = UnitOfTemperature.CELSIUS self._attr_fan_modes = [FAN_ON, FAN_AUTO] self._attr_min_temp = -10 diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index f22e416511b..e322961d179 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -20,7 +20,12 @@ _LOGGER = logging.getLogger(__name__) class YoLinkCoordinator(DataUpdateCoordinator[dict]): """YoLink DataUpdateCoordinator.""" - def __init__(self, hass: HomeAssistant, device: YoLinkDevice) -> None: + def __init__( + self, + hass: HomeAssistant, + device: YoLinkDevice, + paired_device: YoLinkDevice | None = None, + ) -> None: """Init YoLink DataUpdateCoordinator. fetch state every 30 minutes base on yolink device heartbeat interval @@ -31,16 +36,30 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30) ) self.device = device + self.paired_device = paired_device async def _async_update_data(self) -> dict: """Fetch device state.""" try: async with async_timeout.timeout(10): device_state_resp = await self.device.fetch_state() + device_state = device_state_resp.data.get(ATTR_DEVICE_STATE) + if self.paired_device is not None and device_state is not None: + paried_device_state_resp = await self.paired_device.fetch_state() + paried_device_state = paried_device_state_resp.data.get( + ATTR_DEVICE_STATE + ) + if ( + paried_device_state is not None + and ATTR_DEVICE_STATE in paried_device_state + ): + device_state[ATTR_DEVICE_STATE] = paried_device_state[ + ATTR_DEVICE_STATE + ] except YoLinkAuthFailError as yl_auth_err: raise ConfigEntryAuthFailed from yl_auth_err except YoLinkClientError as yl_client_err: raise UpdateFailed from yl_client_err - if ATTR_DEVICE_STATE in device_state_resp.data: - return device_state_resp.data[ATTR_DEVICE_STATE] + if device_state is not None: + return device_state return {} diff --git a/homeassistant/components/yolink/cover.py b/homeassistant/components/yolink/cover.py index 1b22f76f177..6cc1ea3acd6 100644 --- a/homeassistant/components/yolink/cover.py +++ b/homeassistant/components/yolink/cover.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from yolink.client_request import ClientRequest -from yolink.const import ATTR_GARAGE_DOOR_CONTROLLER +from yolink.const import ATTR_DEVICE_FINGER, ATTR_GARAGE_DOOR_CONTROLLER from homeassistant.components.cover import ( CoverDeviceClass, @@ -30,7 +30,8 @@ async def async_setup_entry( entities = [ YoLinkCoverEntity(config_entry, device_coordinator) for device_coordinator in device_coordinators.values() - if device_coordinator.device.device_type == ATTR_GARAGE_DOOR_CONTROLLER + if device_coordinator.device.device_type + in [ATTR_GARAGE_DOOR_CONTROLLER, ATTR_DEVICE_FINGER] ] async_add_entities(entities) @@ -38,6 +39,8 @@ async def async_setup_entry( class YoLinkCoverEntity(YoLinkEntity, CoverEntity): """YoLink Cover Entity.""" + _attr_name = None + def __init__( self, config_entry: ConfigEntry, @@ -46,7 +49,6 @@ class YoLinkCoverEntity(YoLinkEntity, CoverEntity): """Init YoLink garage door entity.""" super().__init__(config_entry, coordinator) self._attr_unique_id = f"{coordinator.device.device_id}_door_state" - self._attr_name = f"{coordinator.device.device_name} (State)" self._attr_device_class = CoverDeviceClass.GARAGE self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE @@ -57,8 +59,12 @@ class YoLinkCoverEntity(YoLinkEntity, CoverEntity): """Update HA Entity State.""" if (state_val := state.get("state")) is None: return - self._attr_is_closed = state_val == "closed" - self.async_write_ha_state() + if self.coordinator.paired_device is None: + self._attr_is_closed = None + self.async_write_ha_state() + elif state_val in ["open", "closed"]: + self._attr_is_closed = state_val == "closed" + self.async_write_ha_state() async def toggle_garage_state(self) -> None: """Toggle Garage door state.""" diff --git a/homeassistant/components/yolink/entity.py b/homeassistant/components/yolink/entity.py index 76ef1ecd534..09da5545d57 100644 --- a/homeassistant/components/yolink/entity.py +++ b/homeassistant/components/yolink/entity.py @@ -19,6 +19,8 @@ from .coordinator import YoLinkCoordinator class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): """YoLink Device Basic Entity.""" + _attr_has_entity_name = True + def __init__( self, config_entry: ConfigEntry, diff --git a/homeassistant/components/yolink/light.py b/homeassistant/components/yolink/light.py index a7f52e801b2..248a42df60c 100644 --- a/homeassistant/components/yolink/light.py +++ b/homeassistant/components/yolink/light.py @@ -35,7 +35,6 @@ class YoLinkDimmerEntity(YoLinkEntity, LightEntity): """YoLink Dimmer Entity.""" _attr_color_mode = ColorMode.BRIGHTNESS - _attr_has_entity_name = True _attr_name = None _attr_supported_color_modes: set[ColorMode] = {ColorMode.BRIGHTNESS} diff --git a/homeassistant/components/yolink/lock.py b/homeassistant/components/yolink/lock.py index 7565c66867a..3b0f68c175c 100644 --- a/homeassistant/components/yolink/lock.py +++ b/homeassistant/components/yolink/lock.py @@ -34,6 +34,8 @@ async def async_setup_entry( class YoLinkLockEntity(YoLinkEntity, LockEntity): """YoLink Lock Entity.""" + _attr_name = None + def __init__( self, config_entry: ConfigEntry, @@ -42,7 +44,6 @@ class YoLinkLockEntity(YoLinkEntity, LockEntity): """Init YoLink Lock.""" super().__init__(config_entry, coordinator) self._attr_unique_id = f"{coordinator.device.device_id}_lock_state" - self._attr_name = f"{coordinator.device.device_name}(LockState)" @callback def update_entity_state(self, state: dict[str, Any]) -> None: diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 088ddd114f8..ced0d527c7d 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.2.9"] + "requirements": ["yolink-api==0.3.0"] } diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 75c4949859c..e4d0aa38fbe 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -8,6 +8,7 @@ from yolink.const import ( ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_DIMMER, ATTR_DEVICE_DOOR_SENSOR, + ATTR_DEVICE_FINGER, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_LOCK, ATTR_DEVICE_MANIPULATOR, @@ -67,6 +68,7 @@ class YoLinkSensorEntityDescription( SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_DIMMER, ATTR_DEVICE_DOOR_SENSOR, + ATTR_DEVICE_FINGER, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_MULTI_OUTLET, @@ -86,6 +88,7 @@ SENSOR_DEVICE_TYPE = [ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_DOOR_SENSOR, + ATTR_DEVICE_FINGER, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_POWER_FAILURE_ALARM, @@ -126,16 +129,15 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( key="battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, - name="Battery", state_class=SensorStateClass.MEASUREMENT, value=cvt_battery, exists_fn=lambda device: device.device_type in BATTERY_POWER_SENSOR, + should_update_entity=lambda value: value is not None, ), YoLinkSensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, - name="Humidity", state_class=SensorStateClass.MEASUREMENT, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_TH_SENSOR], ), @@ -143,7 +145,6 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - name="Temperature", state_class=SensorStateClass.MEASUREMENT, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_TH_SENSOR], ), @@ -152,7 +153,6 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( key="devTemperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - name="Temperature", state_class=SensorStateClass.MEASUREMENT, exists_fn=lambda device: device.device_type in MCU_DEV_TEMPERATURE_SENSOR, should_update_entity=lambda value: value is not None, @@ -161,7 +161,6 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( key="loraInfo", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - name="Signal", value=lambda value: value["signal"] if value is not None else None, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -170,16 +169,16 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( ), YoLinkSensorEntityDescription( key="state", + translation_key="power_failure_alarm", device_class=SensorDeviceClass.ENUM, - name="Power failure alarm", icon="mdi:flash", options=["normal", "alert", "off"], exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, ), YoLinkSensorEntityDescription( key="mute", + translation_key="power_failure_alarm_mute", device_class=SensorDeviceClass.ENUM, - name="Power failure alarm mute", icon="mdi:volume-mute", options=["muted", "unmuted"], exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, @@ -187,8 +186,8 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( ), YoLinkSensorEntityDescription( key="sound", + translation_key="power_failure_alarm_volume", device_class=SensorDeviceClass.ENUM, - name="Power failure alarm volume", icon="mdi:volume-high", options=["low", "medium", "high"], exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, @@ -196,8 +195,8 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( ), YoLinkSensorEntityDescription( key="beep", + translation_key="power_failure_alarm_beep", device_class=SensorDeviceClass.ENUM, - name="Power failure alarm beep", icon="mdi:bullhorn", options=["enabled", "disabled"], exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, @@ -249,9 +248,6 @@ class YoLinkSensorEntity(YoLinkEntity, SensorEntity): self._attr_unique_id = ( f"{coordinator.device.device_id} {self.entity_description.key}" ) - self._attr_name = ( - f"{coordinator.device.device_name} ({self.entity_description.name})" - ) @callback def update_entity_state(self, state: dict) -> None: diff --git a/homeassistant/components/yolink/siren.py b/homeassistant/components/yolink/siren.py index ad51b912193..81c2b46a840 100644 --- a/homeassistant/components/yolink/siren.py +++ b/homeassistant/components/yolink/siren.py @@ -34,7 +34,6 @@ class YoLinkSirenEntityDescription(SirenEntityDescription): DEVICE_TYPES: tuple[YoLinkSirenEntityDescription, ...] = ( YoLinkSirenEntityDescription( key="state", - name="State", value=lambda value: value == "alert" if value is not None else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_SIREN], ), @@ -70,6 +69,8 @@ async def async_setup_entry( class YoLinkSirenEntity(YoLinkEntity, SirenEntity): """YoLink Siren Entity.""" + _attr_name = None + entity_description: YoLinkSirenEntityDescription def __init__( @@ -84,9 +85,6 @@ class YoLinkSirenEntity(YoLinkEntity, SirenEntity): self._attr_unique_id = ( f"{coordinator.device.device_id} {self.entity_description.key}" ) - self._attr_name = ( - f"{coordinator.device.device_name} ({self.entity_description.name})" - ) self._attr_supported_features = ( SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF ) diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index de16e1a6e39..b1cd8d87a75 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -33,5 +33,56 @@ "button_4_short_press": "Button_4 (short press)", "button_4_long_press": "Button_4 (long press)" } + }, + "entity": { + "switch": { + "usb_ports": { + "name": "USB ports" + }, + "plug_1": { + "name": "Plug 1" + }, + "plug_2": { + "name": "Plug 2" + }, + "plug_3": { + "name": "Plug 3" + }, + "plug_4": { + "name": "Plug 4" + } + }, + "sensor": { + "power_failure_alarm": { + "name": "Power failure alarm", + "state": { + "normal": "Normal", + "alert": "Alert", + "off": "[%key:common::state::off%]" + } + }, + "power_failure_alarm_mute": { + "name": "Power failure alarm mute", + "state": { + "muted": "Muted", + "unmuted": "Unmuted" + } + }, + "power_failure_alarm_volume": { + "name": "Power failure alarm volume", + "state": { + "low": "Low", + "medium": "Medium", + "high": "High" + } + }, + "power_failure_alarm_beep": { + "name": "Power failure alarm beep", + "state": { + "enabled": "[%key:common::state::enabled%]", + "disabled": "[%key:common::state::disabled%]" + } + } + } } } diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index 773477e6c3f..018fcb84988 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -5,6 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from typing import Any +from yolink.client_request import ClientRequest from yolink.const import ( ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_MULTI_OUTLET, @@ -40,52 +41,52 @@ DEVICE_TYPES: tuple[YoLinkSwitchEntityDescription, ...] = ( YoLinkSwitchEntityDescription( key="outlet_state", device_class=SwitchDeviceClass.OUTLET, - name="State", + name=None, exists_fn=lambda device: device.device_type == ATTR_DEVICE_OUTLET, ), YoLinkSwitchEntityDescription( key="manipulator_state", - name="State", + name=None, icon="mdi:pipe", exists_fn=lambda device: device.device_type == ATTR_DEVICE_MANIPULATOR, ), YoLinkSwitchEntityDescription( key="switch_state", - name="State", + name=None, device_class=SwitchDeviceClass.SWITCH, exists_fn=lambda device: device.device_type == ATTR_DEVICE_SWITCH, ), YoLinkSwitchEntityDescription( key="multi_outlet_usb_ports", - name="UsbPorts", + translation_key="usb_ports", device_class=SwitchDeviceClass.OUTLET, exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, plug_index=0, ), YoLinkSwitchEntityDescription( key="multi_outlet_plug_1", - name="Plug1", + translation_key="plug_1", device_class=SwitchDeviceClass.OUTLET, exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, plug_index=1, ), YoLinkSwitchEntityDescription( key="multi_outlet_plug_2", - name="Plug2", + translation_key="plug_2", device_class=SwitchDeviceClass.OUTLET, exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, plug_index=2, ), YoLinkSwitchEntityDescription( key="multi_outlet_plug_3", - name="Plug3", + translation_key="plug_3", device_class=SwitchDeviceClass.OUTLET, exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, plug_index=3, ), YoLinkSwitchEntityDescription( key="multi_outlet_plug_4", - name="Plug4", + translation_key="plug_4", device_class=SwitchDeviceClass.OUTLET, exists_fn=lambda device: device.device_type == ATTR_DEVICE_MULTI_OUTLET, plug_index=4, @@ -141,9 +142,6 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): self._attr_unique_id = ( f"{coordinator.device.device_id} {self.entity_description.key}" ) - self._attr_name = ( - f"{coordinator.device.device_name} ({self.entity_description.name})" - ) def _get_state( self, state_value: str | list[str] | None, plug_index: int | None @@ -163,11 +161,17 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): async def call_state_change(self, state: str) -> None: """Call setState api to change switch state.""" - await self.call_device( - OutletRequestBuilder.set_state_request( + client_request: ClientRequest = None + if self.coordinator.device.device_type in [ + ATTR_DEVICE_OUTLET, + ATTR_DEVICE_MULTI_OUTLET, + ]: + client_request = OutletRequestBuilder.set_state_request( state, self.entity_description.plug_index ) - ) + else: + client_request = ClientRequest("setState", {"state": state}) + await self.call_device(client_request) self._attr_is_on = self._get_state(state, self.entity_description.plug_index) self.async_write_ha_state() diff --git a/homeassistant/components/youtube/api.py b/homeassistant/components/youtube/api.py index 64abf1a6753..f8a9008d9b3 100644 --- a/homeassistant/components/youtube/api.py +++ b/homeassistant/components/youtube/api.py @@ -1,16 +1,18 @@ """API for YouTube bound to Home Assistant OAuth.""" -from google.auth.exceptions import RefreshError -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import Resource, build +from youtubeaio.types import AuthScope +from youtubeaio.youtube import YouTube from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession class AsyncConfigEntryAuth: """Provide Google authentication tied to an OAuth2 based config entry.""" + youtube: YouTube | None = None + def __init__( self, hass: HomeAssistant, @@ -30,19 +32,10 @@ class AsyncConfigEntryAuth: await self.oauth_session.async_ensure_token_valid() return self.access_token - async def get_resource(self) -> Resource: - """Create executor job to get current resource.""" - try: - credentials = Credentials(await self.check_and_refresh_token()) - except RefreshError as ex: - self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass) - raise ex - return await self.hass.async_add_executor_job(self._get_resource, credentials) - - def _get_resource(self, credentials: Credentials) -> Resource: - """Get current resource.""" - return build( - "youtube", - "v3", - credentials=credentials, - ) + async def get_resource(self) -> YouTube: + """Create resource.""" + token = await self.check_and_refresh_token() + if self.youtube is None: + self.youtube = YouTube(session=async_get_clientsession(self.hass)) + await self.youtube.set_user_authentication(token, [AuthScope.READ_ONLY]) + return self.youtube diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py index fa3bc6c8237..50dee14d61a 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -1,21 +1,21 @@ """Config flow for YouTube integration.""" from __future__ import annotations -from collections.abc import AsyncGenerator, Mapping +from collections.abc import Mapping import logging from typing import Any -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import Resource, build -from googleapiclient.errors import HttpError -from googleapiclient.http import HttpRequest import voluptuous as vol +from youtubeaio.helper import first +from youtubeaio.types import AuthScope, ForbiddenError +from youtubeaio.youtube import YouTube from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -31,37 +31,6 @@ from .const import ( ) -async def _get_subscriptions(hass: HomeAssistant, resource: Resource) -> AsyncGenerator: - amount_of_subscriptions = 50 - received_amount_of_subscriptions = 0 - next_page_token = None - while received_amount_of_subscriptions < amount_of_subscriptions: - # pylint: disable=no-member - subscription_request: HttpRequest = resource.subscriptions().list( - part="snippet", mine=True, maxResults=50, pageToken=next_page_token - ) - res = await hass.async_add_executor_job(subscription_request.execute) - amount_of_subscriptions = res["pageInfo"]["totalResults"] - if "nextPageToken" in res: - next_page_token = res["nextPageToken"] - for item in res["items"]: - received_amount_of_subscriptions += 1 - yield item - - -async def get_resource(hass: HomeAssistant, token: str) -> Resource: - """Get Youtube resource async.""" - - def _build_resource() -> Resource: - return build( - "youtube", - "v3", - credentials=Credentials(token), - ) - - return await hass.async_add_executor_job(_build_resource) - - class OAuth2FlowHandler( config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN ): @@ -73,6 +42,7 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN reauth_entry: ConfigEntry | None = None + _youtube: YouTube | None = None @staticmethod @callback @@ -112,25 +82,25 @@ class OAuth2FlowHandler( return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() + async def get_resource(self, token: str) -> YouTube: + """Get Youtube resource async.""" + if self._youtube is None: + self._youtube = YouTube(session=async_get_clientsession(self.hass)) + await self._youtube.set_user_authentication(token, [AuthScope.READ_ONLY]) + return self._youtube + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Create an entry for the flow, or update existing entry.""" try: - service = await get_resource(self.hass, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) - # pylint: disable=no-member - own_channel_request: HttpRequest = service.channels().list( - part="snippet", mine=True - ) - response = await self.hass.async_add_executor_job( - own_channel_request.execute - ) - if not response["items"]: + youtube = await self.get_resource(data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + own_channel = await first(youtube.get_user_channels()) + if own_channel is None or own_channel.snippet is None: return self.async_abort( reason="no_channel", description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL}, ) - own_channel = response["items"][0] - except HttpError as ex: - error = ex.reason + except ForbiddenError as ex: + error = ex.args[0] return self.async_abort( reason="access_not_configured", description_placeholders={"message": error}, @@ -138,16 +108,16 @@ class OAuth2FlowHandler( except Exception as ex: # pylint: disable=broad-except LOGGER.error("Unknown error occurred: %s", ex.args) return self.async_abort(reason="unknown") - self._title = own_channel["snippet"]["title"] + self._title = own_channel.snippet.title self._data = data if not self.reauth_entry: - await self.async_set_unique_id(own_channel["id"]) + await self.async_set_unique_id(own_channel.channel_id) self._abort_if_unique_id_configured() return await self.async_step_channels() - if self.reauth_entry.unique_id == own_channel["id"]: + if self.reauth_entry.unique_id == own_channel.channel_id: self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) return self.async_abort(reason="reauth_successful") @@ -167,15 +137,13 @@ class OAuth2FlowHandler( data=self._data, options=user_input, ) - service = await get_resource( - self.hass, self._data[CONF_TOKEN][CONF_ACCESS_TOKEN] - ) + youtube = await self.get_resource(self._data[CONF_TOKEN][CONF_ACCESS_TOKEN]) selectable_channels = [ SelectOptionDict( - value=subscription["snippet"]["resourceId"]["channelId"], - label=subscription["snippet"]["title"], + value=subscription.snippet.channel_id, + label=subscription.snippet.title, ) - async for subscription in _get_subscriptions(self.hass, service) + async for subscription in youtube.get_user_subscriptions() ] return self.async_show_form( step_id="channels", @@ -201,15 +169,16 @@ class YouTubeOptionsFlowHandler(OptionsFlowWithConfigEntry): title=self.config_entry.title, data=user_input, ) - service = await get_resource( - self.hass, self.config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] + youtube = YouTube(session=async_get_clientsession(self.hass)) + await youtube.set_user_authentication( + self.config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN], [AuthScope.READ_ONLY] ) selectable_channels = [ SelectOptionDict( - value=subscription["snippet"]["resourceId"]["channelId"], - label=subscription["snippet"]["title"], + value=subscription.snippet.channel_id, + label=subscription.snippet.title, ) - async for subscription in _get_subscriptions(self.hass, service) + async for subscription in youtube.get_user_subscriptions() ] return self.async_show_form( step_id="init", diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py index 72629544895..07420233baf 100644 --- a/homeassistant/components/youtube/coordinator.py +++ b/homeassistant/components/youtube/coordinator.py @@ -4,13 +4,14 @@ from __future__ import annotations from datetime import timedelta from typing import Any -from googleapiclient.discovery import Resource -from googleapiclient.http import HttpRequest +from youtubeaio.helper import first +from youtubeaio.types import UnauthorizedError, YouTubeBackendError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ICON, ATTR_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from . import AsyncConfigEntryAuth from .const import ( @@ -27,16 +28,7 @@ from .const import ( ) -def get_upload_playlist_id(channel_id: str) -> str: - """Return the playlist id with the uploads of the channel. - - Replacing the UC in the channel id (UCxxxxxxxxxxxx) with UU is - the way to do it without extra request (UUxxxxxxxxxxxx). - """ - return channel_id.replace("UC", "UU", 1) - - -class YouTubeDataUpdateCoordinator(DataUpdateCoordinator): +class YouTubeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """A YouTube Data Update Coordinator.""" config_entry: ConfigEntry @@ -52,64 +44,32 @@ class YouTubeDataUpdateCoordinator(DataUpdateCoordinator): ) async def _async_update_data(self) -> dict[str, Any]: - service = await self._auth.get_resource() - channels = await self._get_channels(service) - - return await self.hass.async_add_executor_job( - self._get_channel_data, service, channels - ) - - async def _get_channels(self, service: Resource) -> list[dict[str, Any]]: - data = [] - received_channels = 0 - channels = self.config_entry.options[CONF_CHANNELS] - while received_channels < len(channels): - # We're slicing the channels in chunks of 50 to avoid making the URI too long - end = min(received_channels + 50, len(channels)) - channel_request: HttpRequest = service.channels().list( - part="snippet,statistics", - id=",".join(channels[received_channels:end]), - maxResults=50, - ) - response: dict = await self.hass.async_add_executor_job( - channel_request.execute - ) - data.extend(response["items"]) - received_channels += len(response["items"]) - return data - - def _get_channel_data( - self, service: Resource, channels: list[dict[str, Any]] - ) -> dict[str, Any]: - data: dict[str, Any] = {} - for channel in channels: - playlist_id = get_upload_playlist_id(channel["id"]) - response = ( - service.playlistItems() - .list( - part="snippet,contentDetails", playlistId=playlist_id, maxResults=1 + youtube = await self._auth.get_resource() + res = {} + channel_ids = self.config_entry.options[CONF_CHANNELS] + try: + async for channel in youtube.get_channels(channel_ids): + video = await first( + youtube.get_playlist_items(channel.upload_playlist_id, 1) ) - .execute() - ) - video = response["items"][0] - data[channel["id"]] = { - ATTR_ID: channel["id"], - ATTR_TITLE: channel["snippet"]["title"], - ATTR_ICON: channel["snippet"]["thumbnails"]["high"]["url"], - ATTR_LATEST_VIDEO: { - ATTR_PUBLISHED_AT: video["snippet"]["publishedAt"], - ATTR_TITLE: video["snippet"]["title"], - ATTR_DESCRIPTION: video["snippet"]["description"], - ATTR_THUMBNAIL: self._get_thumbnail(video), - ATTR_VIDEO_ID: video["contentDetails"]["videoId"], - }, - ATTR_SUBSCRIBER_COUNT: int(channel["statistics"]["subscriberCount"]), - } - return data - - def _get_thumbnail(self, video: dict[str, Any]) -> str | None: - thumbnails = video["snippet"]["thumbnails"] - for size in ("standard", "high", "medium", "default"): - if size in thumbnails: - return thumbnails[size]["url"] - return None + latest_video = None + if video: + latest_video = { + ATTR_PUBLISHED_AT: video.snippet.added_at, + ATTR_TITLE: video.snippet.title, + ATTR_DESCRIPTION: video.snippet.description, + ATTR_THUMBNAIL: video.snippet.thumbnails.get_highest_quality().url, + ATTR_VIDEO_ID: video.content_details.video_id, + } + res[channel.channel_id] = { + ATTR_ID: channel.channel_id, + ATTR_TITLE: channel.snippet.title, + ATTR_ICON: channel.snippet.thumbnails.get_highest_quality().url, + ATTR_LATEST_VIDEO: latest_video, + ATTR_SUBSCRIBER_COUNT: channel.statistics.subscriber_count, + } + except UnauthorizedError as err: + raise ConfigEntryAuthFailed from err + except YouTubeBackendError as err: + raise UpdateFailed("Couldn't connect to YouTube") from err + return res diff --git a/homeassistant/components/youtube/diagnostics.py b/homeassistant/components/youtube/diagnostics.py new file mode 100644 index 00000000000..380033e450a --- /dev/null +++ b/homeassistant/components/youtube/diagnostics.py @@ -0,0 +1,24 @@ +"""Diagnostics support for YouTube.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import ATTR_DESCRIPTION, ATTR_LATEST_VIDEO, COORDINATOR, DOMAIN +from .coordinator import YouTubeDataUpdateCoordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + COORDINATOR + ] + sensor_data = {} + for channel_id, channel_data in coordinator.data.items(): + channel_data.get(ATTR_LATEST_VIDEO, {}).pop(ATTR_DESCRIPTION) + sensor_data[channel_id] = channel_data + return sensor_data diff --git a/homeassistant/components/youtube/entity.py b/homeassistant/components/youtube/entity.py index 2f9238dec26..46deaf40450 100644 --- a/homeassistant/components/youtube/entity.py +++ b/homeassistant/components/youtube/entity.py @@ -9,7 +9,7 @@ from .const import ATTR_TITLE, DOMAIN, MANUFACTURER from .coordinator import YouTubeDataUpdateCoordinator -class YouTubeChannelEntity(CoordinatorEntity): +class YouTubeChannelEntity(CoordinatorEntity[YouTubeDataUpdateCoordinator]): """An HA implementation for YouTube entity.""" _attr_has_entity_name = True diff --git a/homeassistant/components/youtube/manifest.json b/homeassistant/components/youtube/manifest.json index fbc02bda006..a1a71f6712e 100644 --- a/homeassistant/components/youtube/manifest.json +++ b/homeassistant/components/youtube/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/youtube", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-api-python-client==2.71.0"] + "requirements": ["youtubeaio==1.1.5"] } diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index 4560dcfda8c..99cd3ecf095 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -15,6 +15,7 @@ from homeassistant.helpers.typing import StateType from . import YouTubeDataUpdateCoordinator from .const import ( ATTR_LATEST_VIDEO, + ATTR_PUBLISHED_AT, ATTR_SUBSCRIBER_COUNT, ATTR_THUMBNAIL, ATTR_TITLE, @@ -29,9 +30,10 @@ from .entity import YouTubeChannelEntity class YouTubeMixin: """Mixin for required keys.""" + available_fn: Callable[[Any], bool] value_fn: Callable[[Any], StateType] entity_picture_fn: Callable[[Any], str | None] - attributes_fn: Callable[[Any], dict[str, Any]] | None + attributes_fn: Callable[[Any], dict[str, Any] | None] | None @dataclass @@ -44,10 +46,12 @@ SENSOR_TYPES = [ key="latest_upload", translation_key="latest_upload", icon="mdi:youtube", + available_fn=lambda channel: channel[ATTR_LATEST_VIDEO] is not None, value_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_TITLE], entity_picture_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_THUMBNAIL], attributes_fn=lambda channel: { - ATTR_VIDEO_ID: channel[ATTR_LATEST_VIDEO][ATTR_VIDEO_ID] + ATTR_VIDEO_ID: channel[ATTR_LATEST_VIDEO][ATTR_VIDEO_ID], + ATTR_PUBLISHED_AT: channel[ATTR_LATEST_VIDEO][ATTR_PUBLISHED_AT], }, ), YouTubeSensorEntityDescription( @@ -55,6 +59,7 @@ SENSOR_TYPES = [ translation_key="subscribers", icon="mdi:youtube-subscription", native_unit_of_measurement="subscribers", + available_fn=lambda _: True, value_fn=lambda channel: channel[ATTR_SUBSCRIBER_COUNT], entity_picture_fn=lambda channel: channel[ATTR_ICON], attributes_fn=None, @@ -81,6 +86,13 @@ class YouTubeSensor(YouTubeChannelEntity, SensorEntity): entity_description: YouTubeSensorEntityDescription + @property + def available(self) -> bool: + """Return if the entity is available.""" + return super().available and self.entity_description.available_fn( + self.coordinator.data[self._channel_id] + ) + @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" @@ -89,6 +101,8 @@ class YouTubeSensor(YouTubeChannelEntity, SensorEntity): @property def entity_picture(self) -> str | None: """Return the value reported by the sensor.""" + if not self.available: + return None return self.entity_description.entity_picture_fn( self.coordinator.data[self._channel_id] ) diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json index 1ecc2bc4db8..ccb7e9c506e 100644 --- a/homeassistant/components/youtube/strings.json +++ b/homeassistant/components/youtube/strings.json @@ -27,9 +27,9 @@ "options": { "step": { "init": { - "description": "Select the channels you want to add.", + "description": "[%key:component::youtube::config::step::channels::description%]", "data": { - "channels": "YouTube channels" + "channels": "[%key:component::youtube::config::step::channels::data::channels%]" } } } @@ -37,7 +37,15 @@ "entity": { "sensor": { "latest_upload": { - "name": "Latest upload" + "name": "Latest upload", + "state_attributes": { + "video_id": { + "name": "Video ID" + }, + "published_at": { + "name": "Published at" + } + } }, "subscribers": { "name": "Subscribers" diff --git a/homeassistant/components/zamg/strings.json b/homeassistant/components/zamg/strings.json index 6305f68efd9..a92e7aa605e 100644 --- a/homeassistant/components/zamg/strings.json +++ b/homeassistant/components/zamg/strings.json @@ -16,13 +16,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "station_not_found": "Station ID not found at zamg" - } - }, - "issues": { - "deprecated_yaml": { - "title": "The ZAMG YAML configuration is being removed", - "description": "Configuring ZAMG using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the ZAMG YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + "station_not_found": "[%key:component::zamg::config::error::station_not_found%]" } } } diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 1c5d25dfb3d..73ebe15d0c7 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.70.0"] + "requirements": ["zeroconf==0.72.0"] } diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index 5a32ca23332..41ecb751b86 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -82,6 +82,8 @@ class ZerprocLight(LightEntity): _attr_color_mode = ColorMode.HS _attr_icon = "mdi:string-lights" _attr_supported_color_modes = {ColorMode.HS} + _attr_has_entity_name = True + _attr_name = None def __init__(self, light) -> None: """Initialize a Zerproc light.""" @@ -106,11 +108,6 @@ class ZerprocLight(LightEntity): "Exception disconnecting from %s", self._light.address, exc_info=True ) - @property - def name(self): - """Return the display name of this light.""" - return self._light.name - @property def unique_id(self): """Return the ID of this light.""" @@ -122,7 +119,7 @@ class ZerprocLight(LightEntity): return DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, manufacturer="Zerproc", - name=self.name, + name=self._light.name, ) async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/zeversolar/sensor.py b/homeassistant/components/zeversolar/sensor.py index 2243edc48e4..ee9aa5531c8 100644 --- a/homeassistant/components/zeversolar/sensor.py +++ b/homeassistant/components/zeversolar/sensor.py @@ -39,7 +39,6 @@ class ZeversolarEntityDescription( SENSOR_TYPES = ( ZeversolarEntityDescription( key="pac", - name="Current power", icon="mdi:solar-power-variant", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, @@ -49,7 +48,7 @@ SENSOR_TYPES = ( ), ZeversolarEntityDescription( key="energy_today", - name="Energy today", + translation_key="energy_today", icon="mdi:home-battery", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, diff --git a/homeassistant/components/zeversolar/strings.json b/homeassistant/components/zeversolar/strings.json index a4f52dc6aa3..0e2e23f244c 100644 --- a/homeassistant/components/zeversolar/strings.json +++ b/homeassistant/components/zeversolar/strings.json @@ -16,5 +16,12 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "energy_today": { + "name": "Energy today" + } + } } } diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 010c0f63e27..b3b6e7f0483 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -4,9 +4,8 @@ from __future__ import annotations import abc import functools import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self -from typing_extensions import Self import zigpy.exceptions from zigpy.zcl.foundation import Status diff --git a/homeassistant/components/zha/core/cluster_handlers/__init__.py b/homeassistant/components/zha/core/cluster_handlers/__init__.py index dcf8f2a525e..6c05ce2fe4f 100644 --- a/homeassistant/components/zha/core/cluster_handlers/__init__.py +++ b/homeassistant/components/zha/core/cluster_handlers/__init__.py @@ -2,10 +2,11 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable, Callable, Coroutine from enum import Enum -from functools import partialmethod +import functools import logging -from typing import TYPE_CHECKING, Any, TypedDict +from typing import TYPE_CHECKING, Any, ParamSpec, TypedDict import zigpy.exceptions import zigpy.util @@ -19,6 +20,7 @@ from zigpy.zcl.foundation import ( from homeassistant.const import ATTR_COMMAND from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send from ..const import ( @@ -45,8 +47,34 @@ if TYPE_CHECKING: from ..endpoint import Endpoint _LOGGER = logging.getLogger(__name__) +RETRYABLE_REQUEST_DECORATOR = zigpy.util.retryable_request(tries=3) -retry_request = zigpy.util.retryable_request(tries=3) + +_P = ParamSpec("_P") +_FuncType = Callable[_P, Awaitable[Any]] +_ReturnFuncType = Callable[_P, Coroutine[Any, Any, Any]] + + +def retry_request(func: _FuncType[_P]) -> _ReturnFuncType[_P]: + """Send a request with retries and wrap expected zigpy exceptions.""" + + @functools.wraps(func) + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> Any: + try: + return await RETRYABLE_REQUEST_DECORATOR(func)(*args, **kwargs) + except asyncio.TimeoutError as exc: + raise HomeAssistantError( + "Failed to send request: device did not respond" + ) from exc + except zigpy.exceptions.ZigbeeException as exc: + message = "Failed to send request" + + if str(exc): + message = f"{message}: {exc}" + + raise HomeAssistantError(message) from exc + + return wrapper class AttrReportConfig(TypedDict, total=True): @@ -471,7 +499,7 @@ class ClusterHandler(LogMixin): rest = rest[ZHA_CLUSTER_HANDLER_READS_PER_REQ:] return result - get_attributes = partialmethod(_get_attributes, False) + get_attributes = functools.partialmethod(_get_attributes, False) def log(self, level, msg, *args, **kwargs): """Log a message.""" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 51ab65e3318..1455173b27c 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -8,9 +8,8 @@ from enum import Enum import logging import random import time -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self -from typing_extensions import Self from zigpy import types import zigpy.device import zigpy.exceptions diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 0c7369f15e7..03fdc7e37c1 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -244,7 +244,10 @@ class MatchRule: if callable(self.quirk_classes): matches.append(self.quirk_classes(quirk_class)) else: - matches.append(quirk_class in self.quirk_classes) + matches.append( + quirk_class.split(".")[-2:] + in [x.split(".")[-2:] for x in self.quirk_classes] + ) return matches diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 97258a77e2b..7f34629400f 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -5,12 +5,10 @@ import asyncio from collections.abc import Callable import functools import logging -from typing import TYPE_CHECKING, Any - -from typing_extensions import Self +from typing import TYPE_CHECKING, Any, Self from homeassistant.const import ATTR_NAME -from homeassistant.core import CALLBACK_TYPE, Event, callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers import entity from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE @@ -18,8 +16,12 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import EventType from .core.const import ( ATTR_MANUFACTURER, @@ -321,7 +323,9 @@ class ZhaGroupEntity(BaseZhaEntity): self.async_on_remove(send_removed_signal) @callback - def async_state_changed_listener(self, event: Event): + def async_state_changed_listener( + self, event: EventType[EventStateChangedData] + ) -> None: """Handle child updates.""" # Delay to ensure that we get updates from all members before updating the group assert self._change_listener_debouncer diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 7694a85b8ed..5e33377ec0e 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "bellows==0.35.8", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.101", + "zha-quirks==0.0.102", "zigpy-deconz==0.21.0", "zigpy==0.56.2", "zigpy-xbee==0.18.1", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 29d6cafe3c8..807a5e73d00 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -3,9 +3,8 @@ from __future__ import annotations import functools import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self -from typing_extensions import Self import zigpy.exceptions from zigpy.zcl.foundation import Status diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 27b71484f3e..e6f2f6ab482 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -4,9 +4,8 @@ from __future__ import annotations from enum import Enum import functools import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self -from typing_extensions import Self from zigpy import types from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasWd diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index d13dd871865..49ba46038f9 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -5,9 +5,8 @@ import enum import functools import numbers import sys -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self -from typing_extensions import Self from zigpy import types from homeassistant.components.climate import HVACAction diff --git a/homeassistant/components/zha/services.yaml b/homeassistant/components/zha/services.yaml index 132dae6e745..027653a4a6f 100644 --- a/homeassistant/components/zha/services.yaml +++ b/homeassistant/components/zha/services.yaml @@ -1,12 +1,8 @@ # Describes the format for available zha services permit: - name: Permit - description: Allow nodes to join the Zigbee network. fields: duration: - name: Duration - description: Time to permit joins, in seconds default: 60 selector: number: @@ -14,73 +10,46 @@ permit: max: 254 unit_of_measurement: seconds ieee: - name: IEEE - description: IEEE address of the node permitting new joins example: "00:0d:6f:00:05:7d:2d:34" selector: text: source_ieee: - name: Source IEEE - description: IEEE address of the joining device (must be used with install code) example: "00:0a:bf:00:01:10:23:35" selector: text: install_code: - name: Install Code - description: Install code of the joining device (must be used with source_ieee) example: "1234-5678-1234-5678-AABB-CCDD-AABB-CCDD-EEFF" selector: text: qr_code: - name: QR Code - description: value of the QR install code (different between vendors) example: "Z:000D6FFFFED4163B$I:52797BF4A5084DAA8E1712B61741CA024051" selector: text: remove: - name: Remove - description: Remove a node from the Zigbee network. fields: ieee: - name: IEEE - description: IEEE address of the node to remove required: true example: "00:0d:6f:00:05:7d:2d:34" selector: text: reconfigure_device: - name: Reconfigure device - description: >- - Reconfigure ZHA device (heal device). Use this if you are having issues - with the device. If the device in question is a battery powered device - please ensure it is awake and accepting commands when you use this - service. fields: ieee: - name: IEEE - description: IEEE address of the device to reconfigure required: true example: "00:0d:6f:00:05:7d:2d:34" selector: text: set_zigbee_cluster_attribute: - name: Set zigbee cluster attribute - description: >- - Set attribute value for the specified cluster on the specified entity. fields: ieee: - name: IEEE - description: IEEE address for the device required: true example: "00:0d:6f:00:05:7d:2d:34" selector: text: endpoint_id: - name: Endpoint ID - description: Endpoint id for the cluster required: true selector: number: @@ -88,16 +57,12 @@ set_zigbee_cluster_attribute: max: 65535 mode: box cluster_id: - name: Cluster ID - description: ZCL cluster to retrieve attributes for required: true selector: number: min: 1 max: 65535 cluster_type: - name: Cluster Type - description: type of the cluster default: "in" selector: select: @@ -105,8 +70,6 @@ set_zigbee_cluster_attribute: - "in" - "out" attribute: - name: Attribute - description: id of the attribute to set required: true example: 0 selector: @@ -114,50 +77,35 @@ set_zigbee_cluster_attribute: min: 1 max: 65535 value: - name: Value - description: value to write to the attribute required: true example: 0x0001 selector: text: manufacturer: - name: Manufacturer - description: manufacturer code example: 0x00FC selector: text: issue_zigbee_cluster_command: - name: Issue zigbee cluster command - description: >- - Issue command on the specified cluster on the specified entity. fields: ieee: - name: IEEE - description: IEEE address for the device required: true example: "00:0d:6f:00:05:7d:2d:34" selector: text: endpoint_id: - name: Endpoint ID - description: Endpoint id for the cluster required: true selector: number: min: 1 max: 65535 cluster_id: - name: Cluster ID - description: ZCL cluster to retrieve attributes for required: true selector: number: min: 1 max: 65535 cluster_type: - name: Cluster Type - description: type of the cluster default: "in" selector: select: @@ -165,16 +113,12 @@ issue_zigbee_cluster_command: - "in" - "out" command: - name: Command - description: id of the command to execute required: true selector: number: min: 1 max: 65535 command_type: - name: Command Type - description: type of the command to execute required: true selector: select: @@ -182,46 +126,31 @@ issue_zigbee_cluster_command: - "client" - "server" args: - name: Args - description: args to pass to the command example: "[arg1, arg2, argN]" selector: object: params: - name: Params - description: parameters to pass to the command selector: object: manufacturer: - name: Manufacturer - description: manufacturer code example: 0x00FC selector: text: issue_zigbee_group_command: - name: Issue zigbee group command - description: >- - Issue command on the specified cluster on the specified group. fields: group: - name: Group - description: Hexadecimal address of the group required: true example: 0x0222 selector: text: cluster_id: - name: Cluster ID - description: ZCL cluster to send command to required: true selector: number: min: 1 max: 65535 cluster_type: - name: Cluster Type - description: type of the cluster default: "in" selector: select: @@ -229,42 +158,28 @@ issue_zigbee_group_command: - "in" - "out" command: - name: Command - description: id of the command to execute required: true selector: number: min: 1 max: 65535 args: - name: Args - description: args to pass to the command example: "[arg1, arg2, argN]" selector: object: manufacturer: - name: Manufacturer - description: manufacturer code example: 0x00FC selector: text: warning_device_squawk: - name: Warning device squawk - description: >- - This service uses the WD capabilities to emit a quick audible/visible pulse called a "squawk". The squawk command has no effect if the WD is currently active (warning in progress). fields: ieee: - name: IEEE - description: IEEE address for the device required: true example: "00:0d:6f:00:05:7d:2d:34" selector: text: mode: - name: Mode - description: >- - The Squawk Mode field is used as a 4-bit enumeration, and can have one of the values shown in Table 8-24 of the ZCL spec - Squawk Mode Field. The exact operation of each mode (how the WD “squawks”) is implementation specific. default: 0 selector: number: @@ -272,9 +187,6 @@ warning_device_squawk: max: 1 mode: box strobe: - name: Strobe - description: >- - The strobe field is used as a Boolean, and determines if the visual indication is also required in addition to the audible squawk, as shown in Table 8-25 of the ZCL spec - Strobe Bit. default: 1 selector: number: @@ -282,9 +194,6 @@ warning_device_squawk: max: 1 mode: box level: - name: Level - description: >- - The squawk level field is used as a 2-bit enumeration, and determines the intensity of audible squawk sound as shown in Table 8-26 of the ZCL spec - Squawk Level Field Values. default: 2 selector: number: @@ -293,21 +202,13 @@ warning_device_squawk: mode: box warning_device_warn: - name: Warning device warn - description: >- - This service starts the WD operation. The WD alerts the surrounding area by audible (siren) and visual (strobe) signals. fields: ieee: - name: IEEE - description: IEEE address for the device required: true example: "00:0d:6f:00:05:7d:2d:34" selector: text: mode: - name: Mode - description: >- - The Warning Mode field is used as an 4-bit enumeration, can have one of the values 0-6 defined below in table 8-20 of the ZCL spec. The exact behavior of the WD device in each mode is according to the relevant security standards. default: 3 selector: number: @@ -315,9 +216,6 @@ warning_device_warn: max: 6 mode: box strobe: - name: Strobe - description: >- - The Strobe field is used as a 2-bit enumeration, and determines if the visual indication is required in addition to the audible siren, as indicated in Table 8-21 of the ZCL spec. "0" means no strobe, "1" means strobe. If the strobe field is “1” and the Warning Mode is “0” (“Stop”) then only the strobe is activated. default: 1 selector: number: @@ -325,9 +223,6 @@ warning_device_warn: max: 1 mode: box level: - name: Level - description: >- - The Siren Level field is used as a 2-bit enumeration, and indicates the intensity of audible squawk sound as shown in Table 8-22 of the ZCL spec. default: 2 selector: number: @@ -335,9 +230,6 @@ warning_device_warn: max: 3 mode: box duration: - name: Duration - description: >- - Requested duration of warning, in seconds (16 bit). If both Strobe and Warning Mode are "0" this field SHALL be ignored. default: 5 selector: number: @@ -345,9 +237,6 @@ warning_device_warn: max: 65535 unit_of_measurement: seconds duty_cycle: - name: Duty cycle - description: >- - Indicates the length of the flash cycle. This provides a means of varying the flash duration for different alarm types (e.g., fire, police, burglar). Valid range is 0-100 in increments of 10. All other values SHALL be rounded to the nearest valid value. Strobe SHALL calculate duty cycle over a duration of one second. The ON state SHALL precede the OFF state. For example, if Strobe Duty Cycle Field specifies “40,” then the strobe SHALL flash ON for 4/10ths of a second and then turn OFF for 6/10ths of a second. default: 0 selector: number: @@ -355,9 +244,6 @@ warning_device_warn: max: 100 step: 10 intensity: - name: Intensity - description: >- - Indicates the intensity of the strobe as shown in Table 8-23 of the ZCL spec. This attribute is designed to vary the output of the strobe (i.e., brightness) and not its frequency, which is detailed in section 8.4.2.3.1.6 of the ZCL spec. default: 2 selector: number: @@ -366,71 +252,53 @@ warning_device_warn: mode: box clear_lock_user_code: - name: Clear lock user - description: Clear a user code from a lock target: entity: domain: lock integration: zha fields: code_slot: - name: Code slot - description: Code slot to clear code from required: true example: 1 selector: text: enable_lock_user_code: - name: Enable lock user - description: Enable a user code on a lock target: entity: domain: lock integration: zha fields: code_slot: - name: Code slot - description: Code slot to enable required: true example: 1 selector: text: disable_lock_user_code: - name: Disable lock user - description: Disable a user code on a lock target: entity: domain: lock integration: zha fields: code_slot: - name: Code slot - description: Code slot to disable required: true example: 1 selector: text: set_lock_user_code: - name: Set lock user code - description: Set a user code on a lock target: entity: domain: lock integration: zha fields: code_slot: - name: Code slot - description: Code slot to set the code in required: true example: 1 selector: text: user_code: - name: Code - description: Code to set required: true example: 1234 selector: diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index cbdc9cf8477..9731fb0c2d1 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -4,7 +4,9 @@ "step": { "choose_serial_port": { "title": "Select a Serial Port", - "data": { "path": "Serial Device Path" }, + "data": { + "path": "Serial Device Path" + }, "description": "Select the serial port for your Zigbee radio" }, "confirm": { @@ -14,8 +16,10 @@ "description": "Do you want to set up {name}?" }, "manual_pick_radio_type": { - "data": { "radio_type": "Radio Type" }, - "title": "Radio Type", + "data": { + "radio_type": "Radio Type" + }, + "title": "[%key:component::zha::config::step::manual_pick_radio_type::data::radio_type%]", "description": "Pick your Zigbee radio type" }, "manual_port_config": { @@ -90,7 +94,7 @@ } }, "intent_migrate": { - "title": "Migrate to a new radio", + "title": "[%key:component::zha::options::step::prompt_migrate_or_reconfigure::menu_options::intent_migrate%]", "description": "Before plugging in your new radio, your old radio needs to be reset. An automatic backup will be performed. If you are using a combined Z-Wave and Zigbee adapter like the HUSBZB-1, this will only reset the Zigbee portion.\n\n*Note: if you are migrating from a **ConBee/RaspBee**, make sure it is running firmware `0x26720700` or newer! Otherwise, some devices may not be controllable after migrating until they are power cycled.*\n\nDo you wish to continue?" }, "instruct_unplug": { @@ -220,14 +224,14 @@ "device_offline": "Device offline" }, "trigger_subtype": { - "turn_on": "Turn on", - "turn_off": "Turn off", + "turn_on": "[%key:common::action::turn_on%]", + "turn_off": "[%key:common::action::turn_off%]", "dim_up": "Dim up", "dim_down": "Dim down", "left": "Left", "right": "Right", - "open": "Open", - "close": "Close", + "open": "[%key:common::action::open%]", + "close": "[%key:common::action::close%]", "both_buttons": "Both buttons", "button": "Button", "button_1": "First button", @@ -244,5 +248,259 @@ "face_5": "With face 5 activated", "face_6": "With face 6 activated" } + }, + "services": { + "permit": { + "name": "Permit", + "description": "Allows nodes to join the Zigbee network.", + "fields": { + "duration": { + "name": "Duration", + "description": "Time to permit joins." + }, + "ieee": { + "name": "IEEE", + "description": "IEEE address of the node permitting new joins." + }, + "source_ieee": { + "name": "Source IEEE", + "description": "IEEE address of the joining device (must be used with the install code)." + }, + "install_code": { + "name": "Install code", + "description": "Install code of the joining device (must be used with the source_ieee)." + }, + "qr_code": { + "name": "QR code", + "description": "Value of the QR install code (different between vendors)." + } + } + }, + "remove": { + "name": "Remove", + "description": "Removes a node from the Zigbee network.", + "fields": { + "ieee": { + "name": "[%key:component::zha::services::permit::fields::ieee::name%]", + "description": "IEEE address of the node to remove." + } + } + }, + "reconfigure_device": { + "name": "Reconfigure device", + "description": "Reconfigures a ZHA device (heal device). Use this if you are having issues with the device. If the device in question is a battery-powered device, ensure it is awake and accepting commands when you use this service.", + "fields": { + "ieee": { + "name": "[%key:component::zha::services::permit::fields::ieee::name%]", + "description": "IEEE address of the device to reconfigure." + } + } + }, + "set_zigbee_cluster_attribute": { + "name": "Set zigbee cluster attribute", + "description": "Sets an attribute value for the specified cluster on the specified entity.", + "fields": { + "ieee": { + "name": "[%key:component::zha::services::permit::fields::ieee::name%]", + "description": "IEEE address for the device." + }, + "endpoint_id": { + "name": "Endpoint ID", + "description": "Endpoint ID for the cluster." + }, + "cluster_id": { + "name": "Cluster ID", + "description": "ZCL cluster to retrieve attributes for." + }, + "cluster_type": { + "name": "Cluster Type", + "description": "Type of the cluster." + }, + "attribute": { + "name": "Attribute", + "description": "ID of the attribute to set." + }, + "value": { + "name": "Value", + "description": "Value to write to the attribute." + }, + "manufacturer": { + "name": "Manufacturer", + "description": "Manufacturer code." + } + } + }, + "issue_zigbee_cluster_command": { + "name": "Issue zigbee cluster command", + "description": "Issues a command on the specified cluster on the specified entity.", + "fields": { + "ieee": { + "name": "[%key:component::zha::services::permit::fields::ieee::name%]", + "description": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::ieee::description%]" + }, + "endpoint_id": { + "name": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::endpoint_id::name%]", + "description": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::endpoint_id::description%]" + }, + "cluster_id": { + "name": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::cluster_id::name%]", + "description": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::cluster_id::description%]" + }, + "cluster_type": { + "name": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::cluster_type::name%]", + "description": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::cluster_type::description%]" + }, + "command": { + "name": "Command", + "description": "ID of the command to execute." + }, + "command_type": { + "name": "Command Type", + "description": "Type of the command to execute." + }, + "args": { + "name": "Args", + "description": "Arguments to pass to the command." + }, + "params": { + "name": "Params", + "description": "Parameters to pass to the command." + }, + "manufacturer": { + "name": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::manufacturer::name%]", + "description": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::manufacturer::description%]" + } + } + }, + "issue_zigbee_group_command": { + "name": "Issue zigbee group command", + "description": "Issue command on the specified cluster on the specified group.", + "fields": { + "group": { + "name": "Group", + "description": "Hexadecimal address of the group." + }, + "cluster_id": { + "name": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::cluster_id::name%]", + "description": "ZCL cluster to send command to." + }, + "cluster_type": { + "name": "Cluster type", + "description": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::cluster_type::description%]" + }, + "command": { + "name": "Command", + "description": "[%key:component::zha::services::issue_zigbee_cluster_command::fields::command::description%]" + }, + "args": { + "name": "Args", + "description": "[%key:component::zha::services::issue_zigbee_cluster_command::fields::args::description%]" + }, + "manufacturer": { + "name": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::manufacturer::name%]", + "description": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::manufacturer::description%]" + } + } + }, + "warning_device_squawk": { + "name": "Warning device squawk", + "description": "This service uses the WD capabilities to emit a quick audible/visible pulse called a \"squawk\". The squawk command has no effect if the WD is currently active (warning in progress).", + "fields": { + "ieee": { + "name": "[%key:component::zha::services::permit::fields::ieee::name%]", + "description": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::ieee::description%]" + }, + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "The Squawk Mode field is used as a 4-bit enumeration, and can have one of the values shown in Table 8-24 of the ZCL spec - Squawk Mode Field. The exact operation of each mode (how the WD “squawks”) is implementation specific." + }, + "strobe": { + "name": "Strobe", + "description": "The strobe field is used as a Boolean, and determines if the visual indication is also required in addition to the audible squawk, as shown in Table 8-25 of the ZCL spec - Strobe Bit." + }, + "level": { + "name": "Level", + "description": "The squawk level field is used as a 2-bit enumeration, and determines the intensity of audible squawk sound as shown in Table 8-26 of the ZCL spec - Squawk Level Field Values." + } + } + }, + "warning_device_warn": { + "name": "Warning device starts alert", + "description": "This service starts the operation of the warning device. The warning device alerts the surrounding area by audible (siren) and visual (strobe) signals.", + "fields": { + "ieee": { + "name": "[%key:component::zha::services::permit::fields::ieee::name%]", + "description": "[%key:component::zha::services::set_zigbee_cluster_attribute::fields::ieee::description%]" + }, + "mode": { + "name": "[%key:common::config_flow::data::mode%]", + "description": "The Warning Mode field is used as a 4-bit enumeration, can have one of the values 0-6 defined below in table 8-20 of the ZCL spec. The exact behavior of the warning device in each mode is according to the relevant security standards." + }, + "strobe": { + "name": "[%key:component::zha::services::warning_device_squawk::fields::strobe::name%]", + "description": "The Strobe field is used as a 2-bit enumeration, and determines if the visual indication is required in addition to the audible siren, as indicated in Table 8-21 of the ZCL spec. \"0\" means no strobe, \"1\" means strobe. If the strobe field is “1” and the Warning Mode is “0” (“Stop”), then only the strobe is activated." + }, + "level": { + "name": "Level", + "description": "The Siren Level field is used as a 2-bit enumeration, and indicates the intensity of audible squawk sound as shown in Table 8-22 of the ZCL spec." + }, + "duration": { + "name": "Duration", + "description": "Requested duration of warning, in seconds (16 bit). If both Strobe and Warning Mode are \"0\" this field is ignored." + }, + "duty_cycle": { + "name": "Duty cycle", + "description": "Indicates the length of the flash cycle. This allows you to vary the flash duration for different alarm types (e.g., fire, police, burglar). The valid range is 0-100 in increments of 10. All other values must be rounded to the nearest valid value. Strobe calculates a duty cycle over a duration of one second. The ON state must precede the OFF state. For example, if the Strobe Duty Cycle field specifies “40,”, then the strobe flashes ON for 4/10ths of a second and then turns OFF for 6/10ths of a second." + }, + "intensity": { + "name": "Intensity", + "description": "Indicates the intensity of the strobe as shown in Table 8-23 of the ZCL spec. This attribute is designed to vary the output of the strobe (i.e., brightness) and not its frequency, which is detailed in section 8.4.2.3.1.6 of the ZCL spec." + } + } + }, + "clear_lock_user_code": { + "name": "Clear lock user", + "description": "Clears a user code from a lock.", + "fields": { + "code_slot": { + "name": "Code slot", + "description": "Code slot to clear code from." + } + } + }, + "enable_lock_user_code": { + "name": "Enable lock user", + "description": "Enables a user code on a lock.", + "fields": { + "code_slot": { + "name": "[%key:component::zha::services::clear_lock_user_code::fields::code_slot::name%]", + "description": "Code slot to enable." + } + } + }, + "disable_lock_user_code": { + "name": "Disable lock user", + "description": "Disables a user code on a lock.", + "fields": { + "code_slot": { + "name": "[%key:component::zha::services::clear_lock_user_code::fields::code_slot::name%]", + "description": "Code slot to disable." + } + } + }, + "set_lock_user_code": { + "name": "Set lock user code", + "description": "Sets a user code on a lock.", + "fields": { + "code_slot": { + "name": "[%key:component::zha::services::clear_lock_user_code::fields::code_slot::name%]", + "description": "Code slot to set the code in." + }, + "user_code": { + "name": "Code", + "description": "Code to set." + } + } + } } } diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 451d96a122b..f975cc5116d 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -3,9 +3,8 @@ from __future__ import annotations import functools import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Self -from typing_extensions import Self import zigpy.exceptions from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.foundation import Status diff --git a/homeassistant/components/zodiac/__init__.py b/homeassistant/components/zodiac/__init__.py index 35d4d2eefbf..48d1d8aa7aa 100644 --- a/homeassistant/components/zodiac/__init__.py +++ b/homeassistant/components/zodiac/__init__.py @@ -1,9 +1,10 @@ """The zodiac component.""" import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers.discovery import async_load_platform +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -16,8 +17,37 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the zodiac component.""" - hass.async_create_task( - async_load_platform(hass, Platform.SENSOR, DOMAIN, {}, config) - ) + if DOMAIN in config: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.1.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Zodiac", + }, + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Load a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR]) diff --git a/homeassistant/components/zodiac/config_flow.py b/homeassistant/components/zodiac/config_flow.py new file mode 100644 index 00000000000..ebc0a819d1d --- /dev/null +++ b/homeassistant/components/zodiac/config_flow.py @@ -0,0 +1,31 @@ +"""Config flow to configure the Zodiac integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DOMAIN + + +class ZodiacConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for Zodiac.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + return self.async_create_entry(title=DEFAULT_NAME, data={}) + + return self.async_show_form(step_id="user") + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle import from configuration.yaml.""" + return await self.async_step_user(user_input) diff --git a/homeassistant/components/zodiac/const.py b/homeassistant/components/zodiac/const.py index c3e7f13d5e3..f50e108c2aa 100644 --- a/homeassistant/components/zodiac/const.py +++ b/homeassistant/components/zodiac/const.py @@ -1,5 +1,6 @@ """Constants for Zodiac.""" DOMAIN = "zodiac" +DEFAULT_NAME = "Zodiac" # Signs SIGN_ARIES = "aries" diff --git a/homeassistant/components/zodiac/manifest.json b/homeassistant/components/zodiac/manifest.json index ceacbf1645a..88f3d7fadef 100644 --- a/homeassistant/components/zodiac/manifest.json +++ b/homeassistant/components/zodiac/manifest.json @@ -2,7 +2,8 @@ "domain": "zodiac", "name": "Zodiac", "codeowners": ["@JulienTant"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zodiac", - "iot_class": "local_polling", + "iot_class": "calculated", "quality_scale": "silver" } diff --git a/homeassistant/components/zodiac/sensor.py b/homeassistant/components/zodiac/sensor.py index f63c844701f..d9b306da4dd 100644 --- a/homeassistant/components/zodiac/sensor.py +++ b/homeassistant/components/zodiac/sensor.py @@ -2,14 +2,17 @@ from __future__ import annotations from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import as_local, utcnow from .const import ( ATTR_ELEMENT, ATTR_MODALITY, + DEFAULT_NAME, DOMAIN, ELEMENT_AIR, ELEMENT_EARTH, @@ -159,23 +162,21 @@ ZODIAC_ICONS = { } -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Zodiac sensor platform.""" - if discovery_info is None: - return + """Initialize the entries.""" - async_add_entities([ZodiacSensor()], True) + async_add_entities([ZodiacSensor(entry_id=entry.entry_id)], True) class ZodiacSensor(SensorEntity): """Representation of a Zodiac sensor.""" - _attr_name = "Zodiac" + _attr_name = None + _attr_has_entity_name = True _attr_device_class = SensorDeviceClass.ENUM _attr_options = [ SIGN_AQUARIUS, @@ -194,6 +195,14 @@ class ZodiacSensor(SensorEntity): _attr_translation_key = "sign" _attr_unique_id = DOMAIN + def __init__(self, entry_id: str) -> None: + """Initialize Zodiac sensor.""" + self._attr_device_info = DeviceInfo( + name=DEFAULT_NAME, + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + ) + async def async_update(self) -> None: """Get the time and updates the state.""" today = as_local(utcnow()).date() diff --git a/homeassistant/components/zodiac/strings.json b/homeassistant/components/zodiac/strings.json index cbae6ead433..b4eacef7435 100644 --- a/homeassistant/components/zodiac/strings.json +++ b/homeassistant/components/zodiac/strings.json @@ -1,4 +1,15 @@ { + "title": "Zodiac", + "config": { + "step": { + "user": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, "entity": { "sensor": { "sign": { diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 35d835c8f16..bfc9c2fce09 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -4,15 +4,13 @@ from __future__ import annotations from collections.abc import Callable import logging from operator import attrgetter -from typing import Any, cast +from typing import Any, Self, cast -from typing_extensions import Self import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( ATTR_EDITABLE, - ATTR_ENTITY_ID, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_PERSONS, @@ -38,7 +36,7 @@ from homeassistant.helpers import ( service, storage, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.loader import bind_hass from homeassistant.util.location import distance @@ -156,15 +154,19 @@ def async_setup_track_zone_entity_ids(hass: HomeAssistant) -> None: hass.data[ZONE_ENTITY_IDS] = zone_entity_ids @callback - def _async_add_zone_entity_id(event_: Event) -> None: + def _async_add_zone_entity_id( + event_: EventType[event.EventStateChangedData], + ) -> None: """Add zone entity ID.""" - zone_entity_ids.append(event_.data[ATTR_ENTITY_ID]) + zone_entity_ids.append(event_.data["entity_id"]) zone_entity_ids.sort() @callback - def _async_remove_zone_entity_id(event_: Event) -> None: + def _async_remove_zone_entity_id( + event_: EventType[event.EventStateChangedData], + ) -> None: """Remove zone entity ID.""" - zone_entity_ids.remove(event_.data[ATTR_ENTITY_ID]) + zone_entity_ids.remove(event_.data["entity_id"]) event.async_track_state_added_domain(hass, DOMAIN, _async_add_zone_entity_id) event.async_track_state_removed_domain(hass, DOMAIN, _async_remove_zone_entity_id) @@ -375,10 +377,12 @@ class Zone(collection.CollectionEntity): self.async_write_ha_state() @callback - def _person_state_change_listener(self, evt: Event) -> None: - person_entity_id = evt.data[ATTR_ENTITY_ID] + def _person_state_change_listener( + self, evt: EventType[event.EventStateChangedData] + ) -> None: + person_entity_id = evt.data["entity_id"] cur_count = len(self._persons_in_zone) - if self._state_is_in_zone(evt.data.get("new_state")): + if self._state_is_in_zone(evt.data["new_state"]): self._persons_in_zone.add(person_entity_id) elif person_entity_id in self._persons_in_zone: self._persons_in_zone.remove(person_entity_id) diff --git a/homeassistant/components/zone/services.yaml b/homeassistant/components/zone/services.yaml index 2ce77132a53..c983a105c93 100644 --- a/homeassistant/components/zone/services.yaml +++ b/homeassistant/components/zone/services.yaml @@ -1,3 +1 @@ reload: - name: Reload - description: Reload the YAML-based zone configuration. diff --git a/homeassistant/components/zone/strings.json b/homeassistant/components/zone/strings.json new file mode 100644 index 00000000000..a17059c5eab --- /dev/null +++ b/homeassistant/components/zone/strings.json @@ -0,0 +1,8 @@ +{ + "services": { + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads zones from the YAML-configuration." + } + } +} diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index 54a2784467d..9412c612ca2 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -14,10 +14,8 @@ from homeassistant.const import ( ) from homeassistant.core import ( CALLBACK_TYPE, - Event, HassJob, HomeAssistant, - State, callback, ) from homeassistant.helpers import ( @@ -26,9 +24,12 @@ from homeassistant.helpers import ( entity_registry as er, location, ) -from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.event import ( + EventStateChangedData, + async_track_state_change_event, +) from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, EventType EVENT_ENTER = "enter" EVENT_LEAVE = "leave" @@ -78,11 +79,11 @@ async def async_attach_trigger( job = HassJob(action) @callback - def zone_automation_listener(zone_event: Event) -> None: + def zone_automation_listener(zone_event: EventType[EventStateChangedData]) -> None: """Listen for state changes and calls action.""" - entity = zone_event.data.get("entity_id") - from_s: State | None = zone_event.data.get("old_state") - to_s: State | None = zone_event.data.get("new_state") + entity = zone_event.data["entity_id"] + from_s = zone_event.data["old_state"] + to_s = zone_event.data["new_state"] if ( from_s diff --git a/homeassistant/components/zoneminder/services.yaml b/homeassistant/components/zoneminder/services.yaml index 74ab0cf5945..30e6672957d 100644 --- a/homeassistant/components/zoneminder/services.yaml +++ b/homeassistant/components/zoneminder/services.yaml @@ -1,10 +1,6 @@ set_run_state: - name: Set run state - description: Set the ZoneMinder run state fields: name: - name: Name - description: The string name of the ZoneMinder run state to set as active. required: true example: "Home" selector: diff --git a/homeassistant/components/zoneminder/strings.json b/homeassistant/components/zoneminder/strings.json new file mode 100644 index 00000000000..34e8b845472 --- /dev/null +++ b/homeassistant/components/zoneminder/strings.json @@ -0,0 +1,14 @@ +{ + "services": { + "set_run_state": { + "name": "Set run state", + "description": "Sets the ZoneMinder run state.", + "fields": { + "name": { + "name": "[%key:common::config_flow::data::name%]", + "description": "The string name of the ZoneMinder run state to set as active." + } + } + } + } +} diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index b847b76ca17..7ff351893b1 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -254,7 +254,7 @@ class DriverEvents: self.dev_reg, self.config_entry.entry_id ) known_devices = [ - self.dev_reg.async_get_device({get_device_id(driver, node)}) + self.dev_reg.async_get_device(identifiers={get_device_id(driver, node)}) for node in controller.nodes.values() ] @@ -317,7 +317,9 @@ class ControllerEvents: self.discovered_value_ids: dict[str, set[str]] = defaultdict(set) self.driver_events = driver_events self.dev_reg = driver_events.dev_reg - self.registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict(dict) + self.registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict( + lambda: defaultdict(set) + ) self.node_events = NodeEvents(hass, self) @callback @@ -399,7 +401,7 @@ class ControllerEvents: replaced: bool = event.get("replaced", False) # grab device in device registry attached to this node dev_id = get_device_id(self.driver_events.driver, node) - device = self.dev_reg.async_get_device({dev_id}) + device = self.dev_reg.async_get_device(identifiers={dev_id}) # We assert because we know the device exists assert device if replaced: @@ -422,7 +424,7 @@ class ControllerEvents: driver = self.driver_events.driver device_id = get_device_id(driver, node) device_id_ext = get_device_id_ext(driver, node) - device = self.dev_reg.async_get_device({device_id}) + device = self.dev_reg.async_get_device(identifiers={device_id}) via_device_id = None controller = driver.controller # Get the controller node device ID if this node is not the controller @@ -488,9 +490,6 @@ class NodeEvents: LOGGER.debug("Processing node %s", node) # register (or update) node in device registry device = self.controller_events.register_node_in_dev_reg(node) - # We only want to create the defaultdict once, even on reinterviews - if device.id not in self.controller_events.registered_unique_ids: - self.controller_events.registered_unique_ids[device.id] = defaultdict(set) # Remove any old value ids if this is a reinterview. self.controller_events.discovered_value_ids.pop(device.id, None) @@ -611,7 +610,7 @@ class NodeEvents: ) if ( not value.node.ready - or not (device := self.dev_reg.async_get_device({device_id})) + or not (device := self.dev_reg.async_get_device(identifiers={device_id})) or value.value_id in self.controller_events.discovered_value_ids[device.id] ): return @@ -633,7 +632,7 @@ class NodeEvents: """Relay stateless value notification events from Z-Wave nodes to hass.""" driver = self.controller_events.driver_events.driver device = self.dev_reg.async_get_device( - {get_device_id(driver, notification.node)} + identifiers={get_device_id(driver, notification.node)} ) # We assert because we know the device exists assert device @@ -672,7 +671,7 @@ class NodeEvents: "notification" ] device = self.dev_reg.async_get_device( - {get_device_id(driver, notification.node)} + identifiers={get_device_id(driver, notification.node)} ) # We assert because we know the device exists assert device @@ -742,7 +741,9 @@ class NodeEvents: driver = self.controller_events.driver_events.driver disc_info = value_updates_disc_info[value.value_id] - device = self.dev_reg.async_get_device({get_device_id(driver, value.node)}) + device = self.dev_reg.async_get_device( + identifiers={get_device_id(driver, value.node)} + ) # We assert because we know the device exists assert device diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 867405530ab..5fc7da68e99 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -2347,7 +2347,7 @@ def _get_node_statistics_dict( """Convert a node to a device id.""" driver = node.client.driver assert driver - device = dev_reg.async_get_device({get_device_id(driver, node)}) + device = dev_reg.async_get_device(identifiers={get_device_id(driver, node)}) assert device return device.id diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 82c212a99a5..327db05cb00 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -10,7 +10,6 @@ from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_HUMIDITY_PROPERTY, THERMOSTAT_MODE_PROPERTY, THERMOSTAT_MODE_SETPOINT_MAP, - THERMOSTAT_MODES, THERMOSTAT_OPERATING_STATE_PROPERTY, THERMOSTAT_SETPOINT_PROPERTY, ThermostatMode, @@ -38,9 +37,10 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemper from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util.unit_conversion import TemperatureConverter -from .const import DATA_CLIENT, DOMAIN +from .const import DATA_CLIENT, DOMAIN, LOGGER from .discovery import ZwaveDiscoveryInfo from .discovery_data_template import DynamicCurrentTempClimateDataTemplate from .entity import ZWaveBaseEntity @@ -48,6 +48,16 @@ from .helpers import get_value_of_zwave_value PARALLEL_UPDATES = 0 +THERMOSTAT_MODES = [ + ThermostatMode.OFF, + ThermostatMode.HEAT, + ThermostatMode.COOL, + ThermostatMode.AUTO, + ThermostatMode.AUTO_CHANGE_OVER, + ThermostatMode.FAN, + ThermostatMode.DRY, +] + # Map Z-Wave HVAC Mode to Home Assistant value # Note: We treat "auto" as "heat_cool" as most Z-Wave devices # report auto_changeover as auto without schedule support. @@ -233,9 +243,15 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): # treat value as hvac mode if hass_mode := ZW_HVAC_MODE_MAP.get(mode_id): all_modes[hass_mode] = mode_id + # Dry and Fan modes are in the process of being migrated from + # presets to hvac modes. In the meantime, we will set them as + # both, presets and hvac modes, to maintain backwards compatibility + if mode_id in (ThermostatMode.DRY, ThermostatMode.FAN): + all_presets[mode_name] = mode_id else: # treat value as hvac preset all_presets[mode_name] = mode_id + self._hvac_modes = all_modes self._hvac_presets = all_presets @@ -487,6 +503,27 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): preset_mode_value = self._hvac_presets.get(preset_mode) if preset_mode_value is None: raise ValueError(f"Received an invalid preset mode: {preset_mode}") + # Dry and Fan preset modes are deprecated as of Home Assistant 2023.8. + # Please use Dry and Fan HVAC modes instead. + if preset_mode_value in (ThermostatMode.DRY, ThermostatMode.FAN): + LOGGER.warning( + "Dry and Fan preset modes are deprecated and will be removed in Home Assistant 2024.2. " + "Please use the corresponding Dry and Fan HVAC modes instead" + ) + async_create_issue( + self.hass, + DOMAIN, + f"dry_fan_presets_deprecation_{self.entity_id}", + breaks_in_ha_version="2024.2.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="dry_fan_presets_deprecation", + translation_placeholders={ + "entity_id": self.entity_id, + }, + ) + await self._async_set_value(self._current_mode, preset_mode_value) diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py index 18a3ccef7d8..04db33fdff6 100644 --- a/homeassistant/components/zwave_js/device_action.py +++ b/homeassistant/components/zwave_js/device_action.py @@ -12,6 +12,7 @@ from zwave_js_server.const.command_class.meter import CC_SPECIFIC_METER_TYPE from zwave_js_server.model.value import get_value_id_str from zwave_js_server.util.command_class.meter import get_meter_type +from homeassistant.components.device_automation import async_validate_entity_schema from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( @@ -70,7 +71,7 @@ ACTION_TYPES = { CLEAR_LOCK_USERCODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): SERVICE_CLEAR_LOCK_USERCODE, - vol.Required(CONF_ENTITY_ID): cv.entity_domain(LOCK_DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), } ) @@ -84,7 +85,7 @@ PING_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( REFRESH_VALUE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): SERVICE_REFRESH_VALUE, - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Optional(ATTR_REFRESH_ALL_VALUES, default=False): cv.boolean, } ) @@ -92,7 +93,7 @@ REFRESH_VALUE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( RESET_METER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): SERVICE_RESET_METER, - vol.Required(CONF_ENTITY_ID): cv.entity_domain(SENSOR_DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Optional(ATTR_METER_TYPE): vol.Coerce(int), vol.Optional(ATTR_VALUE): vol.Coerce(int), } @@ -112,7 +113,7 @@ SET_CONFIG_PARAMETER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( SET_LOCK_USERCODE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): SERVICE_SET_LOCK_USERCODE, - vol.Required(CONF_ENTITY_ID): cv.entity_domain(LOCK_DOMAIN), + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), vol.Required(ATTR_USERCODE): cv.string, } @@ -130,7 +131,7 @@ SET_VALUE_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( } ) -ACTION_SCHEMA = vol.Any( +_ACTION_SCHEMA = vol.Any( CLEAR_LOCK_USERCODE_SCHEMA, PING_SCHEMA, REFRESH_VALUE_SCHEMA, @@ -141,6 +142,13 @@ ACTION_SCHEMA = vol.Any( ) +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) + + async def async_get_actions( hass: HomeAssistant, device_id: str ) -> list[dict[str, Any]]: @@ -192,7 +200,7 @@ async def async_get_actions( or state.state == STATE_UNAVAILABLE ): continue - entity_action = {**base_action, CONF_ENTITY_ID: entry.entity_id} + entity_action = {**base_action, CONF_ENTITY_ID: entry.id} actions.append({**entity_action, CONF_TYPE: SERVICE_REFRESH_VALUE}) if entry.domain == LOCK_DOMAIN: actions.extend( @@ -213,9 +221,7 @@ async def async_get_actions( # action for it if CC_SPECIFIC_METER_TYPE in value.metadata.cc_specific: endpoint_idx = value.endpoint or 0 - meter_endpoints[endpoint_idx].setdefault( - CONF_ENTITY_ID, entry.entity_id - ) + meter_endpoints[endpoint_idx].setdefault(CONF_ENTITY_ID, entry.id) meter_endpoints[endpoint_idx].setdefault(ATTR_METER_TYPE, set()).add( get_meter_type(value) ) diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py index da26e4f293e..d2b6ab7af15 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -142,7 +142,7 @@ SCENE_ACTIVATION_VALUE_NOTIFICATION_SCHEMA = ( # State based trigger schemas BASE_STATE_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, } ) @@ -272,7 +272,7 @@ async def async_get_triggers( and not entity.disabled ): triggers.append( - {**base_trigger, CONF_TYPE: NODE_STATUS, CONF_ENTITY_ID: entity_id} + {**base_trigger, CONF_TYPE: NODE_STATUS, CONF_ENTITY_ID: entity.id} ) # Handle notification event triggers diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 947e5157a8a..9569ba97167 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import asdict, dataclass, field +from enum import StrEnum from typing import TYPE_CHECKING, Any, cast from awesomeversion import AwesomeVersion @@ -47,7 +48,6 @@ from zwave_js_server.model.value import ( Value as ZwaveValue, ) -from homeassistant.backports.enum import StrEnum from homeassistant.const import EntityCategory, Platform from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceEntry diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index 05e2f8bd9fb..e3d59ff43f7 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -1,105 +1,77 @@ # Describes the format for available Z-Wave services clear_lock_usercode: - name: Clear a usercode from a lock - description: Clear a usercode from a lock target: entity: domain: lock integration: zwave_js fields: code_slot: - name: Code slot - description: Code slot to clear code from required: true example: 1 selector: text: set_lock_usercode: - name: Set a usercode on a lock - description: Set a usercode on a lock target: entity: domain: lock integration: zwave_js fields: code_slot: - name: Code slot - description: Code slot to set the code. required: true example: 1 selector: text: usercode: - name: Code - description: Code to set. required: true example: 1234 selector: text: set_config_parameter: - name: Set a Z-Wave device configuration parameter - description: Allow for changing configuration parameters of your Z-Wave devices. target: entity: integration: zwave_js fields: endpoint: - name: Endpoint - description: The configuration parameter's endpoint. example: 1 default: 0 required: false selector: text: parameter: - name: Parameter - description: The (name or id of the) configuration parameter you want to configure. example: Minimum brightness level required: true selector: text: bitmask: - name: Bitmask - description: Target a specific bitmask (see the documentation for more information). advanced: true selector: text: value: - name: Value - description: The new value to set for this configuration parameter. example: 5 required: true selector: text: bulk_set_partial_config_parameters: - name: Bulk set partial configuration parameters for a Z-Wave device (Advanced). - description: Allow for bulk setting partial parameters. Useful when multiple partial parameters have to be set at the same time. target: entity: integration: zwave_js fields: endpoint: - name: Endpoint - description: The configuration parameter's endpoint. example: 1 default: 0 required: false selector: text: parameter: - name: Parameter - description: The id of the configuration parameter you want to configure. example: 9 required: true selector: text: value: - name: Value - description: The new value(s) to set for this configuration parameter. Can either be a raw integer value to represent the bulk change or a mapping where the key is the bitmask (either in hex or integer form) and the value is the new value you want to set for that partial parameter. example: | "0x1": 1 "0x10": 1 @@ -110,12 +82,8 @@ bulk_set_partial_config_parameters: object: refresh_value: - name: Refresh value(s) of a Z-Wave entity - description: Force update value(s) for a Z-Wave entity fields: entity_id: - name: Entities - description: Entities to refresh values for. required: true example: | - sensor.family_room_motion @@ -125,184 +93,132 @@ refresh_value: integration: zwave_js multiple: true refresh_all_values: - name: Refresh all values? - description: Whether to refresh all values (true) or just the primary value (false) default: false selector: boolean: set_value: - name: Set a value on a Z-Wave device (Advanced) - description: Allow for changing any value that Z-Wave JS recognizes on a Z-Wave device. This service has minimal validation so only use this service if you know what you are doing. target: entity: integration: zwave_js fields: command_class: - name: Command Class - description: The ID of the command class for the value. example: 117 required: true selector: text: endpoint: - name: Endpoint - description: The endpoint for the value. example: 1 required: false selector: text: property: - name: Property - description: The ID of the property for the value. example: currentValue required: true selector: text: property_key: - name: Property Key - description: The ID of the property key for the value example: 1 required: false selector: text: value: - name: Value - description: The new value to set. example: "ffbb99" required: true selector: object: options: - name: Options - description: Set value options map. Refer to the Z-Wave JS documentation for more information on what options can be set. required: false selector: object: wait_for_result: - name: Wait for result? - description: Whether or not to wait for a response from the node. If not included in the payload, the integration will decide whether to wait or not. If set to `true`, note that the service call can take a while if setting a value on an asleep battery device. required: false selector: boolean: multicast_set_value: - name: Set a value on multiple Z-Wave devices via multicast (Advanced) - description: Allow for changing any value that Z-Wave JS recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This service has minimal validation so only use this service if you know what you are doing. target: entity: integration: zwave_js fields: broadcast: - name: Broadcast? - description: Whether command should be broadcast to all devices on the network. example: true required: false selector: boolean: command_class: - name: Command Class - description: The ID of the command class for the value. example: 117 required: true selector: text: endpoint: - name: Endpoint - description: The endpoint for the value. example: 1 required: false selector: text: property: - name: Property - description: The ID of the property for the value. example: currentValue required: true selector: text: property_key: - name: Property Key - description: The ID of the property key for the value example: 1 required: false selector: text: options: - name: Options - description: Set value options map. Refer to the Z-Wave JS documentation for more information on what options can be set. required: false selector: object: value: - name: Value - description: The new value to set. example: "ffbb99" required: true selector: object: ping: - name: Ping a node - description: Forces Z-Wave JS to try to reach a node. This can be used to update the status of the node in Z-Wave JS when you think it doesn't accurately reflect reality, e.g. reviving a failed/dead node or marking the node as asleep. target: entity: integration: zwave_js reset_meter: - name: Reset meter(s) on a node - description: Resets the meter(s) on a node. target: entity: domain: sensor integration: zwave_js fields: meter_type: - name: Meter Type - description: The type of meter to reset. Not all meters support the ability to pick a meter type to reset. example: 1 required: false selector: text: value: - name: Target Value - description: The value that meter(s) should be reset to. Not all meters support the ability to be reset to a specific value. example: 5 required: false selector: text: invoke_cc_api: - name: Invoke a Command Class API on a node (Advanced) - description: Allows for calling a Command Class API on a node. Some Command Classes can't be fully controlled via the `set_value` service and require direct calls to the Command Class API. target: entity: integration: zwave_js fields: command_class: - name: Command Class - description: The ID of the command class that you want to issue a command to. example: 132 required: true selector: text: endpoint: - name: Endpoint - description: The endpoint to call the API on. If an endpoint is specified, that endpoint will be targeted for all nodes associated with the target areas, devices, and/or entities. If an endpoint is not specified, the root endpoint (0) will be targeted for nodes associated with target areas and devices, and the endpoint for the primary value of each entity will be targeted. example: 1 required: false selector: text: method_name: - name: Method Name - description: The name of the API method to call. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods. example: setInterval required: true selector: text: parameters: - name: Parameters - description: A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters. example: "[1, 1]" required: true selector: diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 0bcb209a760..934307947d8 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -74,50 +74,50 @@ } }, "on_supervisor": { - "title": "Select connection method", - "description": "Do you want to use the Z-Wave JS Supervisor add-on?", + "title": "[%key:component::zwave_js::config::step::on_supervisor::title%]", + "description": "[%key:component::zwave_js::config::step::on_supervisor::description%]", "data": { - "use_addon": "Use the Z-Wave JS Supervisor add-on" + "use_addon": "[%key:component::zwave_js::config::step::on_supervisor::data::use_addon%]" } }, "install_addon": { - "title": "The Z-Wave JS add-on installation has started" + "title": "[%key:component::zwave_js::config::step::install_addon::title%]" }, "configure_addon": { - "title": "Enter the Z-Wave JS add-on configuration", - "description": "The add-on will generate security keys if those fields are left empty.", + "title": "[%key:component::zwave_js::config::step::configure_addon::title%]", + "description": "[%key:component::zwave_js::config::step::configure_addon::description%]", "data": { "usb_path": "[%key:common::config_flow::data::usb_path%]", - "s0_legacy_key": "S0 Key (Legacy)", - "s2_authenticated_key": "S2 Authenticated Key", - "s2_unauthenticated_key": "S2 Unauthenticated Key", - "s2_access_control_key": "S2 Access Control Key", + "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon::data::s0_legacy_key%]", + "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_authenticated_key%]", + "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_unauthenticated_key%]", + "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon::data::s2_access_control_key%]", "log_level": "Log level", "emulate_hardware": "Emulate Hardware" } }, "start_addon": { - "title": "The Z-Wave JS add-on is starting." + "title": "[%key:component::zwave_js::config::step::start_addon::title%]" } }, "error": { - "invalid_ws_url": "Invalid websocket URL", + "invalid_ws_url": "[%key:component::zwave_js::config::error::invalid_ws_url%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "addon_info_failed": "Failed to get Z-Wave JS add-on info.", - "addon_install_failed": "Failed to install the Z-Wave JS add-on.", - "addon_set_config_failed": "Failed to set Z-Wave JS configuration.", - "addon_start_failed": "Failed to start the Z-Wave JS add-on.", - "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", + "addon_info_failed": "[%key:component::zwave_js::config::abort::addon_info_failed%]", + "addon_install_failed": "[%key:component::zwave_js::config::abort::addon_install_failed%]", + "addon_set_config_failed": "[%key:component::zwave_js::config::abort::addon_set_config_failed%]", + "addon_start_failed": "[%key:component::zwave_js::config::abort::addon_start_failed%]", + "addon_get_discovery_info_failed": "[%key:component::zwave_js::config::abort::addon_get_discovery_info_failed%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device." }, "progress": { - "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", - "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." + "install_addon": "[%key:component::zwave_js::config::progress::install_addon%]", + "start_addon": "[%key:component::zwave_js::config::progress::start_addon%]" } }, "device_automation": { @@ -150,6 +150,205 @@ "invalid_server_version": { "title": "Newer version of Z-Wave JS Server needed", "description": "The version of Z-Wave JS Server you are currently running is too old for this version of Home Assistant. Please update the Z-Wave JS Server to the latest version to fix this issue." + }, + "dry_fan_presets_deprecation": { + "title": "Dry and Fan preset modes will be removed: {entity_id}", + "fix_flow": { + "step": { + "confirm": { + "title": "Dry and Fan preset modes will be removed: {entity_id}", + "description": "You are using the Dry or Fan preset modes in your entity `{entity_id}`.\n\nDry and Fan preset modes are deprecated and will be removed. Please update your automations to use the corresponding Dry and Fan **HVAC modes** instead.\n\nClick on SUBMIT below once you have manually fixed this issue." + } + } + } + } + }, + "services": { + "clear_lock_usercode": { + "name": "Clear lock user code", + "description": "Clears a user code from a lock.", + "fields": { + "code_slot": { + "name": "Code slot", + "description": "Code slot to clear code from." + } + } + }, + "set_lock_usercode": { + "name": "Set lock user code", + "description": "Sets a user code on a lock.", + "fields": { + "code_slot": { + "name": "[%key:component::zwave_js::services::clear_lock_usercode::fields::code_slot::name%]", + "description": "Code slot to set the code." + }, + "usercode": { + "name": "Code", + "description": "Lock code to set." + } + } + }, + "set_config_parameter": { + "name": "Set device configuration parameter", + "description": "Changes the configuration parameters of your Z-Wave devices.", + "fields": { + "endpoint": { + "name": "Endpoint", + "description": "The configuration parameter's endpoint." + }, + "parameter": { + "name": "Parameter", + "description": "The name (or ID) of the configuration parameter you want to configure." + }, + "bitmask": { + "name": "Bitmask", + "description": "Target a specific bitmask (see the documentation for more information)." + }, + "value": { + "name": "Value", + "description": "The new value to set for this configuration parameter." + } + } + }, + "bulk_set_partial_config_parameters": { + "name": "Bulk set partial configuration parameters (advanced).", + "description": "Allows for bulk setting partial parameters. Useful when multiple partial parameters have to be set at the same time.", + "fields": { + "endpoint": { + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]", + "description": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::description%]" + }, + "parameter": { + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::parameter::name%]", + "description": "[%key:component::zwave_js::services::set_config_parameter::fields::parameter::description%]" + }, + "value": { + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]", + "description": "The new value(s) to set for this configuration parameter. Can either be a raw integer value to represent the bulk change or a mapping where the key is the bitmask (either in hex or integer form) and the value is the new value you want to set for that partial parameter." + } + } + }, + "refresh_value": { + "name": "Refresh values", + "description": "Force updates the values of a Z-Wave entity.", + "fields": { + "entity_id": { + "name": "Entities", + "description": "Entities to refresh." + }, + "refresh_all_values": { + "name": "Refresh all values?", + "description": "Whether to refresh all values (true) or just the primary value (false)." + } + } + }, + "set_value": { + "name": "Set a value (advanced)", + "description": "Changes any value that Z-Wave JS recognizes on a Z-Wave device. This service has minimal validation so only use this service if you know what you are doing.", + "fields": { + "command_class": { + "name": "Command class", + "description": "The ID of the command class for the value." + }, + "endpoint": { + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]", + "description": "The endpoint for the value." + }, + "property": { + "name": "Property", + "description": "The ID of the property for the value." + }, + "property_key": { + "name": "Property key", + "description": "The ID of the property key for the value." + }, + "value": { + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]", + "description": "The new value to set." + }, + "options": { + "name": "Options", + "description": "Set value options map. Refer to the Z-Wave JS documentation for more information on what options can be set." + }, + "wait_for_result": { + "name": "Wait for result?", + "description": "Whether or not to wait for a response from the node. If not included in the payload, the integration will decide whether to wait or not. If set to `true`, note that the service call can take a while if setting a value on an asleep battery device." + } + } + }, + "multicast_set_value": { + "name": "Set a value on multiple devices via multicast (advanced)", + "description": "Changes any value that Z-Wave JS recognizes on multiple Z-Wave devices using multicast, so all devices receive the message simultaneously. This service has minimal validation so only use this service if you know what you are doing.", + "fields": { + "broadcast": { + "name": "Broadcast?", + "description": "Whether command should be broadcast to all devices on the network." + }, + "command_class": { + "name": "[%key:component::zwave_js::services::set_value::fields::command_class::name%]", + "description": "[%key:component::zwave_js::services::set_value::fields::command_class::description%]" + }, + "endpoint": { + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]", + "description": "[%key:component::zwave_js::services::set_value::fields::endpoint::description%]" + }, + "property": { + "name": "[%key:component::zwave_js::services::set_value::fields::property::name%]", + "description": "[%key:component::zwave_js::services::set_value::fields::property::description%]" + }, + "property_key": { + "name": "[%key:component::zwave_js::services::set_value::fields::property_key::name%]", + "description": "[%key:component::zwave_js::services::set_value::fields::property_key::description%]" + }, + "options": { + "name": "[%key:component::zwave_js::services::set_value::fields::options::name%]", + "description": "[%key:component::zwave_js::services::set_value::fields::options::description%]" + }, + "value": { + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]", + "description": "[%key:component::zwave_js::services::set_value::fields::value::description%]" + } + } + }, + "ping": { + "name": "Ping a node", + "description": "Forces Z-Wave JS to try to reach a node. This can be used to update the status of the node in Z-Wave JS when you think it doesn't accurately reflect reality, e.g. reviving a failed/dead node or marking the node as asleep." + }, + "reset_meter": { + "name": "Reset meters on a node", + "description": "Resets the meters on a node.", + "fields": { + "meter_type": { + "name": "Meter type", + "description": "The type of meter to reset. Not all meters support the ability to pick a meter type to reset." + }, + "value": { + "name": "Target value", + "description": "The value that meters should be reset to. Not all meters support the ability to be reset to a specific value." + } + } + }, + "invoke_cc_api": { + "name": "Invoke a Command Class API on a node (advanced)", + "description": "Calls a Command Class API on a node. Some Command Classes can't be fully controlled via the `set_value` service and require direct calls to the Command Class API.", + "fields": { + "command_class": { + "name": "[%key:component::zwave_js::services::set_value::fields::command_class::name%]", + "description": "The ID of the command class that you want to issue a command to." + }, + "endpoint": { + "name": "[%key:component::zwave_js::services::set_config_parameter::fields::endpoint::name%]", + "description": "The endpoint to call the API on. If an endpoint is specified, that endpoint will be targeted for all nodes associated with the target areas, devices, and/or entities. If an endpoint is not specified, the root endpoint (0) will be targeted for nodes associated with target areas and devices, and the endpoint for the primary value of each entity will be targeted." + }, + "method_name": { + "name": "Method name", + "description": "The name of the API method to call. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for available methods." + }, + "parameters": { + "name": "Parameters", + "description": "A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters." + } + } } } } diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 33cb59d8505..edc10d4a16e 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -232,7 +232,7 @@ async def async_attach_trigger( assert driver is not None # The node comes from the driver. drivers.add(driver) device_identifier = get_device_id(driver, node) - device = dev_reg.async_get_device({device_identifier}) + device = dev_reg.async_get_device(identifiers={device_identifier}) assert device # We need to store the device for the callback unsubs.append( diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index 52ecc0a7742..c44a0c6336a 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -179,7 +179,7 @@ async def async_attach_trigger( assert driver is not None # The node comes from the driver. drivers.add(driver) device_identifier = get_device_id(driver, node) - device = dev_reg.async_get_device({device_identifier}) + device = dev_reg.async_get_device(identifiers={device_identifier}) assert device value_id = get_value_id_str( node, command_class, property_, endpoint, property_key diff --git a/homeassistant/components/zwave_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index 1740820d0ba..86cebe81180 100644 --- a/homeassistant/components/zwave_me/__init__.py +++ b/homeassistant/components/zwave_me/__init__.py @@ -96,7 +96,7 @@ class ZWaveMeController: """Remove old-format devices in the registry.""" for device_id in self.device_ids: device = registry.async_get_device( - {(DOMAIN, f"{self.config.unique_id}-{device_id}")} + identifiers={(DOMAIN, f"{self.config.unique_id}-{device_id}")} ) if device is not None: registry.async_remove_device(device.id) diff --git a/homeassistant/components/zwave_me/const.py b/homeassistant/components/zwave_me/const.py index 84d49ff7b9d..1ec4f8d1601 100644 --- a/homeassistant/components/zwave_me/const.py +++ b/homeassistant/components/zwave_me/const.py @@ -1,5 +1,6 @@ """Constants for ZWaveMe.""" -from homeassistant.backports.enum import StrEnum +from enum import StrEnum + from homeassistant.const import Platform # Base component constants diff --git a/homeassistant/components/zwave_me/strings.json b/homeassistant/components/zwave_me/strings.json index 63add194d08..0c5a1d30976 100644 --- a/homeassistant/components/zwave_me/strings.json +++ b/homeassistant/components/zwave_me/strings.json @@ -14,7 +14,7 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_valid_uuid_set": "No valid UUID set" + "no_valid_uuid_set": "[%key:component::zwave_me::config::error::no_valid_uuid_set%]" } } } diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7b8401ec8b5..15fcb9a50de 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -6,18 +6,14 @@ from collections import ChainMap from collections.abc import Callable, Coroutine, Generator, Iterable, Mapping from contextvars import ContextVar from copy import deepcopy -from enum import Enum +from enum import Enum, StrEnum import functools import logging from random import randint -from types import MappingProxyType, MethodType -from typing import TYPE_CHECKING, Any, TypeVar, cast -import weakref - -from typing_extensions import Self +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, Self, TypeVar, cast from . import data_entry_flow, loader -from .backports.enum import StrEnum from .components import persistent_notification from .const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, Platform from .core import CALLBACK_TYPE, CoreState, Event, HassJob, HomeAssistant, callback @@ -303,9 +299,7 @@ class ConfigEntry: self.supports_remove_device: bool | None = None # Listeners to call on update - self.update_listeners: list[ - weakref.ReferenceType[UpdateListenerType] | weakref.WeakMethod - ] = [] + self.update_listeners: list[UpdateListenerType] = [] # Reason why config entry is in a failed state self.reason: str | None = None @@ -341,7 +335,7 @@ class ConfigEntry: # Only store setup result as state if it was not forwarded. if self.domain == integration.domain: - self.async_set_state(hass, ConfigEntryState.SETUP_IN_PROGRESS, None) + self._async_set_state(hass, ConfigEntryState.SETUP_IN_PROGRESS, None) if self.supports_unload is None: self.supports_unload = await support_entry_unload(hass, self.domain) @@ -360,7 +354,9 @@ class ConfigEntry: err, ) if self.domain == integration.domain: - self.async_set_state(hass, ConfigEntryState.SETUP_ERROR, "Import error") + self._async_set_state( + hass, ConfigEntryState.SETUP_ERROR, "Import error" + ) return if self.domain == integration.domain: @@ -376,12 +372,14 @@ class ConfigEntry: self.domain, err, ) - self.async_set_state(hass, ConfigEntryState.SETUP_ERROR, "Import error") + self._async_set_state( + hass, ConfigEntryState.SETUP_ERROR, "Import error" + ) return # Perform migration if not await self.async_migrate(hass): - self.async_set_state(hass, ConfigEntryState.MIGRATION_ERROR, None) + self._async_set_state(hass, ConfigEntryState.MIGRATION_ERROR, None) return error_reason = None @@ -421,7 +419,7 @@ class ConfigEntry: self.async_start_reauth(hass) result = False except ConfigEntryNotReady as ex: - self.async_set_state(hass, ConfigEntryState.SETUP_RETRY, str(ex) or None) + self._async_set_state(hass, ConfigEntryState.SETUP_RETRY, str(ex) or None) wait_time = 2 ** min(tries, 4) * 5 + ( randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) / 1000000 ) @@ -482,9 +480,9 @@ class ConfigEntry: return if result: - self.async_set_state(hass, ConfigEntryState.LOADED, None) + self._async_set_state(hass, ConfigEntryState.LOADED, None) else: - self.async_set_state(hass, ConfigEntryState.SETUP_ERROR, error_reason) + self._async_set_state(hass, ConfigEntryState.SETUP_ERROR, error_reason) async def async_shutdown(self) -> None: """Call when Home Assistant is stopping.""" @@ -505,7 +503,7 @@ class ConfigEntry: Returns if unload is possible and was successful. """ if self.source == SOURCE_IGNORE: - self.async_set_state(hass, ConfigEntryState.NOT_LOADED, None) + self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None) return True if self.state == ConfigEntryState.NOT_LOADED: @@ -519,7 +517,7 @@ class ConfigEntry: # that was uninstalled, or an integration # that has been renamed without removing the config # entry. - self.async_set_state(hass, ConfigEntryState.NOT_LOADED, None) + self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None) return True component = integration.get_component() @@ -530,14 +528,14 @@ class ConfigEntry: if self.state is not ConfigEntryState.LOADED: self.async_cancel_retry_setup() - self.async_set_state(hass, ConfigEntryState.NOT_LOADED, None) + self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None) return True supports_unload = hasattr(component, "async_unload_entry") if not supports_unload: if integration.domain == self.domain: - self.async_set_state( + self._async_set_state( hass, ConfigEntryState.FAILED_UNLOAD, "Unload not supported" ) return False @@ -549,7 +547,7 @@ class ConfigEntry: # Only adjust state if we unloaded the component if result and integration.domain == self.domain: - self.async_set_state(hass, ConfigEntryState.NOT_LOADED, None) + self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None) await self._async_process_on_unload(hass) @@ -559,7 +557,7 @@ class ConfigEntry: "Error unloading entry %s for %s", self.title, integration.domain ) if integration.domain == self.domain: - self.async_set_state( + self._async_set_state( hass, ConfigEntryState.FAILED_UNLOAD, str(ex) or "Unknown error" ) return False @@ -591,7 +589,7 @@ class ConfigEntry: ) @callback - def async_set_state( + def _async_set_state( self, hass: HomeAssistant, state: ConfigEntryState, reason: str | None ) -> None: """Set the state of the config entry.""" @@ -653,16 +651,8 @@ class ConfigEntry: Returns function to unlisten. """ - weak_listener: Any - # weakref.ref is not applicable to a bound method, e.g., - # method of a class instance, as reference will die immediately. - if hasattr(listener, "__self__"): - weak_listener = weakref.WeakMethod(cast(MethodType, listener)) - else: - weak_listener = weakref.ref(listener) - self.update_listeners.append(weak_listener) - - return lambda: self.update_listeners.remove(weak_listener) + self.update_listeners.append(listener) + return lambda: self.update_listeners.remove(listener) def as_dict(self) -> dict[str, Any]: """Return dictionary version of this entry.""" @@ -699,8 +689,9 @@ class ConfigEntry: if not self._tasks and not self._background_tasks: return + cancel_message = f"Config entry {self.title} with {self.domain} unloading" for task in self._background_tasks: - task.cancel() + task.cancel(cancel_message) _, pending = await asyncio.wait( [*self._tasks, *self._background_tasks], timeout=10 @@ -892,7 +883,9 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): """Cancel any initializing flows.""" for task_list in self._initialize_tasks.values(): for task in task_list: - task.cancel() + task.cancel( + "Config entry initialize canceled: Home Assistant is shutting down" + ) await self._discovery_debouncer.async_shutdown() async def async_finish_flow( @@ -1348,12 +1341,11 @@ class ConfigEntries: if not changed: return False - for listener_ref in entry.update_listeners: - if (listener := listener_ref()) is not None: - self.hass.async_create_task( - listener(self.hass, entry), - f"config entry update listener {entry.title} {entry.domain} {entry.domain}", - ) + for listener in entry.update_listeners: + self.hass.async_create_task( + listener(self.hass, entry), + f"config entry update listener {entry.title} {entry.domain} {entry.domain}", + ) self._async_schedule_save() self._async_dispatch(ConfigEntryChange.UPDATED, entry) diff --git a/homeassistant/const.py b/homeassistant/const.py index e9b7abd5200..db7ef9e305a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,20 +1,19 @@ """Constants used by Home Assistant components.""" from __future__ import annotations +from enum import StrEnum from typing import Final -from .backports.enum import StrEnum - APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "3" +MINOR_VERSION: Final = 8 +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" -REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) +REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) # Truthy date string triggers showing related deprecation warning messages. -REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2023.8" +REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" @@ -34,6 +33,7 @@ class Platform(StrEnum): DATE = "date" DATETIME = "datetime" DEVICE_TRACKER = "device_tracker" + EVENT = "event" FAN = "fan" GEO_LOCATION = "geo_location" HUMIDIFIER = "humidifier" @@ -1101,6 +1101,7 @@ SERVER_PORT: Final = 8123 URL_ROOT: Final = "/" URL_API: Final = "/api/" URL_API_STREAM: Final = "/api/stream" +URL_API_CORE_STATE: Final = "/api/core/state" URL_API_CONFIG: Final = "/api/config" URL_API_STATES: Final = "/api/states" URL_API_STATES_ENTITY: Final = "/api/states/{}" diff --git a/homeassistant/core.py b/homeassistant/core.py index 661f087fe6a..3673f9acba5 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -30,8 +30,8 @@ from typing import ( TYPE_CHECKING, Any, Generic, - NamedTuple, ParamSpec, + Self, TypeVar, cast, overload, @@ -39,12 +39,10 @@ from typing import ( from urllib.parse import urlparse import async_timeout -from typing_extensions import Self import voluptuous as vol import yarl from . import block_async_io, loader, util -from .backports.enum import StrEnum from .const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, @@ -134,7 +132,7 @@ BLOCK_LOG_TIMEOUT = 60 ServiceResponse = JsonObjectType | None -class ConfigSource(StrEnum): +class ConfigSource(enum.StrEnum): """Source of core configuration.""" DEFAULT = "default" @@ -765,7 +763,7 @@ class HomeAssistant: for task in self._background_tasks: self._tasks.add(task) task.add_done_callback(self._tasks.remove) - task.cancel() + task.cancel("Home Assistant is stopping") self._cancel_cancellable_timers() self.exit_code = exit_code @@ -815,7 +813,7 @@ class HomeAssistant: "the stop event to prevent delaying shutdown", task, ) - task.cancel() + task.cancel("Home Assistant stage 2 shutdown") try: async with async_timeout.timeout(0.1): await task @@ -972,21 +970,22 @@ class Event: return f"" -class _FilterableJob(NamedTuple): - """Event listener job to be executed with optional filter.""" - - job: HassJob[[Event], Coroutine[Any, Any, None] | None] - event_filter: Callable[[Event], bool] | None - run_immediately: bool +_FilterableJobType = tuple[ + HassJob[[Event], Coroutine[Any, Any, None] | None], # job + Callable[[Event], bool] | None, # event_filter + bool, # run_immediately +] class EventBus: """Allow the firing of and listening for events.""" + __slots__ = ("_listeners", "_match_all_listeners", "_hass") + def __init__(self, hass: HomeAssistant) -> None: """Initialize a new event bus.""" - self._listeners: dict[str, list[_FilterableJob]] = {} - self._match_all_listeners: list[_FilterableJob] = [] + self._listeners: dict[str, list[_FilterableJobType]] = {} + self._match_all_listeners: list[_FilterableJobType] = [] self._listeners[MATCH_ALL] = self._match_all_listeners self._hass = hass @@ -1113,14 +1112,12 @@ class EventBus: raise HomeAssistantError(f"Event listener {listener} is not a callback") return self._async_listen_filterable_job( event_type, - _FilterableJob( - HassJob(listener, f"listen {event_type}"), event_filter, run_immediately - ), + (HassJob(listener, f"listen {event_type}"), event_filter, run_immediately), ) @callback def _async_listen_filterable_job( - self, event_type: str, filterable_job: _FilterableJob + self, event_type: str, filterable_job: _FilterableJobType ) -> CALLBACK_TYPE: self._listeners.setdefault(event_type, []).append(filterable_job) @@ -1167,7 +1164,7 @@ class EventBus: This method must be run in the event loop. """ - filterable_job: _FilterableJob | None = None + filterable_job: _FilterableJobType | None = None @callback def _onetime_listener(event: Event) -> None: @@ -1189,7 +1186,7 @@ class EventBus: _onetime_listener, listener, ("__name__", "__qualname__", "__module__"), [] ) - filterable_job = _FilterableJob( + filterable_job = ( HassJob(_onetime_listener, f"onetime listen {event_type} {listener}"), None, False, @@ -1199,7 +1196,7 @@ class EventBus: @callback def _async_remove_listener( - self, event_type: str, filterable_job: _FilterableJob + self, event_type: str, filterable_job: _FilterableJobType ) -> None: """Remove a listener of a specific event_type. @@ -1422,6 +1419,8 @@ class State: class StateMachine: """Helper class that tracks the state of different entities.""" + __slots__ = ("_states", "_reservations", "_bus", "_loop") + def __init__(self, bus: EventBus, loop: asyncio.events.AbstractEventLoop) -> None: """Initialize state machine.""" self._states: dict[str, State] = {} @@ -1669,7 +1668,7 @@ class StateMachine: ) -class SupportsResponse(StrEnum): +class SupportsResponse(enum.StrEnum): """Service call response configuration.""" NONE = "none" @@ -1705,7 +1704,7 @@ class Service: class ServiceCall: """Representation of a call to a service.""" - __slots__ = ["domain", "service", "data", "context", "return_response"] + __slots__ = ("domain", "service", "data", "context", "return_response") def __init__( self, @@ -1716,8 +1715,8 @@ class ServiceCall: return_response: bool = False, ) -> None: """Initialize a service call.""" - self.domain = domain.lower() - self.service = service.lower() + self.domain = domain + self.service = service self.data = ReadOnlyDict(data or {}) self.context = context or Context() self.return_response = return_response @@ -1736,6 +1735,8 @@ class ServiceCall: class ServiceRegistry: """Offer the services over the eventbus.""" + __slots__ = ("_services", "_hass") + def __init__(self, hass: HomeAssistant) -> None: """Initialize a service registry.""" self._services: dict[str, dict[str, Service]] = {} @@ -1902,15 +1903,20 @@ class ServiceRegistry: This method is a coroutine. """ - domain = domain.lower() - service = service.lower() context = context or Context() service_data = service_data or {} try: handler = self._services[domain][service] except KeyError: - raise ServiceNotFound(domain, service) from None + # Almost all calls are already lower case, so we avoid + # calling lower() on the arguments in the common case. + domain = domain.lower() + service = service.lower() + try: + handler = self._services[domain][service] + except KeyError: + raise ServiceNotFound(domain, service) from None if return_response: if not blocking: @@ -1950,8 +1956,8 @@ class ServiceRegistry: self._hass.bus.async_fire( EVENT_CALL_SERVICE, { - ATTR_DOMAIN: domain.lower(), - ATTR_SERVICE: service.lower(), + ATTR_DOMAIN: domain, + ATTR_SERVICE: service, ATTR_SERVICE_DATA: service_data, }, context=context, @@ -1959,7 +1965,10 @@ class ServiceRegistry: coro = self._execute_service(handler, service_call) if not blocking: - self._run_service_in_background(coro, service_call) + self._hass.async_create_task( + self._run_service_call_catch_exceptions(coro, service_call), + f"service call background {service_call.domain}.{service_call.service}", + ) return None response_data = await coro @@ -1971,49 +1980,42 @@ class ServiceRegistry: ) return response_data - def _run_service_in_background( + async def _run_service_call_catch_exceptions( self, coro_or_task: Coroutine[Any, Any, Any] | asyncio.Task[Any], service_call: ServiceCall, ) -> None: """Run service call in background, catching and logging any exceptions.""" - - async def catch_exceptions() -> None: - try: - await coro_or_task - except Unauthorized: - _LOGGER.warning( - "Unauthorized service called %s/%s", - service_call.domain, - service_call.service, - ) - except asyncio.CancelledError: - _LOGGER.debug("Service was cancelled: %s", service_call) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error executing service: %s", service_call) - - self._hass.async_create_task( - catch_exceptions(), - f"service call background {service_call.domain}.{service_call.service}", - ) + try: + await coro_or_task + except Unauthorized: + _LOGGER.warning( + "Unauthorized service called %s/%s", + service_call.domain, + service_call.service, + ) + except asyncio.CancelledError: + _LOGGER.debug("Service was cancelled: %s", service_call) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error executing service: %s", service_call) async def _execute_service( self, handler: Service, service_call: ServiceCall ) -> ServiceResponse: """Execute a service.""" - if handler.job.job_type == HassJobType.Coroutinefunction: - return await cast( - Callable[[ServiceCall], Awaitable[ServiceResponse]], - handler.job.target, - )(service_call) - if handler.job.job_type == HassJobType.Callback: - return cast(Callable[[ServiceCall], ServiceResponse], handler.job.target)( - service_call - ) - return await self._hass.async_add_executor_job( - cast(Callable[[ServiceCall], ServiceResponse], handler.job.target), - service_call, - ) + job = handler.job + target = job.target + if job.job_type == HassJobType.Coroutinefunction: + if TYPE_CHECKING: + target = cast(Callable[..., Coroutine[Any, Any, _R]], target) + return await target(service_call) + if job.job_type == HassJobType.Callback: + if TYPE_CHECKING: + target = cast(Callable[..., _R], target) + return target(service_call) + if TYPE_CHECKING: + target = cast(Callable[..., _R], target) + return await self._hass.async_add_executor_job(target, service_call) class Config: diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 6f125ce359a..e0408a24b2e 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -5,14 +5,13 @@ import abc from collections.abc import Callable, Iterable, Mapping import copy from dataclasses import dataclass +from enum import StrEnum import logging from types import MappingProxyType -from typing import Any, TypedDict +from typing import Any, Required, TypedDict -from typing_extensions import Required import voluptuous as vol -from .backports.enum import StrEnum from .core import HomeAssistant, callback from .exceptions import HomeAssistantError from .helpers.frame import report diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index bfc96eabfdf..2946c8c3743 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -191,21 +191,6 @@ class MaxLengthExceeded(HomeAssistantError): self.max_length = max_length -class RequiredParameterMissing(HomeAssistantError): - """Raised when a required parameter is missing from a function call.""" - - def __init__(self, parameter_names: list[str]) -> None: - """Initialize error.""" - super().__init__( - self, - ( - "Call must include at least one of the following parameters: " - f"{', '.join(parameter_names)}" - ), - ) - self.parameter_names = parameter_names - - class DependencyError(HomeAssistantError): """Raised when dependencies cannot be setup.""" diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index d1b330b5dbe..78c98bcc03d 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -4,6 +4,7 @@ To update, run python3 -m script.hassfest """ APPLICATION_CREDENTIALS = [ + "electric_kiwi", "geocaching", "google", "google_assistant_sdk", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 24215a8a0c4..7b0aa78d69e 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -9,6 +9,22 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ { "domain": "airthings_ble", "manufacturer_id": 820, + "service_uuid": "b42e1f6e-ade7-11e4-89d3-123b93f75cba", + }, + { + "domain": "airthings_ble", + "manufacturer_id": 820, + "service_uuid": "b42e4a8e-ade7-11e4-89d3-123b93f75cba", + }, + { + "domain": "airthings_ble", + "manufacturer_id": 820, + "service_uuid": "b42e1c08-ade7-11e4-89d3-123b93f75cba", + }, + { + "domain": "airthings_ble", + "manufacturer_id": 820, + "service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba", }, { "connectable": False, @@ -83,6 +99,12 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ ], "manufacturer_id": 20296, }, + { + "connectable": True, + "domain": "gardena_bluetooth", + "manufacturer_id": 1062, + "service_uuid": "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", + }, { "connectable": False, "domain": "govee_ble", @@ -498,6 +520,16 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ ], "manufacturer_id": 76, }, + { + "connectable": False, + "domain": "xiaomi_ble", + "service_data_uuid": "0000181b-0000-1000-8000-00805f9b34fb", + }, + { + "connectable": False, + "domain": "xiaomi_ble", + "service_data_uuid": "0000181d-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "xiaomi_ble", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3145c5cdc49..10221d1d589 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -106,6 +106,7 @@ FLOWS = { "dsmr", "dsmr_reader", "dunehd", + "duotecno", "dwd_weather_warnings", "dynalite", "eafm", @@ -117,6 +118,7 @@ FLOWS = { "efergy", "eight_sleep", "electrasmart", + "electric_kiwi", "elgato", "elkm1", "elmax", @@ -155,6 +157,7 @@ FLOWS = { "frontier_silicon", "fully_kiosk", "garages_amsterdam", + "gardena_bluetooth", "gdacs", "generic", "geo_json_events", @@ -322,9 +325,11 @@ FLOWS = { "openexchangerates", "opengarage", "openhome", + "opensky", "opentherm_gw", "openuv", "openweathermap", + "opower", "oralb", "otbr", "overkiz", @@ -333,6 +338,7 @@ FLOWS = { "p1_monitor", "panasonic_viera", "peco", + "pegel_online", "philips_js", "pi_hole", "picnic", @@ -534,6 +540,7 @@ FLOWS = { "zerproc", "zeversolar", "zha", + "zodiac", "zwave_js", "zwave_me", ], diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 6c8910cd7f9..8b5dd91f64c 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -185,6 +185,10 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "flux_led", "hostname": "zengge_[0-9a-f][0-9a-f]_*", }, + { + "domain": "flux_led", + "hostname": "zengge", + }, { "domain": "flux_led", "hostname": "sta*", @@ -597,24 +601,39 @@ DHCP: list[dict[str, str | bool]] = [ }, { "domain": "tplink", - "hostname": "es*", + "hostname": "e[sp]*", "macaddress": "54AF97*", }, { "domain": "tplink", - "hostname": "ep*", + "hostname": "e[sp]*", "macaddress": "E848B8*", }, { "domain": "tplink", - "hostname": "ep*", + "hostname": "e[sp]*", "macaddress": "1C61B4*", }, { "domain": "tplink", - "hostname": "ep*", + "hostname": "e[sp]*", "macaddress": "003192*", }, + { + "domain": "tplink", + "hostname": "hs*", + "macaddress": "B4B024*", + }, + { + "domain": "tplink", + "hostname": "hs*", + "macaddress": "9C5322*", + }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "9C5322*", + }, { "domain": "tplink", "hostname": "hs*", @@ -672,84 +691,99 @@ DHCP: list[dict[str, str | bool]] = [ }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "60A4B7*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "005F67*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "1027F5*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "B0A7B9*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "403F8C*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "C0C9E3*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "909A4A*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "E848B8*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "003192*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "1C3BF3*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "50C7BF*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "68FF7B*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "98DAC4*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "B09575*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "C006C3*", }, { "domain": "tplink", - "hostname": "k[lp]*", + "hostname": "k[lps]*", "macaddress": "6C5AB0*", }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "54AF97*", + }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "AC15A2*", + }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "788C5B*", + }, { "domain": "tuya", "macaddress": "105A17*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 26833d62368..350bcde8236 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -456,6 +456,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "atlanticcityelectric": { + "name": "Atlantic City Electric", + "integration_type": "virtual", + "supported_by": "opower" + }, "atome": { "name": "Atome Linky", "integration_type": "hub", @@ -555,6 +560,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "bge": { + "name": "Baltimore Gas and Electric (BGE)", + "integration_type": "virtual", + "supported_by": "opower" + }, "bitcoin": { "name": "Bitcoin", "integration_type": "hub", @@ -703,7 +713,7 @@ }, "bsblan": { "name": "BSB-Lan", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "local_polling" }, @@ -846,7 +856,7 @@ "iot_class": "local_polling" }, "co2signal": { - "name": "CO2 Signal", + "name": "Electricity Maps", "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" @@ -862,6 +872,11 @@ "integration_type": "hub", "config_flow": false }, + "comed": { + "name": "Commonwealth Edison (ComEd)", + "integration_type": "virtual", + "supported_by": "opower" + }, "comed_hourly_pricing": { "name": "ComEd Hourly Pricing", "integration_type": "hub", @@ -991,6 +1006,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "delmarva": { + "name": "Delmarva Power", + "integration_type": "virtual", + "supported_by": "opower" + }, "deluge": { "name": "Deluge", "integration_type": "service", @@ -1220,6 +1240,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "duotecno": { + "name": "Duotecno", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "dwd_weather_warnings": { "name": "Deutscher Wetterdienst (DWD) Weather Warnings", "integration_type": "hub", @@ -1328,6 +1354,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "electric_kiwi": { + "name": "Electric Kiwi", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "elgato": { "name": "Elgato", "integrations": { @@ -1542,6 +1574,11 @@ } } }, + "evergy": { + "name": "Evergy", + "integration_type": "virtual", + "supported_by": "opower" + }, "everlights": { "name": "EverLights", "integration_type": "hub", @@ -1884,6 +1921,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "gardena_bluetooth": { + "name": "Gardena Bluetooth", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "gaviota": { "name": "Gaviota", "integration_type": "virtual", @@ -1896,7 +1939,6 @@ "iot_class": "cloud_polling" }, "generic": { - "name": "Generic Camera", "integration_type": "hub", "config_flow": true, "iot_class": "local_push" @@ -3010,7 +3052,6 @@ "iot_class": "cloud_push" }, "local_calendar": { - "name": "Local Calendar", "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" @@ -3349,12 +3390,6 @@ } } }, - "miflora": { - "name": "Mi Flora", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "mijndomein_energie": { "name": "Mijndomein Energie", "integration_type": "virtual", @@ -3384,12 +3419,6 @@ "config_flow": false, "iot_class": "cloud_push" }, - "mitemp_bt": { - "name": "Xiaomi Mijia BLE Temperature and Humidity Sensor", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "mjpeg": { "name": "MJPEG IP Camera", "integration_type": "hub", @@ -3972,7 +4001,7 @@ "opensky": { "name": "OpenSky Network", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "opentherm_gw": { @@ -4016,6 +4045,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "opower": { + "name": "Opower", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "opple": { "name": "Opple", "integration_type": "hub", @@ -4126,12 +4161,33 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "peco_opower": { + "name": "PECO Energy Company (PECO)", + "integration_type": "virtual", + "supported_by": "opower" + }, + "pegel_online": { + "name": "PEGELONLINE", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "pencom": { "name": "Pencom", "integration_type": "hub", "config_flow": false, "iot_class": "local_polling" }, + "pepco": { + "name": "Potomac Electric Power Company (Pepco)", + "integration_type": "virtual", + "supported_by": "opower" + }, + "pge": { + "name": "Pacific Gas & Electric (PG&E)", + "integration_type": "virtual", + "supported_by": "opower" + }, "philips": { "name": "Philips", "integrations": { @@ -4305,6 +4361,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "pse": { + "name": "Puget Sound Energy (PSE)", + "integration_type": "virtual", + "supported_by": "opower" + }, "pulseaudio_loopback": { "name": "PulseAudio Loopback", "integration_type": "hub", @@ -4818,7 +4879,6 @@ "iot_class": "local_polling" }, "season": { - "name": "Season", "integration_type": "service", "config_flow": true, "iot_class": "local_polling" @@ -5905,16 +5965,9 @@ }, "u_tec": { "name": "U-tec", - "integrations": { - "ultraloq": { - "integration_type": "virtual", - "config_flow": false, - "iot_standards": [ - "zwave" - ], - "name": "Ultraloq" - } - } + "iot_standards": [ + "zwave" + ] }, "ubiquiti": { "name": "Ubiquiti", @@ -6074,7 +6127,6 @@ "iot_class": "local_polling" }, "version": { - "name": "Version", "integration_type": "hub", "config_flow": true, "iot_class": "local_push" @@ -6286,7 +6338,6 @@ "iot_class": "cloud_polling" }, "workday": { - "name": "Workday", "integration_type": "hub", "config_flow": true, "iot_class": "local_polling" @@ -6539,10 +6590,9 @@ "iot_class": "local_polling" }, "zodiac": { - "name": "Zodiac", "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" + "config_flow": true, + "iot_class": "calculated" }, "zoneminder": { "name": "ZoneMinder", @@ -6659,6 +6709,7 @@ "emulated_roku", "filesize", "garages_amsterdam", + "generic", "google_travel_time", "group", "growatt_server", @@ -6671,6 +6722,7 @@ "input_text", "integration", "islamic_prayer_times", + "local_calendar", "local_ip", "min_max", "mobile_app", @@ -6681,6 +6733,7 @@ "proximity", "rpi_power", "schedule", + "season", "shopping_list", "sun", "switch_as_x", @@ -6689,6 +6742,9 @@ "tod", "uptime", "utility_meter", - "waze_travel_time" + "version", + "waze_travel_time", + "workday", + "zodiac" ] } diff --git a/homeassistant/helpers/aiohttp_compat.py b/homeassistant/helpers/aiohttp_compat.py index 1780cd053f5..78aad44fa66 100644 --- a/homeassistant/helpers/aiohttp_compat.py +++ b/homeassistant/helpers/aiohttp_compat.py @@ -12,7 +12,7 @@ class CancelOnDisconnectRequestHandler(web_protocol.RequestHandler): task_handler = self._task_handler super().connection_lost(exc) if task_handler is not None: - task_handler.cancel() + task_handler.cancel("aiohttp connection lost") def restore_original_aiohttp_cancel_behavior() -> None: diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 21a54d64728..a580c013cd0 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -5,9 +5,8 @@ from collections import OrderedDict import logging import os from pathlib import Path -from typing import NamedTuple +from typing import NamedTuple, Self -from typing_extensions import Self import voluptuous as vol from homeassistant import loader @@ -181,7 +180,9 @@ async def async_check_ha_config_file( # noqa: C901 if config_schema is not None: try: config = config_schema(config) - result[domain] = config[domain] + # Don't fail if the validator removed the domain from the config + if domain in config: + result[domain] = config[domain] except vol.Invalid as ex: _comp_error(ex, domain, config) continue diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e8f1e58615c..122fd752a84 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -9,7 +9,7 @@ from datetime import ( time as time_sys, timedelta, ) -from enum import Enum +from enum import Enum, StrEnum import inspect import logging from numbers import Number @@ -106,6 +106,22 @@ from . import script_variables as script_variables_helper, template as template_ TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM', 'HH:MM:SS' or 'HH:MM:SS.F'" + +class UrlProtocolSchema(StrEnum): + """Valid URL protocol schema values.""" + + HTTP = "http" + HTTPS = "https" + HOMEASSISTANT = "homeassistant" + + +EXTERNAL_URL_PROTOCOL_SCHEMA_LIST = frozenset( + {UrlProtocolSchema.HTTP, UrlProtocolSchema.HTTPS} +) +CONFIGURATION_URL_PROTOCOL_SCHEMA_LIST = frozenset( + {UrlProtocolSchema.HOMEASSISTANT, UrlProtocolSchema.HTTP, UrlProtocolSchema.HTTPS} +) + # Home Assistant types byte = vol.All(vol.Coerce(int), vol.Range(min=0, max=255)) small_float = vol.All(vol.Coerce(float), vol.Range(min=0, max=1)) @@ -728,16 +744,24 @@ def socket_timeout(value: Any | None) -> object: # pylint: disable=no-value-for-parameter -def url(value: Any) -> str: +def url( + value: Any, + _schema_list: frozenset[UrlProtocolSchema] = EXTERNAL_URL_PROTOCOL_SCHEMA_LIST, +) -> str: """Validate an URL.""" url_in = str(value) - if urlparse(url_in).scheme in ["http", "https"]: + if urlparse(url_in).scheme in _schema_list: return cast(str, vol.Schema(vol.Url())(url_in)) raise vol.Invalid("invalid url") +def configuration_url(value: Any) -> str: + """Validate an URL that allows the homeassistant schema.""" + return url(value, CONFIGURATION_URL_PROTOCOL_SCHEMA_LIST) + + def url_no_path(value: Any) -> str: """Validate a url without a path.""" url_in = url(value) @@ -1127,7 +1151,11 @@ def _no_yaml_config_schema( def config_entry_only_config_schema(domain: str) -> Callable[[dict], dict]: - """Return a config schema which logs if attempted to setup from YAML.""" + """Return a config schema which logs if attempted to setup from YAML. + + Use this when an integration's __init__.py defines setup or async_setup + but setup from yaml is not supported. + """ return _no_yaml_config_schema( domain, @@ -1138,7 +1166,11 @@ def config_entry_only_config_schema(domain: str) -> Callable[[dict], dict]: def platform_only_config_schema(domain: str) -> Callable[[dict], dict]: - """Return a config schema which logs if attempted to setup from YAML.""" + """Return a config schema which logs if attempted to setup from YAML. + + Use this when an integration's __init__.py defines setup or async_setup + but setup from the integration key is not supported. + """ return _no_yaml_config_schema( domain, diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 7df01fc8fd2..4dd9233c6ab 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -3,16 +3,18 @@ from __future__ import annotations from collections import UserDict from collections.abc import Coroutine, ValuesView +from enum import StrEnum import logging import time -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, cast +from urllib.parse import urlparse import attr +from yarl import URL -from homeassistant.backports.enum import StrEnum from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError, RequiredParameterMissing +from homeassistant.exceptions import HomeAssistantError from homeassistant.util.json import format_unserializable_data import homeassistant.util.uuid as uuid_util @@ -26,6 +28,7 @@ if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry from . import entity_registry + from .entity import DeviceInfo _LOGGER = logging.getLogger(__name__) @@ -46,6 +49,8 @@ ORPHANED_DEVICE_KEEP_SECONDS = 86400 * 30 RUNTIME_ONLY_ATTRS = {"suggested_area"} +CONFIGURATION_URL_SCHEMES = {"http", "https", "homeassistant"} + class DeviceEntryDisabler(StrEnum): """What disabled a device entry.""" @@ -60,6 +65,60 @@ DISABLED_CONFIG_ENTRY = DeviceEntryDisabler.CONFIG_ENTRY.value DISABLED_INTEGRATION = DeviceEntryDisabler.INTEGRATION.value DISABLED_USER = DeviceEntryDisabler.USER.value +DEVICE_INFO_TYPES = { + # Device info is categorized by finding the first device info type which has all + # the keys of the device info. The link device info type must be kept first + # to make it preferred over primary. + "link": { + "connections", + "identifiers", + }, + "primary": { + "configuration_url", + "connections", + "entry_type", + "hw_version", + "identifiers", + "manufacturer", + "model", + "name", + "suggested_area", + "sw_version", + "via_device", + }, + "secondary": { + "connections", + "default_manufacturer", + "default_model", + "default_name", + # Used by Fritz + "via_device", + }, +} + +DEVICE_INFO_KEYS = set.union(*(itm for itm in DEVICE_INFO_TYPES.values())) + + +class _EventDeviceRegistryUpdatedData_CreateRemove(TypedDict): + """EventDeviceRegistryUpdated data for action type 'create' and 'remove'.""" + + action: Literal["create", "remove"] + device_id: str + + +class _EventDeviceRegistryUpdatedData_Update(TypedDict): + """EventDeviceRegistryUpdated data for action type 'update'.""" + + action: Literal["update"] + device_id: str + changes: dict[str, Any] + + +EventDeviceRegistryUpdatedData = ( + _EventDeviceRegistryUpdatedData_CreateRemove + | _EventDeviceRegistryUpdatedData_Update +) + class DeviceEntryType(StrEnum): """Device entry type.""" @@ -67,6 +126,72 @@ class DeviceEntryType(StrEnum): SERVICE = "service" +class DeviceInfoError(HomeAssistantError): + """Raised when device info is invalid.""" + + def __init__(self, domain: str, device_info: DeviceInfo, message: str) -> None: + """Initialize error.""" + super().__init__( + f"Invalid device info {device_info} for '{domain}' config entry: {message}", + ) + self.device_info = device_info + self.domain = domain + + +def _validate_device_info( + config_entry: ConfigEntry | None, + device_info: DeviceInfo, +) -> str: + """Process a device info.""" + keys = set(device_info) + + # If no keys or not enough info to match up, abort + if not device_info.get("connections") and not device_info.get("identifiers"): + raise DeviceInfoError( + config_entry.domain if config_entry else "unknown", + device_info, + "device info must include at least one of identifiers or connections", + ) + + device_info_type: str | None = None + + # Find the first device info type which has all keys in the device info + for possible_type, allowed_keys in DEVICE_INFO_TYPES.items(): + if keys <= allowed_keys: + device_info_type = possible_type + break + + if device_info_type is None: + raise DeviceInfoError( + config_entry.domain if config_entry else "unknown", + device_info, + ( + "device info needs to either describe a device, " + "link to existing device or provide extra information." + ), + ) + + return device_info_type + + +def _validate_configuration_url(value: Any) -> str | None: + """Validate and convert configuration_url.""" + if value is None: + return None + if ( + isinstance(value, URL) + and (value.scheme not in CONFIGURATION_URL_SCHEMES or not value.host) + ) or ( + (parsed_url := urlparse(str(value))) + and ( + parsed_url.scheme not in CONFIGURATION_URL_SCHEMES + or not parsed_url.hostname + ) + ): + raise ValueError(f"invalid configuration_url '{value}'") + return str(value) + + @attr.s(slots=True, frozen=True) class DeviceEntry: """Device Registry Entry.""" @@ -272,13 +397,14 @@ class DeviceRegistryItems(UserDict[str, _EntryTypeT]): def get_entry( self, - identifiers: set[tuple[str, str]], + identifiers: set[tuple[str, str]] | None, connections: set[tuple[str, str]] | None, ) -> _EntryTypeT | None: """Get entry from identifiers or connections.""" - for identifier in identifiers: - if identifier in self._identifiers: - return self._identifiers[identifier] + if identifiers: + for identifier in identifiers: + if identifier in self._identifiers: + return self._identifiers[identifier] if not connections: return None for connection in _normalize_connections(connections): @@ -317,7 +443,7 @@ class DeviceRegistry: @callback def async_get_device( self, - identifiers: set[tuple[str, str]], + identifiers: set[tuple[str, str]] | None = None, connections: set[tuple[str, str]] | None = None, ) -> DeviceEntry | None: """Check if device is registered.""" @@ -326,7 +452,7 @@ class DeviceRegistry: def _async_get_deleted_device( self, identifiers: set[tuple[str, str]], - connections: set[tuple[str, str]] | None, + connections: set[tuple[str, str]], ) -> DeletedDeviceEntry | None: """Check if device is deleted.""" return self.deleted_devices.get_entry(identifiers, connections) @@ -336,8 +462,8 @@ class DeviceRegistry: self, *, config_entry_id: str, - configuration_url: str | None | UndefinedType = UNDEFINED, - connections: set[tuple[str, str]] | None = None, + configuration_url: str | URL | None | UndefinedType = UNDEFINED, + connections: set[tuple[str, str]] | None | UndefinedType = UNDEFINED, default_manufacturer: str | None | UndefinedType = UNDEFINED, default_model: str | None | UndefinedType = UNDEFINED, default_name: str | None | UndefinedType = UNDEFINED, @@ -345,27 +471,54 @@ class DeviceRegistry: disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, hw_version: str | None | UndefinedType = UNDEFINED, - identifiers: set[tuple[str, str]] | None = None, + identifiers: set[tuple[str, str]] | None | UndefinedType = UNDEFINED, manufacturer: str | None | UndefinedType = UNDEFINED, model: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, - via_device: tuple[str, str] | None = None, + via_device: tuple[str, str] | None | UndefinedType = UNDEFINED, ) -> DeviceEntry: """Get device. Create if it doesn't exist.""" - if not identifiers and not connections: - raise RequiredParameterMissing(["identifiers", "connections"]) + if configuration_url is not UNDEFINED: + configuration_url = _validate_configuration_url(configuration_url) - if identifiers is None: + # Reconstruct a DeviceInfo dict from the arguments. + # When we upgrade to Python 3.12, we can change this method to instead + # accept kwargs typed as a DeviceInfo dict (PEP 692) + device_info: DeviceInfo = {} + for key, val in ( + ("configuration_url", configuration_url), + ("connections", connections), + ("default_manufacturer", default_manufacturer), + ("default_model", default_model), + ("default_name", default_name), + ("entry_type", entry_type), + ("hw_version", hw_version), + ("identifiers", identifiers), + ("manufacturer", manufacturer), + ("model", model), + ("name", name), + ("suggested_area", suggested_area), + ("sw_version", sw_version), + ("via_device", via_device), + ): + if val is UNDEFINED: + continue + device_info[key] = val # type: ignore[literal-required] + + config_entry = self.hass.config_entries.async_get_entry(config_entry_id) + device_info_type = _validate_device_info(config_entry, device_info) + + if identifiers is None or identifiers is UNDEFINED: identifiers = set() - if connections is None: + if connections is None or connections is UNDEFINED: connections = set() else: connections = _normalize_connections(connections) - device = self.async_get_device(identifiers, connections) + device = self.async_get_device(identifiers=identifiers, connections=connections) if device is None: deleted_device = self._async_get_deleted_device(identifiers, connections) @@ -377,6 +530,13 @@ class DeviceRegistry: config_entry_id, connections, identifiers ) self.devices[device.id] = device + # If creating a new device, default to the config entry name + if ( + device_info_type == "primary" + and (not name or name is UNDEFINED) + and config_entry + ): + name = config_entry.title if default_manufacturer is not UNDEFINED and device.manufacturer is None: manufacturer = default_manufacturer @@ -387,8 +547,8 @@ class DeviceRegistry: if default_name is not UNDEFINED and device.name is None: name = default_name - if via_device is not None: - via = self.async_get_device({via_device}) + if via_device is not None and via_device is not UNDEFINED: + via = self.async_get_device(identifiers={via_device}) via_device_id: str | UndefinedType = via.id if via else UNDEFINED else: via_device_id = UNDEFINED @@ -433,7 +593,7 @@ class DeviceRegistry: *, add_config_entry_id: str | UndefinedType = UNDEFINED, area_id: str | None | UndefinedType = UNDEFINED, - configuration_url: str | None | UndefinedType = UNDEFINED, + configuration_url: str | URL | None | UndefinedType = UNDEFINED, disabled_by: DeviceEntryDisabler | None | UndefinedType = UNDEFINED, entry_type: DeviceEntryType | None | UndefinedType = UNDEFINED, hw_version: str | None | UndefinedType = UNDEFINED, @@ -521,6 +681,9 @@ class DeviceRegistry: new_values["identifiers"] = new_identifiers old_values["identifiers"] = old.identifiers + if configuration_url is not UNDEFINED: + configuration_url = _validate_configuration_url(configuration_url) + for attr_name, value in ( ("area_id", area_id), ("configuration_url", configuration_url), diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index e87eb15b954..7d240cc0320 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -15,6 +15,7 @@ from timeit import default_timer as timer from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict, TypeVar, final import voluptuous as vol +from yarl import URL from homeassistant.backports.functools import cached_property from homeassistant.config import DATA_CUSTOMIZE @@ -34,18 +35,18 @@ from homeassistant.const import ( STATE_UNKNOWN, EntityCategory, ) -from homeassistant.core import CALLBACK_TYPE, Context, Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util, ensure_unique_string, slugify from . import device_registry as dr, entity_registry as er -from .device_registry import DeviceEntryType +from .device_registry import DeviceEntryType, EventDeviceRegistryUpdatedData from .event import ( async_track_device_registry_updated_event, async_track_entity_registry_updated_event, ) -from .typing import UNDEFINED, StateType, UndefinedType +from .typing import UNDEFINED, EventType, StateType, UndefinedType if TYPE_CHECKING: from .entity_platform import EntityPlatform @@ -177,7 +178,7 @@ def get_unit_of_measurement(hass: HomeAssistant, entity_id: str) -> str | None: class DeviceInfo(TypedDict, total=False): """Entity device information for device registry.""" - configuration_url: str | None + configuration_url: str | URL | None connections: set[tuple[str, str]] default_manufacturer: str default_model: str @@ -277,6 +278,9 @@ class Entity(ABC): # Entry in the entity registry registry_entry: er.RegistryEntry | None = None + # The device entry for this entity + device_entry: dr.DeviceEntry | None = None + # Hold list for functions to call on remove. _on_remove: list[CALLBACK_TYPE] | None = None @@ -763,13 +767,7 @@ class Entity(ABC): if name is UNDEFINED: name = None - if not self.has_entity_name or not self.registry_entry: - return name - - device_registry = dr.async_get(self.hass) - if not (device_id := self.registry_entry.device_id) or not ( - device_entry := device_registry.async_get(device_id) - ): + if not self.has_entity_name or not (device_entry := self.device_entry): return name device_name = device_entry.name_by_user or device_entry.name @@ -1100,7 +1098,9 @@ class Entity(ABC): if self.platform: self.hass.data[DATA_ENTITY_SOURCE].pop(self.entity_id) - async def _async_registry_updated(self, event: Event) -> None: + async def _async_registry_updated( + self, event: EventType[er.EventEntityRegistryUpdatedData] + ) -> None: """Handle entity registry update.""" data = event.data if data["action"] == "remove": @@ -1116,22 +1116,26 @@ class Entity(ABC): ent_reg = er.async_get(self.hass) old = self.registry_entry - self.registry_entry = ent_reg.async_get(data["entity_id"]) - assert self.registry_entry is not None + registry_entry = ent_reg.async_get(data["entity_id"]) + assert registry_entry is not None + self.registry_entry = registry_entry - if self.registry_entry.disabled: + if device_id := registry_entry.device_id: + self.device_entry = dr.async_get(self.hass).async_get(device_id) + + if registry_entry.disabled: await self.async_remove() return assert old is not None - if self.registry_entry.entity_id == old.entity_id: + if registry_entry.entity_id == old.entity_id: self.async_registry_entry_updated() self.async_write_ha_state() return await self.async_remove(force_remove=True) - self.entity_id = self.registry_entry.entity_id + self.entity_id = registry_entry.entity_id await self.platform.async_add_entities([self]) @callback @@ -1143,7 +1147,9 @@ class Entity(ABC): self._unsub_device_updates = None @callback - def _async_device_registry_updated(self, event: Event) -> None: + def _async_device_registry_updated( + self, event: EventType[EventDeviceRegistryUpdatedData] + ) -> None: """Handle device registry update.""" data = event.data @@ -1153,6 +1159,7 @@ class Entity(ABC): if "name" not in data["changes"] and "name_by_user" not in data["changes"]: return + self.device_entry = dr.async_get(self.hass).async_get(data["device_id"]) self.async_write_ha_state() @callback diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 675d368873a..b7dadcf0f67 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -7,7 +7,6 @@ from contextvars import ContextVar from datetime import datetime, timedelta from logging import Logger, getLogger from typing import TYPE_CHECKING, Any, Protocol -from urllib.parse import urlparse import voluptuous as vol @@ -19,6 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import ( CALLBACK_TYPE, + DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, HomeAssistant, ServiceCall, @@ -29,7 +29,6 @@ from homeassistant.core import ( from homeassistant.exceptions import ( HomeAssistantError, PlatformNotReady, - RequiredParameterMissing, ) from homeassistant.generated import languages from homeassistant.setup import async_start_setup @@ -42,7 +41,6 @@ from . import ( service, translation, ) -from .device_registry import DeviceRegistry from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider from .event import async_call_later, async_track_time_interval from .issue_registry import IssueSeverity, async_create_issue @@ -216,16 +214,27 @@ class EntityPlatform: self.platform_name, self.domain, ) + learn_more_url = None + if self.platform and "custom_components" not in self.platform.__file__: # type: ignore[attr-defined] + learn_more_url = ( + f"https://www.home-assistant.io/integrations/{self.platform_name}/" + ) + platform_key = f"platform: {self.platform_name}" + yaml_example = f"```yaml\n{self.domain}:\n - {platform_key}\n```" async_create_issue( self.hass, - self.domain, + HOMEASSISTANT_DOMAIN, f"platform_integration_no_support_{self.domain}_{self.platform_name}", is_fixable=False, + issue_domain=self.platform_name, + learn_more_url=learn_more_url, severity=IssueSeverity.ERROR, - translation_key="platform_integration_no_support", + translation_key="no_platform_setup", translation_placeholders={ "domain": self.domain, "platform": self.platform_name, + "platform_key": platform_key, + "yaml_example": yaml_example, }, ) @@ -485,12 +494,9 @@ class EntityPlatform: hass = self.hass - device_registry = dev_reg.async_get(hass) entity_registry = ent_reg.async_get(hass) tasks = [ - self._async_add_entity( - entity, update_before_add, entity_registry, device_registry - ) + self._async_add_entity(entity, update_before_add, entity_registry) for entity in new_entities ] @@ -552,7 +558,6 @@ class EntityPlatform: entity: Entity, update_before_add: bool, entity_registry: EntityRegistry, - device_registry: DeviceRegistry, ) -> None: """Add an entity to the platform.""" if entity is None: @@ -564,7 +569,8 @@ class EntityPlatform: self._get_parallel_updates_semaphore(hasattr(entity, "update")), ) - # Update properties before we generate the entity_id + # Update properties before we generate the entity_id. This will happen + # also for disabled entities. if update_before_add: try: await entity.async_device_update(warning=False) @@ -608,63 +614,17 @@ class EntityPlatform: entity.add_to_platform_abort() return - if self.config_entry is not None: - config_entry_id: str | None = self.config_entry.entry_id - else: - config_entry_id = None - - device_info = entity.device_info - device_id = None - device = None - - if config_entry_id is not None and device_info is not None: - processed_dev_info: dict[str, str | None] = { - "config_entry_id": config_entry_id - } - for key in ( - "connections", - "default_manufacturer", - "default_model", - "default_name", - "entry_type", - "identifiers", - "manufacturer", - "model", - "name", - "suggested_area", - "sw_version", - "hw_version", - "via_device", - ): - if key in device_info: - processed_dev_info[key] = device_info[ - key # type: ignore[literal-required] - ] - - if "configuration_url" in device_info: - if device_info["configuration_url"] is None: - processed_dev_info["configuration_url"] = None - else: - configuration_url = str(device_info["configuration_url"]) - if urlparse(configuration_url).scheme in [ - "http", - "https", - "homeassistant", - ]: - processed_dev_info["configuration_url"] = configuration_url - else: - _LOGGER.warning( - "Ignoring invalid device configuration_url '%s'", - configuration_url, - ) - + if self.config_entry and (device_info := entity.device_info): try: - device = device_registry.async_get_or_create( - **processed_dev_info # type: ignore[arg-type] + device = dev_reg.async_get(self.hass).async_get_or_create( + config_entry_id=self.config_entry.entry_id, + **device_info, ) - device_id = device.id - except RequiredParameterMissing: - pass + except dev_reg.DeviceInfoError as exc: + self.logger.error("Ignoring invalid device info: %s", str(exc)) + device = None + else: + device = None # An entity may suggest the entity_id by setting entity_id itself suggested_entity_id: str | None = entity.entity_id @@ -699,7 +659,7 @@ class EntityPlatform: entity.unique_id, capabilities=entity.capability_attributes, config_entry=self.config_entry, - device_id=device_id, + device_id=device.id if device else None, disabled_by=disabled_by, entity_category=entity.entity_category, get_initial_options=entity.get_initial_entity_options, @@ -721,6 +681,8 @@ class EntityPlatform: ) entity.registry_entry = entry + if device: + entity.device_entry = device entity.entity_id = entry.entity_id # We won't generate an entity ID if the platform has already set one diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index cabac2617c2..a46dd3c3a52 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -12,14 +12,15 @@ from __future__ import annotations from collections import UserDict from collections.abc import Callable, Iterable, Mapping, ValuesView from datetime import datetime, timedelta +from enum import StrEnum import logging import time -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, cast import attr +from typing_extensions import NotRequired import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -107,6 +108,28 @@ class RegistryEntryHider(StrEnum): USER = "user" +class _EventEntityRegistryUpdatedData_CreateRemove(TypedDict): + """EventEntityRegistryUpdated data for action type 'create' and 'remove'.""" + + action: Literal["create", "remove"] + entity_id: str + + +class _EventEntityRegistryUpdatedData_Update(TypedDict): + """EventEntityRegistryUpdated data for action type 'update'.""" + + action: Literal["update"] + entity_id: str + changes: dict[str, Any] # Required with action == "update" + old_entity_id: NotRequired[str] + + +EventEntityRegistryUpdatedData = ( + _EventEntityRegistryUpdatedData_CreateRemove + | _EventEntityRegistryUpdatedData_Update +) + + EntityOptionsType = Mapping[str, Mapping[str, Any]] ReadOnlyEntityOptionsType = ReadOnlyDict[str, Mapping[str, Any]] diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index b7254c5c347..e615a6422f0 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine, Iterable, Sequence +from collections.abc import Callable, Coroutine, Iterable, Mapping, Sequence import copy from dataclasses import dataclass from datetime import datetime, timedelta @@ -10,12 +10,11 @@ import functools as ft import logging from random import randint import time -from typing import Any, Concatenate, ParamSpec, cast +from typing import Any, Concatenate, ParamSpec, TypedDict, TypeVar import attr from homeassistant.const import ( - ATTR_ENTITY_ID, EVENT_CORE_CONFIG_UPDATE, EVENT_STATE_CHANGED, MATCH_ALL, @@ -24,7 +23,6 @@ from homeassistant.const import ( ) from homeassistant.core import ( CALLBACK_TYPE, - Event, HassJob, HomeAssistant, State, @@ -36,12 +34,18 @@ from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe -from .device_registry import EVENT_DEVICE_REGISTRY_UPDATED -from .entity_registry import EVENT_ENTITY_REGISTRY_UPDATED +from .device_registry import ( + EVENT_DEVICE_REGISTRY_UPDATED, + EventDeviceRegistryUpdatedData, +) +from .entity_registry import ( + EVENT_ENTITY_REGISTRY_UPDATED, + EventEntityRegistryUpdatedData, +) from .ratelimit import KeyedRateLimit from .sun import get_astral_event_next from .template import RenderInfo, Template, result_as_boolean -from .typing import TemplateVarsType +from .typing import EventType, TemplateVarsType TRACK_STATE_CHANGE_CALLBACKS = "track_state_change_callbacks" TRACK_STATE_CHANGE_LISTENER = "track_state_change_listener" @@ -67,6 +71,7 @@ _LOGGER = logging.getLogger(__name__) RANDOM_MICROSECOND_MIN = 50000 RANDOM_MICROSECOND_MAX = 500000 +_TypedDictT = TypeVar("_TypedDictT", bound=Mapping[str, Any]) _P = ParamSpec("_P") @@ -117,6 +122,14 @@ class TrackTemplateResult: result: Any +class EventStateChangedData(TypedDict): + """EventStateChanged data.""" + + entity_id: str + old_state: State | None + new_state: State | None + + def threaded_listener_factory( async_factory: Callable[Concatenate[HomeAssistant, _P], Any] ) -> Callable[Concatenate[HomeAssistant, _P], CALLBACK_TYPE]: @@ -183,36 +196,38 @@ def async_track_state_change( job = HassJob(action, f"track state change {entity_ids} {from_state} {to_state}") @callback - def state_change_filter(event: Event) -> bool: + def state_change_filter(event: EventType[EventStateChangedData]) -> bool: """Handle specific state changes.""" if from_state is not None: - if (old_state := event.data.get("old_state")) is not None: - old_state = old_state.state + old_state_str: str | None = None + if (old_state := event.data["old_state"]) is not None: + old_state_str = old_state.state - if not match_from_state(old_state): + if not match_from_state(old_state_str): return False if to_state is not None: - if (new_state := event.data.get("new_state")) is not None: - new_state = new_state.state + new_state_str: str | None = None + if (new_state := event.data["new_state"]) is not None: + new_state_str = new_state.state - if not match_to_state(new_state): + if not match_to_state(new_state_str): return False return True @callback - def state_change_dispatcher(event: Event) -> None: + def state_change_dispatcher(event: EventType[EventStateChangedData]) -> None: """Handle specific state changes.""" hass.async_run_hass_job( job, event.data["entity_id"], - event.data.get("old_state"), + event.data["old_state"], event.data["new_state"], ) @callback - def state_change_listener(event: Event) -> None: + def state_change_listener(event: EventType[EventStateChangedData]) -> None: """Handle specific state changes.""" if not state_change_filter(event): return @@ -231,7 +246,7 @@ def async_track_state_change( return async_track_state_change_event(hass, entity_ids, state_change_listener) return hass.bus.async_listen( - EVENT_STATE_CHANGED, state_change_dispatcher, event_filter=state_change_filter + EVENT_STATE_CHANGED, state_change_dispatcher, event_filter=state_change_filter # type: ignore[arg-type] ) @@ -242,7 +257,7 @@ track_state_change = threaded_listener_factory(async_track_state_change) def async_track_state_change_event( hass: HomeAssistant, entity_ids: str | Iterable[str], - action: Callable[[Event], Any], + action: Callable[[EventType[EventStateChangedData]], Any], ) -> CALLBACK_TYPE: """Track specific state change events indexed by entity_id. @@ -263,8 +278,8 @@ def async_track_state_change_event( @callback def _async_dispatch_entity_id_event( hass: HomeAssistant, - callbacks: dict[str, list[HassJob[[Event], Any]]], - event: Event, + callbacks: dict[str, list[HassJob[[EventType[EventStateChangedData]], Any]]], + event: EventType[EventStateChangedData], ) -> None: """Dispatch to listeners.""" if not (callbacks_list := callbacks.get(event.data["entity_id"])): @@ -282,7 +297,9 @@ def _async_dispatch_entity_id_event( @callback def _async_state_change_filter( - hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event + hass: HomeAssistant, + callbacks: dict[str, list[HassJob[[EventType[EventStateChangedData]], Any]]], + event: EventType[EventStateChangedData], ) -> bool: """Filter state changes by entity_id.""" return event.data["entity_id"] in callbacks @@ -292,7 +309,7 @@ def _async_state_change_filter( def _async_track_state_change_event( hass: HomeAssistant, entity_ids: str | Iterable[str], - action: Callable[[Event], Any], + action: Callable[[EventType[EventStateChangedData]], Any], ) -> CALLBACK_TYPE: """async_track_state_change_event without lowercasing.""" return _async_track_event( @@ -312,13 +329,13 @@ def _remove_empty_listener() -> None: """Remove a listener that does nothing.""" -@callback +@callback # type: ignore[arg-type] # mypy bug? def _remove_listener( hass: HomeAssistant, listeners_key: str, keys: Iterable[str], - job: HassJob[[Event], Any], - callbacks: dict[str, list[HassJob[[Event], Any]]], + job: HassJob[[EventType[_TypedDictT]], Any], + callbacks: dict[str, list[HassJob[[EventType[_TypedDictT]], Any]]], ) -> None: """Remove listener.""" for key in keys: @@ -338,12 +355,22 @@ def _async_track_event( listeners_key: str, event_type: str, dispatcher_callable: Callable[ - [HomeAssistant, dict[str, list[HassJob[[Event], Any]]], Event], None + [ + HomeAssistant, + dict[str, list[HassJob[[EventType[_TypedDictT]], Any]]], + EventType[_TypedDictT], + ], + None, ], filter_callable: Callable[ - [HomeAssistant, dict[str, list[HassJob[[Event], Any]]], Event], bool + [ + HomeAssistant, + dict[str, list[HassJob[[EventType[_TypedDictT]], Any]]], + EventType[_TypedDictT], + ], + bool, ], - action: Callable[[Event], None], + action: Callable[[EventType[_TypedDictT]], None], ) -> CALLBACK_TYPE: """Track an event by a specific key.""" if not keys: @@ -354,9 +381,9 @@ def _async_track_event( hass_data = hass.data - callbacks: dict[str, list[HassJob[[Event], Any]]] | None = hass_data.get( - callbacks_key - ) + callbacks: dict[ + str, list[HassJob[[EventType[_TypedDictT]], Any]] + ] | None = hass_data.get(callbacks_key) if not callbacks: callbacks = hass_data[callbacks_key] = {} @@ -382,12 +409,14 @@ def _async_track_event( @callback def _async_dispatch_old_entity_id_or_entity_id_event( hass: HomeAssistant, - callbacks: dict[str, list[HassJob[[Event], Any]]], - event: Event, + callbacks: dict[ + str, list[HassJob[[EventType[EventEntityRegistryUpdatedData]], Any]] + ], + event: EventType[EventEntityRegistryUpdatedData], ) -> None: """Dispatch to listeners.""" if not ( - callbacks_list := callbacks.get( + callbacks_list := callbacks.get( # type: ignore[call-overload] # mypy bug? event.data.get("old_entity_id", event.data["entity_id"]) ) ): @@ -405,7 +434,11 @@ def _async_dispatch_old_entity_id_or_entity_id_event( @callback def _async_entity_registry_updated_filter( - hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event + hass: HomeAssistant, + callbacks: dict[ + str, list[HassJob[[EventType[EventEntityRegistryUpdatedData]], Any]] + ], + event: EventType[EventEntityRegistryUpdatedData], ) -> bool: """Filter entity registry updates by entity_id.""" return event.data.get("old_entity_id", event.data["entity_id"]) in callbacks @@ -416,7 +449,7 @@ def _async_entity_registry_updated_filter( def async_track_entity_registry_updated_event( hass: HomeAssistant, entity_ids: str | Iterable[str], - action: Callable[[Event], Any], + action: Callable[[EventType[EventEntityRegistryUpdatedData]], Any], ) -> CALLBACK_TYPE: """Track specific entity registry updated events indexed by entity_id. @@ -438,7 +471,11 @@ def async_track_entity_registry_updated_event( @callback def _async_device_registry_updated_filter( - hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event + hass: HomeAssistant, + callbacks: dict[ + str, list[HassJob[[EventType[EventDeviceRegistryUpdatedData]], Any]] + ], + event: EventType[EventDeviceRegistryUpdatedData], ) -> bool: """Filter device registry updates by device_id.""" return event.data["device_id"] in callbacks @@ -447,8 +484,10 @@ def _async_device_registry_updated_filter( @callback def _async_dispatch_device_id_event( hass: HomeAssistant, - callbacks: dict[str, list[HassJob[[Event], Any]]], - event: Event, + callbacks: dict[ + str, list[HassJob[[EventType[EventDeviceRegistryUpdatedData]], Any]] + ], + event: EventType[EventDeviceRegistryUpdatedData], ) -> None: """Dispatch to listeners.""" if not (callbacks_list := callbacks.get(event.data["device_id"])): @@ -468,7 +507,7 @@ def _async_dispatch_device_id_event( def async_track_device_registry_updated_event( hass: HomeAssistant, device_ids: str | Iterable[str], - action: Callable[[Event], Any], + action: Callable[[EventType[EventDeviceRegistryUpdatedData]], Any], ) -> CALLBACK_TYPE: """Track specific device registry updated events indexed by device_id. @@ -488,7 +527,9 @@ def async_track_device_registry_updated_event( @callback def _async_dispatch_domain_event( - hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event + hass: HomeAssistant, + callbacks: dict[str, list[HassJob[[EventType[EventStateChangedData]], Any]]], + event: EventType[EventStateChangedData], ) -> None: """Dispatch domain event listeners.""" domain = split_entity_id(event.data["entity_id"])[0] @@ -503,10 +544,12 @@ def _async_dispatch_domain_event( @callback def _async_domain_added_filter( - hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event + hass: HomeAssistant, + callbacks: dict[str, list[HassJob[[EventType[EventStateChangedData]], Any]]], + event: EventType[EventStateChangedData], ) -> bool: """Filter state changes by entity_id.""" - return event.data.get("old_state") is None and ( + return event.data["old_state"] is None and ( MATCH_ALL in callbacks or split_entity_id(event.data["entity_id"])[0] in callbacks ) @@ -516,7 +559,7 @@ def _async_domain_added_filter( def async_track_state_added_domain( hass: HomeAssistant, domains: str | Iterable[str], - action: Callable[[Event], Any], + action: Callable[[EventType[EventStateChangedData]], Any], ) -> CALLBACK_TYPE: """Track state change events when an entity is added to domains.""" if not (domains := _async_string_to_lower_list(domains)): @@ -528,7 +571,7 @@ def async_track_state_added_domain( def _async_track_state_added_domain( hass: HomeAssistant, domains: str | Iterable[str], - action: Callable[[Event], Any], + action: Callable[[EventType[EventStateChangedData]], Any], ) -> CALLBACK_TYPE: """Track state change events when an entity is added to domains.""" return _async_track_event( @@ -545,10 +588,12 @@ def _async_track_state_added_domain( @callback def _async_domain_removed_filter( - hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event + hass: HomeAssistant, + callbacks: dict[str, list[HassJob[[EventType[EventStateChangedData]], Any]]], + event: EventType[EventStateChangedData], ) -> bool: """Filter state changes by entity_id.""" - return event.data.get("new_state") is None and ( + return event.data["new_state"] is None and ( MATCH_ALL in callbacks or split_entity_id(event.data["entity_id"])[0] in callbacks ) @@ -558,7 +603,7 @@ def _async_domain_removed_filter( def async_track_state_removed_domain( hass: HomeAssistant, domains: str | Iterable[str], - action: Callable[[Event], Any], + action: Callable[[EventType[EventStateChangedData]], Any], ) -> CALLBACK_TYPE: """Track state change events when an entity is removed from domains.""" return _async_track_event( @@ -588,7 +633,7 @@ class _TrackStateChangeFiltered: self, hass: HomeAssistant, track_states: TrackStates, - action: Callable[[Event], Any], + action: Callable[[EventType[EventStateChangedData]], Any], ) -> None: """Handle removal / refresh of tracker init.""" self.hass = hass @@ -692,7 +737,7 @@ class _TrackStateChangeFiltered: ) @callback - def _state_added(self, event: Event) -> None: + def _state_added(self, event: EventType[EventStateChangedData]) -> None: self._cancel_listener(_ENTITIES_LISTENER) self._setup_entities_listener( self._last_track_states.domains, self._last_track_states.entities @@ -711,7 +756,7 @@ class _TrackStateChangeFiltered: @callback def _setup_all_listener(self) -> None: self._listeners[_ALL_LISTENER] = self.hass.bus.async_listen( - EVENT_STATE_CHANGED, self._action + EVENT_STATE_CHANGED, self._action # type: ignore[arg-type] ) @@ -720,7 +765,7 @@ class _TrackStateChangeFiltered: def async_track_state_change_filtered( hass: HomeAssistant, track_states: TrackStates, - action: Callable[[Event], Any], + action: Callable[[EventType[EventStateChangedData]], Any], ) -> _TrackStateChangeFiltered: """Track state changes with a TrackStates filter that can be updated. @@ -794,7 +839,8 @@ def async_track_template( @callback def _template_changed_listener( - event: Event | None, updates: list[TrackTemplateResult] + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], ) -> None: """Check if condition is correct and run action.""" track_result = updates.pop() @@ -820,9 +866,9 @@ def async_track_template( hass.async_run_hass_job( job, - event and event.data.get("entity_id"), - event and event.data.get("old_state"), - event and event.data.get("new_state"), + event and event.data["entity_id"], + event and event.data["old_state"], + event and event.data["new_state"], ) info = async_track_template_result( @@ -842,7 +888,7 @@ class TrackTemplateResultInfo: self, hass: HomeAssistant, track_templates: Sequence[TrackTemplate], - action: Callable[[Event | None, list[TrackTemplateResult]], None], + action: TrackTemplateResultListener, has_super_template: bool = False, ) -> None: """Handle removal / refresh of tracker init.""" @@ -979,7 +1025,7 @@ class TrackTemplateResultInfo: self, track_template_: TrackTemplate, now: datetime, - event: Event | None, + event: EventType[EventStateChangedData] | None, ) -> bool | TrackTemplateResult: """Re-render the template if conditions match. @@ -1050,7 +1096,7 @@ class TrackTemplateResultInfo: @callback def _refresh( self, - event: Event | None, + event: EventType[EventStateChangedData] | None, track_templates: Iterable[TrackTemplate] | None = None, replayed: bool | None = False, ) -> None: @@ -1158,10 +1204,10 @@ class TrackTemplateResultInfo: TrackTemplateResultListener = Callable[ [ - Event | None, + EventType[EventStateChangedData] | None, list[TrackTemplateResult], ], - None, + Coroutine[Any, Any, None] | None, ] """Type for the listener for template results. @@ -1268,11 +1314,11 @@ def async_track_same_state( hass.async_run_hass_job(job) @callback - def state_for_cancel_listener(event: Event) -> None: + def state_for_cancel_listener(event: EventType[EventStateChangedData]) -> None: """Fire on changes and cancel for listener if changed.""" - entity: str = event.data["entity_id"] - from_state: State | None = event.data.get("old_state") - to_state: State | None = event.data.get("new_state") + entity = event.data["entity_id"] + from_state = event.data["old_state"] + to_state = event.data["new_state"] if not async_check_same_func(entity, from_state, to_state): clear_listener() @@ -1283,7 +1329,7 @@ def async_track_same_state( if entity_ids == MATCH_ALL: async_remove_state_for_cancel = hass.bus.async_listen( - EVENT_STATE_CHANGED, state_for_cancel_listener + EVENT_STATE_CHANGED, state_for_cancel_listener # type: ignore[arg-type] ) else: async_remove_state_for_cancel = async_track_state_change_event( @@ -1714,17 +1760,16 @@ def _render_infos_to_track_states(render_infos: Iterable[RenderInfo]) -> TrackSt @callback -def _event_triggers_rerender(event: Event, info: RenderInfo) -> bool: +def _event_triggers_rerender( + event: EventType[EventStateChangedData], info: RenderInfo +) -> bool: """Determine if a template should be re-rendered from an event.""" - entity_id = cast(str, event.data.get(ATTR_ENTITY_ID)) + entity_id = event.data["entity_id"] if info.filter(entity_id): return True - if ( - event.data.get("new_state") is not None - and event.data.get("old_state") is not None - ): + if event.data["new_state"] is not None and event.data["old_state"] is not None: return False return bool(info.filter_lifecycle(entity_id)) @@ -1732,12 +1777,14 @@ def _event_triggers_rerender(event: Event, info: RenderInfo) -> bool: @callback def _rate_limit_for_event( - event: Event, info: RenderInfo, track_template_: TrackTemplate + event: EventType[EventStateChangedData], + info: RenderInfo, + track_template_: TrackTemplate, ) -> timedelta | None: """Determine the rate limit for an event.""" # Specifically referenced entities are excluded # from the rate limit - if event.data.get(ATTR_ENTITY_ID) in info.entities: + if event.data["entity_id"] in info.entities: return None if track_template_.rate_limit is not None: diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py index beb084d8c1c..ed02f8a710e 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -3,10 +3,9 @@ from __future__ import annotations from collections.abc import Callable import sys -from typing import Any +from typing import Any, Self import httpx -from typing_extensions import Self from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, HomeAssistant, callback @@ -19,8 +18,13 @@ from homeassistant.util.ssl import ( from .frame import warn_use +# We have a lot of integrations that poll every 10-30 seconds +# and we want to keep the connection open for a while so we +# don't have to reconnect every time so we use 15s to match aiohttp. +KEEP_ALIVE_TIMEOUT = 15 DATA_ASYNC_CLIENT = "httpx_async_client" DATA_ASYNC_CLIENT_NOVERIFY = "httpx_async_client_noverify" +DEFAULT_LIMITS = limits = httpx.Limits(keepalive_expiry=KEEP_ALIVE_TIMEOUT) SERVER_SOFTWARE = "{0}/{1} httpx/{2} Python/{3[0]}.{3[1]}".format( APPLICATION_NAME, __version__, httpx.__version__, sys.version_info ) @@ -78,6 +82,7 @@ def create_async_httpx_client( client = HassHttpXAsyncClient( verify=ssl_context, headers={USER_AGENT: SERVER_SOFTWARE}, + limits=DEFAULT_LIMITS, **kwargs, ) diff --git a/homeassistant/helpers/issue_registry.py b/homeassistant/helpers/issue_registry.py index afe2d98ed0b..9bd6ebffadb 100644 --- a/homeassistant/helpers/issue_registry.py +++ b/homeassistant/helpers/issue_registry.py @@ -3,12 +3,12 @@ from __future__ import annotations import dataclasses from datetime import datetime +from enum import StrEnum import functools as ft from typing import Any, cast from awesomeversion import AwesomeVersion, AwesomeVersionStrategy -from homeassistant.backports.enum import StrEnum from homeassistant.const import __version__ as ha_version from homeassistant.core import HomeAssistant, callback from homeassistant.util.async_ import run_callback_threadsafe diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index ab3b93cf3c4..4dd71a584ec 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -4,9 +4,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from datetime import datetime, timedelta import logging -from typing import Any, cast - -from typing_extensions import Self +from typing import Any, Self, cast from homeassistant.const import ATTR_RESTORED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, State, callback, valid_entity_id diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 84c0f769c7c..08975c5c881 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -2,15 +2,13 @@ from __future__ import annotations from collections.abc import Callable, Mapping, Sequence -from enum import IntFlag +from enum import IntFlag, StrEnum from functools import cache -from typing import Any, Generic, Literal, TypedDict, TypeVar, cast +from typing import Any, Generic, Literal, Required, TypedDict, TypeVar, cast from uuid import UUID -from typing_extensions import Required import voluptuous as vol -from homeassistant.backports.enum import StrEnum from homeassistant.const import CONF_MODE, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import split_entity_id, valid_entity_id from homeassistant.util import decorator @@ -122,12 +120,9 @@ def _entity_features() -> dict[str, type[IntFlag]]: } -def _validate_supported_feature(supported_feature: int | str) -> int: +def _validate_supported_feature(supported_feature: str) -> int: """Validate a supported feature and resolve an enum string to its value.""" - if isinstance(supported_feature, int): - return supported_feature - known_entity_features = _entity_features() try: @@ -144,6 +139,20 @@ def _validate_supported_feature(supported_feature: int | str) -> int: raise vol.Invalid(f"Unknown supported feature '{supported_feature}'") from exc +def _validate_supported_features(supported_features: int | list[str]) -> int: + """Validate a supported feature and resolve an enum string to its value.""" + + if isinstance(supported_features, int): + return supported_features + + feature_mask = 0 + + for supported_feature in supported_features: + feature_mask |= _validate_supported_feature(supported_feature) + + return feature_mask + + ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( { # Integration that provided the entity @@ -153,7 +162,9 @@ ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( # Device class of the entity vol.Optional("device_class"): vol.All(cv.ensure_list, [str]), # Features supported by the entity - vol.Optional("supported_features"): [vol.All(str, _validate_supported_feature)], + vol.Optional("supported_features"): [ + vol.All(cv.ensure_list, [str], _validate_supported_features) + ], } ) @@ -440,6 +451,27 @@ class ColorTempSelector(Selector[ColorTempSelectorConfig]): return value +class ConditionSelectorConfig(TypedDict): + """Class to represent an action selector config.""" + + +@SELECTORS.register("condition") +class ConditionSelector(Selector[ConditionSelectorConfig]): + """Selector of an condition sequence (script syntax).""" + + selector_type = "condition" + + CONFIG_SCHEMA = vol.Schema({}) + + def __init__(self, config: ConditionSelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + return vol.Schema(cv.CONDITIONS_SCHEMA)(data) + + class ConfigEntrySelectorConfig(TypedDict, total=False): """Class to represent a config entry selector config.""" @@ -507,7 +539,7 @@ class ConversationAgentSelectorConfig(TypedDict, total=False): @SELECTORS.register("conversation_agent") -class COnversationAgentSelector(Selector[ConversationAgentSelectorConfig]): +class ConversationAgentSelector(Selector[ConversationAgentSelectorConfig]): """Selector for a conversation agent.""" selector_type = "conversation_agent" @@ -1103,6 +1135,7 @@ class TextSelectorConfig(TypedDict, total=False): """Class to represent a text selector config.""" multiline: bool + prefix: str suffix: str type: TextSelectorType autocomplete: str @@ -1135,6 +1168,7 @@ class TextSelector(Selector[TextSelectorConfig]): CONFIG_SCHEMA = vol.Schema( { vol.Optional("multiline", default=False): bool, + vol.Optional("prefix"): str, vol.Optional("suffix"): str, # The "type" controls the input field in the browser, the resulting # data can be any string so we don't validate it. @@ -1165,7 +1199,11 @@ class ThemeSelector(Selector[ThemeSelectorConfig]): selector_type = "theme" - CONFIG_SCHEMA = vol.Schema({}) + CONFIG_SCHEMA = vol.Schema( + { + vol.Optional("include_default", default=False): cv.boolean, + } + ) def __init__(self, config: ThemeSelectorConfig | None = None) -> None: """Instantiate a selector.""" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index dcd7115f363..74823dea953 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -50,6 +50,7 @@ from . import ( device_registry, entity_registry, template, + translation, ) from .selector import TargetSelector from .typing import ConfigType, TemplateVarsType @@ -172,7 +173,7 @@ _SERVICE_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -_SERVICES_SCHEMA = vol.Schema({cv.slug: _SERVICE_SCHEMA}) +_SERVICES_SCHEMA = vol.Schema({cv.slug: vol.Any(None, _SERVICE_SCHEMA)}) class ServiceParams(TypedDict): @@ -240,7 +241,7 @@ class SelectedEntities: return _LOGGER.warning( - "Unable to find referenced %s or it is/they are currently not available", + "Referenced %s are missing or not currently available", ", ".join(parts), ) @@ -607,6 +608,11 @@ async def async_get_all_descriptions( ) loaded = dict(zip(missing, contents)) + # Load translations for all service domains + translations = await translation.async_get_translations( + hass, "en", "services", list(services) + ) + # Build response descriptions: dict[str, dict[str, Any]] = {} for domain, services_map in services.items(): @@ -616,37 +622,66 @@ async def async_get_all_descriptions( for service_name in services_map: cache_key = (domain, service_name) description = descriptions_cache.get(cache_key) + if description is not None: + domain_descriptions[service_name] = description + continue + # Cache missing descriptions - if description is None: - domain_yaml = loaded.get(domain) or {} - # The YAML may be empty for dynamically defined - # services (ie shell_command) that never call - # service.async_set_service_schema for the dynamic - # service + domain_yaml = loaded.get(domain) or {} + # The YAML may be empty for dynamically defined + # services (ie shell_command) that never call + # service.async_set_service_schema for the dynamic + # service - yaml_description = domain_yaml.get( # type: ignore[union-attr] - service_name, {} - ) + yaml_description = ( + domain_yaml.get(service_name) or {} # type: ignore[union-attr] + ) - # Don't warn for missing services, because it triggers false - # positives for things like scripts, that register as a service - description = { - "name": yaml_description.get("name", ""), - "description": yaml_description.get("description", ""), - "fields": yaml_description.get("fields", {}), + # Don't warn for missing services, because it triggers false + # positives for things like scripts, that register as a service + # + # When name & description are in the translations use those; + # otherwise fallback to backwards compatible behavior from + # the time when we didn't have translations for descriptions yet. + # This mimics the behavior of the frontend. + description = { + "name": translations.get( + f"component.{domain}.services.{service_name}.name", + yaml_description.get("name", ""), + ), + "description": translations.get( + f"component.{domain}.services.{service_name}.description", + yaml_description.get("description", ""), + ), + "fields": dict(yaml_description.get("fields", {})), + } + + # Translate fields names & descriptions as well + for field_name, field_schema in description["fields"].items(): + if name := translations.get( + f"component.{domain}.services.{service_name}.fields.{field_name}.name" + ): + field_schema["name"] = name + if desc := translations.get( + f"component.{domain}.services.{service_name}.fields.{field_name}.description" + ): + field_schema["description"] = desc + if example := translations.get( + f"component.{domain}.services.{service_name}.fields.{field_name}.example" + ): + field_schema["example"] = example + + if "target" in yaml_description: + description["target"] = yaml_description["target"] + + if ( + response := hass.services.supports_response(domain, service_name) + ) != SupportsResponse.NONE: + description["response"] = { + "optional": response == SupportsResponse.OPTIONAL, } - if "target" in yaml_description: - description["target"] = yaml_description["target"] - - if ( - response := hass.services.supports_response(domain, service_name) - ) != SupportsResponse.NONE: - description["response"] = { - "optional": response == SupportsResponse.OPTIONAL, - } - - descriptions_cache[cache_key] = description + descriptions_cache[cache_key] = description domain_descriptions[service_name] = description @@ -710,6 +745,8 @@ async def entity_service_call( # noqa: C901 Calls all platforms simultaneously. """ entity_perms: None | (Callable[[str, str], bool]) = None + return_response = call.return_response + if call.context.user_id: user = await hass.auth.async_get_user(call.context.user_id) if user is None: @@ -820,13 +857,27 @@ async def entity_service_call( # noqa: C901 entities.append(entity) if not entities: - if call.return_response: + if return_response: raise HomeAssistantError( "Service call requested response data but did not match any entities" ) return None - if call.return_response and len(entities) != 1: + if len(entities) == 1: + # Single entity case avoids creating tasks and allows returning + # ServiceResponse + entity = entities[0] + response_data = await _handle_entity_call( + hass, entity, func, data, call.context + ) + if entity.should_poll: + # Context expires if the turn on commands took a long time. + # Set context again so it's there when we update + entity.async_set_context(call.context) + await entity.async_update_ha_state(True) + return response_data if return_response else None + + if return_response: raise HomeAssistantError( "Service call requested response data but matched more than one entity" ) @@ -843,9 +894,8 @@ async def entity_service_call( # noqa: C901 ) assert not pending - response_data: ServiceResponse | None for task in done: - response_data = task.result() # pop exception if have + task.result() # pop exception if have tasks: list[asyncio.Task[None]] = [] @@ -864,7 +914,7 @@ async def entity_service_call( # noqa: C901 for future in done: future.result() # pop exception if have - return response_data if call.return_response else None + return None async def _handle_entity_call( diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 21d060f4ba7..dae63b4ead1 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -4,9 +4,8 @@ from __future__ import annotations import asyncio from collections import defaultdict from collections.abc import Iterable -import datetime as dt import logging -from types import ModuleType, TracebackType +from types import ModuleType from typing import Any from homeassistant.components.sun import STATE_ABOVE_HORIZON, STATE_BELOW_HORIZON @@ -23,57 +22,10 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, State from homeassistant.loader import IntegrationNotFound, async_get_integration, bind_hass -import homeassistant.util.dt as dt_util - -from .frame import report _LOGGER = logging.getLogger(__name__) -class AsyncTrackStates: - """Record the time when the with-block is entered. - - Add all states that have changed since the start time to the return list - when with-block is exited. - - Must be run within the event loop. - - Deprecated. Remove after June 2021. - Warning added via `get_changed_since`. - """ - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize a TrackStates block.""" - self.hass = hass - self.states: list[State] = [] - - # pylint: disable=attribute-defined-outside-init - def __enter__(self) -> list[State]: - """Record time from which to track changes.""" - self.now = dt_util.utcnow() - return self.states - - def __exit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - """Add changes states to changes list.""" - self.states.extend(get_changed_since(self.hass.states.async_all(), self.now)) - - -def get_changed_since( - states: Iterable[State], utc_point_in_time: dt.datetime -) -> list[State]: - """Return list of states that have been changed since utc_point_in_time. - - Deprecated. Remove after June 2021. - """ - report("uses deprecated `get_changed_since`") - return [state for state in states if state.last_updated >= utc_point_in_time] - - @bind_hass async def async_reproduce_state( hass: HomeAssistant, diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 128a36e3e14..dd394c84f91 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -6,15 +6,24 @@ from collections.abc import Callable, Mapping, Sequence from contextlib import suppress from copy import deepcopy import inspect -from json import JSONEncoder +from json import JSONDecodeError, JSONEncoder import logging import os from typing import Any, Generic, TypeVar from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE -from homeassistant.core import CALLBACK_TYPE, CoreState, Event, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + DOMAIN as HOMEASSISTANT_DOMAIN, + CoreState, + Event, + HomeAssistant, + callback, +) +from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import MAX_LOAD_CONCURRENTLY, bind_hass from homeassistant.util import json as json_util +import homeassistant.util.dt as dt_util from homeassistant.util.file import WriteError from . import json as json_helper @@ -146,9 +155,63 @@ class Store(Generic[_T]): # and we don't want that to mess with what we're trying to store. data = deepcopy(data) else: - data = await self.hass.async_add_executor_job( - json_util.load_json, self.path - ) + try: + data = await self.hass.async_add_executor_job( + json_util.load_json, self.path + ) + except HomeAssistantError as err: + if isinstance(err.__cause__, JSONDecodeError): + # If we have a JSONDecodeError, it means the file is corrupt. + # We can't recover from this, so we'll log an error, rename the file and + # return None so that we can start with a clean slate which will + # allow startup to continue so they can restore from a backup. + isotime = dt_util.utcnow().isoformat() + corrupt_postfix = f".corrupt.{isotime}" + corrupt_path = f"{self.path}{corrupt_postfix}" + await self.hass.async_add_executor_job( + os.rename, self.path, corrupt_path + ) + storage_key = self.key + _LOGGER.error( + "Unrecoverable error decoding storage %s at %s; " + "This may indicate an unclean shutdown, invalid syntax " + "from manual edits, or disk corruption; " + "The corrupt file has been saved as %s; " + "It is recommended to restore from backup: %s", + storage_key, + self.path, + corrupt_path, + err, + ) + from .issue_registry import ( # pylint: disable=import-outside-toplevel + IssueSeverity, + async_create_issue, + ) + + issue_domain = HOMEASSISTANT_DOMAIN + if ( + domain := (storage_key.partition(".")[0]) + ) and domain in self.hass.config.components: + issue_domain = domain + + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"storage_corruption_{storage_key}_{isotime}", + is_fixable=True, + issue_domain=issue_domain, + translation_key="storage_corruption", + is_persistent=True, + severity=IssueSeverity.CRITICAL, + translation_placeholders={ + "storage_key": storage_key, + "original_path": self.path, + "corrupt_path": corrupt_path, + "error": str(err), + }, + ) + return None + raise if data == {}: return None diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index a551c6e3b9e..8af04c11c18 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -1,7 +1,9 @@ """Helper to gather system info.""" from __future__ import annotations +from functools import cache from getpass import getuser +import logging import os import platform from typing import Any @@ -9,17 +11,32 @@ from typing import Any from homeassistant.const import __version__ as current_version from homeassistant.core import HomeAssistant from homeassistant.loader import bind_hass -from homeassistant.util.package import is_virtual_env +from homeassistant.util.package import is_docker_env, is_virtual_env + +_LOGGER = logging.getLogger(__name__) + + +@cache +def is_official_image() -> bool: + """Return True if Home Assistant is running in an official container.""" + return os.path.isfile("/OFFICIAL_IMAGE") + + +# Cache the result of getuser() because it can call getpwuid() which +# can do blocking I/O to look up the username in /etc/passwd. +cached_get_user = cache(getuser) @bind_hass async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: """Return info about the system.""" + is_hassio = hass.components.hassio.is_hassio() + info_object = { "installation_type": "Unknown", "version": current_version, "dev": "dev" in current_version, - "hassio": hass.components.hassio.is_hassio(), + "hassio": is_hassio, "virtualenv": is_virtual_env(), "python_version": platform.python_version(), "docker": False, @@ -30,18 +47,18 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: } try: - info_object["user"] = getuser() + info_object["user"] = cached_get_user() except KeyError: info_object["user"] = None if platform.system() == "Darwin": info_object["os_version"] = platform.mac_ver()[0] elif platform.system() == "Linux": - info_object["docker"] = os.path.isfile("/.dockerenv") + info_object["docker"] = is_docker_env() # Determine installation type on current data if info_object["docker"]: - if info_object["user"] == "root" and os.path.isfile("/OFFICIAL_IMAGE"): + if info_object["user"] == "root" and is_official_image(): info_object["installation_type"] = "Home Assistant Container" else: info_object["installation_type"] = "Unsupported Third Party Container" @@ -50,10 +67,12 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: info_object["installation_type"] = "Home Assistant Core" # Enrich with Supervisor information - if hass.components.hassio.is_hassio(): - info = hass.components.hassio.get_info() - host = hass.components.hassio.get_host_info() + if is_hassio: + if not (info := hass.components.hassio.get_info()): + _LOGGER.warning("No Home Assistant Supervisor info available") + info = {} + host = hass.components.hassio.get_host_info() or {} info_object["supervisor"] = info.get("supervisor") info_object["host_os"] = host.get("operating_system") info_object["docker_version"] = info.get("docker") diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py index 42d578555ab..2e5cebf8571 100644 --- a/homeassistant/helpers/template_entity.py +++ b/homeassistant/helpers/template_entity.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_ICON, @@ -27,13 +26,18 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, ) -from homeassistant.core import Context, CoreState, Event, HomeAssistant, State, callback +from homeassistant.core import Context, CoreState, HomeAssistant, State, callback from homeassistant.exceptions import TemplateError from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from . import config_validation as cv from .entity import Entity -from .event import TrackTemplate, TrackTemplateResult, async_track_template_result +from .event import ( + EventStateChangedData, + TrackTemplate, + TrackTemplateResult, + async_track_template_result, +) from .script import Script, _VarsType from .template import ( Template, @@ -42,7 +46,7 @@ from .template import ( render_complex, result_as_boolean, ) -from .typing import ConfigType +from .typing import ConfigType, EventType _LOGGER = logging.getLogger(__name__) @@ -127,7 +131,7 @@ class _TemplateAttribute: @callback def handle_result( self, - event: Event | None, + event: EventType[EventStateChangedData] | None, template: Template, last_result: str | None | TemplateError, result: str | TemplateError, @@ -327,14 +331,14 @@ class TemplateEntity(Entity): @callback def _handle_results( self, - event: Event | None, + event: EventType[EventStateChangedData] | None, updates: list[TrackTemplateResult], ) -> None: """Call back the results to the attributes.""" if event: self.async_set_context(event.context) - entity_id = event and event.data.get(ATTR_ENTITY_ID) + entity_id = event and event.data["entity_id"] if entity_id and entity_id == self.entity_id: self._self_ref_update_count += 1 @@ -622,9 +626,14 @@ class ManualTriggerEntity(TriggerBaseEntity): ) -> None: """Initialize the entity.""" TriggerBaseEntity.__init__(self, hass, config) + # Need initial rendering on `name` as it influence the `entity_id` + self._rendered[CONF_NAME] = config[CONF_NAME].async_render( + {}, + parse_result=CONF_NAME in self._parse_result, + ) @callback - def _process_manual_data(self, value: str | None = None) -> None: + def _process_manual_data(self, value: Any | None = None) -> None: """Process new data manually. Implementing class should call this last in update method to render templates. diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index c1d22157a31..fd7a3081f7a 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -17,6 +17,17 @@ from .typing import TemplateVarsType class TraceElement: """Container for trace data.""" + __slots__ = ( + "_child_key", + "_child_run_id", + "_error", + "path", + "_result", + "reuse_by_child", + "_timestamp", + "_variables", + ) + def __init__(self, variables: TemplateVarsType, path: str) -> None: """Container for trace data.""" self._child_key: str | None = None diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 96ce9b618c2..79ac3a0c5b7 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -302,7 +302,7 @@ async def async_get_translations( components = set(integrations) elif config_flow: components = (await async_get_config_flows(hass)) - hass.config.components - elif category in ("state", "entity_component"): + elif category in ("state", "entity_component", "services"): components = set(hass.config.components) else: # Only 'state' supports merging, so remove platforms from selection diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py index 326d2f98259..9e3f9de34fa 100644 --- a/homeassistant/helpers/typing.py +++ b/homeassistant/helpers/typing.py @@ -1,15 +1,16 @@ """Typing Helpers for Home Assistant.""" from collections.abc import Mapping from enum import Enum -from typing import Any +from typing import Any, Generic, TypeVar import homeassistant.core +_DataT = TypeVar("_DataT") + GPSType = tuple[float, float] ConfigType = dict[str, Any] ContextType = homeassistant.core.Context DiscoveryInfoType = dict[str, Any] -EventType = homeassistant.core.Event ServiceDataType = dict[str, Any] StateType = str | int | float | None TemplateVarsType = Mapping[str, Any] | None @@ -34,3 +35,9 @@ UNDEFINED = UndefinedType._singleton # pylint: disable=protected-access # In due time they will be removed. HomeAssistantType = homeassistant.core.HomeAssistant ServiceCallType = homeassistant.core.ServiceCall + + +class EventType(homeassistant.core.Event, Generic[_DataT]): + """Generic Event class to better type data.""" + + data: _DataT # type: ignore[assignment] diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 6a8131d2454..6c083b6a024 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1098,7 +1098,11 @@ class Helpers: def bind_hass(func: _CallableT) -> _CallableT: - """Decorate function to indicate that first argument is hass.""" + """Decorate function to indicate that first argument is hass. + + The use of this decorator is discouraged, and it should not be used + for new functions. + """ setattr(func, "__bind_hass", True) return func diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b08aa7246db..7401c747890 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,63 +3,65 @@ aiohttp-cors==0.7.0 aiohttp==3.8.5 astral==2.2 async-timeout==4.0.2 -async-upnp-client==0.33.2 +async-upnp-client==0.34.1 atomicwrites-homeassistant==1.4.1 attrs==22.2.0 awesomeversion==22.9.0 bcrypt==4.0.1 -bleak-retry-connector==3.0.2 +bleak-retry-connector==3.1.1 bleak==0.20.2 -bluetooth-adapters==0.15.3 -bluetooth-auto-recovery==1.2.0 -bluetooth-data-tools==1.3.0 +bluetooth-adapters==0.16.0 +bluetooth-auto-recovery==1.2.1 +bluetooth-data-tools==1.6.1 certifi>=2021.5.30 ciso8601==2.3.0 -cryptography==41.0.1 -dbus-fast==1.86.0 -fnv-hash-fast==0.3.1 -ha-av==10.1.0 +cryptography==41.0.2 +dbus-fast==1.87.5 +fnv-hash-fast==0.4.0 +ha-av==10.1.1 hass-nabucasa==0.69.0 -hassil==1.0.6 -home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230705.1 -home-assistant-intents==2023.6.28 +hassil==1.2.5 +home-assistant-bluetooth==1.10.2 +home-assistant-frontend==20230802.0 +home-assistant-intents==2023.7.25 httpx==0.24.1 ifaddr==0.2.0 janus==1.0.0 Jinja2==3.1.2 lru-dict==1.2.0 mutagen==1.46.0 -orjson==3.9.1 +orjson==3.9.2 paho-mqtt==1.6.1 -Pillow==9.5.0 -pip>=21.3.1,<23.2 +Pillow==10.0.0 +pip>=21.3.1 psutil-home-assistant==0.0.1 -PyJWT==2.7.0 +PyJWT==2.8.0 PyNaCl==1.5.0 pyOpenSSL==23.2.0 pyserial==3.5 python-slugify==4.0.1 -PyTurboJPEG==1.6.7 +PyTurboJPEG==1.7.1 pyudev==0.23.2 PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 SQLAlchemy==2.0.15 -typing_extensions>=4.6.3,<5.0 -ulid-transform==0.7.2 +typing-extensions>=4.7.0,<5.0 +ulid-transform==0.8.0 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.70.0 +zeroconf==0.72.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 pycryptodome>=3.6.6 # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 -urllib3>=1.26.5 +# Temporary setting an upper bound, to prevent compat issues with urllib3>=2 +# https://github.com/home-assistant/core/issues/97248 +urllib3>=1.26.5,<2 # Constrain httplib2 to protect against GHSA-93xj-8mrv-444m # https://github.com/advisories/GHSA-93xj-8mrv-444m @@ -100,9 +102,9 @@ regex==2021.8.28 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==3.7.0 +anyio==3.7.1 h11==0.14.0 -httpcore==0.17.2 +httpcore==0.17.3 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation @@ -121,10 +123,6 @@ python-socketio>=4.6.0,<5.0 # https://github.com/home-assistant/core/pull/67046 multidict>=6.0.2 -# Required for compatibility with point integration - ensure_active_token -# https://github.com/home-assistant/core/pull/68176 -authlib<1.0 - # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 @@ -139,10 +137,6 @@ pubnub!=6.4.0 # https://github.com/dahlia/iso4217/issues/16 iso4217!=1.10.20220401 -# Pandas 1.4.4 has issues with wheels om armhf + Py3.10 -# Limit this to Python 3.10, to be able to install Python 3.11 wheels for now -pandas==1.4.3;python_version<'3.11' - # Matplotlib 3.6.2 has issues building wheels on armhf/armv7 # We need at least >=2.1.0 (tensorflow integration -> pycocotools) matplotlib==3.6.1 @@ -153,7 +147,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.23.1 +protobuf==4.23.3 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 9a86bed7594..67ec232db9c 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -196,7 +196,7 @@ def _cancel_all_tasks_with_timeout( return for task in to_cancel: - task.cancel() + task.cancel("Final process shutdown") loop.run_until_complete(asyncio.wait(to_cancel, timeout=timeout)) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 92f5b442d9e..5384b86cb98 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -26,7 +26,7 @@ import homeassistant.util.yaml.loader as yaml_loader # mypy: allow-untyped-calls, allow-untyped-defs -REQUIREMENTS = ("colorlog==6.6.0",) +REQUIREMENTS = ("colorlog==6.7.0",) _LOGGER = logging.getLogger(__name__) # pylint: disable=protected-access diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 4da9c25ca10..871e1b4ecbc 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -4,6 +4,47 @@ "model": "Model", "ui_managed": "Managed via UI" }, + "device_automation": { + "condition_type": { + "is_on": "{entity_name} is on", + "is_off": "{entity_name} is off" + }, + "trigger_type": { + "changed_states": "{entity_name} turned on or off", + "turned_on": "{entity_name} turned on", + "turned_off": "{entity_name} turned off" + }, + "action_type": { + "toggle": "Toggle {entity_name}", + "turn_on": "Turn on {entity_name}", + "turn_off": "Turn off {entity_name}" + } + }, + "action": { + "connect": "Connect", + "disconnect": "Disconnect", + "enable": "Enable", + "disable": "Disable", + "open": "Open", + "close": "Close", + "reload": "Reload", + "restart": "Restart", + "start": "Start", + "stop": "Stop", + "pause": "Pause", + "turn_on": "Turn on", + "turn_off": "Turn off", + "toggle": "Toggle" + }, + "time": { + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday" + }, "state": { "off": "Off", "on": "On", @@ -11,6 +52,8 @@ "no": "No", "open": "Open", "closed": "Closed", + "enabled": "Enabled", + "disabled": "Disabled", "connected": "Connected", "disconnected": "Disconnected", "locked": "Locked", @@ -87,10 +130,6 @@ "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", "cloud_not_connected": "Not connected to Home Assistant Cloud." } - }, - "issues": { - "platform_integration_no_support_title": "Platform support not supported", - "platform_integration_no_support_description": "The {platform} platform for the {domain} integration does not support platform setup.\n\nPlease remove it from your configuration and restart Home Assistant to fix this issue." } } } diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 664d6f15650..84585d7a8c7 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -1,13 +1,12 @@ """Helper to create SSL contexts.""" import contextlib +from enum import StrEnum from functools import cache from os import environ import ssl import certifi -from homeassistant.backports.enum import StrEnum - class SSLCipherList(StrEnum): """SSL cipher lists.""" diff --git a/homeassistant/util/timeout.py b/homeassistant/util/timeout.py index df225580dae..6c1de55748f 100644 --- a/homeassistant/util/timeout.py +++ b/homeassistant/util/timeout.py @@ -8,9 +8,7 @@ from __future__ import annotations import asyncio import enum from types import TracebackType -from typing import Any - -from typing_extensions import Self +from typing import Any, Self from .async_ import run_callback_threadsafe @@ -232,7 +230,7 @@ class _GlobalTaskContext: """Cancel own task.""" if self._task.done(): return - self._task.cancel() + self._task.cancel("Global task timeout") def pause(self) -> None: """Pause timers while it freeze.""" @@ -330,7 +328,7 @@ class _ZoneTaskContext: # Timeout if self._task.done(): return - self._task.cancel() + self._task.cancel("Zone timeout") def pause(self) -> None: """Pause timers while it freeze.""" diff --git a/mypy.ini b/mypy.ini index ab8b5a5df89..7d1ec19c4d5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3,7 +3,7 @@ # To update, run python3 -m script.hassfest -p mypy_config [mypy] -python_version = 3.10 +python_version = 3.11 plugins = pydantic.mypy show_error_codes = true follow_imports = silent @@ -842,6 +842,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.electric_kiwi.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.elgato.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -892,6 +902,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.event.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.evil_genius_labs.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index be44c4256ce..8b3aea61ff4 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -18,6 +18,12 @@ class ObsoleteImportMatch: _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { + "homeassistant.backports.enum": [ + ObsoleteImportMatch( + reason="We can now use the Python 3.11 provided enum.StrEnum instead", + constant=re.compile(r"^StrEnum$"), + ), + ], "homeassistant.components.alarm_control_panel": [ ObsoleteImportMatch( reason="replaced by AlarmControlPanelEntityFeature enum", diff --git a/pyproject.toml b/pyproject.toml index 42b857c4401..26a9525a176 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.7.3" +version = "2023.8.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -18,11 +18,10 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Topic :: Home Automation", ] -requires-python = ">=3.10.0" +requires-python = ">=3.11.0" dependencies = [ "aiohttp==3.8.5", "astral==2.2", @@ -36,22 +35,22 @@ dependencies = [ # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.24.1", - "home-assistant-bluetooth==1.10.0", + "home-assistant-bluetooth==1.10.2", "ifaddr==0.2.0", "Jinja2==3.1.2", "lru-dict==1.2.0", - "PyJWT==2.7.0", + "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. - "cryptography==41.0.1", + "cryptography==41.0.2", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", - "orjson==3.9.1", - "pip>=21.3.1,<23.2", + "orjson==3.9.2", + "pip>=21.3.1", "python-slugify==4.0.1", "PyYAML==6.0.1", "requests==2.31.0", - "typing_extensions>=4.6.3,<5.0", - "ulid-transform==0.7.2", + "typing-extensions>=4.7.0,<5.0", + "ulid-transform==0.8.0", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.9.2", @@ -80,7 +79,7 @@ include = ["homeassistant*"] extend-exclude = "/generated/" [tool.pylint.MAIN] -py-version = "3.10" +py-version = "3.11" ignore = [ "tests", ] @@ -190,7 +189,7 @@ disable = [ "invalid-character-nul", # PLE2514 "invalid-character-sub", # PLE2512 "invalid-character-zero-width-space", # PLE2515 - "logging-too-few-args", # PLE1206 + "logging-too-few-args", # PLE1206 "logging-too-many-args", # PLE1205 "missing-format-string-key", # F524 "mixed-format-string", # F506 diff --git a/requirements.txt b/requirements.txt index 5063ab16a59..9f5023c9a1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,20 +11,20 @@ bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 httpx==0.24.1 -home-assistant-bluetooth==1.10.0 +home-assistant-bluetooth==1.10.2 ifaddr==0.2.0 Jinja2==3.1.2 lru-dict==1.2.0 -PyJWT==2.7.0 -cryptography==41.0.1 +PyJWT==2.8.0 +cryptography==41.0.2 pyOpenSSL==23.2.0 -orjson==3.9.1 -pip>=21.3.1,<23.2 +orjson==3.9.2 +pip>=21.3.1 python-slugify==4.0.1 PyYAML==6.0.1 requests==2.31.0 -typing_extensions>=4.6.3,<5.0 -ulid-transform==0.7.2 +typing-extensions>=4.7.0,<5.0 +ulid-transform==0.8.0 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.9.2 diff --git a/requirements_all.txt b/requirements_all.txt index 6d6428f614c..28140477411 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -26,14 +26,11 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.7.0 +HAP-python==4.7.1 # homeassistant.components.tasmota HATasmota==0.6.5 -# homeassistant.components.hydrawise -Hydrawiser==0.2 - # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -45,7 +42,7 @@ Mastodon.py==1.5.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==9.5.0 +Pillow==10.0.0 # homeassistant.components.plex PlexAPI==4.13.2 @@ -82,7 +79,7 @@ PyMetno==0.10.0 PyMicroBot==0.0.9 # homeassistant.components.nina -PyNINA==0.3.0 +PyNINA==0.3.1 # homeassistant.components.mobile_app # homeassistant.components.owntracks @@ -112,7 +109,7 @@ PyTransportNSW==0.1.1 # homeassistant.components.camera # homeassistant.components.stream -PyTurboJPEG==1.6.7 +PyTurboJPEG==1.7.1 # homeassistant.components.vicare PyViCare==2.25.0 @@ -191,7 +188,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.0 +aioairzone-cloud==0.2.1 # homeassistant.components.airzone aioairzone==0.6.4 @@ -234,7 +231,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==15.1.1 +aioesphomeapi==15.1.15 # homeassistant.components.flo aioflo==2021.11.0 @@ -252,7 +249,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.7 +aiohomekit==2.6.12 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -306,6 +303,9 @@ aiooncue==0.3.5 # homeassistant.components.openexchangerates aioopenexchangerates==0.4.0 +# homeassistant.components.pegel_online +aiopegelonline==0.0.5 + # homeassistant.components.acmeda aiopulse==0.4.3 @@ -345,7 +345,7 @@ aioshelly==5.4.0 aioskybell==22.7.0 # homeassistant.components.slimproto -aioslimproto==2.3.2 +aioslimproto==2.3.3 # homeassistant.components.steamist aiosteamist==0.3.2 @@ -360,7 +360,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==49 +aiounifi==50 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -399,7 +399,7 @@ amcrest==1.9.7 androidtv[async]==0.0.70 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.9 +androidtvremote2==0.0.13 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 @@ -414,7 +414,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==1.4.0 +apprise==1.4.5 # homeassistant.components.aprs aprslib==0.7.0 @@ -443,7 +443,10 @@ asterisk-mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.33.2 +async-upnp-client==0.34.1 + +# homeassistant.components.esphome +async_interrupt==1.1.1 # homeassistant.components.keyboard_remote asyncinotify==4.0.2 @@ -506,7 +509,7 @@ bimmer-connected==0.13.8 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.0.2 +bleak-retry-connector==3.1.1 # homeassistant.components.bluetooth bleak==0.20.2 @@ -528,19 +531,19 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.15.3 +bluetooth-adapters==0.16.0 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.0 +bluetooth-auto-recovery==1.2.1 # homeassistant.components.bluetooth # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.3.0 +bluetooth-data-tools==1.6.1 # homeassistant.components.bond -bond-async==0.1.23 +bond-async==0.2.1 # homeassistant.components.bosch_shc boschshcpy==0.2.57 @@ -565,7 +568,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==2.12.1 +bthome-ble==3.0.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -595,7 +598,7 @@ clx-sdk-xms==1.0.0 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==6.6.0 +colorlog==6.7.0 # homeassistant.components.color_extractor colorthief==0.2.1 @@ -608,7 +611,7 @@ connect-box==0.2.8 # homeassistant.components.eq3btsmart # homeassistant.components.xiaomi_miio -construct==2.10.56 +construct==2.10.68 # homeassistant.components.utility_meter croniter==1.0.6 @@ -629,7 +632,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.86.0 +dbus-fast==1.87.5 # homeassistant.components.debugpy debugpy==1.6.7 @@ -652,7 +655,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.2 +denonavr==0.11.3 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 @@ -702,6 +705,9 @@ ebusdpy==0.0.17 # homeassistant.components.ecoal_boiler ecoaliface==0.4.0 +# homeassistant.components.electric_kiwi +electrickiwi-api==0.8.5 + # homeassistant.components.elgato elgato==4.0.1 @@ -794,11 +800,11 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux-led==0.28.37 +flux-led==1.0.1 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==0.3.1 +fnv-hash-fast==0.4.0 # homeassistant.components.foobot foobot-async==1.0.0 @@ -817,11 +823,14 @@ freesms==0.2.0 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor -fritzconnection[qr]==1.12.0 +fritzconnection[qr]==1.12.2 # homeassistant.components.google_translate gTTS==2.2.4 +# homeassistant.components.gardena_bluetooth +gardena_bluetooth==1.0.2 + # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -870,7 +879,6 @@ goalzero==0.2.2 goodwe==0.2.31 # homeassistant.components.google_mail -# homeassistant.components.youtube google-api-python-client==2.71.0 # homeassistant.components.google_pubsub @@ -929,7 +937,7 @@ h2==4.1.0 # homeassistant.components.generic # homeassistant.components.stream -ha-av==10.1.0 +ha-av==10.1.1 # homeassistant.components.ffmpeg ha-ffmpeg==3.1.0 @@ -950,7 +958,7 @@ hass-nabucasa==0.69.0 hass-splunk==0.1.1 # homeassistant.components.conversation -hassil==1.0.6 +hassil==1.2.5 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -977,13 +985,13 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.21.13 +holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230705.1 +home-assistant-frontend==20230802.0 # homeassistant.components.conversation -home-assistant-intents==2023.6.28 +home-assistant-intents==2023.7.25 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -1121,7 +1129,7 @@ librouteros==3.2.0 libsoundtouch==0.8 # homeassistant.components.life360 -life360==5.5.0 +life360==6.0.0 # homeassistant.components.osramlightify lightify==1.0.7.3 @@ -1157,7 +1165,7 @@ lupupy==0.3.0 lw12==0.9.2 # homeassistant.components.scrape -lxml==4.9.1 +lxml==4.9.3 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 @@ -1172,7 +1180,7 @@ maxcube-api==0.4.3 mbddns==0.1.2 # homeassistant.components.minecraft_server -mcstatus==6.0.0 +mcstatus==11.0.0 # homeassistant.components.meater meater-python==0.0.8 @@ -1240,9 +1248,6 @@ nessclient==0.10.0 # homeassistant.components.netdata netdata==1.1.0 -# homeassistant.components.discovery -netdisco==3.0.0 - # homeassistant.components.nmap_tracker netmap==0.7.0.2 @@ -1253,7 +1258,7 @@ nettigo-air-monitor==2.1.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.0.6 +nexia==2.0.7 # homeassistant.components.nextcloud nextcloudmonitor==1.4.0 @@ -1362,6 +1367,9 @@ openwrt-luci-rpc==1.1.16 # homeassistant.components.ubus openwrt-ubus-rpc==0.0.2 +# homeassistant.components.opower +opower==0.0.18 + # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1533,7 +1541,7 @@ pyRFXtrx==0.30.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.27.2 +pyTibber==0.28.0 # homeassistant.components.dlink pyW215==0.7.0 @@ -1630,14 +1638,20 @@ pydelijn==1.1.0 pydexcom==0.2.3 # homeassistant.components.discovergy -pydiscovergy==1.2.1 +pydiscovergy==2.0.1 # homeassistant.components.doods pydoods==1.0.2 +# homeassistant.components.hydrawise +pydrawise==2023.7.1 + # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 +# homeassistant.components.duotecno +pyduotecno==2023.8.0 + # homeassistant.components.ebox pyebox==1.1.4 @@ -1696,7 +1710,7 @@ pyfttt==0.3 pygatt[GATTTOOL]==4.0.5 # homeassistant.components.gtfs -pygtfs==0.1.7 +pygtfs==0.1.9 # homeassistant.components.hvv_departures pygti==0.9.4 @@ -1732,7 +1746,7 @@ pyintesishome==1.8.0 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.14.0 +pyipp==0.14.2 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1858,7 +1872,7 @@ pynuki==1.6.2 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.4.1 +pynws==1.5.0 # homeassistant.components.nx584 pynx584==0.5 @@ -1968,7 +1982,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.sensibo -pysensibo==1.0.28 +pysensibo==1.0.32 # homeassistant.components.serial # homeassistant.components.zha @@ -2084,7 +2098,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.homewizard -python-homewizard-energy==2.0.1 +python-homewizard-energy==2.0.2 # homeassistant.components.hp_ilo python-hpilo==4.3 @@ -2099,13 +2113,13 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.2 +python-kasa[speedups]==0.5.3 # homeassistant.components.lirc # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==3.6.3 +python-matter-server==3.7.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -2116,9 +2130,6 @@ python-mpd2==3.0.5 # homeassistant.components.mystrom python-mystrom==2.2.0 -# homeassistant.components.nest -python-nest==4.2.0 - # homeassistant.components.swiss_public_transport python-opendata-transport==0.3.0 @@ -2127,7 +2138,7 @@ python-opensky==0.0.10 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.2.0 +python-otbr-api==2.3.0 # homeassistant.components.picnic python-picnic-api==1.1.0 @@ -2139,7 +2150,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.30.1 +python-roborock==0.30.2 # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -2163,7 +2174,7 @@ pythonegardia==1.0.52 pytile==2023.04.0 # homeassistant.components.tomorrowio -pytomorrowio==0.3.5 +pytomorrowio==0.3.6 # homeassistant.components.touchline pytouchline==0.7 @@ -2183,7 +2194,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.3 +pyunifiprotect==4.10.6 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2213,7 +2224,7 @@ pyvolumio==0.1.5 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==1.1.0 +pywemo==1.2.1 # homeassistant.components.wilight pywilight==0.0.74 @@ -2267,7 +2278,7 @@ renault-api==0.1.13 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.3 +reolink-aio==0.7.6 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2357,7 +2368,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.25.1 +sentry-sdk==1.28.1 # homeassistant.components.sfr_box sfrbox-api==0.0.6 @@ -2623,7 +2634,7 @@ volkszaehler==0.4.0 volvooncall==0.10.3 # homeassistant.components.verisure -vsure==2.6.1 +vsure==2.6.4 # homeassistant.components.vasttrafik vtjp==0.1.14 @@ -2657,7 +2668,7 @@ webexteamssdk==1.1.1 webrtcvad==2.0.10 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.3 +whirlpool-sixth-sense==0.18.4 # homeassistant.components.whois whois==0.9.27 @@ -2678,16 +2689,16 @@ wled==0.16.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==0.0.1 +wyoming==1.1.0 # homeassistant.components.xbox xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.17.2 +xiaomi-ble==0.20.0 # homeassistant.components.knx -xknx==2.11.1 +xknx==2.11.2 # homeassistant.components.knx xknxproject==3.2.0 @@ -2708,25 +2719,28 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.18 +yalexs-ble==2.2.3 # homeassistant.components.august yalexs==1.5.1 # homeassistant.components.yeelight -yeelight==0.7.11 +yeelight==0.7.12 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.2.9 +yolink-api==0.3.0 # homeassistant.components.youless youless-api==1.0.1 +# homeassistant.components.youtube +youtubeaio==1.1.5 + # homeassistant.components.media_extractor -yt-dlp==2023.3.4 +yt-dlp==2023.7.6 # homeassistant.components.zamg zamg==0.2.4 @@ -2735,13 +2749,13 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.70.0 +zeroconf==0.72.0 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.101 +zha-quirks==0.0.102 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 diff --git a/requirements_test.txt b/requirements_test.txt index 74369507229..bf71ed4d255 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,32 +8,31 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==2.15.4 -coverage==7.2.4 +coverage==7.2.7 freezegun==1.2.2 mock-open==1.4.0 mypy==1.4.1 -pre-commit==3.1.0 -pydantic==1.10.9 +pre-commit==3.3.3 +pydantic==1.10.11 pylint==2.17.4 -pylint-per-file-ignores==1.1.0 -pipdeptree==2.7.0 -pytest-asyncio==0.20.3 +pylint-per-file-ignores==1.2.1 +pipdeptree==2.11.0 +pytest-asyncio==0.21.0 pytest-aiohttp==1.0.4 -pytest-cov==3.0.0 -pytest-freezer==0.4.6 -pytest-socket==0.5.1 +pytest-cov==4.1.0 +pytest-freezer==0.4.8 +pytest-socket==0.6.0 pytest-test-groups==1.0.3 -pytest-sugar==0.9.6 +pytest-sugar==0.9.7 pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.4.6 -pytest-xdist==3.2.1 +pytest-xdist==3.3.1 pytest==7.3.1 requests_mock==1.11.0 -respx==0.20.1 -syrupy==4.0.2 -tomli==2.0.1;python_version<"3.11" -tqdm==4.64.0 +respx==0.20.2 +syrupy==4.0.8 +tqdm==4.65.0 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 types-backports==0.1.3 @@ -48,4 +47,3 @@ types-python-slugify==0.1.2 types-pytz==2023.3.0.0 types-PyYAML==6.0.12.2 types-requests==2.31.0.1 -types-toml==0.10.8.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ad65cdccfd..03dc3bbf994 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.7.0 +HAP-python==4.7.1 # homeassistant.components.tasmota HATasmota==0.6.5 @@ -38,7 +38,7 @@ HATasmota==0.6.5 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==9.5.0 +Pillow==10.0.0 # homeassistant.components.plex PlexAPI==4.13.2 @@ -69,7 +69,7 @@ PyMetno==0.10.0 PyMicroBot==0.0.9 # homeassistant.components.nina -PyNINA==0.3.0 +PyNINA==0.3.1 # homeassistant.components.mobile_app # homeassistant.components.owntracks @@ -96,7 +96,7 @@ PyTransportNSW==0.1.1 # homeassistant.components.camera # homeassistant.components.stream -PyTurboJPEG==1.6.7 +PyTurboJPEG==1.7.1 # homeassistant.components.vicare PyViCare==2.25.0 @@ -169,7 +169,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.0 +aioairzone-cloud==0.2.1 # homeassistant.components.airzone aioairzone==0.6.4 @@ -212,7 +212,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==15.1.1 +aioesphomeapi==15.1.15 # homeassistant.components.flo aioflo==2021.11.0 @@ -227,7 +227,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.7 +aiohomekit==2.6.12 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -278,6 +278,9 @@ aiooncue==0.3.5 # homeassistant.components.openexchangerates aioopenexchangerates==0.4.0 +# homeassistant.components.pegel_online +aiopegelonline==0.0.5 + # homeassistant.components.acmeda aiopulse==0.4.3 @@ -317,7 +320,7 @@ aioshelly==5.4.0 aioskybell==22.7.0 # homeassistant.components.slimproto -aioslimproto==2.3.2 +aioslimproto==2.3.3 # homeassistant.components.steamist aiosteamist==0.3.2 @@ -332,7 +335,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==49 +aiounifi==50 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -365,7 +368,7 @@ amberelectric==1.0.4 androidtv[async]==0.0.70 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.9 +androidtvremote2==0.0.13 # homeassistant.components.anova anova-wifi==0.10.0 @@ -377,7 +380,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==1.4.0 +apprise==1.4.5 # homeassistant.components.aprs aprslib==0.7.0 @@ -394,7 +397,10 @@ arcam-fmj==1.4.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.33.2 +async-upnp-client==0.34.1 + +# homeassistant.components.esphome +async_interrupt==1.1.1 # homeassistant.components.sleepiq asyncsleepiq==1.3.5 @@ -424,7 +430,7 @@ bellows==0.35.8 bimmer-connected==0.13.8 # homeassistant.components.bluetooth -bleak-retry-connector==3.0.2 +bleak-retry-connector==3.1.1 # homeassistant.components.bluetooth bleak==0.20.2 @@ -439,19 +445,19 @@ blinkpy==0.21.0 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.15.3 +bluetooth-adapters==0.16.0 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.0 +bluetooth-auto-recovery==1.2.1 # homeassistant.components.bluetooth # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.3.0 +bluetooth-data-tools==1.6.1 # homeassistant.components.bond -bond-async==0.1.23 +bond-async==0.2.1 # homeassistant.components.bosch_shc boschshcpy==0.2.57 @@ -469,7 +475,7 @@ brottsplatskartan==0.0.1 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==2.12.1 +bthome-ble==3.0.0 # homeassistant.components.buienradar buienradar==1.0.5 @@ -481,14 +487,14 @@ caldav==1.2.0 coinbase==2.1.0 # homeassistant.scripts.check_config -colorlog==6.6.0 +colorlog==6.7.0 # homeassistant.components.color_extractor colorthief==0.2.1 # homeassistant.components.eq3btsmart # homeassistant.components.xiaomi_miio -construct==2.10.56 +construct==2.10.68 # homeassistant.components.utility_meter croniter==1.0.6 @@ -509,7 +515,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.86.0 +dbus-fast==1.87.5 # homeassistant.components.debugpy debugpy==1.6.7 @@ -526,7 +532,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.2 +denonavr==0.11.3 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 @@ -561,6 +567,9 @@ eagle100==0.1.1 # homeassistant.components.easyenergy easyenergy==0.3.0 +# homeassistant.components.electric_kiwi +electrickiwi-api==0.8.5 + # homeassistant.components.elgato elgato==4.0.1 @@ -619,11 +628,11 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux-led==0.28.37 +flux-led==1.0.1 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==0.3.1 +fnv-hash-fast==0.4.0 # homeassistant.components.foobot foobot-async==1.0.0 @@ -636,11 +645,14 @@ freebox-api==1.1.0 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor -fritzconnection[qr]==1.12.0 +fritzconnection[qr]==1.12.2 # homeassistant.components.google_translate gTTS==2.2.4 +# homeassistant.components.gardena_bluetooth +gardena_bluetooth==1.0.2 + # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -683,7 +695,6 @@ goalzero==0.2.2 goodwe==0.2.31 # homeassistant.components.google_mail -# homeassistant.components.youtube google-api-python-client==2.71.0 # homeassistant.components.google_pubsub @@ -724,7 +735,7 @@ h2==4.1.0 # homeassistant.components.generic # homeassistant.components.stream -ha-av==10.1.0 +ha-av==10.1.1 # homeassistant.components.ffmpeg ha-ffmpeg==3.1.0 @@ -742,7 +753,7 @@ habitipy==0.2.0 hass-nabucasa==0.69.0 # homeassistant.components.conversation -hassil==1.0.6 +hassil==1.2.5 # homeassistant.components.jewish_calendar hdate==0.10.4 @@ -760,13 +771,13 @@ hlk-sw16==0.0.9 hole==0.8.0 # homeassistant.components.workday -holidays==0.21.13 +holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230705.1 +home-assistant-frontend==20230802.0 # homeassistant.components.conversation -home-assistant-intents==2023.6.28 +home-assistant-intents==2023.7.25 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -868,7 +879,7 @@ librouteros==3.2.0 libsoundtouch==0.8 # homeassistant.components.life360 -life360==5.5.0 +life360==6.0.0 # homeassistant.components.logi_circle logi-circle==0.2.3 @@ -880,7 +891,7 @@ loqedAPI==2.1.7 luftdaten==0.7.4 # homeassistant.components.scrape -lxml==4.9.1 +lxml==4.9.3 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 @@ -892,7 +903,7 @@ maxcube-api==0.4.3 mbddns==0.1.2 # homeassistant.components.minecraft_server -mcstatus==6.0.0 +mcstatus==11.0.0 # homeassistant.components.meater meater-python==0.0.8 @@ -948,9 +959,6 @@ ndms2-client==0.1.2 # homeassistant.components.ness_alarm nessclient==0.10.0 -# homeassistant.components.discovery -netdisco==3.0.0 - # homeassistant.components.nmap_tracker netmap==0.7.0.2 @@ -958,7 +966,7 @@ netmap==0.7.0.2 nettigo-air-monitor==2.1.0 # homeassistant.components.nexia -nexia==2.0.6 +nexia==2.0.7 # homeassistant.components.nextcloud nextcloudmonitor==1.4.0 @@ -1028,6 +1036,9 @@ openerz-api==0.2.0 # homeassistant.components.openhome openhomedevice==2.2.0 +# homeassistant.components.opower +opower==0.0.18 + # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1145,7 +1156,7 @@ pyElectra==1.2.0 pyRFXtrx==0.30.1 # homeassistant.components.tibber -pyTibber==0.27.2 +pyTibber==0.28.0 # homeassistant.components.dlink pyW215==0.7.0 @@ -1206,11 +1217,14 @@ pydeconz==113 pydexcom==0.2.3 # homeassistant.components.discovergy -pydiscovergy==1.2.1 +pydiscovergy==2.0.1 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 +# homeassistant.components.duotecno +pyduotecno==2023.8.0 + # homeassistant.components.econet pyeconet==0.1.20 @@ -1278,7 +1292,7 @@ pyinsteon==1.4.3 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.14.0 +pyipp==0.14.2 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1374,7 +1388,7 @@ pynuki==1.6.2 pynut2==2.1.2 # homeassistant.components.nws -pynws==1.4.1 +pynws==1.5.0 # homeassistant.components.nx584 pynx584==0.5 @@ -1460,7 +1474,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.sensibo -pysensibo==1.0.28 +pysensibo==1.0.32 # homeassistant.components.serial # homeassistant.components.zha @@ -1531,7 +1545,7 @@ python-ecobee-api==0.2.14 python-fullykiosk==0.0.12 # homeassistant.components.homewizard -python-homewizard-energy==2.0.1 +python-homewizard-energy==2.0.2 # homeassistant.components.izone python-izone==1.2.9 @@ -1540,10 +1554,10 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.2 +python-kasa[speedups]==0.5.3 # homeassistant.components.matter -python-matter-server==3.6.3 +python-matter-server==3.7.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -1551,12 +1565,12 @@ python-miio==0.5.12 # homeassistant.components.mystrom python-mystrom==2.2.0 -# homeassistant.components.nest -python-nest==4.2.0 +# homeassistant.components.opensky +python-opensky==0.0.10 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==2.2.0 +python-otbr-api==2.3.0 # homeassistant.components.picnic python-picnic-api==1.1.0 @@ -1565,7 +1579,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.30.1 +python-roborock==0.30.2 # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -1583,7 +1597,7 @@ python-telegram-bot==13.1 pytile==2023.04.0 # homeassistant.components.tomorrowio -pytomorrowio==0.3.5 +pytomorrowio==0.3.6 # homeassistant.components.traccar pytraccar==1.0.0 @@ -1600,7 +1614,7 @@ pytrafikverket==0.3.3 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.3 +pyunifiprotect==4.10.6 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1621,7 +1635,7 @@ pyvolumio==0.1.5 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==1.1.0 +pywemo==1.2.1 # homeassistant.components.wilight pywilight==0.0.74 @@ -1660,7 +1674,7 @@ renault-api==0.1.13 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.3 +reolink-aio==0.7.6 # homeassistant.components.rflink rflink==0.0.65 @@ -1720,7 +1734,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.25.1 +sentry-sdk==1.28.1 # homeassistant.components.sfr_box sfrbox-api==0.0.6 @@ -1920,7 +1934,7 @@ voip-utils==0.1.0 volvooncall==0.10.3 # homeassistant.components.verisure -vsure==2.6.1 +vsure==2.6.4 # homeassistant.components.vulcan vulcan-api==2.3.0 @@ -1942,7 +1956,7 @@ watchdog==2.3.1 webrtcvad==2.0.10 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.18.3 +whirlpool-sixth-sense==0.18.4 # homeassistant.components.whois whois==0.9.27 @@ -1960,16 +1974,16 @@ wled==0.16.0 wolf-smartset==0.1.11 # homeassistant.components.wyoming -wyoming==0.0.1 +wyoming==1.1.0 # homeassistant.components.xbox xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.17.2 +xiaomi-ble==0.20.0 # homeassistant.components.knx -xknx==2.11.1 +xknx==2.11.2 # homeassistant.components.knx xknxproject==3.2.0 @@ -1987,31 +2001,34 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.1.18 +yalexs-ble==2.2.3 # homeassistant.components.august yalexs==1.5.1 # homeassistant.components.yeelight -yeelight==0.7.11 +yeelight==0.7.12 # homeassistant.components.yolink -yolink-api==0.2.9 +yolink-api==0.3.0 # homeassistant.components.youless youless-api==1.0.1 +# homeassistant.components.youtube +youtubeaio==1.1.5 + # homeassistant.components.zamg zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.70.0 +zeroconf==0.72.0 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.101 +zha-quirks==0.0.102 # homeassistant.components.zha zigpy-deconz==0.21.0 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index eff26bcfe82..e91cbe1ff62 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit -black==23.3.0 +black==23.7.0 codespell==2.2.2 -ruff==0.0.272 -yamllint==1.28.0 +ruff==0.0.280 +yamllint==1.32.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index c211a0fca81..b2954dc777b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -11,14 +11,11 @@ import re import sys from typing import Any +import tomllib + from homeassistant.util.yaml.loader import load_yaml from script.hassfest.model import Integration -if sys.version_info >= (3, 11): - import tomllib -else: - import tomli as tomllib - COMMENT_REQUIREMENTS = ( "Adafruit-BBIO", "atenpdu", # depends on pysnmp which is not maintained at this time @@ -64,7 +61,9 @@ CONSTRAINT_BASE = """ pycryptodome>=3.6.6 # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 -urllib3>=1.26.5 +# Temporary setting an upper bound, to prevent compat issues with urllib3>=2 +# https://github.com/home-assistant/core/issues/97248 +urllib3>=1.26.5,<2 # Constrain httplib2 to protect against GHSA-93xj-8mrv-444m # https://github.com/advisories/GHSA-93xj-8mrv-444m @@ -105,9 +104,9 @@ regex==2021.8.28 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==3.7.0 +anyio==3.7.1 h11==0.14.0 -httpcore==0.17.2 +httpcore==0.17.3 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation @@ -126,10 +125,6 @@ python-socketio>=4.6.0,<5.0 # https://github.com/home-assistant/core/pull/67046 multidict>=6.0.2 -# Required for compatibility with point integration - ensure_active_token -# https://github.com/home-assistant/core/pull/68176 -authlib<1.0 - # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 @@ -144,10 +139,6 @@ pubnub!=6.4.0 # https://github.com/dahlia/iso4217/issues/16 iso4217!=1.10.20220401 -# Pandas 1.4.4 has issues with wheels om armhf + Py3.10 -# Limit this to Python 3.10, to be able to install Python 3.11 wheels for now -pandas==1.4.3;python_version<'3.11' - # Matplotlib 3.6.2 has issues building wheels on armhf/armv7 # We need at least >=2.1.0 (tensorflow integration -> pycocotools) matplotlib==3.6.1 @@ -158,7 +149,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.23.1 +protobuf==4.23.3 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 9a7caec925b..4515f52d8a3 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -62,7 +62,6 @@ NO_IOT_CLASS = [ "device_automation", "device_tracker", "diagnostics", - "discovery", "downloader", "ffmpeg", "file_upload", diff --git a/script/hassfest/metadata.py b/script/hassfest/metadata.py index 88a433fe3fa..091c1b88e30 100644 --- a/script/hassfest/metadata.py +++ b/script/hassfest/metadata.py @@ -1,15 +1,10 @@ """Package metadata validation.""" -import sys +import tomllib from homeassistant.const import REQUIRED_PYTHON_VER, __version__ from .model import Config, Integration -if sys.version_info >= (3, 11): - import tomllib -else: - import tomli as tomllib - def validate(integrations: dict[str, Integration], config: Config) -> None: """Validate project metadata keys.""" diff --git a/script/hassfest/services.py b/script/hassfest/services.py index a0c629567fa..b3f59ab66a3 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -1,6 +1,8 @@ """Validate dependencies.""" from __future__ import annotations +import contextlib +import json import pathlib import re from typing import Any @@ -25,7 +27,7 @@ def exists(value: Any) -> Any: FIELD_SCHEMA = vol.Schema( { - vol.Required("description"): str, + vol.Optional("description"): str, vol.Optional("name"): str, vol.Optional("example"): exists, vol.Optional("default"): exists, @@ -44,13 +46,18 @@ FIELD_SCHEMA = vol.Schema( } ) -SERVICE_SCHEMA = vol.Schema( - { - vol.Required("description"): str, - vol.Optional("name"): str, - vol.Optional("target"): vol.Any(selector.TargetSelector.CONFIG_SCHEMA, None), - vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), - } +SERVICE_SCHEMA = vol.Any( + vol.Schema( + { + vol.Optional("description"): str, + vol.Optional("name"): str, + vol.Optional("target"): vol.Any( + selector.TargetSelector.CONFIG_SCHEMA, None + ), + vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), + } + ), + None, ) SERVICES_SCHEMA = vol.Schema({cv.slug: SERVICE_SCHEMA}) @@ -70,7 +77,7 @@ def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool return False -def validate_services(integration: Integration) -> None: +def validate_services(config: Config, integration: Integration) -> None: """Validate services.""" try: data = load_yaml(str(integration.path / "services.yaml")) @@ -92,15 +99,78 @@ def validate_services(integration: Integration) -> None: return try: - SERVICES_SCHEMA(data) + services = SERVICES_SCHEMA(data) except vol.Invalid as err: integration.add_error( "services", f"Invalid services.yaml: {humanize_error(data, err)}" ) + return + + # Try loading translation strings + if integration.core: + strings_file = integration.path / "strings.json" + else: + # For custom integrations, use the en.json file + strings_file = integration.path / "translations/en.json" + + strings = {} + if strings_file.is_file(): + with contextlib.suppress(ValueError): + strings = json.loads(strings_file.read_text()) + + # For each service in the integration, check if the description if set, + # if not, check if it's in the strings file. If not, add an error. + for service_name, service_schema in services.items(): + if service_schema is None: + continue + if "name" not in service_schema: + try: + strings["services"][service_name]["name"] + except KeyError: + integration.add_error( + "services", + f"Service {service_name} has no name and is not in the translations file", + ) + + if "description" not in service_schema: + try: + strings["services"][service_name]["description"] + except KeyError: + integration.add_error( + "services", + f"Service {service_name} has no description and is not in the translations file", + ) + + # The same check is done for the description in each of the fields of the + # service schema. + for field_name, field_schema in service_schema.get("fields", {}).items(): + if "description" not in field_schema: + try: + strings["services"][service_name]["fields"][field_name][ + "description" + ] + except KeyError: + integration.add_error( + "services", + f"Service {service_name} has a field {field_name} with no description and is not in the translations file", + ) + + if "selector" in field_schema: + with contextlib.suppress(KeyError): + translation_key = field_schema["selector"]["select"][ + "translation_key" + ] + try: + strings["selector"][translation_key] + except KeyError: + integration.add_error( + "services", + f"Service {service_name} has a field {field_name} with a selector with a translation key {translation_key} that is not in the translations file", + ) def validate(integrations: dict[str, Integration], config: Config) -> None: """Handle dependencies for integrations.""" # check services.yaml is cool for integration in integrations.values(): - validate_services(integration) + validate_services(config, integration) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 9efe01cf962..1754c166ef7 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -31,13 +31,16 @@ ALLOW_NAME_TRANSLATION = { "emulated_roku", "faa_delays", "garages_amsterdam", + "generic", "google_travel_time", "homekit_controller", "islamic_prayer_times", + "local_calendar", "local_ip", "nmap_tracker", "rpi_power", "waze_travel_time", + "zodiac", } REMOVED_TITLE_MSG = ( @@ -323,6 +326,21 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: ), slug_validator=cv.slug, ), + vol.Optional("services"): cv.schema_with_slug_keys( + { + vol.Required("name"): translation_value_validator, + vol.Required("description"): translation_value_validator, + vol.Optional("fields"): cv.schema_with_slug_keys( + { + vol.Required("name"): str, + vol.Required("description"): translation_value_validator, + vol.Optional("example"): translation_value_validator, + }, + slug_validator=translation_key_validator, + ), + }, + slug_validator=translation_key_validator, + ), } ) @@ -429,14 +447,6 @@ def validate_translation_file( # noqa: C901 strings_schema = gen_auth_schema(config, integration) elif integration.domain == "onboarding": strings_schema = ONBOARDING_SCHEMA - elif integration.domain == "binary_sensor": - strings_schema = gen_strings_schema(config, integration).extend( - { - vol.Optional("device_class"): cv.schema_with_slug_keys( - translation_value_validator, slug_validator=vol.Any("_", cv.slug) - ) - } - ) elif integration.domain == "homeassistant_hardware": strings_schema = gen_ha_hardware_schema(config, integration) else: diff --git a/script/translations/deduplicate.py b/script/translations/deduplicate.py new file mode 100644 index 00000000000..86812318218 --- /dev/null +++ b/script/translations/deduplicate.py @@ -0,0 +1,131 @@ +"""Deduplicate translations in strings.json.""" + + +import argparse +import json +from pathlib import Path + +from homeassistant.const import Platform + +from . import upload +from .develop import flatten_translations +from .util import get_base_arg_parser + + +def get_arguments() -> argparse.Namespace: + """Get parsed passed in arguments.""" + parser = get_base_arg_parser() + parser.add_argument( + "--limit-reference", + "--lr", + action="store_true", + help="Only allow references to same strings.json or common.", + ) + return parser.parse_args() + + +STRINGS_PATH = "homeassistant/components/{}/strings.json" +ENTITY_COMPONENT_PREFIX = tuple(f"component::{domain}::" for domain in Platform) + + +def run(): + """Clean translations.""" + args = get_arguments() + translations = upload.generate_upload_data() + flattened_translations = flatten_translations(translations) + flattened_translations = { + key: value + for key, value in flattened_translations.items() + # Skip existing references + if not value.startswith("[%key:") + } + + primary = {} + secondary = {} + + for key, value in flattened_translations.items(): + if key.startswith("common::"): + primary[value] = key + elif key.startswith(ENTITY_COMPONENT_PREFIX): + primary.setdefault(value, key) + else: + secondary.setdefault(value, key) + + merged = {**secondary, **primary} + + # Questionable translations are ones that are duplicate but are not referenced + # by the common strings.json or strings.json from an entity component. + questionable = set(secondary.values()) + suggest_new_common = set() + update_keys = {} + + for key, value in flattened_translations.items(): + if merged[value] == key or key.startswith("common::"): + continue + + key_integration = key.split("::")[1] + + key_to_reference = merged[value] + key_to_reference_integration = key_to_reference.split("::")[1] + is_common = key_to_reference.startswith("common::") + + # If we want to only add references to own integrations + # but not include entity integrations + if ( + args.limit_reference + and (key_integration != key_to_reference_integration and not is_common) + # Do not create self-references in entity integrations + or key_integration in Platform.__members__.values() + ): + continue + + if ( + # We don't want integrations to reference arbitrary other integrations + key_to_reference in questionable + # Allow reference own integration + and key_to_reference_integration != key_integration + ): + suggest_new_common.add(value) + continue + + update_keys[key] = f"[%key:{key_to_reference}%]" + + if suggest_new_common: + print("Suggested new common words:") + for key in sorted(suggest_new_common): + print(key) + + components = sorted({key.split("::")[1] for key in update_keys}) + + strings = {} + + for component in components: + comp_strings_path = Path(STRINGS_PATH.format(component)) + strings[component] = json.loads(comp_strings_path.read_text(encoding="utf-8")) + + for path, value in update_keys.items(): + parts = path.split("::") + parts.pop(0) + component = parts.pop(0) + to_write = strings[component] + while len(parts) > 1: + try: + to_write = to_write[parts.pop(0)] + except KeyError: + print(to_write) + raise + + to_write[parts.pop(0)] = value + + for component in components: + comp_strings_path = Path(STRINGS_PATH.format(component)) + comp_strings_path.write_text( + json.dumps( + strings[component], + indent=2, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + return 0 diff --git a/script/translations/develop.py b/script/translations/develop.py index a318c7c08bc..3bfaa279e93 100644 --- a/script/translations/develop.py +++ b/script/translations/develop.py @@ -92,6 +92,7 @@ def substitute_reference(value, flattened_translations): def run_single(translations, flattened_translations, integration): """Run the script for a single integration.""" + print(f"Generating translations for {integration}") if integration not in translations["component"]: print("Integration has no strings.json") @@ -114,8 +115,6 @@ def run_single(translations, flattened_translations, integration): download.write_integration_translations() - print(f"Generating translations for {integration}") - def run(): """Run the script.""" diff --git a/script/translations/util.py b/script/translations/util.py index 9839fefd9d5..0c8c8a2a30f 100644 --- a/script/translations/util.py +++ b/script/translations/util.py @@ -13,7 +13,15 @@ def get_base_arg_parser() -> argparse.ArgumentParser: parser.add_argument( "action", type=str, - choices=["clean", "develop", "download", "frontend", "migrate", "upload"], + choices=[ + "clean", + "deduplicate", + "develop", + "download", + "frontend", + "migrate", + "upload", + ], ) parser.add_argument("--debug", action="store_true", help="Enable log output") return parser diff --git a/tests/backports/__init__.py b/tests/backports/__init__.py deleted file mode 100644 index 3f701810a5d..00000000000 --- a/tests/backports/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The tests for the backports.""" diff --git a/tests/backports/test_enum.py b/tests/backports/test_enum.py deleted file mode 100644 index 06b876eac8d..00000000000 --- a/tests/backports/test_enum.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Test Home Assistant enum utils.""" - -from enum import auto - -import pytest - -from homeassistant.backports.enum import StrEnum - - -def test_strenum() -> None: - """Test StrEnum.""" - - class TestEnum(StrEnum): - Test = "test" - - assert str(TestEnum.Test) == "test" - assert TestEnum.Test == "test" - assert TestEnum("test") is TestEnum.Test - assert TestEnum(TestEnum.Test) is TestEnum.Test - - with pytest.raises(ValueError): - TestEnum(42) - - with pytest.raises(ValueError): - TestEnum("str but unknown") - - with pytest.raises(TypeError): - - class FailEnum(StrEnum): - Test = 42 - - with pytest.raises(TypeError): - - class FailEnum2(StrEnum): - Test = auto() diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index dd5dca8c069..b9e66d51874 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -22,6 +22,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, @@ -61,6 +62,7 @@ async def test_weather_without_forecast(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_WEATHER_DEW_POINT) == 16.2 assert state.attributes.get(ATTR_WEATHER_CLOUD_COVERAGE) == 10 assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 20.3 + assert state.attributes.get(ATTR_WEATHER_UV_INDEX) == 6 assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION entry = registry.async_get("weather.home") @@ -86,6 +88,7 @@ async def test_weather_with_forecast(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_WEATHER_DEW_POINT) == 16.2 assert state.attributes.get(ATTR_WEATHER_CLOUD_COVERAGE) == 10 assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 20.3 + assert state.attributes.get(ATTR_WEATHER_UV_INDEX) == 6 assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == "lightning-rainy" @@ -99,6 +102,7 @@ async def test_weather_with_forecast(hass: HomeAssistant) -> None: assert forecast.get(ATTR_FORECAST_CLOUD_COVERAGE) == 58 assert forecast.get(ATTR_FORECAST_APPARENT_TEMP) == 29.8 assert forecast.get(ATTR_FORECAST_WIND_GUST_SPEED) == 29.6 + assert forecast.get(ATTR_WEATHER_UV_INDEX) == 5 entry = registry.async_get("weather.home") assert entry diff --git a/tests/components/airnow/test_config_flow.py b/tests/components/airnow/test_config_flow.py index efa462ee4e6..5fda5f532a3 100644 --- a/tests/components/airnow/test_config_flow.py +++ b/tests/components/airnow/test_config_flow.py @@ -1,7 +1,7 @@ """Test the AirNow config flow.""" from unittest.mock import AsyncMock -from pyairnow.errors import AirNowError, InvalidKeyError +from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError import pytest from homeassistant import config_entries, data_entry_flow @@ -55,6 +55,17 @@ async def test_form_cannot_connect(hass: HomeAssistant, config, setup_airnow) -> assert result2["errors"] == {"base": "cannot_connect"} +@pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=EmptyResponseError)]) +async def test_form_empty_result(hass: HomeAssistant, config, setup_airnow) -> None: + """Test we handle empty response error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_location"} + + @pytest.mark.parametrize("mock_api_get", [AsyncMock(side_effect=RuntimeError)]) async def test_form_unexpected(hass: HomeAssistant, config, setup_airnow) -> None: """Test we handle an unexpected error.""" diff --git a/tests/components/airvisual_pro/test_diagnostics.py b/tests/components/airvisual_pro/test_diagnostics.py index 7f953946b69..5141782e574 100644 --- a/tests/components/airvisual_pro/test_diagnostics.py +++ b/tests/components/airvisual_pro/test_diagnostics.py @@ -33,6 +33,7 @@ async def test_entry_diagnostics( "time": "16:00:44", "timestamp": "1665072044", }, + "history": {}, "measurements": { "co2": "472", "humidity": "57", diff --git a/tests/components/airzone_cloud/test_binary_sensor.py b/tests/components/airzone_cloud/test_binary_sensor.py index b2c9ee173b7..14f7a078156 100644 --- a/tests/components/airzone_cloud/test_binary_sensor.py +++ b/tests/components/airzone_cloud/test_binary_sensor.py @@ -1,6 +1,6 @@ """The binary sensor tests for the Airzone Cloud platform.""" -from homeassistant.const import STATE_OFF +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from .util import async_init_integration @@ -11,11 +11,26 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: await async_init_integration(hass) + # Aidoo + state = hass.states.get("binary_sensor.bron_problem") + assert state.state == STATE_OFF + assert state.attributes.get("errors") is None + assert state.attributes.get("warnings") is None + + state = hass.states.get("binary_sensor.bron_running") + assert state.state == STATE_OFF + # Zones state = hass.states.get("binary_sensor.dormitorio_problem") assert state.state == STATE_OFF assert state.attributes.get("warnings") is None + state = hass.states.get("binary_sensor.dormitorio_running") + assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.salon_problem") assert state.state == STATE_OFF assert state.attributes.get("warnings") is None + + state = hass.states.get("binary_sensor.salon_running") + assert state.state == STATE_ON diff --git a/tests/components/airzone_cloud/test_diagnostics.py b/tests/components/airzone_cloud/test_diagnostics.py index 730ac27325a..6c8ae366518 100644 --- a/tests/components/airzone_cloud/test_diagnostics.py +++ b/tests/components/airzone_cloud/test_diagnostics.py @@ -5,9 +5,11 @@ from unittest.mock import patch from aioairzone_cloud.const import ( API_DEVICE_ID, API_DEVICES, + API_GROUP_ID, API_GROUPS, API_WS_ID, AZD_AIDOOS, + AZD_GROUPS, AZD_INSTALLATIONS, AZD_SYSTEMS, AZD_WEBSERVERS, @@ -40,9 +42,10 @@ RAW_DATA_MOCK = { CONFIG[CONF_ID]: { API_GROUPS: [ { + API_GROUP_ID: "grp1", API_DEVICES: [ { - API_DEVICE_ID: "device1", + API_DEVICE_ID: "dev1", API_WS_ID: WS_ID, }, ], @@ -91,6 +94,12 @@ async def test_config_entry_diagnostics( assert list(diag["api_data"]) >= list(RAW_DATA_MOCK) assert "dev1" not in diag["api_data"][RAW_DEVICES_CONFIG] assert "device1" in diag["api_data"][RAW_DEVICES_CONFIG] + assert ( + diag["api_data"][RAW_INSTALLATIONS]["installation1"][API_GROUPS][0][ + API_GROUP_ID + ] + == "group1" + ) assert "inst1" not in diag["api_data"][RAW_INSTALLATIONS] assert "installation1" in diag["api_data"][RAW_INSTALLATIONS] assert WS_ID not in diag["api_data"][RAW_WEBSERVERS] @@ -111,6 +120,7 @@ async def test_config_entry_diagnostics( assert list(diag["coord_data"]) >= [ AZD_AIDOOS, + AZD_GROUPS, AZD_INSTALLATIONS, AZD_SYSTEMS, AZD_WEBSERVERS, diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 4eab870297b..0c26755f948 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import patch from aioairzone_cloud.const import ( + API_ACTIVE, API_AZ_AIDOO, API_AZ_SYSTEM, API_AZ_ZONE, @@ -15,6 +16,7 @@ from aioairzone_cloud.const import ( API_DISCONNECTION_DATE, API_ERRORS, API_FAH, + API_GROUP_ID, API_GROUPS, API_HUMIDITY, API_INSTALLATION_ID, @@ -60,6 +62,7 @@ CONFIG = { GET_INSTALLATION_MOCK = { API_GROUPS: [ { + API_GROUP_ID: "grp1", API_NAME: "Group", API_DEVICES: [ { @@ -93,6 +96,7 @@ GET_INSTALLATION_MOCK = { ], }, { + API_GROUP_ID: "grp2", API_NAME: "Aidoo Group", API_DEVICES: [ { @@ -159,6 +163,7 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: if device.get_id() == "aidoo1": return { + API_ACTIVE: False, API_ERRORS: [], API_IS_CONNECTED: True, API_WS_CONNECTED: True, @@ -175,8 +180,21 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_WS_CONNECTED: True, API_WARNINGS: [], } + if device.get_id() == "zone1": + return { + API_ACTIVE: True, + API_HUMIDITY: 30, + API_IS_CONNECTED: True, + API_WS_CONNECTED: True, + API_LOCAL_TEMP: { + API_FAH: 68, + API_CELSIUS: 20, + }, + API_WARNINGS: [], + } if device.get_id() == "zone2": return { + API_ACTIVE: False, API_HUMIDITY: 24, API_IS_CONNECTED: True, API_WS_CONNECTED: True, @@ -186,16 +204,7 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: }, API_WARNINGS: [], } - return { - API_HUMIDITY: 30, - API_IS_CONNECTED: True, - API_WS_CONNECTED: True, - API_LOCAL_TEMP: { - API_FAH: 68, - API_CELSIUS: 20, - }, - API_WARNINGS: [], - } + return None def mock_get_webserver(webserver: WebServer, devices: bool) -> dict[str, Any]: diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index a1f77a9b49b..477e7884e4f 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1,4 +1,5 @@ """Test for smart home alexa support.""" +from typing import Any from unittest.mock import patch import pytest @@ -2136,18 +2137,48 @@ async def test_forced_motion_sensor(hass: HomeAssistant) -> None: properties.assert_equal("Alexa.EndpointHealth", "connectivity", {"value": "OK"}) -async def test_doorbell_sensor(hass: HomeAssistant) -> None: - """Test doorbell sensor discovery.""" - device = ( - "binary_sensor.test_doorbell", - "off", - {"friendly_name": "Test Doorbell Sensor", "device_class": "occupancy"}, - ) +@pytest.mark.parametrize( + ("device", "endpoint_id", "friendly_name", "display_category"), + [ + ( + ( + "binary_sensor.test_doorbell", + "off", + {"friendly_name": "Test Doorbell Sensor", "device_class": "occupancy"}, + ), + "binary_sensor#test_doorbell", + "Test Doorbell Sensor", + "DOORBELL", + ), + ( + ( + "event.test_doorbell", + None, + { + "friendly_name": "Test Doorbell Event", + "event_types": ["press"], + "device_class": "doorbell", + }, + ), + "event#test_doorbell", + "Test Doorbell Event", + "DOORBELL", + ), + ], +) +async def test_doorbell_event( + hass: HomeAssistant, + device: tuple[str, str, dict[str, Any]], + endpoint_id: str, + friendly_name: str, + display_category: str, +) -> None: + """Test doorbell event/sensor discovery.""" appliance = await discovery_test(device, hass) - assert appliance["endpointId"] == "binary_sensor#test_doorbell" - assert appliance["displayCategories"][0] == "DOORBELL" - assert appliance["friendlyName"] == "Test Doorbell Sensor" + assert appliance["endpointId"] == endpoint_id + assert appliance["displayCategories"][0] == display_category + assert appliance["friendlyName"] == friendly_name capabilities = assert_endpoint_capabilities( appliance, "Alexa.DoorbellEventSource", "Alexa.EndpointHealth", "Alexa" @@ -2176,8 +2207,8 @@ async def test_thermostat(hass: HomeAssistant) -> None: "cool", { "temperature": 70.0, - "target_temp_high": 80.0, - "target_temp_low": 60.0, + "target_temp_high": None, + "target_temp_low": None, "current_temperature": 75.0, "friendly_name": "Test Thermostat", "supported_features": 1 | 2 | 4 | 128, @@ -2439,6 +2470,103 @@ async def test_thermostat(hass: HomeAssistant) -> None: assert call.data["preset_mode"] == "eco" +async def test_thermostat_dual(hass: HomeAssistant) -> None: + """Test thermostat discovery with auto mode, with upper and lower target temperatures.""" + hass.config.units = US_CUSTOMARY_SYSTEM + device = ( + "climate.test_thermostat", + "auto", + { + "temperature": None, + "target_temp_high": 80.0, + "target_temp_low": 60.0, + "current_temperature": 75.0, + "friendly_name": "Test Thermostat", + "supported_features": 1 | 2 | 4 | 128, + "hvac_modes": ["off", "heat", "cool", "auto", "dry", "fan_only"], + "preset_mode": None, + "preset_modes": ["eco"], + "min_temp": 50, + "max_temp": 90, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "climate#test_thermostat" + assert appliance["displayCategories"][0] == "THERMOSTAT" + assert appliance["friendlyName"] == "Test Thermostat" + + assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.ThermostatController", + "Alexa.TemperatureSensor", + "Alexa.EndpointHealth", + "Alexa", + ) + + properties = await reported_properties(hass, "climate#test_thermostat") + properties.assert_equal("Alexa.ThermostatController", "thermostatMode", "AUTO") + properties.assert_equal( + "Alexa.ThermostatController", + "upperSetpoint", + {"value": 80.0, "scale": "FAHRENHEIT"}, + ) + properties.assert_equal( + "Alexa.ThermostatController", + "lowerSetpoint", + {"value": 60.0, "scale": "FAHRENHEIT"}, + ) + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 75.0, "scale": "FAHRENHEIT"} + ) + + # Adjust temperature when in auto mode + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "AdjustTargetTemperature", + "climate#test_thermostat", + "climate.set_temperature", + hass, + payload={"targetSetpointDelta": {"value": -5.0, "scale": "KELVIN"}}, + ) + assert call.data["target_temp_high"] == 71.0 + assert call.data["target_temp_low"] == 51.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "upperSetpoint", + {"value": 71.0, "scale": "FAHRENHEIT"}, + ) + properties.assert_equal( + "Alexa.ThermostatController", + "lowerSetpoint", + {"value": 51.0, "scale": "FAHRENHEIT"}, + ) + + # Fails if the upper setpoint goes too high + msg = await assert_request_fails( + "Alexa.ThermostatController", + "AdjustTargetTemperature", + "climate#test_thermostat", + "climate.set_temperature", + hass, + payload={"targetSetpointDelta": {"value": 6.0, "scale": "CELSIUS"}}, + ) + assert msg["event"]["payload"]["type"] == "TEMPERATURE_VALUE_OUT_OF_RANGE" + + # Fails if the lower setpoint goes too low + msg = await assert_request_fails( + "Alexa.ThermostatController", + "AdjustTargetTemperature", + "climate#test_thermostat", + "climate.set_temperature", + hass, + payload={"targetSetpointDelta": {"value": -6.0, "scale": "CELSIUS"}}, + ) + assert msg["event"]["payload"]["type"] == "TEMPERATURE_VALUE_OUT_OF_RANGE" + + async def test_exclude_filters(hass: HomeAssistant) -> None: """Test exclusion filters.""" request = get_new_request("Alexa.Discovery", "Discover") diff --git a/tests/components/amberelectric/test_binary_sensor.py b/tests/components/amberelectric/test_binary_sensor.py index 32cec180dbc..fb95cd1c41e 100644 --- a/tests/components/amberelectric/test_binary_sensor.py +++ b/tests/components/amberelectric/test_binary_sensor.py @@ -11,11 +11,11 @@ from dateutil import parser import pytest from homeassistant.components.amberelectric.const import ( - CONF_API_TOKEN, CONF_SITE_ID, CONF_SITE_NAME, DOMAIN, ) +from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py index 7a35b2c1c7e..286345dba10 100644 --- a/tests/components/amberelectric/test_sensor.py +++ b/tests/components/amberelectric/test_sensor.py @@ -7,11 +7,11 @@ from amberelectric.model.range import Range import pytest from homeassistant.components.amberelectric.const import ( - CONF_API_TOKEN, CONF_SITE_ID, CONF_SITE_NAME, DOMAIN, ) +from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/androidtv/patchers.py b/tests/components/androidtv/patchers.py index 5ebd95ccacd..aae99b34438 100644 --- a/tests/components/androidtv/patchers.py +++ b/tests/components/androidtv/patchers.py @@ -23,7 +23,7 @@ PROPS_DEV_MAC = "ether ab:cd:ef:gh:ij:kl brd" class AdbDeviceTcpAsyncFake: """A fake of the `adb_shell.adb_device_async.AdbDeviceTcpAsync` class.""" - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: """Initialize a fake `adb_shell.adb_device_async.AdbDeviceTcpAsync` instance.""" self.available = False @@ -43,7 +43,7 @@ class AdbDeviceTcpAsyncFake: class ClientAsyncFakeSuccess: """A fake of the `ClientAsync` class when the connection and shell commands succeed.""" - def __init__(self, host=ADB_SERVER_HOST, port=DEFAULT_ADB_SERVER_PORT): + def __init__(self, host=ADB_SERVER_HOST, port=DEFAULT_ADB_SERVER_PORT) -> None: """Initialize a `ClientAsyncFakeSuccess` instance.""" self._devices = [] @@ -57,7 +57,7 @@ class ClientAsyncFakeSuccess: class ClientAsyncFakeFail: """A fake of the `ClientAsync` class when the connection and shell commands fail.""" - def __init__(self, host=ADB_SERVER_HOST, port=DEFAULT_ADB_SERVER_PORT): + def __init__(self, host=ADB_SERVER_HOST, port=DEFAULT_ADB_SERVER_PORT) -> None: """Initialize a `ClientAsyncFakeFail` instance.""" self._devices = [] @@ -70,7 +70,7 @@ class ClientAsyncFakeFail: class DeviceAsyncFake: """A fake of the `DeviceAsync` class.""" - def __init__(self, host): + def __init__(self, host) -> None: """Initialize a `DeviceAsyncFake` instance.""" self.host = host @@ -185,6 +185,10 @@ def isfile(filepath): return filepath.endswith("adbkey") +PATCH_SCREENCAP = patch( + "androidtv.basetv.basetv_async.BaseTVAsync.adb_screencap", + return_value=b"image", +) PATCH_SETUP_ENTRY = patch( "homeassistant.components.androidtv.async_setup_entry", return_value=True, diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index 59c7ce751ac..847bc5c7d2f 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -1,4 +1,5 @@ """The tests for the androidtv platform.""" +from datetime import timedelta import logging from typing import Any from unittest.mock import Mock, patch @@ -70,10 +71,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity from homeassistant.util import slugify +from homeassistant.util.dt import utcnow from . import patchers -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator HOST = "127.0.0.1" @@ -197,7 +199,7 @@ def keygen_fixture() -> None: yield -def _setup(config): +def _setup(config) -> tuple[str, str, MockConfigEntry]: """Perform common setup tasks for the tests.""" patch_key = config[ADB_PATCH_KEY] entity_id = f"{MP_DOMAIN}.{slugify(config[TEST_ENTITY_NAME])}" @@ -263,7 +265,7 @@ async def test_reconnect( caplog.set_level(logging.DEBUG) with patchers.patch_connect(True)[patch_key], patchers.patch_shell( SHELL_RESPONSE_STANDBY - )[patch_key]: + )[patch_key], patchers.PATCH_SCREENCAP: await async_update_entity(hass, entity_id) state = hass.states.get(entity_id) @@ -453,8 +455,8 @@ async def test_exclude_sources( async def _test_select_source( - hass, config, conf_apps, source, expected_arg, method_patch -): + hass: HomeAssistant, config, conf_apps, source, expected_arg, method_patch +) -> None: """Test that the methods for launching and stopping apps are called correctly when selecting a source.""" patch_key, entity_id, config_entry = _setup(config) config_entry.add_to_hass(hass) @@ -751,7 +753,9 @@ async def test_update_lock_not_acquired(hass: HomeAssistant) -> None: assert state is not None assert state.state == STATE_OFF - with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]: + with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[ + patch_key + ], patchers.PATCH_SCREENCAP: await async_update_entity(hass, entity_id) state = hass.states.get(entity_id) assert state is not None @@ -890,8 +894,11 @@ async def test_get_image_http( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patchers.patch_shell("11")[patch_key]: + with patchers.patch_shell("11")[ + patch_key + ], patchers.PATCH_SCREENCAP as patch_screen_cap: await async_update_entity(hass, entity_id) + patch_screen_cap.assert_called() media_player_name = "media_player." + slugify( CONFIG_ANDROID_DEFAULT[TEST_ENTITY_NAME] @@ -901,21 +908,53 @@ async def test_get_image_http( client = await hass_client_no_auth() - with patch( - "androidtv.basetv.basetv_async.BaseTVAsync.adb_screencap", return_value=b"image" - ): - resp = await client.get(state.attributes["entity_picture"]) - content = await resp.read() - + resp = await client.get(state.attributes["entity_picture"]) + content = await resp.read() assert content == b"image" - with patch( + next_update = utcnow() + timedelta(seconds=30) + with patchers.patch_shell("11")[ + patch_key + ], patchers.PATCH_SCREENCAP as patch_screen_cap, patch( + "homeassistant.util.utcnow", return_value=next_update + ): + async_fire_time_changed(hass, next_update, True) + await hass.async_block_till_done() + patch_screen_cap.assert_not_called() + + next_update = utcnow() + timedelta(seconds=60) + with patchers.patch_shell("11")[ + patch_key + ], patchers.PATCH_SCREENCAP as patch_screen_cap, patch( + "homeassistant.util.utcnow", return_value=next_update + ): + async_fire_time_changed(hass, next_update, True) + await hass.async_block_till_done() + patch_screen_cap.assert_called() + + +async def test_get_image_http_fail(hass: HomeAssistant) -> None: + """Test taking a screen capture fail.""" + + patch_key, entity_id, config_entry = _setup(CONFIG_ANDROID_DEFAULT) + config_entry.add_to_hass(hass) + + with patchers.patch_connect(True)[patch_key], patchers.patch_shell( + SHELL_RESPONSE_OFF + )[patch_key]: + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + with patchers.patch_shell("11")[patch_key], patch( "androidtv.basetv.basetv_async.BaseTVAsync.adb_screencap", side_effect=ConnectionResetError, ): - resp = await client.get(state.attributes["entity_picture"]) + await async_update_entity(hass, entity_id) # The device is unavailable, but getting the media image did not cause an exception + media_player_name = "media_player." + slugify( + CONFIG_ANDROID_DEFAULT[TEST_ENTITY_NAME] + ) state = hass.states.get(media_player_name) assert state is not None assert state.state == STATE_UNAVAILABLE @@ -947,13 +986,13 @@ async def test_get_image_disabled(hass: HomeAssistant) -> None: async def _test_service( - hass, + hass: HomeAssistant, entity_id, ha_service_name, androidtv_method, additional_service_data=None, return_value=None, -): +) -> None: """Test generic Android media player entity service.""" service_data = {ATTR_ENTITY_ID: entity_id} if additional_service_data: @@ -986,7 +1025,9 @@ async def test_services_androidtv(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]: + with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[ + patch_key + ], patchers.PATCH_SCREENCAP: await _test_service( hass, entity_id, SERVICE_MEDIA_NEXT_TRACK, "media_next_track" ) @@ -1034,7 +1075,9 @@ async def test_services_firetv(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]: + with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[ + patch_key + ], patchers.PATCH_SCREENCAP: await _test_service(hass, entity_id, SERVICE_MEDIA_STOP, "back") await _test_service(hass, entity_id, SERVICE_TURN_OFF, "adb_shell") await _test_service(hass, entity_id, SERVICE_TURN_ON, "adb_shell") @@ -1050,7 +1093,9 @@ async def test_volume_mute(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[patch_key]: + with patchers.patch_shell(SHELL_RESPONSE_STANDBY)[ + patch_key + ], patchers.PATCH_SCREENCAP: service_data = {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_MUTED: True} with patch( "androidtv.androidtv.androidtv_async.AndroidTVAsync.mute_volume", diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index ec368081a95..4e0067152e7 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -857,3 +857,59 @@ async def test_reauth_flow_cannot_connect( await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == 0 assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test options flow.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_api.disconnect.call_count == 0 + assert mock_api.async_connect.call_count == 1 + + # Trigger options flow, first time + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] == "form" + assert result["step_id"] == "init" + data_schema = result["data_schema"].schema + assert set(data_schema) == {"enable_ime"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"enable_ime": False}, + ) + assert result["type"] == "create_entry" + assert mock_config_entry.options == {"enable_ime": False} + await hass.async_block_till_done() + + assert mock_api.disconnect.call_count == 1 + assert mock_api.async_connect.call_count == 2 + + # Trigger options flow, second time, no change, doesn't reload + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"enable_ime": False}, + ) + assert result["type"] == "create_entry" + assert mock_config_entry.options == {"enable_ime": False} + await hass.async_block_till_done() + + assert mock_api.disconnect.call_count == 1 + assert mock_api.async_connect.call_count == 2 + + # Trigger options flow, third time, change, reloads + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"enable_ime": True}, + ) + assert result["type"] == "create_entry" + assert mock_config_entry.options == {"enable_ime": True} + await hass.async_block_till_done() + + assert mock_api.disconnect.call_count == 2 + assert mock_api.async_connect.call_count == 3 diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index f5c3f573030..b8a83f950d0 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -20,6 +20,7 @@ MOCK_STATUS: Final = OrderedDict( ("CABLE", "USB Cable"), ("DRIVER", "USB UPS Driver"), ("UPSMODE", "Stand Alone"), + ("UPSNAME", "MyUPS"), ("MODEL", "Back-UPS ES 600"), ("STATUS", "ONLINE"), ("LINEV", "124.0 Volts"), diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index a9ef4328e86..6ac7992f404 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -124,7 +124,7 @@ async def test_flow_works(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == MOCK_STATUS["MODEL"] + assert result["title"] == MOCK_STATUS["UPSNAME"] assert result["data"] == CONF_DATA mock_setup.assert_called_once() diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 6e00a382e79..8c29edabbc1 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -8,6 +8,7 @@ from homeassistant.components.apcupsd import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import CONF_DATA, MOCK_MINIMAL_STATUS, MOCK_STATUS, async_init_integration @@ -28,6 +29,53 @@ async def test_async_setup_entry(hass: HomeAssistant, status: OrderedDict) -> No assert state.state == "on" +@pytest.mark.parametrize( + "status", + ( + # We should not create device entries if SERIALNO is not reported. + MOCK_MINIMAL_STATUS, + # We should set the device name to be the friendly UPSNAME field if available. + MOCK_MINIMAL_STATUS | {"SERIALNO": "XXXX", "UPSNAME": "MyUPS"}, + # Otherwise, we should fall back to default device name --- "APC UPS". + MOCK_MINIMAL_STATUS | {"SERIALNO": "XXXX"}, + # We should create all fields of the device entry if they are available. + MOCK_STATUS, + ), +) +async def test_device_entry(hass: HomeAssistant, status: OrderedDict) -> None: + """Test successful setup of device entries.""" + await async_init_integration(hass, status=status) + + # Verify device info is properly set up. + device_entries = dr.async_get(hass) + + if "SERIALNO" not in status: + assert len(device_entries.devices) == 0 + return + + assert len(device_entries.devices) == 1 + entry = device_entries.async_get_device({(DOMAIN, status["SERIALNO"])}) + assert entry is not None + # Specify the mapping between field name and the expected fields in device entry. + fields = { + "UPSNAME": entry.name, + "MODEL": entry.model, + "VERSION": entry.sw_version, + "FIRMWARE": entry.hw_version, + } + + for field, entry_value in fields.items(): + if field in status: + assert entry_value == status[field] + elif field == "UPSNAME": + # Even if UPSNAME is not available, we must fall back to default "APC UPS". + assert entry_value == "APC UPS" + else: + assert entry_value is None + + assert entry.manufacturer == "APC" + + async def test_multiple_integrations(hass: HomeAssistant) -> None: """Test successful setup for multiple entries.""" # Load two integrations from two mock hosts. diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 61da000fc07..5ba9d60996b 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -678,3 +678,19 @@ async def test_api_call_service_bad_data( "/api/services/test_domain/test_service", json={"hello": 5} ) assert resp.status == HTTPStatus.BAD_REQUEST + + +async def test_api_status(hass: HomeAssistant, mock_api_client: TestClient) -> None: + """Test getting the api status.""" + resp = await mock_api_client.get("/api/") + assert resp.status == HTTPStatus.OK + json = await resp.json() + assert json["message"] == "API running." + + +async def test_api_core_state(hass: HomeAssistant, mock_api_client: TestClient) -> None: + """Test getting core status.""" + resp = await mock_api_client.get("/api/core/state") + assert resp.status == HTTPStatus.OK + json = await resp.json() + assert json["state"] == "RUNNING" diff --git a/tests/components/assist_pipeline/test_select.py b/tests/components/assist_pipeline/test_select.py index bb9c4d45a32..29e6f9a8f31 100644 --- a/tests/components/assist_pipeline/test_select.py +++ b/tests/components/assist_pipeline/test_select.py @@ -102,7 +102,7 @@ async def test_select_entity_registering_device( ) -> None: """Test entity registering as an assist device.""" dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device({("test", "test")}) + device = dev_reg.async_get_device(identifiers={("test", "test")}) assert device is not None # Test device is registered diff --git a/tests/components/asuswrt/test_config_flow.py b/tests/components/asuswrt/test_config_flow.py index aabf5d6d46b..bdee4f82f90 100644 --- a/tests/components/asuswrt/test_config_flow.py +++ b/tests/components/asuswrt/test_config_flow.py @@ -62,7 +62,7 @@ def mock_unique_id_fixture(): @pytest.fixture(name="connect") def mock_controller_connect(mock_unique_id): """Mock a successful connection.""" - with patch("homeassistant.components.asuswrt.router.AsusWrt") as service_mock: + with patch("homeassistant.components.asuswrt.bridge.AsusWrtLegacy") as service_mock: service_mock.return_value.connection.async_connect = AsyncMock() service_mock.return_value.is_connected = True service_mock.return_value.connection.disconnect = Mock() @@ -236,11 +236,12 @@ async def test_on_connect_failed(hass: HomeAssistant, side_effect, error) -> Non ) with PATCH_GET_HOST, patch( - "homeassistant.components.asuswrt.router.AsusWrt" + "homeassistant.components.asuswrt.bridge.AsusWrtLegacy" ) as asus_wrt: asus_wrt.return_value.connection.async_connect = AsyncMock( side_effect=side_effect ) + asus_wrt.return_value.async_get_nvram = AsyncMock(return_value={}) asus_wrt.return_value.is_connected = False result = await hass.config_entries.flow.async_configure( diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index 553902b66fd..2d7bda491a8 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -9,9 +9,13 @@ from homeassistant.components import device_tracker, sensor from homeassistant.components.asuswrt.const import ( CONF_INTERFACE, DOMAIN, + MODE_ROUTER, PROTOCOL_TELNET, + SENSORS_BYTES, + SENSORS_LOAD_AVG, + SENSORS_RATES, + SENSORS_TEMPERATURES, ) -from homeassistant.components.asuswrt.router import DEFAULT_NAME from homeassistant.components.device_tracker import CONF_CONSIDER_HOME from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -32,7 +36,7 @@ from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed -ASUSWRT_LIB = "homeassistant.components.asuswrt.router.AsusWrt" +ASUSWRT_LIB = "homeassistant.components.asuswrt.bridge.AsusWrtLegacy" HOST = "myrouter.asuswrt.com" IP_ADDRESS = "192.168.1.1" @@ -43,7 +47,7 @@ CONFIG_DATA = { CONF_PROTOCOL: PROTOCOL_TELNET, CONF_USERNAME: "user", CONF_PASSWORD: "pwd", - CONF_MODE: "router", + CONF_MODE: MODE_ROUTER, } MAC_ADDR = "a1:b2:c3:d4:e5:f6" @@ -57,26 +61,8 @@ MOCK_MAC_2 = "A2:B2:C2:D2:E2:F2" MOCK_MAC_3 = "A3:B3:C3:D3:E3:F3" MOCK_MAC_4 = "A4:B4:C4:D4:E4:F4" -SENSORS_DEFAULT = [ - "Download Speed", - "Download", - "Upload Speed", - "Upload", -] - -SENSORS_LOADAVG = [ - "Load Avg (1m)", - "Load Avg (5m)", - "Load Avg (15m)", -] - -SENSORS_TEMP = [ - "2.4GHz Temperature", - "5GHz Temperature", - "CPU Temperature", -] - -SENSORS_ALL = [*SENSORS_DEFAULT, *SENSORS_LOADAVG, *SENSORS_TEMP] +SENSORS_DEFAULT = [*SENSORS_BYTES, *SENSORS_RATES] +SENSORS_ALL = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES] PATCH_SETUP_ENTRY = patch( "homeassistant.components.asuswrt.async_setup_entry", @@ -105,7 +91,7 @@ def mock_available_temps_fixture(): @pytest.fixture(name="create_device_registry_devices") -def create_device_registry_devices_fixture(hass): +def create_device_registry_devices_fixture(hass: HomeAssistant): """Create device registry devices so the device tracker entities are enabled when added.""" dev_reg = dr.async_get(hass) config_entry = MockConfigEntry(domain="something_else") @@ -182,7 +168,7 @@ def mock_controller_connect_sens_fail(): yield service_mock -def _setup_entry(hass, config, sensors, unique_id=None): +def _setup_entry(hass: HomeAssistant, config, sensors, unique_id=None): """Create mock config entry with enabled sensors.""" entity_reg = er.async_get(hass) @@ -195,16 +181,17 @@ def _setup_entry(hass, config, sensors, unique_id=None): ) # init variable - obj_prefix = slugify(HOST if unique_id else DEFAULT_NAME) + obj_prefix = slugify(HOST) sensor_prefix = f"{sensor.DOMAIN}.{obj_prefix}" + unique_id_prefix = slugify(unique_id or config_entry.entry_id) # Pre-enable the status sensor - for sensor_name in sensors: - sensor_id = slugify(sensor_name) + for sensor_key in sensors: + sensor_id = slugify(sensor_key) entity_reg.async_get_or_create( sensor.DOMAIN, DOMAIN, - f"{DOMAIN} {unique_id or DEFAULT_NAME} {sensor_name}", + f"{unique_id_prefix}_{sensor_id}", suggested_object_id=f"{obj_prefix}_{sensor_id}", config_entry=config_entry, disabled_by=None, @@ -255,10 +242,10 @@ async def test_sensors( assert hass.states.get(f"{device_tracker.DOMAIN}.test").state == STATE_HOME assert hass.states.get(f"{device_tracker.DOMAIN}.testtwo").state == STATE_HOME - assert hass.states.get(f"{sensor_prefix}_download_speed").state == "160.0" - assert hass.states.get(f"{sensor_prefix}_download").state == "60.0" - assert hass.states.get(f"{sensor_prefix}_upload_speed").state == "80.0" - assert hass.states.get(f"{sensor_prefix}_upload").state == "50.0" + assert hass.states.get(f"{sensor_prefix}_sensor_rx_rates").state == "160.0" + assert hass.states.get(f"{sensor_prefix}_sensor_rx_bytes").state == "60.0" + assert hass.states.get(f"{sensor_prefix}_sensor_tx_rates").state == "80.0" + assert hass.states.get(f"{sensor_prefix}_sensor_tx_bytes").state == "50.0" assert hass.states.get(f"{sensor_prefix}_devices_connected").state == "2" # remove first tracked device @@ -296,7 +283,7 @@ async def test_loadavg_sensors( connect, ) -> None: """Test creating an AsusWRT load average sensors.""" - config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA, SENSORS_LOADAVG) + config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA, SENSORS_LOAD_AVG) config_entry.add_to_hass(hass) # initial devices setup @@ -306,31 +293,9 @@ async def test_loadavg_sensors( await hass.async_block_till_done() # assert temperature sensor available - assert hass.states.get(f"{sensor_prefix}_load_avg_1m").state == "1.1" - assert hass.states.get(f"{sensor_prefix}_load_avg_5m").state == "1.2" - assert hass.states.get(f"{sensor_prefix}_load_avg_15m").state == "1.3" - - -async def test_temperature_sensors_fail( - hass: HomeAssistant, - connect, - mock_available_temps, -) -> None: - """Test fail creating AsusWRT temperature sensors.""" - config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA, SENSORS_TEMP) - config_entry.add_to_hass(hass) - - # Only length of 3 booleans is valid. Checking the exception handling. - mock_available_temps.pop(2) - - # initial devices setup - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - # assert temperature availability exception is handled correctly - assert not hass.states.get(f"{sensor_prefix}_2_4ghz_temperature") - assert not hass.states.get(f"{sensor_prefix}_5ghz_temperature") - assert not hass.states.get(f"{sensor_prefix}_cpu_temperature") + assert hass.states.get(f"{sensor_prefix}_sensor_load_avg1").state == "1.1" + assert hass.states.get(f"{sensor_prefix}_sensor_load_avg5").state == "1.2" + assert hass.states.get(f"{sensor_prefix}_sensor_load_avg15").state == "1.3" async def test_temperature_sensors( @@ -338,7 +303,7 @@ async def test_temperature_sensors( connect, ) -> None: """Test creating a AsusWRT temperature sensors.""" - config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA, SENSORS_TEMP) + config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA, SENSORS_TEMPERATURES) config_entry.add_to_hass(hass) # initial devices setup @@ -348,9 +313,9 @@ async def test_temperature_sensors( await hass.async_block_till_done() # assert temperature sensor available - assert hass.states.get(f"{sensor_prefix}_2_4ghz_temperature").state == "40.0" - assert not hass.states.get(f"{sensor_prefix}_5ghz_temperature") - assert hass.states.get(f"{sensor_prefix}_cpu_temperature").state == "71.2" + assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.0" + assert not hass.states.get(f"{sensor_prefix}_5_0ghz") + assert hass.states.get(f"{sensor_prefix}_cpu").state == "71.2" @pytest.mark.parametrize( @@ -418,3 +383,31 @@ async def test_options_reload(hass: HomeAssistant, connect) -> None: assert setup_entry_call.called assert config_entry.state is ConfigEntryState.LOADED + + +async def test_unique_id_migration(hass: HomeAssistant, connect) -> None: + """Test AsusWRT entities unique id format migration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG_DATA, + unique_id=MAC_ADDR, + ) + config_entry.add_to_hass(hass) + + entity_reg = er.async_get(hass) + obj_entity_id = slugify(f"{HOST} Upload") + entity_reg.async_get_or_create( + sensor.DOMAIN, + DOMAIN, + f"{DOMAIN} {MAC_ADDR} Upload", + suggested_object_id=obj_entity_id, + config_entry=config_entry, + disabled_by=None, + ) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + migr_entity = entity_reg.async_get(f"{sensor.DOMAIN}.{obj_entity_id}") + assert migr_entity is not None + assert migr_entity.unique_id == slugify(f"{MAC_ADDR}_sensor_tx_bytes") diff --git a/tests/components/axis/test_diagnostics.py b/tests/components/axis/test_diagnostics.py index df5d071ddbe..a76aa40ebc8 100644 --- a/tests/components/axis/test_diagnostics.py +++ b/tests/components/axis/test_diagnostics.py @@ -38,6 +38,11 @@ async def test_entry_diagnostics( "unique_id": REDACTED, "disabled_by": None, }, + "camera_sources": { + "Image": "http://1.2.3.4:80/axis-cgi/jpg/image.cgi", + "MJPEG": "http://1.2.3.4:80/axis-cgi/mjpg/video.cgi", + "Stream": "rtsp://user:pass@1.2.3.4/axis-media/media.amp?videocodec=h264", + }, "api_discovery": [ { "id": "api-discovery", diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 3aedd6f2deb..55d995dd63c 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -1,9 +1,11 @@ """Tests for the Bluetooth integration.""" +from contextlib import contextmanager +import itertools import time from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock from bleak import BleakClient from bleak.backends.scanner import AdvertisementData, BLEDevice @@ -189,20 +191,46 @@ def inject_bluetooth_service_info( inject_advertisement(hass, device, advertisement_data) +@contextmanager def patch_all_discovered_devices(mock_discovered: list[BLEDevice]) -> None: """Mock all the discovered devices from all the scanners.""" - return patch.object( - _get_manager(), - "_async_all_discovered_addresses", - return_value={ble_device.address for ble_device in mock_discovered}, + manager = _get_manager() + original_history = {} + scanners = list( + itertools.chain( + manager._connectable_scanners, manager._non_connectable_scanners + ) ) + for scanner in scanners: + data = scanner.discovered_devices_and_advertisement_data + original_history[scanner] = data.copy() + data.clear() + if scanners: + data = scanners[0].discovered_devices_and_advertisement_data + data.clear() + data.update( + {device.address: (device, MagicMock()) for device in mock_discovered} + ) + yield + for scanner in scanners: + data = scanner.discovered_devices_and_advertisement_data + data.clear() + data.update(original_history[scanner]) +@contextmanager def patch_discovered_devices(mock_discovered: list[BLEDevice]) -> None: """Mock the combined best path to discovered devices from all the scanners.""" - return patch.object( - _get_manager(), "async_discovered_devices", return_value=mock_discovered - ) + manager = _get_manager() + original_all_history = manager._all_history + original_connectable_history = manager._connectable_history + manager._connectable_history = {} + manager._all_history = { + device.address: MagicMock(device=device) for device in mock_discovered + } + yield + manager._all_history = original_all_history + manager._connectable_history = original_connectable_history async def async_setup_with_default_adapter(hass: HomeAssistant) -> MockConfigEntry: diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 24f1039175b..21fade843f5 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -2386,6 +2386,65 @@ async def test_wrapped_instance_with_service_uuids( assert len(detected) == 2 +async def test_wrapped_instance_with_service_uuids_with_coro_callback( + hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None +) -> None: + """Test consumers can use the wrapped instance with a service_uuids list as if it was normal BleakScanner. + + Verify that coro callbacks are supported. + """ + with patch( + "homeassistant.components.bluetooth.async_get_bluetooth", return_value=[] + ): + await async_setup_with_default_adapter(hass) + + with patch.object(hass.config_entries.flow, "async_init"): + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + detected = [] + + async def _device_detected( + device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Handle a detected device.""" + detected.append((device, advertisement_data)) + + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand") + switchbot_adv = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + manufacturer_data={89: b"\xd8.\xad\xcd\r\x85"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, + ) + switchbot_adv_2 = generate_advertisement_data( + local_name="wohand", + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + manufacturer_data={89: b"\xd8.\xad\xcd\r\x84"}, + service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"H\x10c"}, + ) + empty_device = generate_ble_device("11:22:33:44:55:66", "empty") + empty_adv = generate_advertisement_data(local_name="empty") + + assert _get_manager() is not None + scanner = HaBleakScannerWrapper( + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"] + ) + scanner.register_detection_callback(_device_detected) + + inject_advertisement(hass, switchbot_device, switchbot_adv) + inject_advertisement(hass, switchbot_device, switchbot_adv_2) + + await hass.async_block_till_done() + + assert len(detected) == 2 + + # The UUIDs list we created in the wrapped scanner with should be respected + # and we should not get another callback + inject_advertisement(hass, empty_device, empty_adv) + assert len(detected) == 2 + + async def test_wrapped_instance_with_broken_callbacks( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, enable_bluetooth: None ) -> None: diff --git a/tests/components/bluetooth/test_usage.py b/tests/components/bluetooth/test_usage.py index 0edab3ce77b..12bdba66d75 100644 --- a/tests/components/bluetooth/test_usage.py +++ b/tests/components/bluetooth/test_usage.py @@ -15,7 +15,7 @@ from homeassistant.components.bluetooth.wrappers import ( ) from homeassistant.core import HomeAssistant -from . import _get_manager, generate_ble_device +from . import generate_ble_device MOCK_BLE_DEVICE = generate_ble_device( "00:00:00:00:00:00", @@ -65,12 +65,7 @@ async def test_bleak_client_reports_with_address( """Test we report when we pass an address to BleakClient.""" install_multiple_bleak_catcher() - with patch.object( - _get_manager(), - "async_ble_device_from_address", - return_value=MOCK_BLE_DEVICE, - ): - instance = bleak.BleakClient("00:00:00:00:00:00") + instance = bleak.BleakClient("00:00:00:00:00:00") assert "BleakClient with an address instead of a BLEDevice" in caplog.text @@ -92,14 +87,7 @@ async def test_bleak_retry_connector_client_reports_with_address( """Test we report when we pass an address to BleakClientWithServiceCache.""" install_multiple_bleak_catcher() - with patch.object( - _get_manager(), - "async_ble_device_from_address", - return_value=MOCK_BLE_DEVICE, - ): - instance = bleak_retry_connector.BleakClientWithServiceCache( - "00:00:00:00:00:00" - ) + instance = bleak_retry_connector.BleakClientWithServiceCache("00:00:00:00:00:00") assert "BleakClient with an address instead of a BLEDevice" in caplog.text diff --git a/tests/components/broadlink/test_device.py b/tests/components/broadlink/test_device.py index bcbc0fc9cde..b97911262ef 100644 --- a/tests/components/broadlink/test_device.py +++ b/tests/components/broadlink/test_device.py @@ -260,7 +260,7 @@ async def test_device_setup_registry( assert len(device_registry.devices) == 1 device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) assert device_entry.identifiers == {(DOMAIN, device.mac)} assert device_entry.name == device.name @@ -349,7 +349,7 @@ async def test_device_update_listener( await hass.async_block_till_done() device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) assert device_entry.name == "New Name" for entry in er.async_entries_for_device(entity_registry, device_entry.id): diff --git a/tests/components/broadlink/test_remote.py b/tests/components/broadlink/test_remote.py index 00048e09577..5665f7529d5 100644 --- a/tests/components/broadlink/test_remote.py +++ b/tests/components/broadlink/test_remote.py @@ -33,7 +33,7 @@ async def test_remote_setup_works( mock_setup = await device.setup_entry(hass) device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) remotes = [entry for entry in entries if entry.domain == Platform.REMOTE] @@ -58,7 +58,7 @@ async def test_remote_send_command( mock_setup = await device.setup_entry(hass) device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) remotes = [entry for entry in entries if entry.domain == Platform.REMOTE] @@ -87,7 +87,7 @@ async def test_remote_turn_off_turn_on( mock_setup = await device.setup_entry(hass) device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) remotes = [entry for entry in entries if entry.domain == Platform.REMOTE] diff --git a/tests/components/broadlink/test_sensors.py b/tests/components/broadlink/test_sensors.py index f1802ce51aa..e00350b7627 100644 --- a/tests/components/broadlink/test_sensors.py +++ b/tests/components/broadlink/test_sensors.py @@ -34,7 +34,7 @@ async def test_a1_sensor_setup( assert mock_api.check_sensors_raw.call_count == 1 device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] @@ -75,7 +75,7 @@ async def test_a1_sensor_update( mock_setup = await device.setup_entry(hass, mock_api=mock_api) device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] @@ -121,7 +121,7 @@ async def test_rm_pro_sensor_setup( assert mock_api.check_sensors.call_count == 1 device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] @@ -150,7 +150,7 @@ async def test_rm_pro_sensor_update( mock_setup = await device.setup_entry(hass, mock_api=mock_api) device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] @@ -186,7 +186,7 @@ async def test_rm_pro_filter_crazy_temperature( mock_setup = await device.setup_entry(hass, mock_api=mock_api) device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] @@ -220,7 +220,7 @@ async def test_rm_mini3_no_sensor( assert mock_api.check_sensors.call_count <= 1 device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] @@ -241,7 +241,7 @@ async def test_rm4_pro_hts2_sensor_setup( assert mock_api.check_sensors.call_count == 1 device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] @@ -273,7 +273,7 @@ async def test_rm4_pro_hts2_sensor_update( mock_setup = await device.setup_entry(hass, mock_api=mock_api) device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] @@ -310,7 +310,7 @@ async def test_rm4_pro_no_sensor( assert mock_api.check_sensors.call_count <= 1 device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) sensors = {entry for entry in entries if entry.domain == Platform.SENSOR} @@ -341,7 +341,7 @@ async def test_scb1e_sensor_setup( assert mock_api.get_state.call_count == 1 device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] @@ -392,7 +392,7 @@ async def test_scb1e_sensor_update( mock_setup = await device.setup_entry(hass, mock_api=mock_api) device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) sensors = [entry for entry in entries if entry.domain == Platform.SENSOR] diff --git a/tests/components/broadlink/test_switch.py b/tests/components/broadlink/test_switch.py index 35edfb977a9..93bad2db295 100644 --- a/tests/components/broadlink/test_switch.py +++ b/tests/components/broadlink/test_switch.py @@ -22,7 +22,7 @@ async def test_switch_setup_works( mock_setup = await device.setup_entry(hass) device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) switches = [entry for entry in entries if entry.domain == Platform.SWITCH] @@ -46,7 +46,7 @@ async def test_switch_turn_off_turn_on( mock_setup = await device.setup_entry(hass) device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) switches = [entry for entry in entries if entry.domain == Platform.SWITCH] @@ -82,7 +82,7 @@ async def test_slots_switch_setup_works( mock_setup = await device.setup_entry(hass) device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) switches = [entry for entry in entries if entry.domain == Platform.SWITCH] @@ -107,7 +107,7 @@ async def test_slots_switch_turn_off_turn_on( mock_setup = await device.setup_entry(hass) device_entry = device_registry.async_get_device( - {(DOMAIN, mock_setup.entry.unique_id)} + identifiers={(DOMAIN, mock_setup.entry.unique_id)} ) entries = er.async_entries_for_device(entity_registry, device_entry.id) switches = [entry for entry in entries if entry.domain == Platform.SWITCH] diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py index e05fce9df3c..42bcb9847f1 100644 --- a/tests/components/brother/test_sensor.py +++ b/tests/components/brother/test_sensor.py @@ -110,14 +110,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_yellow_toner_remaining" - state = hass.states.get("sensor.hl_l2340dw_drum_remaining_life") + state = hass.states.get("sensor.hl_l2340dw_drum_remaining_lifetime") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_drum_remaining_life") + entry = registry.async_get("sensor.hl_l2340dw_drum_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_drum_remaining_life" @@ -143,14 +143,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_drum_counter" - state = hass.states.get("sensor.hl_l2340dw_black_drum_remaining_life") + state = hass.states.get("sensor.hl_l2340dw_black_drum_remaining_lifetime") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_black_drum_remaining_life") + entry = registry.async_get("sensor.hl_l2340dw_black_drum_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_black_drum_remaining_life" @@ -176,14 +176,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_black_drum_counter" - state = hass.states.get("sensor.hl_l2340dw_cyan_drum_remaining_life") + state = hass.states.get("sensor.hl_l2340dw_cyan_drum_remaining_lifetime") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_life") + entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_cyan_drum_remaining_life" @@ -209,14 +209,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_cyan_drum_counter" - state = hass.states.get("sensor.hl_l2340dw_magenta_drum_remaining_life") + state = hass.states.get("sensor.hl_l2340dw_magenta_drum_remaining_lifetime") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_remaining_life") + entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_magenta_drum_remaining_life" @@ -242,14 +242,14 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_magenta_drum_counter" - state = hass.states.get("sensor.hl_l2340dw_yellow_drum_remaining_life") + state = hass.states.get("sensor.hl_l2340dw_yellow_drum_remaining_lifetime") assert state assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "92" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_remaining_life") + entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_yellow_drum_remaining_life" @@ -275,36 +275,36 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456789_yellow_drum_counter" - state = hass.states.get("sensor.hl_l2340dw_fuser_remaining_life") + state = hass.states.get("sensor.hl_l2340dw_fuser_remaining_lifetime") assert state assert state.attributes.get(ATTR_ICON) == "mdi:water-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "97" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_fuser_remaining_life") + entry = registry.async_get("sensor.hl_l2340dw_fuser_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_fuser_remaining_life" - state = hass.states.get("sensor.hl_l2340dw_belt_unit_remaining_life") + state = hass.states.get("sensor.hl_l2340dw_belt_unit_remaining_lifetime") assert state assert state.attributes.get(ATTR_ICON) == "mdi:current-ac" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "97" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_belt_unit_remaining_life") + entry = registry.async_get("sensor.hl_l2340dw_belt_unit_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_belt_unit_remaining_life" - state = hass.states.get("sensor.hl_l2340dw_pf_kit_1_remaining_life") + state = hass.states.get("sensor.hl_l2340dw_pf_kit_1_remaining_lifetime") assert state assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.state == "98" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - entry = registry.async_get("sensor.hl_l2340dw_pf_kit_1_remaining_life") + entry = registry.async_get("sensor.hl_l2340dw_pf_kit_1_remaining_lifetime") assert entry assert entry.unique_id == "0123456789_pf_kit_1_remaining_life" diff --git a/tests/components/bthome/test_config_flow.py b/tests/components/bthome/test_config_flow.py index ad5d5b45cbb..ee983148fd4 100644 --- a/tests/components/bthome/test_config_flow.py +++ b/tests/components/bthome/test_config_flow.py @@ -175,7 +175,7 @@ async def test_async_step_user_no_devices_found_2(hass: HomeAssistant) -> None: This variant tests with a non-BTHome device known to us. """ with patch( - "homeassistant.components.xiaomi_ble.config_flow.async_discovered_service_info", + "homeassistant.components.bthome.config_flow.async_discovered_service_info", return_value=[NOT_BTHOME_SERVICE_INFO], ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/bthome/test_device_trigger.py b/tests/components/bthome/test_device_trigger.py index 348894346bb..85169e80394 100644 --- a/tests/components/bthome/test_device_trigger.py +++ b/tests/components/bthome/test_device_trigger.py @@ -112,7 +112,7 @@ async def test_get_triggers_button(hass: HomeAssistant) -> None: assert len(events) == 1 dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device({get_device_id(mac)}) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) assert device expected_trigger = { CONF_PLATFORM: "device", @@ -148,7 +148,7 @@ async def test_get_triggers_dimmer(hass: HomeAssistant) -> None: assert len(events) == 1 dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device({get_device_id(mac)}) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) assert device expected_trigger = { CONF_PLATFORM: "device", @@ -243,7 +243,7 @@ async def test_if_fires_on_motion_detected(hass: HomeAssistant, calls) -> None: await hass.async_block_till_done() dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device({get_device_id(mac)}) + device = dev_reg.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( diff --git a/tests/components/bthome/test_logbook.py b/tests/components/bthome/test_logbook.py new file mode 100644 index 00000000000..f68197f9fe5 --- /dev/null +++ b/tests/components/bthome/test_logbook.py @@ -0,0 +1,64 @@ +"""The tests for bthome logbook.""" +from homeassistant.components.bthome.const import ( + BTHOME_BLE_EVENT, + DOMAIN, + BTHomeBleEvent, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.logbook.common import MockRow, mock_humanify + + +async def test_humanify_bthome_event(hass: HomeAssistant) -> None: + """Test humanifying bthome button presses.""" + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="A4:C1:38:8D:18:B2", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + (event1, event2) = mock_humanify( + hass, + [ + MockRow( + BTHOME_BLE_EVENT, + dict( + BTHomeBleEvent( + device_id=None, + address="A4:C1:38:8D:18:B2", + event_class="button", + event_type="long_press", + event_properties={ + "any": "thing", + }, + ) + ), + ), + MockRow( + BTHOME_BLE_EVENT, + dict( + BTHomeBleEvent( + device_id=None, + address="A4:C1:38:8D:18:B2", + event_class="button", + event_type="press", + event_properties=None, + ) + ), + ), + ], + ) + + assert event1["name"] == "BTHome A4:C1:38:8D:18:B2" + assert event1["domain"] == DOMAIN + assert event1["message"] == "button long_press: {'any': 'thing'}" + + assert event2["name"] == "BTHome A4:C1:38:8D:18:B2" + assert event2["domain"] == DOMAIN + assert event2["message"] == "button press" diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py index 81718fe277c..f8e26289691 100644 --- a/tests/components/canary/test_sensor.py +++ b/tests/components/canary/test_sensor.py @@ -88,7 +88,7 @@ async def test_sensors_pro( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == data[2] assert state.state == data[1] - device = device_registry.async_get_device({(DOMAIN, "20")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, "20")}) assert device assert device.manufacturer == MANUFACTURER assert device.name == "Dining Room" @@ -208,7 +208,7 @@ async def test_sensors_flex( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == data[2] assert state.state == data[1] - device = device_registry.async_get_device({(DOMAIN, "20")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, "20")}) assert device assert device.manufacturer == MANUFACTURER assert device.name == "Dining Room" diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 9e97f053e07..50971219f48 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -206,73 +206,19 @@ async def test_updating_to_often( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test handling updating when command already running.""" + + wait_till_event = asyncio.Event() + wait_till_event.set() called = [] class MockCommandBinarySensor(CommandBinarySensor): - """Mock entity that updates slow.""" + """Mock entity that updates.""" async def _async_update(self) -> None: - """Update slow.""" + """Update the entity.""" called.append(1) - # Add waiting time - await asyncio.sleep(1) - - with patch( - "homeassistant.components.command_line.binary_sensor.CommandBinarySensor", - side_effect=MockCommandBinarySensor, - ): - await setup.async_setup_component( - hass, - DOMAIN, - { - "command_line": [ - { - "binary_sensor": { - "name": "Test", - "command": "echo 1", - "payload_on": "1", - "payload_off": "0", - "scan_interval": 0.1, - } - } - ] - }, - ) - await hass.async_block_till_done() - - assert len(called) == 1 - assert ( - "Updating Command Line Binary Sensor Test took longer than the scheduled update interval" - not in caplog.text - ) - - async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) - await hass.async_block_till_done() - - assert len(called) == 2 - assert ( - "Updating Command Line Binary Sensor Test took longer than the scheduled update interval" - in caplog.text - ) - - await asyncio.sleep(0.2) - - -async def test_updating_manually( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test handling manual updating using homeassistant udate_entity service.""" - await setup.async_setup_component(hass, HA_DOMAIN, {}) - called = [] - - class MockCommandBinarySensor(CommandBinarySensor): - """Mock entity that updates slow.""" - - async def _async_update(self) -> None: - """Update slow.""" - called.append(1) - # Add waiting time - await asyncio.sleep(1) + # Wait till event is set + await wait_till_event.wait() with patch( "homeassistant.components.command_line.binary_sensor.CommandBinarySensor", @@ -297,7 +243,67 @@ async def test_updating_manually( ) await hass.async_block_till_done() - assert len(called) == 1 + assert called + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=15)) + wait_till_event.set() + asyncio.wait(0) + assert ( + "Updating Command Line Binary Sensor Test took longer than the scheduled update interval" + not in caplog.text + ) + + # Simulate update takes too long + wait_till_event.clear() + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) + await asyncio.sleep(0) + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) + wait_till_event.set() + + assert ( + "Updating Command Line Binary Sensor Test took longer than the scheduled update interval" + in caplog.text + ) + + +async def test_updating_manually( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling manual updating using homeassistant udate_entity service.""" + await setup.async_setup_component(hass, HA_DOMAIN, {}) + called = [] + + class MockCommandBinarySensor(CommandBinarySensor): + """Mock entity that updates.""" + + async def _async_update(self) -> None: + """Update.""" + called.append(1) + + with patch( + "homeassistant.components.command_line.binary_sensor.CommandBinarySensor", + side_effect=MockCommandBinarySensor, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 1", + "payload_on": "1", + "payload_off": "0", + "scan_interval": 10, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert called + called.clear await hass.services.async_call( HA_DOMAIN, @@ -306,6 +312,4 @@ async def test_updating_manually( blocking=True, ) await hass.async_block_till_done() - assert len(called) == 2 - - await asyncio.sleep(0.2) + assert called diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index ac0a33fc7a9..64fa2a60719 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -293,16 +293,19 @@ async def test_updating_to_often( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test handling updating when command already running.""" + called = [] + wait_till_event = asyncio.Event() + wait_till_event.set() class MockCommandCover(CommandCover): - """Mock entity that updates slow.""" + """Mock entity that updates.""" async def _async_update(self) -> None: - """Update slow.""" + """Update the entity.""" called.append(1) # Add waiting time - await asyncio.sleep(1) + await wait_till_event.wait() with patch( "homeassistant.components.command_line.cover.CommandCover", @@ -318,7 +321,7 @@ async def test_updating_to_often( "command_state": "echo 1", "value_template": "{{ value }}", "name": "Test", - "scan_interval": 0.1, + "scan_interval": 10, } } ] @@ -326,23 +329,36 @@ async def test_updating_to_often( ) await hass.async_block_till_done() - assert len(called) == 0 + assert not called + assert ( + "Updating Command Line Cover Test took longer than the scheduled update interval" + not in caplog.text + ) + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=11)) + await hass.async_block_till_done() + assert called + called.clear() + assert ( "Updating Command Line Cover Test took longer than the scheduled update interval" not in caplog.text ) - async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) - await hass.async_block_till_done() + # Simulate update takes too long + wait_till_event.clear() + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) + await asyncio.sleep(0) + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) + wait_till_event.set() - assert len(called) == 1 + # Finish processing update + await hass.async_block_till_done() + assert called assert ( "Updating Command Line Cover Test took longer than the scheduled update interval" in caplog.text ) - await asyncio.sleep(0.2) - async def test_updating_manually( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -352,13 +368,11 @@ async def test_updating_manually( called = [] class MockCommandCover(CommandCover): - """Mock entity that updates slow.""" + """Mock entity that updates.""" async def _async_update(self) -> None: - """Update slow.""" + """Update.""" called.append(1) - # Add waiting time - await asyncio.sleep(1) with patch( "homeassistant.components.command_line.cover.CommandCover", @@ -384,7 +398,8 @@ async def test_updating_manually( async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) await hass.async_block_till_done() - assert len(called) == 1 + assert called + called.clear() await hass.services.async_call( HA_DOMAIN, @@ -393,6 +408,4 @@ async def test_updating_manually( blocking=True, ) await hass.async_block_till_done() - assert len(called) == 2 - - await asyncio.sleep(0.2) + assert called diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index b837f580862..a0f8f2cdf84 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -543,71 +543,18 @@ async def test_updating_to_often( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test handling updating when command already running.""" + wait_till_event = asyncio.Event() + wait_till_event.set() called = [] class MockCommandSensor(CommandSensor): - """Mock entity that updates slow.""" + """Mock entity that updates.""" async def _async_update(self) -> None: - """Update slow.""" + """Update entity.""" called.append(1) - # Add waiting time - await asyncio.sleep(1) - - with patch( - "homeassistant.components.command_line.sensor.CommandSensor", - side_effect=MockCommandSensor, - ): - await setup.async_setup_component( - hass, - DOMAIN, - { - "command_line": [ - { - "sensor": { - "name": "Test", - "command": "echo 1", - "scan_interval": 0.1, - } - } - ] - }, - ) - await hass.async_block_till_done() - - assert len(called) == 1 - assert ( - "Updating Command Line Sensor Test took longer than the scheduled update interval" - not in caplog.text - ) - - async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) - await hass.async_block_till_done() - - assert len(called) == 2 - assert ( - "Updating Command Line Sensor Test took longer than the scheduled update interval" - in caplog.text - ) - - await asyncio.sleep(0.2) - - -async def test_updating_manually( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test handling manual updating using homeassistant udate_entity service.""" - await setup.async_setup_component(hass, HA_DOMAIN, {}) - called = [] - - class MockCommandSensor(CommandSensor): - """Mock entity that updates slow.""" - - async def _async_update(self) -> None: - """Update slow.""" - called.append(1) - # Add waiting time - await asyncio.sleep(1) + # Wait till event is set + await wait_till_event.wait() with patch( "homeassistant.components.command_line.sensor.CommandSensor", @@ -630,7 +577,66 @@ async def test_updating_manually( ) await hass.async_block_till_done() - assert len(called) == 1 + assert called + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=15)) + wait_till_event.set() + asyncio.wait(0) + + assert ( + "Updating Command Line Sensor Test took longer than the scheduled update interval" + not in caplog.text + ) + + # Simulate update takes too long + wait_till_event.clear() + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) + await asyncio.sleep(0) + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) + wait_till_event.set() + + assert ( + "Updating Command Line Sensor Test took longer than the scheduled update interval" + in caplog.text + ) + + +async def test_updating_manually( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling manual updating using homeassistant udate_entity service.""" + await setup.async_setup_component(hass, HA_DOMAIN, {}) + called = [] + + class MockCommandSensor(CommandSensor): + """Mock entity that updates.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + + with patch( + "homeassistant.components.command_line.sensor.CommandSensor", + side_effect=MockCommandSensor, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo 1", + "scan_interval": 10, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert called + called.clear() await hass.services.async_call( HA_DOMAIN, @@ -639,6 +645,4 @@ async def test_updating_manually( blocking=True, ) await hass.async_block_till_done() - assert len(called) == 2 - - await asyncio.sleep(0.2) + assert called diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index e5331fbe7dd..09e8c47d708 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -650,16 +650,19 @@ async def test_updating_to_often( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test handling updating when command already running.""" + called = [] + wait_till_event = asyncio.Event() + wait_till_event.set() class MockCommandSwitch(CommandSwitch): - """Mock entity that updates slow.""" + """Mock entity that updates.""" async def _async_update(self) -> None: - """Update slow.""" + """Update entity.""" called.append(1) - # Add waiting time - await asyncio.sleep(1) + # Wait till event is set + await wait_till_event.wait() with patch( "homeassistant.components.command_line.switch.CommandSwitch", @@ -676,7 +679,7 @@ async def test_updating_to_often( "command_on": "echo 2", "command_off": "echo 3", "name": "Test", - "scan_interval": 0.1, + "scan_interval": 10, } } ] @@ -684,23 +687,36 @@ async def test_updating_to_often( ) await hass.async_block_till_done() - assert len(called) == 0 + assert not called + assert ( + "Updating Command Line Switch Test took longer than the scheduled update interval" + not in caplog.text + ) + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=11)) + await hass.async_block_till_done() + assert called + called.clear() + assert ( "Updating Command Line Switch Test took longer than the scheduled update interval" not in caplog.text ) - async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) - await hass.async_block_till_done() + # Simulate update takes too long + wait_till_event.clear() + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) + await asyncio.sleep(0) + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) + wait_till_event.set() - assert len(called) == 1 + # Finish processing update + await hass.async_block_till_done() + assert called assert ( "Updating Command Line Switch Test took longer than the scheduled update interval" in caplog.text ) - await asyncio.sleep(0.2) - async def test_updating_manually( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -710,13 +726,11 @@ async def test_updating_manually( called = [] class MockCommandSwitch(CommandSwitch): - """Mock entity that updates slow.""" + """Mock entity that updates.""" async def _async_update(self) -> None: """Update slow.""" called.append(1) - # Add waiting time - await asyncio.sleep(1) with patch( "homeassistant.components.command_line.switch.CommandSwitch", @@ -743,7 +757,8 @@ async def test_updating_manually( async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) await hass.async_block_till_done() - assert len(called) == 1 + assert called + called.clear() await hass.services.async_call( HA_DOMAIN, @@ -752,6 +767,4 @@ async def test_updating_manually( blocking=True, ) await hass.async_block_till_done() - assert len(called) == 2 - - await asyncio.sleep(0.2) + assert called diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py index 34f807e3cc5..86ea2cf9e7f 100644 --- a/tests/components/config/test_script.py +++ b/tests/components/config/test_script.py @@ -86,6 +86,40 @@ async def test_update_script_config( assert new_data["moon"] == {"alias": "Moon updated", "sequence": []} +@pytest.mark.parametrize("script_config", ({},)) +async def test_invalid_object_id( + hass: HomeAssistant, hass_client: ClientSessionGenerator, hass_config_store +) -> None: + """Test creating a script with an invalid object_id.""" + with patch.object(config, "SECTIONS", ["script"]): + await async_setup_component(hass, "config", {}) + + assert sorted(hass.states.async_entity_ids("script")) == [] + + client = await hass_client() + + hass_config_store["scripts.yaml"] = {} + + resp = await client.post( + "/api/config/script/config/turn_on", + data=json.dumps({"alias": "Turn on", "sequence": []}), + ) + await hass.async_block_till_done() + assert sorted(hass.states.async_entity_ids("script")) == [] + + assert resp.status == HTTPStatus.BAD_REQUEST + result = await resp.json() + assert result == { + "message": ( + "Message malformed: A script's object_id must not be one of " + "reload, toggle, turn_off, turn_on" + ) + } + + new_data = hass_config_store["scripts.yaml"] + assert new_data == {} + + @pytest.mark.parametrize("script_config", ({},)) @pytest.mark.parametrize( ("updated_config", "validation_error"), diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py index df57c78c9aa..648f8f33811 100644 --- a/tests/components/conversation/__init__.py +++ b/tests/components/conversation/__init__.py @@ -24,11 +24,6 @@ class MockAgent(conversation.AbstractConversationAgent): self.response = "Test response" self._supported_languages = supported_languages - @property - def attribution(self) -> conversation.Attribution | None: - """Return the attribution.""" - return {"name": "Mock assistant", "url": "https://assist.me"} - @property def supported_languages(self) -> list[str]: """Return a list of supported languages.""" diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py index 85d5b5daa91..a08823255e9 100644 --- a/tests/components/conversation/conftest.py +++ b/tests/components/conversation/conftest.py @@ -1,8 +1,10 @@ """Conversation test helpers.""" +from unittest.mock import patch import pytest from homeassistant.components import conversation +from homeassistant.components.shopping_list import intent as sl_intent from homeassistant.const import MATCH_ALL from . import MockAgent @@ -28,3 +30,24 @@ def mock_agent_support_all(hass): agent = MockAgent(entry.entry_id, MATCH_ALL) conversation.async_set_agent(hass, entry, agent) return agent + + +@pytest.fixture(autouse=True) +def mock_shopping_list_io(): + """Stub out the persistence.""" + with patch("homeassistant.components.shopping_list.ShoppingData.save"), patch( + "homeassistant.components.shopping_list.ShoppingData.async_load" + ): + yield + + +@pytest.fixture +async def sl_setup(hass): + """Set up the shopping list.""" + + entry = MockConfigEntry(domain="shopping_list") + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + + await sl_intent.async_setup_intents(hass) diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 8ef0cef52f9..f9fe284bcb0 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -372,7 +372,7 @@ dict({ 'results': list([ dict({ - 'entities': dict({ + 'details': dict({ 'name': dict({ 'name': 'name', 'text': 'my cool light', @@ -382,6 +382,9 @@ 'intent': dict({ 'name': 'HassTurnOn', }), + 'slots': dict({ + 'name': 'my cool light', + }), 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -389,7 +392,7 @@ }), }), dict({ - 'entities': dict({ + 'details': dict({ 'name': dict({ 'name': 'name', 'text': 'my cool light', @@ -399,6 +402,9 @@ 'intent': dict({ 'name': 'HassTurnOff', }), + 'slots': dict({ + 'name': 'my cool light', + }), 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -406,7 +412,7 @@ }), }), dict({ - 'entities': dict({ + 'details': dict({ 'area': dict({ 'name': 'area', 'text': 'kitchen', @@ -421,6 +427,10 @@ 'intent': dict({ 'name': 'HassTurnOn', }), + 'slots': dict({ + 'area': 'kitchen', + 'domain': 'light', + }), 'targets': dict({ 'light.kitchen': dict({ 'matched': True, @@ -428,7 +438,7 @@ }), }), dict({ - 'entities': dict({ + 'details': dict({ 'area': dict({ 'name': 'area', 'text': 'kitchen', @@ -448,6 +458,11 @@ 'intent': dict({ 'name': 'HassGetState', }), + 'slots': dict({ + 'area': 'kitchen', + 'domain': 'light', + 'state': 'on', + }), 'targets': dict({ 'light.kitchen': dict({ 'matched': False, diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 899fd761d5e..c3c2e621260 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -246,7 +246,8 @@ async def test_trigger_sentences(hass: HomeAssistant, init_components) -> None: for sentence in test_sentences: callback.reset_mock() result = await conversation.async_converse(hass, sentence, None, Context()) - callback.assert_called_once_with(sentence) + assert callback.call_count == 1 + assert callback.call_args[0][0] == sentence assert ( result.response.response_type == intent.IntentResponseType.ACTION_DONE ), sentence @@ -265,3 +266,16 @@ async def test_trigger_sentences(hass: HomeAssistant, init_components) -> None: ), sentence assert len(callback.mock_calls) == 0 + + +async def test_shopping_list_add_item( + hass: HomeAssistant, init_components, sl_setup +) -> None: + """Test adding an item to the shopping list through the default agent.""" + result = await conversation.async_converse( + hass, "add apples to my shopping list", None, Context() + ) + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech == { + "plain": {"speech": "Added apples", "extra_data": None} + } diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 6ad9beb3362..f89af1dc201 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -1611,43 +1611,6 @@ async def test_get_agent_info( assert agent_info == snapshot -async def test_ws_get_agent_info( - hass: HomeAssistant, - init_components, - mock_agent, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test get agent info.""" - client = await hass_ws_client(hass) - - await client.send_json_auto_id({"type": "conversation/agent/info"}) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] == snapshot - - await client.send_json_auto_id( - {"type": "conversation/agent/info", "agent_id": "homeassistant"} - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] == snapshot - - await client.send_json_auto_id( - {"type": "conversation/agent/info", "agent_id": mock_agent.agent_id} - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] == snapshot - - await client.send_json_auto_id( - {"type": "conversation/agent/info", "agent_id": "not_exist"} - ) - msg = await client.receive_json() - assert not msg["success"] - assert msg["error"] == snapshot - - async def test_ws_hass_agent_debug( hass: HomeAssistant, init_components, diff --git a/tests/components/conversation/test_trigger.py b/tests/components/conversation/test_trigger.py index 522162fa457..3f4dd9e3a7e 100644 --- a/tests/components/conversation/test_trigger.py +++ b/tests/components/conversation/test_trigger.py @@ -61,6 +61,8 @@ async def test_if_fires_on_event(hass: HomeAssistant, calls, setup_comp) -> None "idx": "0", "platform": "conversation", "sentence": "Ha ha ha", + "slots": {}, + "details": {}, } @@ -103,6 +105,8 @@ async def test_same_trigger_multiple_sentences( "idx": "0", "platform": "conversation", "sentence": "hello", + "slots": {}, + "details": {}, } @@ -188,3 +192,60 @@ async def test_fails_on_punctuation(hass: HomeAssistant, command: str) -> None: }, ], ) + + +async def test_wildcards(hass: HomeAssistant, calls, setup_comp) -> None: + """Test wildcards in trigger sentences.""" + assert await async_setup_component( + hass, + "automation", + { + "automation": { + "trigger": { + "platform": "conversation", + "command": [ + "play {album} by {artist}", + ], + }, + "action": { + "service": "test.automation", + "data_template": {"data": "{{ trigger }}"}, + }, + } + }, + ) + + await hass.services.async_call( + "conversation", + "process", + { + "text": "play the white album by the beatles", + }, + blocking=True, + ) + + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["data"] == { + "alias": None, + "id": "0", + "idx": "0", + "platform": "conversation", + "sentence": "play the white album by the beatles", + "slots": { + "album": "the white album", + "artist": "the beatles", + }, + "details": { + "album": { + "name": "album", + "text": "the white album", + "value": "the white album", + }, + "artist": { + "name": "artist", + "text": "the beatles", + "value": "the beatles", + }, + }, + } diff --git a/tests/components/daikin/test_init.py b/tests/components/daikin/test_init.py index 8145a7a1e99..a6a58b4fb39 100644 --- a/tests/components/daikin/test_init.py +++ b/tests/components/daikin/test_init.py @@ -67,7 +67,7 @@ async def test_unique_id_migrate(hass: HomeAssistant, mock_daikin) -> None: assert config_entry.unique_id == HOST - assert device_registry.async_get_device({}, {(KEY_MAC, HOST)}).name is None + assert device_registry.async_get_device(connections={(KEY_MAC, HOST)}).name is None entity = entity_registry.async_get("climate.daikin_127_0_0_1") assert entity.unique_id == HOST @@ -86,7 +86,8 @@ async def test_unique_id_migrate(hass: HomeAssistant, mock_daikin) -> None: assert config_entry.unique_id == MAC assert ( - device_registry.async_get_device({}, {(KEY_MAC, MAC)}).name == "DaikinAP00000" + device_registry.async_get_device(connections={(KEY_MAC, MAC)}).name + == "DaikinAP00000" ) entity = entity_registry.async_get("climate.daikin_127_0_0_1") diff --git a/tests/components/datadog/test_init.py b/tests/components/datadog/test_init.py index c42d532b800..76956874e73 100644 --- a/tests/components/datadog/test_init.py +++ b/tests/components/datadog/test_init.py @@ -1,15 +1,13 @@ """The tests for the Datadog component.""" from unittest import mock -from unittest.mock import MagicMock, patch +from unittest.mock import patch import homeassistant.components.datadog as datadog from homeassistant.const import ( EVENT_LOGBOOK_ENTRY, - EVENT_STATE_CHANGED, STATE_OFF, STATE_ON, ) -import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -27,7 +25,6 @@ async def test_invalid_config(hass: HomeAssistant) -> None: async def test_datadog_setup_full(hass: HomeAssistant) -> None: """Test setup with all data.""" config = {datadog.DOMAIN: {"host": "host", "port": 123, "rate": 1, "prefix": "foo"}} - hass.bus.listen = MagicMock() with patch("homeassistant.components.datadog.initialize") as mock_init, patch( "homeassistant.components.datadog.statsd" @@ -37,15 +34,9 @@ async def test_datadog_setup_full(hass: HomeAssistant) -> None: assert mock_init.call_count == 1 assert mock_init.call_args == mock.call(statsd_host="host", statsd_port=123) - assert hass.bus.listen.called - assert hass.bus.listen.call_args_list[0][0][0] == EVENT_LOGBOOK_ENTRY - assert hass.bus.listen.call_args_list[1][0][0] == EVENT_STATE_CHANGED - async def test_datadog_setup_defaults(hass: HomeAssistant) -> None: """Test setup with defaults.""" - hass.bus.listen = mock.MagicMock() - with patch("homeassistant.components.datadog.initialize") as mock_init, patch( "homeassistant.components.datadog.statsd" ): @@ -63,13 +54,10 @@ async def test_datadog_setup_defaults(hass: HomeAssistant) -> None: assert mock_init.call_count == 1 assert mock_init.call_args == mock.call(statsd_host="host", statsd_port=8125) - assert hass.bus.listen.called async def test_logbook_entry(hass: HomeAssistant) -> None: """Test event listener.""" - hass.bus.listen = mock.MagicMock() - with patch("homeassistant.components.datadog.initialize"), patch( "homeassistant.components.datadog.statsd" ) as mock_statsd: @@ -79,16 +67,14 @@ async def test_logbook_entry(hass: HomeAssistant) -> None: {datadog.DOMAIN: {"host": "host", "rate": datadog.DEFAULT_RATE}}, ) - assert hass.bus.listen.called - handler_method = hass.bus.listen.call_args_list[0][0][1] - event = { "domain": "automation", "entity_id": "sensor.foo.bar", "message": "foo bar biz", "name": "triggered something", } - handler_method(mock.MagicMock(data=event)) + hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, event) + await hass.async_block_till_done() assert mock_statsd.event.call_count == 1 assert mock_statsd.event.call_args == mock.call( @@ -102,8 +88,6 @@ async def test_logbook_entry(hass: HomeAssistant) -> None: async def test_state_changed(hass: HomeAssistant) -> None: """Test event listener.""" - hass.bus.listen = mock.MagicMock() - with patch("homeassistant.components.datadog.initialize"), patch( "homeassistant.components.datadog.statsd" ) as mock_statsd: @@ -119,9 +103,6 @@ async def test_state_changed(hass: HomeAssistant) -> None: }, ) - assert hass.bus.listen.called - handler_method = hass.bus.listen.call_args_list[1][0][1] - valid = {"1": 1, "1.0": 1.0, STATE_ON: 1, STATE_OFF: 0} attributes = {"elevation": 3.2, "temperature": 5.0, "up": True, "down": False} @@ -129,12 +110,12 @@ async def test_state_changed(hass: HomeAssistant) -> None: for in_, out in valid.items(): state = mock.MagicMock( domain="sensor", - entity_id="sensor.foo.bar", + entity_id="sensor.foobar", state=in_, attributes=attributes, ) - handler_method(mock.MagicMock(data={"new_state": state})) - + hass.states.async_set(state.entity_id, state.state, state.attributes) + await hass.async_block_till_done() assert mock_statsd.gauge.call_count == 5 for attribute, value in attributes.items(): @@ -160,7 +141,6 @@ async def test_state_changed(hass: HomeAssistant) -> None: mock_statsd.gauge.reset_mock() for invalid in ("foo", "", object): - handler_method( - mock.MagicMock(data={"new_state": ha.State("domain.test", invalid, {})}) - ) + hass.states.async_set("domain.test", invalid, {}) + await hass.async_block_till_done() assert not mock_statsd.gauge.called diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index d0c51e39987..1211d4dfa46 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for deCONZ config flow.""" import asyncio +import logging from unittest.mock import patch import pydeconz @@ -42,6 +43,7 @@ async def test_flow_discovered_bridges( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test that config flow works for discovered bridges.""" + logging.getLogger("homeassistant.components.deconz").setLevel(logging.DEBUG) aioclient_mock.get( pydeconz.utils.URL_DISCOVER, json=[ @@ -142,6 +144,7 @@ async def test_flow_manual_configuration( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test that config flow works with manual configuration after no discovered bridges.""" + logging.getLogger("homeassistant.components.deconz").setLevel(logging.DEBUG) aioclient_mock.get( pydeconz.utils.URL_DISCOVER, json=[], diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index bc003c6e27b..711d0217f2d 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -37,6 +37,7 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -201,70 +202,64 @@ async def test_unsupported_methods(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert vacuum.is_on(hass, ENTITY_VACUUM_NONE) - await common.async_turn_off(hass, ENTITY_VACUUM_NONE) - assert vacuum.is_on(hass, ENTITY_VACUUM_NONE) + with pytest.raises(HomeAssistantError): + await common.async_turn_off(hass, ENTITY_VACUUM_NONE) - await common.async_stop(hass, ENTITY_VACUUM_NONE) - assert vacuum.is_on(hass, ENTITY_VACUUM_NONE) + with pytest.raises(HomeAssistantError): + await common.async_stop(hass, ENTITY_VACUUM_NONE) hass.states.async_set(ENTITY_VACUUM_NONE, STATE_OFF) await hass.async_block_till_done() assert not vacuum.is_on(hass, ENTITY_VACUUM_NONE) - await common.async_turn_on(hass, ENTITY_VACUUM_NONE) - assert not vacuum.is_on(hass, ENTITY_VACUUM_NONE) + with pytest.raises(HomeAssistantError): + await common.async_turn_on(hass, ENTITY_VACUUM_NONE) - await common.async_toggle(hass, ENTITY_VACUUM_NONE) - assert not vacuum.is_on(hass, ENTITY_VACUUM_NONE) + with pytest.raises(HomeAssistantError): + await common.async_toggle(hass, ENTITY_VACUUM_NONE) # Non supported methods: - await common.async_start_pause(hass, ENTITY_VACUUM_NONE) - assert not vacuum.is_on(hass, ENTITY_VACUUM_NONE) + with pytest.raises(HomeAssistantError): + await common.async_start_pause(hass, ENTITY_VACUUM_NONE) - await common.async_locate(hass, ENTITY_VACUUM_NONE) - state = hass.states.get(ENTITY_VACUUM_NONE) - assert state.attributes.get(ATTR_STATUS) is None + with pytest.raises(HomeAssistantError): + await common.async_locate(hass, ENTITY_VACUUM_NONE) - await common.async_return_to_base(hass, ENTITY_VACUUM_NONE) - state = hass.states.get(ENTITY_VACUUM_NONE) - assert state.attributes.get(ATTR_STATUS) is None + with pytest.raises(HomeAssistantError): + await common.async_return_to_base(hass, ENTITY_VACUUM_NONE) - await common.async_set_fan_speed(hass, FAN_SPEEDS[-1], entity_id=ENTITY_VACUUM_NONE) - state = hass.states.get(ENTITY_VACUUM_NONE) - assert state.attributes.get(ATTR_FAN_SPEED) != FAN_SPEEDS[-1] + with pytest.raises(HomeAssistantError): + await common.async_set_fan_speed( + hass, FAN_SPEEDS[-1], entity_id=ENTITY_VACUUM_NONE + ) - await common.async_clean_spot(hass, entity_id=ENTITY_VACUUM_BASIC) - state = hass.states.get(ENTITY_VACUUM_BASIC) - assert "spot" not in state.attributes.get(ATTR_STATUS) - assert state.state == STATE_OFF + with pytest.raises(HomeAssistantError): + await common.async_clean_spot(hass, entity_id=ENTITY_VACUUM_BASIC) # VacuumEntity should not support start and pause methods. hass.states.async_set(ENTITY_VACUUM_COMPLETE, STATE_ON) await hass.async_block_till_done() assert vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) - await common.async_pause(hass, ENTITY_VACUUM_COMPLETE) - assert vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) + with pytest.raises(AttributeError): + await common.async_pause(hass, ENTITY_VACUUM_COMPLETE) hass.states.async_set(ENTITY_VACUUM_COMPLETE, STATE_OFF) await hass.async_block_till_done() assert not vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) - await common.async_start(hass, ENTITY_VACUUM_COMPLETE) - assert not vacuum.is_on(hass, ENTITY_VACUUM_COMPLETE) + with pytest.raises(HomeAssistantError): + await common.async_start(hass, ENTITY_VACUUM_COMPLETE) # StateVacuumEntity does not support on/off - await common.async_turn_on(hass, entity_id=ENTITY_VACUUM_STATE) - state = hass.states.get(ENTITY_VACUUM_STATE) - assert state.state != STATE_CLEANING + with pytest.raises(HomeAssistantError): + await common.async_turn_on(hass, entity_id=ENTITY_VACUUM_STATE) - await common.async_turn_off(hass, entity_id=ENTITY_VACUUM_STATE) - state = hass.states.get(ENTITY_VACUUM_STATE) - assert state.state != STATE_RETURNING + with pytest.raises(HomeAssistantError): + await common.async_turn_off(hass, entity_id=ENTITY_VACUUM_STATE) - await common.async_toggle(hass, entity_id=ENTITY_VACUUM_STATE) - state = hass.states.get(ENTITY_VACUUM_STATE) - assert state.state != STATE_CLEANING + with pytest.raises(HomeAssistantError): + await common.async_toggle(hass, entity_id=ENTITY_VACUUM_STATE) async def test_services(hass: HomeAssistant) -> None: @@ -302,22 +297,15 @@ async def test_services(hass: HomeAssistant) -> None: async def test_set_fan_speed(hass: HomeAssistant) -> None: """Test vacuum service to set the fan speed.""" - group_vacuums = ",".join( - [ENTITY_VACUUM_BASIC, ENTITY_VACUUM_COMPLETE, ENTITY_VACUUM_STATE] - ) - old_state_basic = hass.states.get(ENTITY_VACUUM_BASIC) + group_vacuums = ",".join([ENTITY_VACUUM_COMPLETE, ENTITY_VACUUM_STATE]) old_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE) old_state_state = hass.states.get(ENTITY_VACUUM_STATE) await common.async_set_fan_speed(hass, FAN_SPEEDS[0], entity_id=group_vacuums) - new_state_basic = hass.states.get(ENTITY_VACUUM_BASIC) new_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE) new_state_state = hass.states.get(ENTITY_VACUUM_STATE) - assert old_state_basic == new_state_basic - assert ATTR_FAN_SPEED not in new_state_basic.attributes - assert old_state_complete != new_state_complete assert old_state_complete.attributes[ATTR_FAN_SPEED] == FAN_SPEEDS[1] assert new_state_complete.attributes[ATTR_FAN_SPEED] == FAN_SPEEDS[0] @@ -329,18 +317,15 @@ async def test_set_fan_speed(hass: HomeAssistant) -> None: async def test_send_command(hass: HomeAssistant) -> None: """Test vacuum service to send a command.""" - group_vacuums = ",".join([ENTITY_VACUUM_BASIC, ENTITY_VACUUM_COMPLETE]) - old_state_basic = hass.states.get(ENTITY_VACUUM_BASIC) + group_vacuums = ",".join([ENTITY_VACUUM_COMPLETE]) old_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE) await common.async_send_command( hass, "test_command", params={"p1": 3}, entity_id=group_vacuums ) - new_state_basic = hass.states.get(ENTITY_VACUUM_BASIC) new_state_complete = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert old_state_basic == new_state_basic assert old_state_complete != new_state_complete assert new_state_complete.state == STATE_ON assert ( diff --git a/tests/components/demo/test_water_heater.py b/tests/components/demo/test_water_heater.py index 9e45b4e39bf..cc91f57d872 100644 --- a/tests/components/demo/test_water_heater.py +++ b/tests/components/demo/test_water_heater.py @@ -112,3 +112,19 @@ async def test_set_only_target_temp_with_convert(hass: HomeAssistant) -> None: await common.async_set_temperature(hass, 114, ENTITY_WATER_HEATER_CELSIUS) state = hass.states.get(ENTITY_WATER_HEATER_CELSIUS) assert state.attributes.get("temperature") == 114 + + +async def test_turn_on_off(hass: HomeAssistant) -> None: + """Test turn on and off.""" + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("temperature") == 119 + assert state.attributes.get("away_mode") == "off" + assert state.attributes.get("operation_mode") == "eco" + + await common.async_turn_off(hass, ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("operation_mode") == "off" + + await common.async_turn_on(hass, ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.attributes.get("operation_mode") == "eco" diff --git a/tests/components/demo/test_weather.py b/tests/components/demo/test_weather.py index b2b789a084f..ced801a4d46 100644 --- a/tests/components/demo/test_weather.py +++ b/tests/components/demo/test_weather.py @@ -1,12 +1,13 @@ """The tests for the demo weather component.""" +import datetime +from typing import Any + +from freezegun.api import FrozenDateTimeFactory +import pytest + from homeassistant.components import weather +from homeassistant.components.demo.weather import WEATHER_UPDATE_INTERVAL from homeassistant.components.weather import ( - ATTR_FORECAST, - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_OZONE, ATTR_WEATHER_PRESSURE, @@ -19,6 +20,8 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import METRIC_SYSTEM +from tests.typing import WebSocketGenerator + async def test_attributes(hass: HomeAssistant, disable_platforms) -> None: """Test weather attributes.""" @@ -41,16 +44,120 @@ async def test_attributes(hass: HomeAssistant, disable_platforms) -> None: assert data.get(ATTR_WEATHER_WIND_BEARING) is None assert data.get(ATTR_WEATHER_OZONE) is None assert data.get(ATTR_ATTRIBUTION) == "Powered by Home Assistant" - assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_CONDITION) == "rainy" - assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_PRECIPITATION) == 1 - assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 60 - assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_TEMP) == 22 - assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_TEMP_LOW) == 15 - assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_CONDITION) == "fog" - assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_PRECIPITATION) == 0.2 - assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_TEMP) == 21 - assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_TEMP_LOW) == 12 - assert ( - data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 100 + + +TEST_TIME_ADVANCE_INTERVAL = datetime.timedelta(seconds=5 + 1) + + +@pytest.mark.parametrize( + ("forecast_type", "expected_forecast"), + [ + ( + "daily", + [ + { + "condition": "snowy", + "precipitation": 2.0, + "temperature": -23.3, + "templow": -26.1, + "precipitation_probability": 60, + }, + { + "condition": "sunny", + "precipitation": 0.0, + "temperature": -22.8, + "templow": -24.4, + "precipitation_probability": 0, + }, + ], + ), + ( + "hourly", + [ + { + "condition": "sunny", + "precipitation": 2.0, + "temperature": -23.3, + "templow": -26.1, + "precipitation_probability": 60, + }, + { + "condition": "sunny", + "precipitation": 0.0, + "temperature": -22.8, + "templow": -24.4, + "precipitation_probability": 0, + }, + ], + ), + ( + "twice_daily", + [ + { + "condition": "snowy", + "precipitation": 2.0, + "temperature": -23.3, + "templow": -26.1, + "precipitation_probability": 60, + }, + { + "condition": "sunny", + "precipitation": 0.0, + "temperature": -22.8, + "templow": -24.4, + "precipitation_probability": 0, + }, + ], + ), + ], +) +async def test_forecast( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + disable_platforms: None, + forecast_type: str, + expected_forecast: list[dict[str, Any]], +) -> None: + """Test multiple forecast.""" + assert await async_setup_component( + hass, weather.DOMAIN, {"weather": {"platform": "demo"}} ) - assert len(data.get(ATTR_FORECAST)) == 7 + hass.config.units = METRIC_SYSTEM + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": forecast_type, + "entity_id": "weather.demo_weather_north", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert len(forecast1) == 7 + for key, val in expected_forecast[0].items(): + assert forecast1[0][key] == val + for key, val in expected_forecast[1].items(): + assert forecast1[6][key] == val + + freezer.tick(WEATHER_UPDATE_INTERVAL + datetime.timedelta(seconds=1)) + await hass.async_block_till_done() + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 != forecast1 + assert len(forecast2) == 7 diff --git a/tests/components/devolo_home_network/snapshots/test_button.ambr b/tests/components/devolo_home_network/snapshots/test_button.ambr new file mode 100644 index 00000000000..a124ef57693 --- /dev/null +++ b/tests/components/devolo_home_network/snapshots/test_button.ambr @@ -0,0 +1,345 @@ +# serializer version: 1 +# name: test_button[identify_device_with_a_blinking_led-async_identify_device_start] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Identify device with a blinking LED', + 'icon': 'mdi:led-on', + }), + 'context': , + 'entity_id': 'button.mock_title_identify_device_with_a_blinking_led', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[identify_device_with_a_blinking_led-async_identify_device_start].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mock_title_identify_device_with_a_blinking_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:led-on', + 'original_name': 'Identify device with a blinking LED', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'identify', + 'unique_id': '1234567890_identify', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[identify_device_with_a_blinking_led-plcnet-async_identify_device_start] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Identify device with a blinking LED', + 'icon': 'mdi:led-on', + }), + 'context': , + 'entity_id': 'button.mock_title_identify_device_with_a_blinking_led', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[identify_device_with_a_blinking_led-plcnet-async_identify_device_start].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mock_title_identify_device_with_a_blinking_led', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:led-on', + 'original_name': 'Identify device with a blinking LED', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'identify', + 'unique_id': '1234567890_identify', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[restart_device-async_restart] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Mock Title Restart device', + }), + 'context': , + 'entity_id': 'button.mock_title_restart_device', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[restart_device-async_restart].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mock_title_restart_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart device', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'restart', + 'unique_id': '1234567890_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[restart_device-device-async_restart] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Mock Title Restart device', + }), + 'context': , + 'entity_id': 'button.mock_title_restart_device', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[restart_device-device-async_restart].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.mock_title_restart_device', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart device', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'restart', + 'unique_id': '1234567890_restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[start_plc_pairing-async_pair_device] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Start PLC pairing', + 'icon': 'mdi:plus-network-outline', + }), + 'context': , + 'entity_id': 'button.mock_title_start_plc_pairing', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[start_plc_pairing-async_pair_device].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.mock_title_start_plc_pairing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:plus-network-outline', + 'original_name': 'Start PLC pairing', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'pairing', + 'unique_id': '1234567890_pairing', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[start_plc_pairing-plcnet-async_pair_device] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Start PLC pairing', + 'icon': 'mdi:plus-network-outline', + }), + 'context': , + 'entity_id': 'button.mock_title_start_plc_pairing', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[start_plc_pairing-plcnet-async_pair_device].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.mock_title_start_plc_pairing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:plus-network-outline', + 'original_name': 'Start PLC pairing', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'pairing', + 'unique_id': '1234567890_pairing', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[start_wps-async_start_wps] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Start WPS', + 'icon': 'mdi:wifi-plus', + }), + 'context': , + 'entity_id': 'button.mock_title_start_wps', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[start_wps-async_start_wps].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.mock_title_start_wps', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi-plus', + 'original_name': 'Start WPS', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'start_wps', + 'unique_id': '1234567890_start_wps', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[start_wps-device-async_start_wps] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Start WPS', + 'icon': 'mdi:wifi-plus', + }), + 'context': , + 'entity_id': 'button.mock_title_start_wps', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[start_wps-device-async_start_wps].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.mock_title_start_wps', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi-plus', + 'original_name': 'Start WPS', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'start_wps', + 'unique_id': '1234567890_start_wps', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_network/snapshots/test_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..241313965c4 --- /dev/null +++ b/tests/components/devolo_home_network/snapshots/test_sensor.ambr @@ -0,0 +1,133 @@ +# serializer version: 1 +# name: test_sensor[connected_plc_devices-async_get_network_overview-interval2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Connected PLC devices', + 'icon': 'mdi:lan', + }), + 'context': , + 'entity_id': 'sensor.mock_title_connected_plc_devices', + 'last_changed': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[connected_plc_devices-async_get_network_overview-interval2].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_connected_plc_devices', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:lan', + 'original_name': 'Connected PLC devices', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'connected_plc_devices', + 'unique_id': '1234567890_connected_plc_devices', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[connected_wifi_clients-async_get_wifi_connected_station-interval0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Connected Wifi clients', + 'icon': 'mdi:wifi', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_connected_wifi_clients', + 'last_changed': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[connected_wifi_clients-async_get_wifi_connected_station-interval0].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_connected_wifi_clients', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi', + 'original_name': 'Connected Wifi clients', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'connected_wifi_clients', + 'unique_id': '1234567890_connected_wifi_clients', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[neighboring_wifi_networks-async_get_wifi_neighbor_access_points-interval1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Neighboring Wifi networks', + 'icon': 'mdi:wifi-marker', + }), + 'context': , + 'entity_id': 'sensor.mock_title_neighboring_wifi_networks', + 'last_changed': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[neighboring_wifi_networks-async_get_wifi_neighbor_access_points-interval1].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_neighboring_wifi_networks', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:wifi-marker', + 'original_name': 'Neighboring Wifi networks', + 'platform': 'devolo_home_network', + 'supported_features': 0, + 'translation_key': 'neighboring_wifi_networks', + 'unique_id': '1234567890_neighboring_wifi_networks', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_network/test_button.py b/tests/components/devolo_home_network/test_button.py index 69252a7c508..4b8521b5798 100644 --- a/tests/components/devolo_home_network/test_button.py +++ b/tests/components/devolo_home_network/test_button.py @@ -3,19 +3,18 @@ from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import ( DOMAIN as PLATFORM, SERVICE_PRESS, - ButtonDeviceClass, ) from homeassistant.components.devolo_home_network.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity import EntityCategory from . import configure_integration from .mock import MockDevice @@ -40,164 +39,68 @@ async def test_button_setup(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(entry.entry_id) -@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") -async def test_identify_device( - hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry -) -> None: - """Test start PLC pairing button.""" - entry = configure_integration(hass) - device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_identify_device_with_a_blinking_led" - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNKNOWN - assert ( - entity_registry.async_get(state_key).entity_category - is EntityCategory.DIAGNOSTIC - ) - - # Emulate button press - await hass.services.async_call( - PLATFORM, - SERVICE_PRESS, - {ATTR_ENTITY_ID: state_key}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state.state == "2023-01-13T12:00:00+00:00" - assert mock_device.plcnet.async_identify_device_start.call_count == 1 - - await hass.config_entries.async_unload(entry.entry_id) - - -@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") -async def test_start_plc_pairing(hass: HomeAssistant, mock_device: MockDevice) -> None: - """Test start PLC pairing button.""" - entry = configure_integration(hass) - device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_start_plc_pairing" - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNKNOWN - - # Emulate button press - await hass.services.async_call( - PLATFORM, - SERVICE_PRESS, - {ATTR_ENTITY_ID: state_key}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state.state == "2023-01-13T12:00:00+00:00" - assert mock_device.plcnet.async_pair_device.call_count == 1 - - await hass.config_entries.async_unload(entry.entry_id) - - -@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") -async def test_restart( - hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry -) -> None: - """Test restart button.""" - entry = configure_integration(hass) - device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_restart_device" - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNKNOWN - assert state.attributes["device_class"] == ButtonDeviceClass.RESTART - assert entity_registry.async_get(state_key).entity_category is EntityCategory.CONFIG - - # Emulate button press - await hass.services.async_call( - PLATFORM, - SERVICE_PRESS, - {ATTR_ENTITY_ID: state_key}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state.state == "2023-01-13T12:00:00+00:00" - assert mock_device.device.async_restart.call_count == 1 - - await hass.config_entries.async_unload(entry.entry_id) - - -@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") -async def test_start_wps(hass: HomeAssistant, mock_device: MockDevice) -> None: - """Test start WPS button.""" - entry = configure_integration(hass) - device_name = entry.title.replace(" ", "_").lower() - state_key = f"{PLATFORM}.{device_name}_start_wps" - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNKNOWN - - # Emulate button press - await hass.services.async_call( - PLATFORM, - SERVICE_PRESS, - {ATTR_ENTITY_ID: state_key}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state.state == "2023-01-13T12:00:00+00:00" - assert mock_device.device.async_start_wps.call_count == 1 - - await hass.config_entries.async_unload(entry.entry_id) - - @pytest.mark.parametrize( - ("name", "trigger_method"), + ("name", "api_name", "trigger_method"), [ - ["identify_device_with_a_blinking_led", "async_identify_device_start"], - ["start_plc_pairing", "async_pair_device"], - ["restart_device", "async_restart"], - ["start_wps", "async_start_wps"], + [ + "identify_device_with_a_blinking_led", + "plcnet", + "async_identify_device_start", + ], + [ + "start_plc_pairing", + "plcnet", + "async_pair_device", + ], + [ + "restart_device", + "device", + "async_restart", + ], + [ + "start_wps", + "device", + "async_start_wps", + ], ], ) -async def test_device_failure( +@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") +async def test_button( hass: HomeAssistant, mock_device: MockDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, name: str, + api_name: str, trigger_method: str, ) -> None: - """Test device failure.""" + """Test a button.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() state_key = f"{PLATFORM}.{device_name}_{name}" - - setattr(mock_device.device, trigger_method, AsyncMock()) - api = getattr(mock_device.device, trigger_method) - api.side_effect = DeviceUnavailable - setattr(mock_device.plcnet, trigger_method, AsyncMock()) - api = getattr(mock_device.plcnet, trigger_method) - api.side_effect = DeviceUnavailable - await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert hass.states.get(state_key) == snapshot + assert entity_registry.async_get(state_key) == snapshot + # Emulate button press + await hass.services.async_call( + PLATFORM, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state.state == "2023-01-13T12:00:00+00:00" + api = getattr(mock_device, api_name) + assert getattr(api, trigger_method).call_count == 1 + + # Emulate device failure + setattr(api, trigger_method, AsyncMock()) + getattr(api, trigger_method).side_effect = DeviceUnavailable with pytest.raises(HomeAssistantError): await hass.services.async_call( PLATFORM, @@ -228,7 +131,8 @@ async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None {ATTR_ENTITY_ID: state_key}, blocking=True, ) - await hass.async_block_till_done() + + await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index 0511544224a..dc7842e5fbd 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -1,15 +1,17 @@ """Tests for the devolo Home Network sensors.""" +from datetime import timedelta from unittest.mock import AsyncMock from devolo_plc_api.exceptions.device import DeviceUnavailable import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.devolo_home_network.const import ( LONG_UPDATE_INTERVAL, SHORT_UPDATE_INTERVAL, ) -from homeassistant.components.sensor import DOMAIN, SensorStateClass -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE, EntityCategory +from homeassistant.components.sensor import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -35,75 +37,50 @@ async def test_sensor_setup(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(entry.entry_id) -async def test_update_connected_wifi_clients( - hass: HomeAssistant, mock_device: MockDevice -) -> None: - """Test state change of a connected_wifi_clients sensor device.""" - entry = configure_integration(hass) - device_name = entry.title.replace(" ", "_").lower() - state_key = f"{DOMAIN}.{device_name}_connected_wifi_clients" - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state is not None - assert state.state == "1" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] == f"{entry.title} Connected Wifi clients" - ) - assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT - - # Emulate device failure - mock_device.device.async_get_wifi_connected_station = AsyncMock( - side_effect=DeviceUnavailable - ) - async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE - - # Emulate state change - mock_device.reset() - async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state is not None - assert state.state == "1" - - await hass.config_entries.async_unload(entry.entry_id) - - +@pytest.mark.parametrize( + ("name", "get_method", "interval"), + [ + [ + "connected_wifi_clients", + "async_get_wifi_connected_station", + SHORT_UPDATE_INTERVAL, + ], + [ + "neighboring_wifi_networks", + "async_get_wifi_neighbor_access_points", + LONG_UPDATE_INTERVAL, + ], + [ + "connected_plc_devices", + "async_get_network_overview", + LONG_UPDATE_INTERVAL, + ], + ], +) @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_update_neighboring_wifi_networks( - hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry +async def test_sensor( + hass: HomeAssistant, + mock_device: MockDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + name: str, + get_method: str, + interval: timedelta, ) -> None: - """Test state change of a neighboring_wifi_networks sensor device.""" + """Test state change of a sensor device.""" entry = configure_integration(hass) device_name = entry.title.replace(" ", "_").lower() - state_key = f"{DOMAIN}.{device_name}_neighboring_wifi_networks" + state_key = f"{DOMAIN}.{device_name}_{name}" await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == "1" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] - == f"{entry.title} Neighboring Wifi networks" - ) - assert ( - entity_registry.async_get(state_key).entity_category - is EntityCategory.DIAGNOSTIC - ) + assert hass.states.get(state_key) == snapshot + assert entity_registry.async_get(state_key) == snapshot # Emulate device failure - mock_device.device.async_get_wifi_neighbor_access_points = AsyncMock( - side_effect=DeviceUnavailable - ) - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + setattr(mock_device.device, get_method, AsyncMock(side_effect=DeviceUnavailable)) + setattr(mock_device.plcnet, get_method, AsyncMock(side_effect=DeviceUnavailable)) + async_fire_time_changed(hass, dt_util.utcnow() + interval) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -112,52 +89,7 @@ async def test_update_neighboring_wifi_networks( # Emulate state change mock_device.reset() - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state is not None - assert state.state == "1" - - await hass.config_entries.async_unload(entry.entry_id) - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_update_connected_plc_devices( - hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry -) -> None: - """Test state change of a connected_plc_devices sensor device.""" - entry = configure_integration(hass) - device_name = entry.title.replace(" ", "_").lower() - state_key = f"{DOMAIN}.{device_name}_connected_plc_devices" - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state is not None - assert state.state == "1" - assert ( - state.attributes[ATTR_FRIENDLY_NAME] == f"{entry.title} Connected PLC devices" - ) - assert ( - entity_registry.async_get(state_key).entity_category - is EntityCategory.DIAGNOSTIC - ) - - # Emulate device failure - mock_device.plcnet.async_get_network_overview = AsyncMock( - side_effect=DeviceUnavailable - ) - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) - await hass.async_block_till_done() - - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_UNAVAILABLE - - # Emulate state change - mock_device.reset() - async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + interval) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/devolo_home_network/test_switch.py b/tests/components/devolo_home_network/test_switch.py index 8b84a0a9344..00c06a6acc1 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -307,6 +307,9 @@ async def test_auth_failed( await hass.services.async_call( PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True ) + + await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 diff --git a/tests/components/discovergy/conftest.py b/tests/components/discovergy/conftest.py index 313985bd7d2..ea0fe84852f 100644 --- a/tests/components/discovergy/conftest.py +++ b/tests/components/discovergy/conftest.py @@ -14,7 +14,7 @@ from tests.components.discovergy.const import GET_METERS @pytest.fixture def mock_meters() -> Mock: """Patch libraries.""" - with patch("pydiscovergy.Discovergy.get_meters") as discovergy: + with patch("pydiscovergy.Discovergy.meters") as discovergy: discovergy.side_effect = AsyncMock(return_value=GET_METERS) yield discovergy diff --git a/tests/components/discovergy/const.py b/tests/components/discovergy/const.py index 2205a70830e..5c233d50ba8 100644 --- a/tests/components/discovergy/const.py +++ b/tests/components/discovergy/const.py @@ -1,31 +1,34 @@ """Constants for Discovergy integration tests.""" import datetime -from pydiscovergy.models import Meter, Reading +from pydiscovergy.models import Location, Meter, Reading GET_METERS = [ Meter( - meterId="f8d610b7a8cc4e73939fa33b990ded54", - serialNumber="abc123", - fullSerialNumber="abc123", + meter_id="f8d610b7a8cc4e73939fa33b990ded54", + serial_number="abc123", + full_serial_number="abc123", type="TST", - measurementType="ELECTRICITY", - loadProfileType="SLP", - location={ - "city": "Testhause", - "street": "Teststraße", - "streetNumber": "1", - "country": "Germany", + measurement_type="ELECTRICITY", + load_profile_type="SLP", + location=Location( + zip=12345, + city="Testhause", + street="Teststraße", + street_number="1", + country="Germany", + ), + additional={ + "manufacturer_id": "TST", + "printed_full_serial_number": "abc123", + "administration_number": "12345", + "scaling_factor": 1, + "current_scaling_factor": 1, + "voltage_scaling_factor": 1, + "internal_meters": 1, + "first_measurement_time": 1517569090926, + "last_measurement_time": 1678430543742, }, - manufacturerId="TST", - printedFullSerialNumber="abc123", - administrationNumber="12345", - scalingFactor=1, - currentScalingFactor=1, - voltageScalingFactor=1, - internalMeters=1, - firstMeasurementTime=1517569090926, - lastMeasurementTime=1678430543742, ), ] diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index f42a4a983fb..bc4fd2d9e9d 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -80,7 +80,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: ) with patch( - "pydiscovergy.Discovergy.get_meters", + "pydiscovergy.Discovergy.meters", side_effect=InvalidLogin, ): result2 = await hass.config_entries.flow.async_configure( @@ -101,7 +101,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) - with patch("pydiscovergy.Discovergy.get_meters", side_effect=HTTPError): + with patch("pydiscovergy.Discovergy.meters", side_effect=HTTPError): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -120,7 +120,7 @@ async def test_form_unknown_exception(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) - with patch("pydiscovergy.Discovergy.get_meters", side_effect=Exception): + with patch("pydiscovergy.Discovergy.meters", side_effect=Exception): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { diff --git a/tests/components/discovergy/test_diagnostics.py b/tests/components/discovergy/test_diagnostics.py index 1d465dda0e0..b9da2bb7e6f 100644 --- a/tests/components/discovergy/test_diagnostics.py +++ b/tests/components/discovergy/test_diagnostics.py @@ -16,8 +16,8 @@ async def test_entry_diagnostics( mock_config_entry: MockConfigEntry, ) -> None: """Test config entry diagnostics.""" - with patch("pydiscovergy.Discovergy.get_meters", return_value=GET_METERS), patch( - "pydiscovergy.Discovergy.get_last_reading", return_value=LAST_READING + with patch("pydiscovergy.Discovergy.meters", return_value=GET_METERS), patch( + "pydiscovergy.Discovergy.meter_last_reading", return_value=LAST_READING ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() @@ -43,18 +43,15 @@ async def test_entry_diagnostics( assert result["meters"] == [ { "additional": { - "administrationNumber": REDACTED, - "currentScalingFactor": 1, - "firstMeasurementTime": 1517569090926, - "fullSerialNumber": REDACTED, - "internalMeters": 1, - "lastMeasurementTime": 1678430543742, - "loadProfileType": "SLP", - "manufacturerId": "TST", - "printedFullSerialNumber": REDACTED, - "scalingFactor": 1, - "type": "TST", - "voltageScalingFactor": 1, + "administration_number": REDACTED, + "current_scaling_factor": 1, + "first_measurement_time": 1517569090926, + "internal_meters": 1, + "last_measurement_time": 1678430543742, + "manufacturer_id": "TST", + "printed_full_serial_number": REDACTED, + "scaling_factor": 1, + "voltage_scaling_factor": 1, }, "full_serial_number": REDACTED, "load_profile_type": "SLP", diff --git a/tests/components/discovery/__init__.py b/tests/components/discovery/__init__.py deleted file mode 100644 index b5744b42d6b..00000000000 --- a/tests/components/discovery/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the discovery component.""" diff --git a/tests/components/discovery/test_init.py b/tests/components/discovery/test_init.py deleted file mode 100644 index 7a9fda82511..00000000000 --- a/tests/components/discovery/test_init.py +++ /dev/null @@ -1,105 +0,0 @@ -"""The tests for the discovery component.""" -from unittest.mock import MagicMock, patch - -import pytest - -from homeassistant import config_entries -from homeassistant.bootstrap import async_setup_component -from homeassistant.components import discovery -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import HomeAssistant -from homeassistant.util.dt import utcnow - -from tests.common import async_fire_time_changed, mock_coro - -# One might consider to "mock" services, but it's easy enough to just use -# what is already available. -SERVICE = "yamaha" -SERVICE_COMPONENT = "media_player" - -SERVICE_INFO = {"key": "value"} # Can be anything - -UNKNOWN_SERVICE = "this_service_will_never_be_supported" - -BASE_CONFIG = {discovery.DOMAIN: {"ignore": [], "enable": []}} - - -@pytest.fixture(autouse=True) -def netdisco_mock(): - """Mock netdisco.""" - with patch.dict("sys.modules", {"netdisco.discovery": MagicMock()}): - yield - - -async def mock_discovery(hass, discoveries, config=BASE_CONFIG): - """Mock discoveries.""" - with patch("homeassistant.components.zeroconf.async_get_instance"), patch( - "homeassistant.components.zeroconf.async_setup", return_value=True - ), patch.object(discovery, "_discover", discoveries), patch( - "homeassistant.components.discovery.async_discover" - ) as mock_discover, patch( - "homeassistant.components.discovery.async_load_platform", - return_value=mock_coro(), - ) as mock_platform: - assert await async_setup_component(hass, "discovery", config) - await hass.async_block_till_done() - await hass.async_start() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow()) - # Work around an issue where our loop.call_soon not get caught - await hass.async_block_till_done() - await hass.async_block_till_done() - - return mock_discover, mock_platform - - -async def test_unknown_service(hass: HomeAssistant) -> None: - """Test that unknown service is ignored.""" - - def discover(netdisco, zeroconf_instance, suppress_mdns_types): - """Fake discovery.""" - return [("this_service_will_never_be_supported", {"info": "some"})] - - mock_discover, mock_platform = await mock_discovery(hass, discover) - - assert not mock_discover.called - assert not mock_platform.called - - -async def test_load_platform(hass: HomeAssistant) -> None: - """Test load a platform.""" - - def discover(netdisco, zeroconf_instance, suppress_mdns_types): - """Fake discovery.""" - return [(SERVICE, SERVICE_INFO)] - - mock_discover, mock_platform = await mock_discovery(hass, discover) - - assert not mock_discover.called - assert mock_platform.called - mock_platform.assert_called_with( - hass, SERVICE_COMPONENT, SERVICE, SERVICE_INFO, BASE_CONFIG - ) - - -async def test_discover_config_flow(hass: HomeAssistant) -> None: - """Test discovery triggering a config flow.""" - discovery_info = {"hello": "world"} - - def discover(netdisco, zeroconf_instance, suppress_mdns_types): - """Fake discovery.""" - return [("mock-service", discovery_info)] - - with patch.dict( - discovery.CONFIG_ENTRY_HANDLERS, {"mock-service": "mock-component"} - ), patch( - "homeassistant.config_entries.ConfigEntriesFlowManager.async_init" - ) as m_init: - await mock_discovery(hass, discover) - - assert len(m_init.mock_calls) == 1 - args, kwargs = m_init.mock_calls[0][1:] - assert args == ("mock-component",) - assert kwargs["context"]["source"] == config_entries.SOURCE_DISCOVERY - assert kwargs["data"] == discovery_info diff --git a/tests/components/dlink/test_init.py b/tests/components/dlink/test_init.py index c931fed78e2..dbd4cef0139 100644 --- a/tests/components/dlink/test_init.py +++ b/tests/components/dlink/test_init.py @@ -66,7 +66,7 @@ async def test_device_info( entry = hass.config_entries.async_entries(DOMAIN)[0] device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) assert device.connections == {("mac", "aa:bb:cc:dd:ee:ff")} assert device.identifiers == {(DOMAIN, entry.entry_id)} diff --git a/tests/components/dlink/test_switch.py b/tests/components/dlink/test_switch.py index 24316006b5e..845e8dfe85a 100644 --- a/tests/components/dlink/test_switch.py +++ b/tests/components/dlink/test_switch.py @@ -28,7 +28,7 @@ async def test_switch_state(hass: HomeAssistant, mocked_plug: AsyncMock) -> None await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - entity_id = "switch.mock_title_switch" + entity_id = "switch.mock_title" state = hass.states.get(entity_id) assert state.state == STATE_OFF assert state.attributes["total_consumption"] == 1040.0 @@ -62,7 +62,7 @@ async def test_switch_no_value( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get("switch.mock_title_switch") + state = hass.states.get("switch.mock_title") assert state.state == STATE_OFF assert state.attributes["total_consumption"] is None assert state.attributes["temperature"] is None diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index c3251cd31a2..43e60638ba9 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterable import dataclasses +import logging from unittest.mock import Mock, patch from async_upnp_client.client import UpnpDevice @@ -286,6 +287,9 @@ async def test_user_flow_wrong_st(hass: HomeAssistant, domain_data_mock: Mock) - async def test_ssdp_flow_success(hass: HomeAssistant) -> None: """Test that SSDP discovery with an available device works.""" + logging.getLogger("homeassistant.components.dlna_dmr.config_flow").setLevel( + logging.DEBUG + ) result = await hass.config_entries.flow.async_init( DLNA_DOMAIN, context={"source": config_entries.SOURCE_SSDP}, diff --git a/tests/components/dlna_dmr/test_media_player.py b/tests/components/dlna_dmr/test_media_player.py index e07e0b6cfcb..f8413e8f620 100644 --- a/tests/components/dlna_dmr/test_media_player.py +++ b/tests/components/dlna_dmr/test_media_player.py @@ -50,6 +50,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, + CONNECTION_UPNP, async_get as async_get_dr, ) from homeassistant.helpers.entity_component import async_update_entity @@ -347,7 +348,10 @@ async def test_setup_entry_mac_address( # Check the device registry connections for MAC address dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + device = dev_reg.async_get_device( + connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + identifiers=set(), + ) assert device is not None assert (CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) in device.connections @@ -364,7 +368,10 @@ async def test_setup_entry_no_mac_address( # Check the device registry connections does not include the MAC address dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + device = dev_reg.async_get_device( + connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + identifiers=set(), + ) assert device is not None assert (CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) not in device.connections @@ -427,7 +434,10 @@ async def test_available_device( """Test a DlnaDmrEntity with a connected DmrDevice.""" # Check hass device information is filled in dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + device = dev_reg.async_get_device( + connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + identifiers=set(), + ) assert device is not None # Device properties are set in dmr_device_mock before the entity gets constructed assert device.manufacturer == "device_manufacturer" @@ -1323,7 +1333,10 @@ async def test_unavailable_device( # Check hass device information has not been filled in yet dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + device = dev_reg.async_get_device( + connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + identifiers=set(), + ) assert device is None # Unload config entry to clean up @@ -1360,7 +1373,10 @@ async def test_become_available( # Check hass device information has not been filled in yet dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + device = dev_reg.async_get_device( + connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + identifiers=set(), + ) assert device is None # Mock device is now available. @@ -1399,7 +1415,10 @@ async def test_become_available( assert mock_state.state == MediaPlayerState.IDLE # Check hass device information is now filled in dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + device = dev_reg.async_get_device( + connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + identifiers=set(), + ) assert device is not None assert device.manufacturer == "device_manufacturer" assert device.model == "device_model_name" @@ -2231,7 +2250,10 @@ async def test_config_update_mac_address( # Check the device registry connections does not include the MAC address dev_reg = async_get_dr(hass) - device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + device = dev_reg.async_get_device( + connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + identifiers=set(), + ) assert device is not None assert (CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) not in device.connections @@ -2248,6 +2270,9 @@ async def test_config_update_mac_address( await hass.async_block_till_done() # Device registry connections should now include the MAC address - device = dev_reg.async_get_device(identifiers={(DLNA_DOMAIN, MOCK_DEVICE_UDN)}) + device = dev_reg.async_get_device( + connections={(CONNECTION_UPNP, MOCK_DEVICE_UDN)}, + identifiers=set(), + ) assert device is not None assert (CONNECTION_NETWORK_MAC, MOCK_MAC_ADDRESS) in device.connections diff --git a/tests/components/dlna_dms/test_config_flow.py b/tests/components/dlna_dms/test_config_flow.py index 1d6ac0eaf80..c8c2998458f 100644 --- a/tests/components/dlna_dms/test_config_flow.py +++ b/tests/components/dlna_dms/test_config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Iterable import dataclasses +import logging from typing import Final from unittest.mock import Mock, patch @@ -125,6 +126,9 @@ async def test_user_flow_no_devices( async def test_ssdp_flow_success(hass: HomeAssistant) -> None: """Test that SSDP discovery with an available device works.""" + logging.getLogger("homeassistant.components.dlna_dms.config_flow").setLevel( + logging.DEBUG + ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, diff --git a/tests/components/dremel_3d_printer/test_init.py b/tests/components/dremel_3d_printer/test_init.py index a77c6159927..2740b638316 100644 --- a/tests/components/dremel_3d_printer/test_init.py +++ b/tests/components/dremel_3d_printer/test_init.py @@ -80,7 +80,9 @@ async def test_device_info( await hass.config_entries.async_setup(config_entry.entry_id) assert await async_setup_component(hass, DOMAIN, {}) device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, config_entry.unique_id)}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, config_entry.unique_id)} + ) assert device.manufacturer == "Dremel" assert device.model == "3D45" diff --git a/tests/components/duotecno/__init__.py b/tests/components/duotecno/__init__.py new file mode 100644 index 00000000000..9cb20bcaec6 --- /dev/null +++ b/tests/components/duotecno/__init__.py @@ -0,0 +1 @@ +"""Tests for the duotecno integration.""" diff --git a/tests/components/duotecno/conftest.py b/tests/components/duotecno/conftest.py new file mode 100644 index 00000000000..82c3e0c7f44 --- /dev/null +++ b/tests/components/duotecno/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the duotecno tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.duotecno.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/duotecno/test_config_flow.py b/tests/components/duotecno/test_config_flow.py new file mode 100644 index 00000000000..a2dc265ae6e --- /dev/null +++ b/tests/components/duotecno/test_config_flow.py @@ -0,0 +1,89 @@ +"""Test the duotecno config flow.""" +from unittest.mock import AsyncMock, patch + +from duotecno.exceptions import InvalidPassword +import pytest + +from homeassistant import config_entries +from homeassistant.components.duotecno.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "duotecno.controller.PyDuotecno.connect", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 1234, + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "1.1.1.1" + assert result2["data"] == { + "host": "1.1.1.1", + "port": 1234, + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("test_side_effect", "test_error"), + [ + (InvalidPassword, "invalid_auth"), + (ConnectionError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_invalid(hass: HomeAssistant, test_side_effect, test_error): + """Test all side_effects on the controller.connect via parameters.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("duotecno.controller.PyDuotecno.connect", side_effect=test_side_effect): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 1234, + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": test_error} + + with patch("duotecno.controller.PyDuotecno.connect"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "port": 1234, + "password": "test-password2", + }, + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "1.1.1.1" + assert result2["data"] == { + "host": "1.1.1.1", + "port": 1234, + "password": "test-password2", + } diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py index d0bd335decc..f337c7c3e74 100644 --- a/tests/components/dynalite/test_config_flow.py +++ b/tests/components/dynalite/test_config_flow.py @@ -6,7 +6,7 @@ import pytest from homeassistant import config_entries from homeassistant.components import dynalite from homeassistant.const import CONF_PORT -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers.issue_registry import ( IssueSeverity, async_get as async_get_issue_registry, @@ -52,8 +52,11 @@ async def test_flow( assert result["result"].state == exp_result if exp_reason: assert result["reason"] == exp_reason - issue = registry.async_get_issue(dynalite.DOMAIN, "deprecated_yaml") + issue = registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{dynalite.DOMAIN}" + ) assert issue is not None + assert issue.issue_domain == dynalite.DOMAIN assert issue.severity == IssueSeverity.WARNING diff --git a/tests/components/efergy/test_init.py b/tests/components/efergy/test_init.py index 723fd0d6332..e82d6615923 100644 --- a/tests/components/efergy/test_init.py +++ b/tests/components/efergy/test_init.py @@ -53,7 +53,7 @@ async def test_device_info( entry = await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN) device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) assert device.configuration_url == "https://engage.efergy.com/user/login" assert device.connections == {("mac", "ff:ff:ff:ff:ff:ff")} diff --git a/tests/components/electric_kiwi/__init__.py b/tests/components/electric_kiwi/__init__.py new file mode 100644 index 00000000000..7f5e08a56b5 --- /dev/null +++ b/tests/components/electric_kiwi/__init__.py @@ -0,0 +1 @@ +"""Tests for the Electric Kiwi integration.""" diff --git a/tests/components/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py new file mode 100644 index 00000000000..525f5742382 --- /dev/null +++ b/tests/components/electric_kiwi/conftest.py @@ -0,0 +1,63 @@ +"""Define fixtures for electric kiwi tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.electric_kiwi.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +REDIRECT_URI = "https://example.com/auth/external/callback" + + +@pytest.fixture(autouse=True) +async def request_setup(current_request_with_host) -> None: + """Request setup.""" + return + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked config entry.""" + entry = MockConfigEntry( + title="Electric Kiwi", + domain=DOMAIN, + data={ + "id": "mock_user", + "auth_implementation": DOMAIN, + }, + unique_id=DOMAIN, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.electric_kiwi.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/electric_kiwi/test_config_flow.py b/tests/components/electric_kiwi/test_config_flow.py new file mode 100644 index 00000000000..51d00722341 --- /dev/null +++ b/tests/components/electric_kiwi/test_config_flow.py @@ -0,0 +1,187 @@ +"""Test the Electric Kiwi config flow.""" +from __future__ import annotations + +from http import HTTPStatus +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.electric_kiwi.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + SCOPE_VALUES, +) +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from .conftest import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: + """Test config flow base case with no credentials registered.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "missing_credentials" + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + setup_credentials, + mock_setup_entry: AsyncMock, +) -> None: + """Check full flow.""" + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + URL_SCOPE = SCOPE_VALUES.replace(" ", "+") + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URI}" + f"&state={state}" + f"&scope={URL_SCOPE}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_existing_entry( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + setup_credentials: None, + config_entry: MockConfigEntry, +) -> None: + """Check existing entry.""" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER, "entry_id": DOMAIN} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": OAUTH2_AUTHORIZE, + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "access_token": "mock-access-token", + "token_type": "bearer", + "expires_in": 3599, + "refresh_token": "mock-refresh_token", + }, + ) + + await hass.config_entries.flow.async_configure(result["flow_id"]) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_reauthentication( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + aioclient_mock: AiohttpClientMocker, + mock_setup_entry: MagicMock, + config_entry: MockConfigEntry, + setup_credentials: None, +) -> None: + """Test Electric Kiwi reauthentication.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH, "entry_id": DOMAIN} + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {}) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URI, + }, + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == HTTPStatus.OK + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "access_token": "mock-access-token", + "token_type": "bearer", + "expires_in": 3599, + "refresh_token": "mock-refresh_token", + }, + ) + + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index ffd87691b38..f373c2fdb17 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -15,18 +15,19 @@ from aioesphomeapi import ( ReconnectLogic, UserService, ) +import async_timeout import pytest from zeroconf import Zeroconf from homeassistant.components.esphome import ( - CONF_DEVICE_NAME, - CONF_NOISE_PSK, - DOMAIN, dashboard, ) from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_DEVICE_NAME, + CONF_NOISE_PSK, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + DOMAIN, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant @@ -53,6 +54,11 @@ async def load_homeassistant(hass) -> None: assert await async_setup_component(hass, "homeassistant", {}) +@pytest.fixture(autouse=True) +def mock_tts(mock_tts_cache_dir): + """Auto mock the tts cache.""" + + @pytest.fixture def mock_config_entry(hass) -> MockConfigEntry: """Return the default mocked config entry.""" @@ -154,6 +160,7 @@ class MockESPHomeDevice: self.entry = entry self.state_callback: Callable[[EntityState], None] self.on_disconnect: Callable[[bool], None] + self.on_connect: Callable[[bool], None] def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None: """Set the state callback.""" @@ -171,6 +178,14 @@ class MockESPHomeDevice: """Mock disconnecting.""" await self.on_disconnect(expected_disconnect) + def set_on_connect(self, on_connect: Callable[[], None]) -> None: + """Set the connect callback.""" + self.on_connect = on_connect + + async def mock_connect(self) -> None: + """Mock connecting.""" + await self.on_connect() + async def _mock_generic_device_entry( hass: HomeAssistant, @@ -196,13 +211,13 @@ async def _mock_generic_device_entry( mock_device = MockESPHomeDevice(entry) - device_info = DeviceInfo( - name="test", - friendly_name="Test", - mac_address="11:22:33:44:55:aa", - esphome_version="1.0.0", - **mock_device_info, - ) + default_device_info = { + "name": "test", + "friendly_name": "Test", + "esphome_version": "1.0.0", + "mac_address": "11:22:33:44:55:aa", + } + device_info = DeviceInfo(**(default_device_info | mock_device_info)) async def _subscribe_states(callback: Callable[[EntityState], None]) -> None: """Subscribe to state.""" @@ -226,6 +241,7 @@ async def _mock_generic_device_entry( """Init the mock.""" super().__init__(*args, **kwargs) mock_device.set_on_disconnect(kwargs["on_disconnect"]) + mock_device.set_on_connect(kwargs["on_connect"]) self._try_connect = self.mock_try_connect async def mock_try_connect(self): @@ -234,12 +250,14 @@ async def _mock_generic_device_entry( try_connect_done.set() return result - with patch("homeassistant.components.esphome.ReconnectLogic", MockReconnectLogic): + with patch( + "homeassistant.components.esphome.manager.ReconnectLogic", MockReconnectLogic + ): assert await hass.config_entries.async_setup(entry.entry_id) - await try_connect_done.wait() + async with async_timeout.timeout(2): + await try_connect_done.wait() await hass.async_block_till_done() - return mock_device @@ -311,9 +329,15 @@ async def mock_esphome_device( user_service: list[UserService], states: list[EntityState], entry: MockConfigEntry | None = None, + device_info: dict[str, Any] | None = None, ) -> MockESPHomeDevice: return await _mock_generic_device_entry( - hass, mock_client, {}, (entity_info, user_service), states, entry + hass, + mock_client, + device_info or {}, + (entity_info, user_service), + states, + entry, ) return _mock_device diff --git a/tests/components/esphome/test_alarm_control_panel.py b/tests/components/esphome/test_alarm_control_panel.py index 90d7bde5215..5a99f403394 100644 --- a/tests/components/esphome/test_alarm_control_panel.py +++ b/tests/components/esphome/test_alarm_control_panel.py @@ -61,7 +61,7 @@ async def test_generic_alarm_control_panel_requires_code( user_service=user_service, states=states, ) - state = hass.states.get("alarm_control_panel.test_my_alarm_control_panel") + state = hass.states.get("alarm_control_panel.test_myalarm_control_panel") assert state is not None assert state.state == STATE_ALARM_ARMED_AWAY @@ -69,7 +69,7 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_AWAY, { - ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", ATTR_CODE: 1234, }, blocking=True, @@ -83,7 +83,7 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_CUSTOM_BYPASS, { - ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", ATTR_CODE: 1234, }, blocking=True, @@ -97,7 +97,7 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_HOME, { - ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", ATTR_CODE: 1234, }, blocking=True, @@ -111,7 +111,7 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_NIGHT, { - ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", ATTR_CODE: 1234, }, blocking=True, @@ -125,7 +125,7 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_VACATION, { - ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", ATTR_CODE: 1234, }, blocking=True, @@ -139,7 +139,7 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_TRIGGER, { - ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", ATTR_CODE: 1234, }, blocking=True, @@ -153,7 +153,7 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_DISARM, { - ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", ATTR_CODE: 1234, }, blocking=True, @@ -196,14 +196,14 @@ async def test_generic_alarm_control_panel_no_code( user_service=user_service, states=states, ) - state = hass.states.get("alarm_control_panel.test_my_alarm_control_panel") + state = hass.states.get("alarm_control_panel.test_myalarm_control_panel") assert state is not None assert state.state == STATE_ALARM_ARMED_AWAY await hass.services.async_call( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_DISARM, - {ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel"}, + {ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel"}, blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( @@ -242,6 +242,6 @@ async def test_generic_alarm_control_panel_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("alarm_control_panel.test_my_alarm_control_panel") + state = hass.states.get("alarm_control_panel.test_myalarm_control_panel") assert state is not None assert state.state == STATE_UNKNOWN diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index 231bd51c0a3..209ea344328 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -73,7 +73,7 @@ async def test_binary_sensor_generic_entity( user_service=user_service, states=states, ) - state = hass.states.get("binary_sensor.test_my_binary_sensor") + state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == hass_state @@ -104,7 +104,7 @@ async def test_status_binary_sensor( user_service=user_service, states=states, ) - state = hass.states.get("binary_sensor.test_my_binary_sensor") + state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_ON @@ -134,7 +134,7 @@ async def test_binary_sensor_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("binary_sensor.test_my_binary_sensor") + state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_UNKNOWN @@ -164,12 +164,12 @@ async def test_binary_sensor_has_state_false( user_service=user_service, states=states, ) - state = hass.states.get("binary_sensor.test_my_binary_sensor") + state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_UNKNOWN mock_device.set_state(BinarySensorState(key=1, state=True, missing_state=False)) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_my_binary_sensor") + state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_ON diff --git a/tests/components/esphome/test_button.py b/tests/components/esphome/test_button.py index c0e7db14998..f33026800e7 100644 --- a/tests/components/esphome/test_button.py +++ b/tests/components/esphome/test_button.py @@ -33,22 +33,22 @@ async def test_button_generic_entity( user_service=user_service, states=states, ) - state = hass.states.get("button.test_my_button") + state = hass.states.get("button.test_mybutton") assert state is not None assert state.state == STATE_UNKNOWN await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.test_my_button"}, + {ATTR_ENTITY_ID: "button.test_mybutton"}, blocking=True, ) mock_client.button_command.assert_has_calls([call(1)]) - state = hass.states.get("button.test_my_button") + state = hass.states.get("button.test_mybutton") assert state is not None assert state.state != STATE_UNKNOWN await mock_device.mock_disconnect(False) - state = hass.states.get("button.test_my_button") + state = hass.states.get("button.test_mybutton") assert state is not None assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/esphome/test_camera.py b/tests/components/esphome/test_camera.py new file mode 100644 index 00000000000..f9a25d6b5f2 --- /dev/null +++ b/tests/components/esphome/test_camera.py @@ -0,0 +1,313 @@ +"""Test ESPHome cameras.""" +from collections.abc import Awaitable, Callable + +from aioesphomeapi import ( + APIClient, + CameraInfo, + CameraState, + EntityInfo, + EntityState, + UserService, +) + +from homeassistant.components.camera import ( + STATE_IDLE, +) +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from .conftest import MockESPHomeDevice + +from tests.typing import ClientSessionGenerator + +SMALLEST_VALID_JPEG = ( + "ffd8ffe000104a46494600010101004800480000ffdb00430003020202020203020202030303030406040404040408060" + "6050609080a0a090809090a0c0f0c0a0b0e0b09090d110d0e0f101011100a0c12131210130f101010ffc9000b08000100" + "0101011100ffcc000600101005ffda0008010100003f00d2cf20ffd9" +) +SMALLEST_VALID_JPEG_BYTES = bytes.fromhex(SMALLEST_VALID_JPEG) + + +async def test_camera_single_image( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + hass_client: ClientSessionGenerator, +) -> None: + """Test a generic camera single image request.""" + entity_info = [ + CameraInfo( + object_id="mycamera", + key=1, + name="my camera", + unique_id="my_camera", + ) + ] + states = [] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("camera.test_mycamera") + assert state is not None + assert state.state == STATE_IDLE + + async def _mock_camera_image(): + mock_device.set_state(CameraState(key=1, data=SMALLEST_VALID_JPEG_BYTES)) + + mock_client.request_single_image = _mock_camera_image + + client = await hass_client() + resp = await client.get("/api/camera_proxy/camera.test_mycamera") + await hass.async_block_till_done() + state = hass.states.get("camera.test_mycamera") + assert state is not None + assert state.state == STATE_IDLE + + assert resp.status == 200 + assert resp.content_type == "image/jpeg" + assert resp.content_length == len(SMALLEST_VALID_JPEG_BYTES) + assert await resp.read() == SMALLEST_VALID_JPEG_BYTES + + +async def test_camera_single_image_unavailable_before_requested( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + hass_client: ClientSessionGenerator, +) -> None: + """Test a generic camera that goes unavailable before the request.""" + entity_info = [ + CameraInfo( + object_id="mycamera", + key=1, + name="my camera", + unique_id="my_camera", + ) + ] + states = [] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("camera.test_mycamera") + assert state is not None + assert state.state == STATE_IDLE + await mock_device.mock_disconnect(False) + + client = await hass_client() + resp = await client.get("/api/camera_proxy/camera.test_mycamera") + await hass.async_block_till_done() + state = hass.states.get("camera.test_mycamera") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + assert resp.status == 500 + + +async def test_camera_single_image_unavailable_during_request( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + hass_client: ClientSessionGenerator, +) -> None: + """Test a generic camera that goes unavailable before the request.""" + entity_info = [ + CameraInfo( + object_id="mycamera", + key=1, + name="my camera", + unique_id="my_camera", + ) + ] + states = [] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("camera.test_mycamera") + assert state is not None + assert state.state == STATE_IDLE + + async def _mock_camera_image(): + await mock_device.mock_disconnect(False) + + mock_client.request_single_image = _mock_camera_image + + client = await hass_client() + resp = await client.get("/api/camera_proxy/camera.test_mycamera") + await hass.async_block_till_done() + state = hass.states.get("camera.test_mycamera") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + assert resp.status == 500 + + +async def test_camera_stream( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + hass_client: ClientSessionGenerator, +) -> None: + """Test a generic camera stream.""" + entity_info = [ + CameraInfo( + object_id="mycamera", + key=1, + name="my camera", + unique_id="my_camera", + ) + ] + states = [] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("camera.test_mycamera") + assert state is not None + assert state.state == STATE_IDLE + remaining_responses = 3 + + async def _mock_camera_image(): + nonlocal remaining_responses + if remaining_responses == 0: + return + remaining_responses -= 1 + mock_device.set_state(CameraState(key=1, data=SMALLEST_VALID_JPEG_BYTES)) + + mock_client.request_image_stream = _mock_camera_image + mock_client.request_single_image = _mock_camera_image + + client = await hass_client() + resp = await client.get("/api/camera_proxy_stream/camera.test_mycamera") + await hass.async_block_till_done() + state = hass.states.get("camera.test_mycamera") + assert state is not None + assert state.state == STATE_IDLE + + assert resp.status == 200 + assert resp.content_type == "multipart/x-mixed-replace" + assert resp.content_length is None + raw_stream = b"" + async for data in resp.content.iter_any(): + raw_stream += data + if len(raw_stream) > 300: + break + + assert b"image/jpeg" in raw_stream + + +async def test_camera_stream_unavailable( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + hass_client: ClientSessionGenerator, +) -> None: + """Test a generic camera stream when the device is disconnected.""" + entity_info = [ + CameraInfo( + object_id="mycamera", + key=1, + name="my camera", + unique_id="my_camera", + ) + ] + states = [] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("camera.test_mycamera") + assert state is not None + assert state.state == STATE_IDLE + + await mock_device.mock_disconnect(False) + + client = await hass_client() + await client.get("/api/camera_proxy_stream/camera.test_mycamera") + await hass.async_block_till_done() + state = hass.states.get("camera.test_mycamera") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_camera_stream_with_disconnection( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + hass_client: ClientSessionGenerator, +) -> None: + """Test a generic camera stream that goes unavailable during the request.""" + entity_info = [ + CameraInfo( + object_id="mycamera", + key=1, + name="my camera", + unique_id="my_camera", + ) + ] + states = [] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("camera.test_mycamera") + assert state is not None + assert state.state == STATE_IDLE + remaining_responses = 3 + + async def _mock_camera_image(): + nonlocal remaining_responses + if remaining_responses == 0: + return + if remaining_responses == 2: + await mock_device.mock_disconnect(False) + remaining_responses -= 1 + mock_device.set_state(CameraState(key=1, data=SMALLEST_VALID_JPEG_BYTES)) + + mock_client.request_image_stream = _mock_camera_image + mock_client.request_single_image = _mock_camera_image + + client = await hass_client() + await client.get("/api/camera_proxy_stream/camera.test_mycamera") + await hass.async_block_till_done() + state = hass.states.get("camera.test_mycamera") + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 59072dc2e59..7e00fd22a1c 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -71,14 +71,14 @@ async def test_climate_entity( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_my_climate") + state = hass.states.get("climate.test_myclimate") assert state is not None assert state.state == HVACMode.COOL await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, + {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_TEMPERATURE: 25}, blocking=True, ) mock_client.climate_command.assert_has_calls([call(key=1, target_temperature=25.0)]) @@ -123,14 +123,14 @@ async def test_climate_entity_with_step_and_two_point( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_my_climate") + state = hass.states.get("climate.test_myclimate") assert state is not None assert state.state == HVACMode.COOL await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, + {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_TEMPERATURE: 25}, blocking=True, ) mock_client.climate_command.assert_has_calls([call(key=1, target_temperature=25.0)]) @@ -140,7 +140,7 @@ async def test_climate_entity_with_step_and_two_point( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.test_my_climate", + ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_HVAC_MODE: HVACMode.AUTO, ATTR_TARGET_TEMP_LOW: 20, ATTR_TARGET_TEMP_HIGH: 30, @@ -202,14 +202,14 @@ async def test_climate_entity_with_step_and_target_temp( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_my_climate") + state = hass.states.get("climate.test_myclimate") assert state is not None assert state.state == HVACMode.COOL await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, + {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_TEMPERATURE: 25}, blocking=True, ) mock_client.climate_command.assert_has_calls([call(key=1, target_temperature=25.0)]) @@ -219,7 +219,7 @@ async def test_climate_entity_with_step_and_target_temp( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.test_my_climate", + ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_HVAC_MODE: HVACMode.AUTO, ATTR_TARGET_TEMP_LOW: 20, ATTR_TARGET_TEMP_HIGH: 30, @@ -242,7 +242,7 @@ async def test_climate_entity_with_step_and_target_temp( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - ATTR_ENTITY_ID: "climate.test_my_climate", + ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_HVAC_MODE: HVACMode.HEAT, }, blocking=True, @@ -260,7 +260,7 @@ async def test_climate_entity_with_step_and_target_temp( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_PRESET_MODE: "away"}, + {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_PRESET_MODE: "away"}, blocking=True, ) mock_client.climate_command.assert_has_calls( @@ -276,7 +276,7 @@ async def test_climate_entity_with_step_and_target_temp( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_PRESET_MODE: "preset1"}, + {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_PRESET_MODE: "preset1"}, blocking=True, ) mock_client.climate_command.assert_has_calls([call(key=1, custom_preset="preset1")]) @@ -285,7 +285,7 @@ async def test_climate_entity_with_step_and_target_temp( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_FAN_MODE: FAN_HIGH}, + {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_FAN_MODE: FAN_HIGH}, blocking=True, ) mock_client.climate_command.assert_has_calls( @@ -296,7 +296,7 @@ async def test_climate_entity_with_step_and_target_temp( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_FAN_MODE: "fan2"}, + {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_FAN_MODE: "fan2"}, blocking=True, ) mock_client.climate_command.assert_has_calls([call(key=1, custom_fan_mode="fan2")]) @@ -305,7 +305,7 @@ async def test_climate_entity_with_step_and_target_temp( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_SWING_MODE: SWING_BOTH}, + {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_SWING_MODE: SWING_BOTH}, blocking=True, ) mock_client.climate_command.assert_has_calls( diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 86472a8aa57..fc37e1e51ee 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -18,15 +18,15 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp, zeroconf from homeassistant.components.esphome import ( - CONF_DEVICE_NAME, - CONF_NOISE_PSK, - DOMAIN, DomainData, dashboard, ) from homeassistant.components.esphome.const import ( CONF_ALLOW_SERVICE_CALLS, + CONF_DEVICE_NAME, + CONF_NOISE_PSK, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, + DOMAIN, ) from homeassistant.components.hassio import HassioServiceInfo from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT @@ -1233,6 +1233,72 @@ async def test_zeroconf_encryption_key_via_dashboard( assert mock_client.noise_psk == VALID_NOISE_PSK +async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( + hass: HomeAssistant, + mock_client, + mock_zeroconf: None, + mock_dashboard, + mock_setup_entry: None, +) -> None: + """Test encryption key retrieved from dashboard with api_encryption property set.""" + service_info = zeroconf.ZeroconfServiceInfo( + host="192.168.43.183", + addresses=["192.168.43.183"], + hostname="test8266.local.", + name="mock_name", + port=6053, + properties={ + "mac": "1122334455aa", + "api_encryption": "any", + }, + type="mock_type", + ) + flow = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info + ) + + assert flow["type"] == FlowResultType.FORM + assert flow["step_id"] == "discovery_confirm" + + mock_dashboard["configured"].append( + { + "name": "test8266", + "configuration": "test8266.yaml", + } + ) + + await dashboard.async_get_dashboard(hass).async_refresh() + + mock_client.device_info.side_effect = [ + DeviceInfo( + uses_password=False, + name="test8266", + mac_address="11:22:33:44:55:AA", + ), + ] + + with patch( + "homeassistant.components.esphome.dashboard.ESPHomeDashboardAPI.get_encryption_key", + return_value=VALID_NOISE_PSK, + ) as mock_get_encryption_key: + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], user_input={} + ) + + assert len(mock_get_encryption_key.mock_calls) == 1 + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test8266" + assert result["data"][CONF_HOST] == "192.168.43.183" + assert result["data"][CONF_PORT] == 6053 + assert result["data"][CONF_NOISE_PSK] == VALID_NOISE_PSK + + assert result["result"] + assert result["result"].unique_id == "11:22:33:44:55:aa" + + assert mock_client.noise_psk == VALID_NOISE_PSK + + async def test_zeroconf_no_encryption_key_via_dashboard( hass: HomeAssistant, mock_client, diff --git a/tests/components/esphome/test_cover.py b/tests/components/esphome/test_cover.py index 59eadb3cfd9..b190d287198 100644 --- a/tests/components/esphome/test_cover.py +++ b/tests/components/esphome/test_cover.py @@ -72,7 +72,7 @@ async def test_cover_entity( user_service=user_service, states=states, ) - state = hass.states.get("cover.test_my_cover") + state = hass.states.get("cover.test_mycover") assert state is not None assert state.state == STATE_OPENING assert state.attributes[ATTR_CURRENT_POSITION] == 50 @@ -81,7 +81,7 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.test_my_cover"}, + {ATTR_ENTITY_ID: "cover.test_mycover"}, blocking=True, ) mock_client.cover_command.assert_has_calls([call(key=1, position=0.0)]) @@ -90,7 +90,7 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.test_my_cover"}, + {ATTR_ENTITY_ID: "cover.test_mycover"}, blocking=True, ) mock_client.cover_command.assert_has_calls([call(key=1, position=1.0)]) @@ -99,7 +99,7 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test_my_cover", ATTR_POSITION: 50}, + {ATTR_ENTITY_ID: "cover.test_mycover", ATTR_POSITION: 50}, blocking=True, ) mock_client.cover_command.assert_has_calls([call(key=1, position=0.5)]) @@ -108,7 +108,7 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: "cover.test_my_cover"}, + {ATTR_ENTITY_ID: "cover.test_mycover"}, blocking=True, ) mock_client.cover_command.assert_has_calls([call(key=1, stop=True)]) @@ -117,7 +117,7 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER_TILT, - {ATTR_ENTITY_ID: "cover.test_my_cover"}, + {ATTR_ENTITY_ID: "cover.test_mycover"}, blocking=True, ) mock_client.cover_command.assert_has_calls([call(key=1, tilt=1.0)]) @@ -126,7 +126,7 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER_TILT, - {ATTR_ENTITY_ID: "cover.test_my_cover"}, + {ATTR_ENTITY_ID: "cover.test_mycover"}, blocking=True, ) mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.0)]) @@ -135,7 +135,7 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: "cover.test_my_cover", ATTR_TILT_POSITION: 50}, + {ATTR_ENTITY_ID: "cover.test_mycover", ATTR_TILT_POSITION: 50}, blocking=True, ) mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.5)]) @@ -145,7 +145,7 @@ async def test_cover_entity( CoverState(key=1, position=0.0, current_operation=CoverOperation.IDLE) ) await hass.async_block_till_done() - state = hass.states.get("cover.test_my_cover") + state = hass.states.get("cover.test_mycover") assert state is not None assert state.state == STATE_CLOSED @@ -153,7 +153,7 @@ async def test_cover_entity( CoverState(key=1, position=0.5, current_operation=CoverOperation.IS_CLOSING) ) await hass.async_block_till_done() - state = hass.states.get("cover.test_my_cover") + state = hass.states.get("cover.test_mycover") assert state is not None assert state.state == STATE_CLOSING @@ -161,7 +161,7 @@ async def test_cover_entity( CoverState(key=1, position=1.0, current_operation=CoverOperation.IDLE) ) await hass.async_block_till_done() - state = hass.states.get("cover.test_my_cover") + state = hass.states.get("cover.test_mycover") assert state is not None assert state.state == STATE_OPEN @@ -201,7 +201,7 @@ async def test_cover_entity_without_position( user_service=user_service, states=states, ) - state = hass.states.get("cover.test_my_cover") + state = hass.states.get("cover.test_mycover") assert state is not None assert state.state == STATE_OPENING assert ATTR_CURRENT_TILT_POSITION not in state.attributes diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index c1df7c024cd..a77fd9b0087 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -1,7 +1,10 @@ """Tests for the diagnostics data provided by the ESPHome integration.""" -from homeassistant.components.esphome import CONF_DEVICE_NAME, CONF_NOISE_PSK +from homeassistant.components.esphome.const import ( + CONF_DEVICE_NAME, + CONF_NOISE_PSK, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 39bfec852e7..ac121a93eff 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -11,7 +11,7 @@ from aioesphomeapi import ( UserService, ) -from homeassistant.const import ATTR_RESTORED, STATE_ON +from homeassistant.const import ATTR_RESTORED, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from .conftest import MockESPHomeDevice @@ -55,10 +55,10 @@ async def test_entities_removed( entry = mock_device.entry entry_id = entry.entry_id storage_key = f"esphome.{entry_id}" - state = hass.states.get("binary_sensor.test_my_binary_sensor") + state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_ON - state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") assert state is not None assert state.state == STATE_ON @@ -67,10 +67,10 @@ async def test_entities_removed( assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2 - state = hass.states.get("binary_sensor.test_my_binary_sensor") + state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.attributes[ATTR_RESTORED] is True - state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") assert state is not None assert state.attributes[ATTR_RESTORED] is True @@ -93,11 +93,129 @@ async def test_entities_removed( entry=entry, ) assert mock_device.entry.entry_id == entry_id - state = hass.states.get("binary_sensor.test_my_binary_sensor") + state = hass.states.get("binary_sensor.test_mybinary_sensor") assert state is not None assert state.state == STATE_ON - state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") assert state is None await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 + + +async def test_entity_info_object_ids( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test how object ids affect entity id.""" + entity_info = [ + BinarySensorInfo( + object_id="object_id_is_used", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + ) + ] + states = [] + user_service = [] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("binary_sensor.test_object_id_is_used") + assert state is not None + + +async def test_deep_sleep_device( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a deep sleep device.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + BinarySensorState(key=2, state=True, missing_state=False), + ] + user_service = [] + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"has_deep_sleep": True}, + ) + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + + await mock_device.mock_disconnect(False) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + await mock_device.mock_connect() + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + + await mock_device.mock_disconnect(True) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test_mybinary_sensor") + assert state is not None + assert state.state == STATE_ON + + +async def test_esphome_device_without_friendly_name( + hass: HomeAssistant, + mock_client: APIClient, + hass_storage: dict[str, Any], + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device without friendly_name set.""" + entity_info = [ + BinarySensorInfo( + object_id="mybinary_sensor", + key=1, + name="my binary_sensor", + unique_id="my_binary_sensor", + ), + ] + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + BinarySensorState(key=2, state=True, missing_state=False), + ] + user_service = [] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"friendly_name": None}, + ) + state = hass.states.get("binary_sensor.my_binary_sensor") + assert state is not None + assert state.state == STATE_ON diff --git a/tests/components/esphome/test_enum_mapper.py b/tests/components/esphome/test_enum_mapper.py index 52b81bb3836..a9ee5242592 100644 --- a/tests/components/esphome/test_enum_mapper.py +++ b/tests/components/esphome/test_enum_mapper.py @@ -1,8 +1,9 @@ """Test ESPHome enum mapper.""" +from enum import StrEnum + from aioesphomeapi import APIIntEnum -from homeassistant.backports.enum import StrEnum from homeassistant.components.esphome.enum_mapper import EsphomeEnumMapper diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index 4f8f3918a1b..99f4bbc86a9 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -61,14 +61,14 @@ async def test_fan_entity_with_all_features_old_api( user_service=user_service, states=states, ) - state = hass.states.get("fan.test_my_fan") + state = hass.states.get("fan.test_myfan") assert state is not None assert state.state == STATE_ON await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 20}, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 20}, blocking=True, ) mock_client.fan_command.assert_has_calls( @@ -79,7 +79,7 @@ async def test_fan_entity_with_all_features_old_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 50}, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 50}, blocking=True, ) mock_client.fan_command.assert_has_calls( @@ -90,7 +90,7 @@ async def test_fan_entity_with_all_features_old_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_DECREASE_SPEED, - {ATTR_ENTITY_ID: "fan.test_my_fan"}, + {ATTR_ENTITY_ID: "fan.test_myfan"}, blocking=True, ) mock_client.fan_command.assert_has_calls( @@ -101,7 +101,7 @@ async def test_fan_entity_with_all_features_old_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_INCREASE_SPEED, - {ATTR_ENTITY_ID: "fan.test_my_fan"}, + {ATTR_ENTITY_ID: "fan.test_myfan"}, blocking=True, ) mock_client.fan_command.assert_has_calls( @@ -112,7 +112,7 @@ async def test_fan_entity_with_all_features_old_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "fan.test_my_fan"}, + {ATTR_ENTITY_ID: "fan.test_myfan"}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) @@ -121,7 +121,7 @@ async def test_fan_entity_with_all_features_old_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PERCENTAGE, - {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 100}, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 100}, blocking=True, ) mock_client.fan_command.assert_has_calls( @@ -163,14 +163,14 @@ async def test_fan_entity_with_all_features_new_api( user_service=user_service, states=states, ) - state = hass.states.get("fan.test_my_fan") + state = hass.states.get("fan.test_myfan") assert state is not None assert state.state == STATE_ON await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 20}, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 20}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, speed_level=1, state=True)]) @@ -179,7 +179,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 50}, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 50}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, speed_level=2, state=True)]) @@ -188,7 +188,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_DECREASE_SPEED, - {ATTR_ENTITY_ID: "fan.test_my_fan"}, + {ATTR_ENTITY_ID: "fan.test_myfan"}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, speed_level=2, state=True)]) @@ -197,7 +197,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_INCREASE_SPEED, - {ATTR_ENTITY_ID: "fan.test_my_fan"}, + {ATTR_ENTITY_ID: "fan.test_myfan"}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, speed_level=4, state=True)]) @@ -206,7 +206,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "fan.test_my_fan"}, + {ATTR_ENTITY_ID: "fan.test_myfan"}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) @@ -215,7 +215,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PERCENTAGE, - {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 100}, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 100}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, speed_level=4, state=True)]) @@ -224,7 +224,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PERCENTAGE, - {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 0}, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 0}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) @@ -233,7 +233,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_OSCILLATE, - {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_OSCILLATING: True}, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_OSCILLATING: True}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, oscillating=True)]) @@ -242,7 +242,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_OSCILLATE, - {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_OSCILLATING: False}, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_OSCILLATING: False}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, oscillating=False)]) @@ -251,7 +251,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_DIRECTION, - {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_DIRECTION: "forward"}, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_DIRECTION: "forward"}, blocking=True, ) mock_client.fan_command.assert_has_calls( @@ -262,7 +262,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_DIRECTION, - {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_DIRECTION: "reverse"}, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_DIRECTION: "reverse"}, blocking=True, ) mock_client.fan_command.assert_has_calls( @@ -295,14 +295,14 @@ async def test_fan_entity_with_no_features_new_api( user_service=user_service, states=states, ) - state = hass.states.get("fan.test_my_fan") + state = hass.states.get("fan.test_myfan") assert state is not None assert state.state == STATE_ON await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_my_fan"}, + {ATTR_ENTITY_ID: "fan.test_myfan"}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, state=True)]) @@ -311,7 +311,7 @@ async def test_fan_entity_with_no_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "fan.test_my_fan"}, + {ATTR_ENTITY_ID: "fan.test_myfan"}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) diff --git a/tests/components/esphome/test_init.py b/tests/components/esphome/test_init.py index 84bafc9fd84..d3d47a40d66 100644 --- a/tests/components/esphome/test_init.py +++ b/tests/components/esphome/test_init.py @@ -31,3 +31,19 @@ async def test_unique_id_updated_to_mac( await hass.async_block_till_done() assert entry.unique_id == "11:22:33:44:55:aa" + + +async def test_delete_entry( + hass: HomeAssistant, mock_client, mock_zeroconf: None +) -> None: + """Test we can delete an entry with error.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="mock-config-name", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index a0998898e75..99058ad3ed4 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -65,14 +65,14 @@ async def test_light_on_off( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -105,14 +105,14 @@ async def test_light_brightness( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -123,7 +123,7 @@ async def test_light_brightness( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -141,7 +141,7 @@ async def test_light_brightness( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_TRANSITION: 2}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_TRANSITION: 2}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -152,7 +152,7 @@ async def test_light_brightness( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_FLASH: FLASH_LONG}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_FLASH: FLASH_LONG}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -163,7 +163,7 @@ async def test_light_brightness( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_TRANSITION: 2}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_TRANSITION: 2}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -181,7 +181,7 @@ async def test_light_brightness( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_FLASH: FLASH_SHORT}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_FLASH: FLASH_SHORT}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -223,14 +223,14 @@ async def test_light_brightness_on_off( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -248,7 +248,7 @@ async def test_light_brightness_on_off( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -293,14 +293,14 @@ async def test_light_legacy_white_converted_to_brightness( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -343,14 +343,14 @@ async def test_light_brightness_on_off_with_unknown_color_mode( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -369,7 +369,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -414,14 +414,14 @@ async def test_light_on_and_brightness( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -464,14 +464,14 @@ async def test_rgb_color_temp_light( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -489,7 +489,7 @@ async def test_rgb_color_temp_light( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -508,7 +508,7 @@ async def test_rgb_color_temp_light( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_COLOR_TEMP_KELVIN: 2500}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 2500}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -552,14 +552,14 @@ async def test_light_rgb( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -578,7 +578,7 @@ async def test_light_rgb( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -599,7 +599,7 @@ async def test_light_rgb( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_my_light", + ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127, ATTR_HS_COLOR: (100, 100), }, @@ -624,7 +624,7 @@ async def test_light_rgb( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -684,7 +684,7 @@ async def test_light_rgbw( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBW] @@ -693,7 +693,7 @@ async def test_light_rgbw( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -713,7 +713,7 @@ async def test_light_rgbw( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -735,7 +735,7 @@ async def test_light_rgbw( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_my_light", + ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127, ATTR_HS_COLOR: (100, 100), }, @@ -762,7 +762,7 @@ async def test_light_rgbw( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -785,7 +785,7 @@ async def test_light_rgbw( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -855,7 +855,7 @@ async def test_light_rgbww_with_cold_warm_white_support( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBWW] @@ -865,7 +865,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -887,7 +887,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -911,7 +911,7 @@ async def test_light_rgbww_with_cold_warm_white_support( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_my_light", + ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127, ATTR_HS_COLOR: (100, 100), }, @@ -941,7 +941,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -967,7 +967,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -994,7 +994,7 @@ async def test_light_rgbww_with_cold_warm_white_support( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_my_light", + ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGBWW_COLOR: (255, 255, 255, 255, 255), }, blocking=True, @@ -1022,7 +1022,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_COLOR_TEMP_KELVIN: 2500}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 2500}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1092,7 +1092,7 @@ async def test_light_rgbww_without_cold_warm_white_support( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBWW] @@ -1102,7 +1102,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1123,7 +1123,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1146,7 +1146,7 @@ async def test_light_rgbww_without_cold_warm_white_support( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_my_light", + ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127, ATTR_HS_COLOR: (100, 100), }, @@ -1175,7 +1175,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1200,7 +1200,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1226,7 +1226,7 @@ async def test_light_rgbww_without_cold_warm_white_support( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_my_light", + ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGBWW_COLOR: (255, 255, 255, 255, 255), }, blocking=True, @@ -1253,7 +1253,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_COLOR_TEMP_KELVIN: 2500}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 2500}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1312,7 +1312,7 @@ async def test_light_color_temp( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON attributes = state.attributes @@ -1325,7 +1325,7 @@ async def test_light_color_temp( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1344,7 +1344,7 @@ async def test_light_color_temp( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls([call(key=1, state=False)]) @@ -1387,7 +1387,7 @@ async def test_light_color_temp_no_mireds_set( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON attributes = state.attributes @@ -1400,7 +1400,7 @@ async def test_light_color_temp_no_mireds_set( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1419,7 +1419,7 @@ async def test_light_color_temp_no_mireds_set( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_COLOR_TEMP_KELVIN: 6000}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 6000}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1439,7 +1439,7 @@ async def test_light_color_temp_no_mireds_set( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls([call(key=1, state=False)]) @@ -1489,7 +1489,7 @@ async def test_light_color_temp_legacy( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON attributes = state.attributes @@ -1504,7 +1504,7 @@ async def test_light_color_temp_legacy( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1523,7 +1523,7 @@ async def test_light_color_temp_legacy( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls([call(key=1, state=False)]) @@ -1575,7 +1575,7 @@ async def test_light_rgb_legacy( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON attributes = state.attributes @@ -1585,7 +1585,7 @@ async def test_light_rgb_legacy( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1601,7 +1601,7 @@ async def test_light_rgb_legacy( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_my_light"}, + {ATTR_ENTITY_ID: "light.test_mylight"}, blocking=True, ) mock_client.light_command.assert_has_calls([call(key=1, state=False)]) @@ -1610,7 +1610,7 @@ async def test_light_rgb_legacy( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1653,7 +1653,7 @@ async def test_light_effects( user_service=user_service, states=states, ) - state = hass.states.get("light.test_my_light") + state = hass.states.get("light.test_mylight") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_EFFECT_LIST] == ["effect1", "effect2"] @@ -1661,7 +1661,7 @@ async def test_light_effects( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_my_light", ATTR_EFFECT: "effect1"}, + {ATTR_ENTITY_ID: "light.test_mylight", ATTR_EFFECT: "effect1"}, blocking=True, ) mock_client.light_command.assert_has_calls( diff --git a/tests/components/esphome/test_lock.py b/tests/components/esphome/test_lock.py index 6e6461d34b1..83312c85934 100644 --- a/tests/components/esphome/test_lock.py +++ b/tests/components/esphome/test_lock.py @@ -40,14 +40,14 @@ async def test_lock_entity_no_open( user_service=user_service, states=states, ) - state = hass.states.get("lock.test_my_lock") + state = hass.states.get("lock.test_mylock") assert state is not None assert state.state == STATE_UNLOCKING await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, - {ATTR_ENTITY_ID: "lock.test_my_lock"}, + {ATTR_ENTITY_ID: "lock.test_mylock"}, blocking=True, ) mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK)]) @@ -74,7 +74,7 @@ async def test_lock_entity_start_locked( user_service=user_service, states=states, ) - state = hass.states.get("lock.test_my_lock") + state = hass.states.get("lock.test_mylock") assert state is not None assert state.state == STATE_LOCKED @@ -101,14 +101,14 @@ async def test_lock_entity_supports_open( user_service=user_service, states=states, ) - state = hass.states.get("lock.test_my_lock") + state = hass.states.get("lock.test_mylock") assert state is not None assert state.state == STATE_LOCKING await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, - {ATTR_ENTITY_ID: "lock.test_my_lock"}, + {ATTR_ENTITY_ID: "lock.test_mylock"}, blocking=True, ) mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK)]) @@ -117,7 +117,7 @@ async def test_lock_entity_supports_open( await hass.services.async_call( LOCK_DOMAIN, SERVICE_UNLOCK, - {ATTR_ENTITY_ID: "lock.test_my_lock"}, + {ATTR_ENTITY_ID: "lock.test_mylock"}, blocking=True, ) mock_client.lock_command.assert_has_calls([call(1, LockCommand.UNLOCK, None)]) @@ -126,7 +126,7 @@ async def test_lock_entity_supports_open( await hass.services.async_call( LOCK_DOMAIN, SERVICE_OPEN, - {ATTR_ENTITY_ID: "lock.test_my_lock"}, + {ATTR_ENTITY_ID: "lock.test_mylock"}, blocking=True, ) mock_client.lock_command.assert_has_calls([call(1, LockCommand.OPEN)]) diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py new file mode 100644 index 00000000000..7a487f3a385 --- /dev/null +++ b/tests/components/esphome/test_manager.py @@ -0,0 +1,120 @@ +"""Test ESPHome manager.""" +from collections.abc import Awaitable, Callable + +from aioesphomeapi import ( + APIClient, + EntityInfo, + EntityState, + UserService, +) + +from homeassistant.components.esphome.const import DOMAIN, STABLE_BLE_VERSION_STR +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from .conftest import MockESPHomeDevice + +from tests.common import MockConfigEntry + + +async def test_esphome_device_with_old_bluetooth( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with old bluetooth creates an issue.""" + entity_info = [] + states = [] + user_service = [] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"bluetooth_proxy_feature_flags": 1, "esphome_version": "2023.3.0"}, + ) + await hass.async_block_till_done() + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + "esphome", "ble_firmware_outdated-11:22:33:44:55:aa" + ) + assert ( + issue.learn_more_url + == f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html" + ) + + +async def test_esphome_device_with_password( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with legacy password creates an issue.""" + entity_info = [] + states = [] + user_service = [] + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "has", + }, + ) + entry.add_to_hass(hass) + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={"bluetooth_proxy_feature_flags": 0, "esphome_version": "2023.3.0"}, + entry=entry, + ) + await hass.async_block_till_done() + issue_registry = ir.async_get(hass) + assert ( + issue_registry.async_get_issue( + "esphome", "api_password_deprecated-11:22:33:44:55:aa" + ) + is not None + ) + + +async def test_esphome_device_with_current_bluetooth( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], +) -> None: + """Test a device with recent bluetooth does not create an issue.""" + entity_info = [] + states = [] + user_service = [] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + device_info={ + "bluetooth_proxy_feature_flags": 1, + "esphome_version": STABLE_BLE_VERSION_STR, + }, + ) + await hass.async_block_till_done() + issue_registry = ir.async_get(hass) + assert ( + issue_registry.async_get_issue( + "esphome", "ble_firmware_outdated-11:22:33:44:55:aa" + ) + is None + ) diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index bcef78e9345..ca97d9abeba 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -63,7 +63,7 @@ async def test_media_player_entity( user_service=user_service, states=states, ) - state = hass.states.get("media_player.test_my_media_player") + state = hass.states.get("media_player.test_mymedia_player") assert state is not None assert state.state == "paused" @@ -71,7 +71,7 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, { - ATTR_ENTITY_ID: "media_player.test_my_media_player", + ATTR_ENTITY_ID: "media_player.test_mymedia_player", ATTR_MEDIA_VOLUME_MUTED: True, }, blocking=True, @@ -85,7 +85,7 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, { - ATTR_ENTITY_ID: "media_player.test_my_media_player", + ATTR_ENTITY_ID: "media_player.test_mymedia_player", ATTR_MEDIA_VOLUME_MUTED: True, }, blocking=True, @@ -99,7 +99,7 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_SET, { - ATTR_ENTITY_ID: "media_player.test_my_media_player", + ATTR_ENTITY_ID: "media_player.test_mymedia_player", ATTR_MEDIA_VOLUME_LEVEL: 0.5, }, blocking=True, @@ -111,7 +111,7 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PAUSE, { - ATTR_ENTITY_ID: "media_player.test_my_media_player", + ATTR_ENTITY_ID: "media_player.test_mymedia_player", }, blocking=True, ) @@ -124,7 +124,7 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PLAY, { - ATTR_ENTITY_ID: "media_player.test_my_media_player", + ATTR_ENTITY_ID: "media_player.test_mymedia_player", }, blocking=True, ) @@ -137,7 +137,7 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_STOP, { - ATTR_ENTITY_ID: "media_player.test_my_media_player", + ATTR_ENTITY_ID: "media_player.test_mymedia_player", }, blocking=True, ) @@ -206,7 +206,7 @@ async def test_media_player_entity_with_source( user_service=user_service, states=states, ) - state = hass.states.get("media_player.test_my_media_player") + state = hass.states.get("media_player.test_mymedia_player") assert state is not None assert state.state == "playing" @@ -215,7 +215,7 @@ async def test_media_player_entity_with_source( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_my_media_player", + ATTR_ENTITY_ID: "media_player.test_mymedia_player", ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, ATTR_MEDIA_CONTENT_ID: "media-source://local/xz", }, @@ -228,7 +228,7 @@ async def test_media_player_entity_with_source( { "id": 1, "type": "media_player/browse_media", - "entity_id": "media_player.test_my_media_player", + "entity_id": "media_player.test_mymedia_player", } ) response = await client.receive_json() @@ -238,7 +238,7 @@ async def test_media_player_entity_with_source( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_my_media_player", + ATTR_ENTITY_ID: "media_player.test_mymedia_player", ATTR_MEDIA_CONTENT_TYPE: MediaType.URL, ATTR_MEDIA_CONTENT_ID: "media-source://tts?message=hello", }, diff --git a/tests/components/esphome/test_number.py b/tests/components/esphome/test_number.py index 3af94cba39d..dc90d1c1098 100644 --- a/tests/components/esphome/test_number.py +++ b/tests/components/esphome/test_number.py @@ -45,14 +45,14 @@ async def test_generic_number_entity( user_service=user_service, states=states, ) - state = hass.states.get("number.test_my_number") + state = hass.states.get("number.test_mynumber") assert state is not None assert state.state == "50" await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: "number.test_my_number", ATTR_VALUE: 50}, + {ATTR_ENTITY_ID: "number.test_mynumber", ATTR_VALUE: 50}, blocking=True, ) mock_client.number_command.assert_has_calls([call(1, 50)]) @@ -86,7 +86,7 @@ async def test_generic_number_nan( user_service=user_service, states=states, ) - state = hass.states.get("number.test_my_number") + state = hass.states.get("number.test_mynumber") assert state is not None assert state.state == STATE_UNKNOWN @@ -118,7 +118,7 @@ async def test_generic_number_with_unit_of_measurement_as_empty_string( user_service=user_service, states=states, ) - state = hass.states.get("number.test_my_number") + state = hass.states.get("number.test_mynumber") assert state is not None assert state.state == "42" assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index 8d17276c304..528483d4290 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -60,14 +60,14 @@ async def test_select_generic_entity( user_service=user_service, states=states, ) - state = hass.states.get("select.test_my_select") + state = hass.states.get("select.test_myselect") assert state is not None assert state.state == "a" await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: "select.test_my_select", ATTR_OPTION: "b"}, + {ATTR_ENTITY_ID: "select.test_myselect", ATTR_OPTION: "b"}, blocking=True, ) mock_client.select_command.assert_has_calls([call(1, "b")]) diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 27644617a7a..83661a58280 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -1,30 +1,45 @@ """Test ESPHome sensors.""" +from collections.abc import Awaitable, Callable +import logging import math from aioesphomeapi import ( APIClient, EntityCategory as ESPHomeEntityCategory, + EntityInfo, + EntityState, LastResetType, SensorInfo, SensorState, SensorStateClass as ESPHomeSensorStateClass, TextSensorInfo, TextSensorState, + UserService, ) from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass -from homeassistant.const import ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import EntityCategory +from .conftest import MockESPHomeDevice + async def test_generic_numeric_sensor( hass: HomeAssistant, mock_client: APIClient, - mock_generic_device_entry, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], ) -> None: """Test a generic sensor entity.""" + logging.getLogger("homeassistant.components.esphome").setLevel(logging.DEBUG) entity_info = [ SensorInfo( object_id="mysensor", @@ -35,16 +50,44 @@ async def test_generic_numeric_sensor( ] states = [SensorState(key=1, state=50)] user_service = [] - await mock_generic_device_entry( + mock_device = await mock_esphome_device( mock_client=mock_client, entity_info=entity_info, user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_my_sensor") + state = hass.states.get("sensor.test_mysensor") assert state is not None assert state.state == "50" + # Test updating state + mock_device.set_state(SensorState(key=1, state=60)) + await hass.async_block_till_done() + state = hass.states.get("sensor.test_mysensor") + assert state is not None + assert state.state == "60" + + # Test sending the same state again + mock_device.set_state(SensorState(key=1, state=60)) + await hass.async_block_till_done() + state = hass.states.get("sensor.test_mysensor") + assert state is not None + assert state.state == "60" + + # Test we can still update after the same state + mock_device.set_state(SensorState(key=1, state=70)) + await hass.async_block_till_done() + state = hass.states.get("sensor.test_mysensor") + assert state is not None + assert state.state == "70" + + # Test invalid data from the underlying api does not crash us + mock_device.set_state(SensorState(key=1, state=object())) + await hass.async_block_till_done() + state = hass.states.get("sensor.test_mysensor") + assert state is not None + assert state.state == "70" + async def test_generic_numeric_sensor_with_entity_category_and_icon( hass: HomeAssistant, @@ -70,12 +113,12 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_my_sensor") + state = hass.states.get("sensor.test_mysensor") assert state is not None assert state.state == "50" assert state.attributes[ATTR_ICON] == "mdi:leaf" entity_reg = er.async_get(hass) - entry = entity_reg.async_get("sensor.test_my_sensor") + entry = entity_reg.async_get("sensor.test_mysensor") assert entry is not None assert entry.unique_id == "my_sensor" assert entry.entity_category is EntityCategory.CONFIG @@ -106,12 +149,12 @@ async def test_generic_numeric_sensor_state_class_measurement( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_my_sensor") + state = hass.states.get("sensor.test_mysensor") assert state is not None assert state.state == "50" assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT entity_reg = er.async_get(hass) - entry = entity_reg.async_get("sensor.test_my_sensor") + entry = entity_reg.async_get("sensor.test_mysensor") assert entry is not None assert entry.unique_id == "my_sensor" assert entry.entity_category is None @@ -140,7 +183,7 @@ async def test_generic_numeric_sensor_device_class_timestamp( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_my_sensor") + state = hass.states.get("sensor.test_mysensor") assert state is not None assert state.state == "2023-06-22T18:43:52+00:00" @@ -169,7 +212,7 @@ async def test_generic_numeric_sensor_legacy_last_reset_convert( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_my_sensor") + state = hass.states.get("sensor.test_mysensor") assert state is not None assert state.state == "50" assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING @@ -195,7 +238,7 @@ async def test_generic_numeric_sensor_no_state( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_my_sensor") + state = hass.states.get("sensor.test_mysensor") assert state is not None assert state.state == STATE_UNKNOWN @@ -220,7 +263,7 @@ async def test_generic_numeric_sensor_nan_state( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_my_sensor") + state = hass.states.get("sensor.test_mysensor") assert state is not None assert state.state == STATE_UNKNOWN @@ -245,7 +288,7 @@ async def test_generic_numeric_sensor_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_my_sensor") + state = hass.states.get("sensor.test_mysensor") assert state is not None assert state.state == STATE_UNKNOWN @@ -272,7 +315,7 @@ async def test_generic_text_sensor( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_my_sensor") + state = hass.states.get("sensor.test_mysensor") assert state is not None assert state.state == "i am a teapot" @@ -298,7 +341,7 @@ async def test_generic_numeric_sensor_empty_string_uom( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_my_sensor") + state = hass.states.get("sensor.test_mysensor") assert state is not None assert state.state == "123" assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes diff --git a/tests/components/esphome/test_switch.py b/tests/components/esphome/test_switch.py index 39e01a7d07c..cd60eb70edd 100644 --- a/tests/components/esphome/test_switch.py +++ b/tests/components/esphome/test_switch.py @@ -34,14 +34,14 @@ async def test_switch_generic_entity( user_service=user_service, states=states, ) - state = hass.states.get("switch.test_my_switch") + state = hass.states.get("switch.test_myswitch") assert state is not None assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_my_switch"}, + {ATTR_ENTITY_ID: "switch.test_myswitch"}, blocking=True, ) mock_client.switch_command.assert_has_calls([call(1, True)]) @@ -49,7 +49,7 @@ async def test_switch_generic_entity( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_my_switch"}, + {ATTR_ENTITY_ID: "switch.test_myswitch"}, blocking=True, ) mock_client.switch_command.assert_has_calls([call(1, False)]) diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index dd0daf1c455..bd38f4d3302 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -1,21 +1,38 @@ """Test ESPHome update entities.""" import asyncio +from collections.abc import Awaitable, Callable import dataclasses from unittest.mock import Mock, patch +from aioesphomeapi import ( + APIClient, + EntityInfo, + EntityState, + UserService, +) import pytest -from homeassistant.components.esphome.dashboard import async_get_dashboard +from homeassistant.components.esphome.dashboard import ( + async_get_dashboard, +) from homeassistant.components.update import UpdateEntityFeature -from homeassistant.const import STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ( + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send +from .conftest import MockESPHomeDevice -@pytest.fixture(autouse=True) + +@pytest.fixture def stub_reconnect(): """Stub reconnect.""" - with patch("homeassistant.components.esphome.ReconnectLogic.start"): + with patch("homeassistant.components.esphome.manager.ReconnectLogic.start"): yield @@ -30,7 +47,7 @@ def stub_reconnect(): "configuration": "test.yaml", } ], - "on", + STATE_ON, { "latest_version": "2023.2.0-dev", "installed_version": "1.0.0", @@ -44,7 +61,7 @@ def stub_reconnect(): "current_version": "1.0.0", }, ], - "off", + STATE_OFF, { "latest_version": "1.0.0", "installed_version": "1.0.0", @@ -53,13 +70,14 @@ def stub_reconnect(): ), ( [], - "unavailable", + STATE_UNKNOWN, # dashboard is available but device is unknown {"supported_features": 0}, ), ], ) async def test_update_entity( hass: HomeAssistant, + stub_reconnect, mock_config_entry, mock_device_info, mock_dashboard, @@ -88,6 +106,48 @@ async def test_update_entity( if expected_state != "on": return + # Compile failed, don't try to upload + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=False + ) as mock_compile, patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=True + ) as mock_upload, pytest.raises( + HomeAssistantError, match="compiling" + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.none_firmware"}, + blocking=True, + ) + + assert len(mock_compile.mock_calls) == 1 + assert mock_compile.mock_calls[0][1][0] == "test.yaml" + + assert len(mock_upload.mock_calls) == 0 + + # Compile success, upload fails + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=True + ) as mock_compile, patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.upload", return_value=False + ) as mock_upload, pytest.raises( + HomeAssistantError, match="OTA" + ): + await hass.services.async_call( + "update", + "install", + {"entity_id": "update.none_firmware"}, + blocking=True, + ) + + assert len(mock_compile.mock_calls) == 1 + assert mock_compile.mock_calls[0][1][0] == "test.yaml" + + assert len(mock_upload.mock_calls) == 1 + assert mock_upload.mock_calls[0][1][0] == "test.yaml" + + # Everything works with patch( "esphome_dashboard_api.ESPHomeDashboardAPI.compile", return_value=True ) as mock_compile, patch( @@ -109,6 +169,7 @@ async def test_update_entity( async def test_update_static_info( hass: HomeAssistant, + stub_reconnect, mock_config_entry, mock_device_info, mock_dashboard, @@ -155,6 +216,7 @@ async def test_update_static_info( ) async def test_update_device_state_for_availability( hass: HomeAssistant, + stub_reconnect, expected_disconnect_state: tuple[bool, str], mock_config_entry, mock_device_info, @@ -210,7 +272,11 @@ async def test_update_device_state_for_availability( async def test_update_entity_dashboard_not_available_startup( - hass: HomeAssistant, mock_config_entry, mock_device_info, mock_dashboard + hass: HomeAssistant, + stub_reconnect, + mock_config_entry, + mock_device_info, + mock_dashboard, ) -> None: """Test ESPHome update entity when dashboard is not available at startup.""" with patch( @@ -225,6 +291,7 @@ async def test_update_entity_dashboard_not_available_startup( mock_config_entry, "update" ) + # We have a dashboard but it is not available state = hass.states.get("update.none_firmware") assert state is None @@ -239,7 +306,7 @@ async def test_update_entity_dashboard_not_available_startup( await hass.async_block_till_done() state = hass.states.get("update.none_firmware") - assert state.state == "on" + assert state.state == STATE_ON expected_attributes = { "latest_version": "2023.2.0-dev", "installed_version": "1.0.0", @@ -247,3 +314,69 @@ async def test_update_entity_dashboard_not_available_startup( } for key, expected_value in expected_attributes.items(): assert state.attributes.get(key) == expected_value + + +async def test_update_entity_dashboard_discovered_after_startup_but_update_failed( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: Callable[ + [APIClient, list[EntityInfo], list[UserService], list[EntityState]], + Awaitable[MockESPHomeDevice], + ], + mock_dashboard, +) -> None: + """Test ESPHome update entity when dashboard is discovered after startup and the first update fails.""" + with patch( + "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices", + side_effect=asyncio.TimeoutError, + ): + await async_get_dashboard(hass).async_refresh() + await hass.async_block_till_done() + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[], + user_service=[], + states=[], + ) + await hass.async_block_till_done() + state = hass.states.get("update.test_firmware") + assert state is None + + await mock_device.mock_disconnect(False) + + mock_dashboard["configured"] = [ + { + "name": "test", + "current_version": "2023.2.0-dev", + "configuration": "test.yaml", + } + ] + # Device goes unavailable, and dashboard becomes available + await async_get_dashboard(hass).async_refresh() + await hass.async_block_till_done() + + state = hass.states.get("update.test_firmware") + assert state is None + + # Finally both are available + await mock_device.mock_connect() + await async_get_dashboard(hass).async_refresh() + await hass.async_block_till_done() + state = hass.states.get("update.test_firmware") + assert state is not None + + +async def test_update_entity_not_present_without_dashboard( + hass: HomeAssistant, stub_reconnect, mock_config_entry, mock_device_info +) -> None: + """Test ESPHome update entity does not get created if there is no dashboard.""" + with patch( + "homeassistant.components.esphome.update.DomainData.get_entry_data", + return_value=Mock(available=True, device_info=mock_device_info), + ): + assert await hass.config_entries.async_forward_entry_setup( + mock_config_entry, "update" + ) + + state = hass.states.get("update.none_firmware") + assert state is None diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index 08750d06dd0..4188e375907 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -8,7 +8,6 @@ from aioesphomeapi import VoiceAssistantEventType import async_timeout import pytest -from homeassistant.components import esphome from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType from homeassistant.components.esphome import DomainData from homeassistant.components.esphome.voice_assistant import VoiceAssistantUDPServer @@ -103,15 +102,15 @@ async def test_pipeline_events( ) def handle_event( - event_type: esphome.VoiceAssistantEventType, data: dict[str, str] | None + event_type: VoiceAssistantEventType, data: dict[str, str] | None ) -> None: - if event_type == esphome.VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: + if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: assert data is not None assert data["text"] == _TEST_INPUT_TEXT - elif event_type == esphome.VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: assert data is not None assert data["text"] == _TEST_OUTPUT_TEXT - elif event_type == esphome.VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: assert data is not None assert data["url"] == _TEST_OUTPUT_URL @@ -274,7 +273,7 @@ async def test_error_event_type( ) ) - assert voice_assistant_udp_server_v1.handle_event.called_with( + voice_assistant_udp_server_v1.handle_event.assert_called_with( VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, {"code": "code", "message": "message"}, ) @@ -399,9 +398,9 @@ async def test_no_speech( return sum(chunk) > 0 def handle_event( - event_type: esphome.VoiceAssistantEventType, data: dict[str, str] | None + event_type: VoiceAssistantEventType, data: dict[str, str] | None ) -> None: - assert event_type == esphome.VoiceAssistantEventType.VOICE_ASSISTANT_ERROR + assert event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR assert data is not None assert data["code"] == "speech-timeout" diff --git a/tests/components/event/__init__.py b/tests/components/event/__init__.py new file mode 100644 index 00000000000..e8236163d05 --- /dev/null +++ b/tests/components/event/__init__.py @@ -0,0 +1 @@ +"""The tests for the event integration.""" diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py new file mode 100644 index 00000000000..66cda6a088a --- /dev/null +++ b/tests/components/event/test_init.py @@ -0,0 +1,352 @@ +"""The tests for the event integration.""" +from collections.abc import Generator +from typing import Any + +from freezegun import freeze_time +import pytest + +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + ATTR_EVENT_TYPES, + DOMAIN, + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_PLATFORM, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + async_mock_restore_state_shutdown_restart, + mock_config_flow, + mock_integration, + mock_platform, + mock_restore_cache, + mock_restore_cache_with_extra_data, +) + +TEST_DOMAIN = "test" + + +async def test_event() -> None: + """Test the event entity.""" + event = EventEntity() + event.entity_id = "event.doorbell" + # Test event with no data at all + assert event.state is None + assert event.state_attributes == {ATTR_EVENT_TYPE: None} + assert not event.extra_state_attributes + assert event.device_class is None + + # No event types defined, should raise + with pytest.raises(AttributeError): + event.event_types + + # Test retrieving data from entity description + event.entity_description = EventEntityDescription( + key="test_event", + event_types=["short_press", "long_press"], + device_class=EventDeviceClass.DOORBELL, + ) + assert event.event_types == ["short_press", "long_press"] + assert event.device_class == EventDeviceClass.DOORBELL + + # Test attrs win over entity description + event._attr_event_types = ["short_press", "long_press", "double_press"] + assert event.event_types == ["short_press", "long_press", "double_press"] + event._attr_device_class = EventDeviceClass.BUTTON + assert event.device_class == EventDeviceClass.BUTTON + + # Test triggering an event + now = dt_util.utcnow() + with freeze_time(now): + event._trigger_event("long_press") + + assert event.state == now.isoformat(timespec="milliseconds") + assert event.state_attributes == {ATTR_EVENT_TYPE: "long_press"} + assert not event.extra_state_attributes + + # Test triggering an event, with extra attribute data + now = dt_util.utcnow() + with freeze_time(now): + event._trigger_event("short_press", {"hello": "world"}) + + assert event.state == now.isoformat(timespec="milliseconds") + assert event.state_attributes == { + ATTR_EVENT_TYPE: "short_press", + "hello": "world", + } + + # Test triggering an unknown event + with pytest.raises( + ValueError, match="^Invalid event type unknown_event for event.doorbell$" + ): + event._trigger_event("unknown_event") + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_restore_state(hass: HomeAssistant) -> None: + """Test we restore state integration.""" + mock_restore_cache_with_extra_data( + hass, + ( + ( + State( + "event.doorbell", + "2021-01-01T23:59:59.123+00:00", + attributes={ + ATTR_EVENT_TYPE: "ignored", + ATTR_EVENT_TYPES: [ + "single_press", + "double_press", + "do", + "not", + "restore", + ], + "hello": "worm", + }, + ), + { + "last_event_type": "double_press", + "last_event_attributes": { + "hello": "world", + }, + }, + ), + ), + ) + + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("event.doorbell") + assert state + assert state.state == "2021-01-01T23:59:59.123+00:00" + assert state.attributes[ATTR_EVENT_TYPES] == ["short_press", "long_press"] + assert state.attributes[ATTR_EVENT_TYPE] == "double_press" + assert state.attributes["hello"] == "world" + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_invalid_extra_restore_state(hass: HomeAssistant) -> None: + """Test we restore state integration.""" + mock_restore_cache_with_extra_data( + hass, + ( + ( + State( + "event.doorbell", + "2021-01-01T23:59:59.123+00:00", + ), + { + "invalid_unexpected_key": "double_press", + "last_event_attributes": { + "hello": "world", + }, + }, + ), + ), + ) + + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("event.doorbell") + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_EVENT_TYPES] == ["short_press", "long_press"] + assert state.attributes[ATTR_EVENT_TYPE] is None + assert "hello" not in state.attributes + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_no_extra_restore_state(hass: HomeAssistant) -> None: + """Test we restore state integration.""" + mock_restore_cache( + hass, + ( + State( + "event.doorbell", + "2021-01-01T23:59:59.123+00:00", + attributes={ + ATTR_EVENT_TYPES: [ + "single_press", + "double_press", + ], + ATTR_EVENT_TYPE: "double_press", + "hello": "world", + }, + ), + ), + ) + + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("event.doorbell") + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_EVENT_TYPES] == ["short_press", "long_press"] + assert state.attributes[ATTR_EVENT_TYPE] is None + assert "hello" not in state.attributes + + +@pytest.mark.usefixtures("enable_custom_integrations") +async def test_saving_state(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None: + """Test we restore state integration.""" + restore_data = {"last_event_type": "double_press", "last_event_attributes": None} + + mock_restore_cache_with_extra_data( + hass, + ( + ( + State( + "event.doorbell", + "2021-01-01T23:59:59.123+00:00", + ), + restore_data, + ), + ), + ) + + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == "event.doorbell" + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == restore_data + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.mark.usefixtures("config_flow_fixture") +async def test_name(hass: HomeAssistant) -> None: + """Test event name.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed event without device class -> no name + entity1 = EventEntity() + entity1._attr_event_types = ["ding"] + entity1.entity_id = "event.test1" + + # Unnamed event with device class but has_entity_name False -> no name + entity2 = EventEntity() + entity2._attr_event_types = ["ding"] + entity2.entity_id = "event.test2" + entity2._attr_device_class = EventDeviceClass.DOORBELL + + # Unnamed event with device class and has_entity_name True -> named + entity3 = EventEntity() + entity3._attr_event_types = ["ding"] + entity3.entity_id = "event.test3" + entity3._attr_device_class = EventDeviceClass.DOORBELL + entity3._attr_has_entity_name = True + + # Unnamed event with device class and has_entity_name True -> named + entity4 = EventEntity() + entity4._attr_event_types = ["ding"] + entity4.entity_id = "event.test4" + entity4.entity_description = EventEntityDescription( + "test", + EventDeviceClass.DOORBELL, + has_entity_name=True, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test event platform via config entry.""" + async_add_entities([entity1, entity2, entity3, entity4]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity1.entity_id) + assert state + assert state.attributes == {"event_types": ["ding"], "event_type": None} + + state = hass.states.get(entity2.entity_id) + assert state + assert state.attributes == { + "event_types": ["ding"], + "event_type": None, + "device_class": "doorbell", + } + + state = hass.states.get(entity3.entity_id) + assert state + assert state.attributes == { + "event_types": ["ding"], + "event_type": None, + "device_class": "doorbell", + "friendly_name": "Doorbell", + } + + state = hass.states.get(entity4.entity_id) + assert state + assert state.attributes == { + "event_types": ["ding"], + "event_type": None, + "device_class": "doorbell", + "friendly_name": "Doorbell", + } diff --git a/tests/components/event/test_recorder.py b/tests/components/event/test_recorder.py new file mode 100644 index 00000000000..133f7e173e3 --- /dev/null +++ b/tests/components/event/test_recorder.py @@ -0,0 +1,50 @@ +"""The tests for event recorder.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from homeassistant.components import select +from homeassistant.components.event import ATTR_EVENT_TYPES +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.history import get_significant_states +from homeassistant.const import ATTR_FRIENDLY_NAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.components.recorder.common import async_wait_recording_done + + +@pytest.fixture(autouse=True) +async def event_only() -> None: + """Enable only the event platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.EVENT], + ): + yield + + +async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test select registered attributes to be excluded.""" + now = dt_util.utcnow() + assert await async_setup_component(hass, "homeassistant", {}) + await async_setup_component( + hass, select.DOMAIN, {select.DOMAIN: {"platform": "demo"}} + ) + await hass.async_block_till_done() + hass.bus.async_fire("demo_button_pressed") + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, hass.states.async_entity_ids() + ) + assert len(states) >= 1 + for entity_states in states.values(): + for state in entity_states: + assert state + assert ATTR_EVENT_TYPES not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/ffmpeg/test_init.py b/tests/components/ffmpeg/test_init.py index 521ac732e5b..0c6ce300d01 100644 --- a/tests/components/ffmpeg/test_init.py +++ b/tests/components/ffmpeg/test_init.py @@ -8,7 +8,11 @@ from homeassistant.components.ffmpeg import ( SERVICE_START, SERVICE_STOP, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ( + ATTR_ENTITY_ID, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component, setup_component @@ -54,7 +58,7 @@ class MockFFmpegDev(ffmpeg.FFmpegBase): self.hass = hass self.entity_id = entity_id - self.ffmpeg = MagicMock + self.ffmpeg = MagicMock() self.called_stop = False self.called_start = False self.called_restart = False @@ -104,12 +108,18 @@ async def test_setup_component_test_register(hass: HomeAssistant) -> None: with assert_setup_component(1): await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) - hass.bus.async_listen_once = MagicMock() ffmpeg_dev = MockFFmpegDev(hass) + ffmpeg_dev._async_stop_ffmpeg = AsyncMock() + ffmpeg_dev._async_start_ffmpeg = AsyncMock() await ffmpeg_dev.async_added_to_hass() - assert hass.bus.async_listen_once.called - assert hass.bus.async_listen_once.call_count == 2 + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert len(ffmpeg_dev._async_start_ffmpeg.mock_calls) == 2 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert len(ffmpeg_dev._async_stop_ffmpeg.mock_calls) == 2 async def test_setup_component_test_register_no_startup(hass: HomeAssistant) -> None: @@ -117,12 +127,18 @@ async def test_setup_component_test_register_no_startup(hass: HomeAssistant) -> with assert_setup_component(1): await async_setup_component(hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}}) - hass.bus.async_listen_once = MagicMock() ffmpeg_dev = MockFFmpegDev(hass, False) + ffmpeg_dev._async_stop_ffmpeg = AsyncMock() + ffmpeg_dev._async_start_ffmpeg = AsyncMock() await ffmpeg_dev.async_added_to_hass() - assert hass.bus.async_listen_once.called - assert hass.bus.async_listen_once.call_count == 1 + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert len(ffmpeg_dev._async_start_ffmpeg.mock_calls) == 1 + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert len(ffmpeg_dev._async_stop_ffmpeg.mock_calls) == 2 async def test_setup_component_test_service_start(hass: HomeAssistant) -> None: diff --git a/tests/components/flux_led/test_light.py b/tests/components/flux_led/test_light.py index 2216e4df737..171112c9097 100644 --- a/tests/components/flux_led/test_light.py +++ b/tests/components/flux_led/test_light.py @@ -182,7 +182,7 @@ async def test_light_device_registry( device_registry = dr.async_get(hass) device = device_registry.async_get_device( - identifiers={}, connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)} + connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)} ) assert device.sw_version == str(sw_version) assert device.model == model diff --git a/tests/components/freebox/conftest.py b/tests/components/freebox/conftest.py index 7bf1cbfe7a4..b950d44508d 100644 --- a/tests/components/freebox/conftest.py +++ b/tests/components/freebox/conftest.py @@ -11,6 +11,7 @@ from .const import ( DATA_HOME_GET_NODES, DATA_LAN_GET_HOSTS_LIST, DATA_STORAGE_GET_DISKS, + DATA_STORAGE_GET_RAIDS, DATA_SYSTEM_GET_CONFIG, WIFI_GET_GLOBAL_CONFIG, ) @@ -56,6 +57,7 @@ def mock_router(mock_device_registry_devices): # sensor instance.call.get_calls_log = AsyncMock(return_value=DATA_CALL_GET_CALLS_LOG) instance.storage.get_disks = AsyncMock(return_value=DATA_STORAGE_GET_DISKS) + instance.storage.get_raids = AsyncMock(return_value=DATA_STORAGE_GET_RAIDS) # home devices instance.home.get_home_nodes = AsyncMock(return_value=DATA_HOME_GET_NODES) instance.connection.get_status = AsyncMock( diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index 96fe96c19c5..7028366d02b 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -93,75 +93,177 @@ DATA_STORAGE_GET_DISKS = [ { "idle_duration": 0, "read_error_requests": 0, - "read_requests": 110, + "read_requests": 1815106, "spinning": True, - # "table_type": "ms-dos", API returns without dash, but codespell isn't agree - "firmware": "SC1D", - "type": "internal", - "idle": False, - "connector": 0, - "id": 0, + "table_type": "raid", + "firmware": "0001", + "type": "sata", + "idle": True, + "connector": 2, + "id": 1000, "write_error_requests": 0, - "state": "enabled", - "write_requests": 2708929, - "total_bytes": 250050000000, - "model": "ST9250311CS", + "time_before_spindown": 600, + "state": "disabled", + "write_requests": 80386151, + "total_bytes": 2000000000000, + "model": "ST2000LM015-2E8174", "active_duration": 0, - "temp": 40, - "serial": "6VCQY907", + "temp": 30, + "serial": "ZDZLBFHC", "partitions": [ { - "fstype": "ext4", - "total_bytes": 244950000000, - "label": "Disque dur", - "id": 2, - "internal": True, + "fstype": "raid", + "total_bytes": 0, + "label": "Volume 2000Go", + "id": 1000, + "internal": False, "fsck_result": "no_run_yet", - "state": "mounted", - "disk_id": 0, - "free_bytes": 227390000000, - "used_bytes": 5090000000, - "path": "L0Rpc3F1ZSBkdXI=", + "state": "umounted", + "disk_id": 1000, + "free_bytes": 0, + "used_bytes": 0, + "path": "L1ZvbHVtZSAyMDAwR28=", } ], }, { - "idle_duration": 8290, + "idle_duration": 0, "read_error_requests": 0, - "read_requests": 2326826, - "spinning": False, - "table_type": "gpt", + "read_requests": 3622038, + "spinning": True, + "table_type": "raid", "firmware": "0001", "type": "sata", "idle": True, "connector": 0, "id": 2000, "write_error_requests": 0, - "state": "enabled", - "write_requests": 122733632, + "time_before_spindown": 600, + "state": "disabled", + "write_requests": 80386151, "total_bytes": 2000000000000, "model": "ST2000LM015-2E8174", "active_duration": 0, + "temp": 31, + "serial": "ZDZLEJXE", + "partitions": [ + { + "fstype": "raid", + "total_bytes": 0, + "label": "Volume 2000Go 1", + "id": 2000, + "internal": False, + "fsck_result": "no_run_yet", + "state": "umounted", + "disk_id": 2000, + "free_bytes": 0, + "used_bytes": 0, + "path": "L1ZvbHVtZSAyMDAwR28gMQ==", + } + ], + }, + { + "idle_duration": 0, + "read_error_requests": 0, + "read_requests": 0, + "spinning": False, + "table_type": "superfloppy", + "firmware": "", + "type": "raid", + "idle": False, + "connector": 0, + "id": 3000, + "write_error_requests": 0, + "state": "enabled", + "write_requests": 0, + "total_bytes": 2000000000000, + "model": "", + "active_duration": 0, "temp": 0, - "serial": "WDZYJ27Q", + "serial": "", "partitions": [ { "fstype": "ext4", "total_bytes": 1960000000000, - "label": "Disque 2", - "id": 2001, + "label": "Freebox", + "id": 3000, "internal": False, "fsck_result": "no_run_yet", "state": "mounted", - "disk_id": 2000, - "free_bytes": 1880000000000, - "used_bytes": 85410000000, - "path": "L0Rpc3F1ZSAy", + "disk_id": 3000, + "free_bytes": 1730000000000, + "used_bytes": 236910000000, + "path": "L0ZyZWVib3g=", } ], }, ] +DATA_STORAGE_GET_RAIDS = [ + { + "degraded": False, + "raid_disks": 2, # Number of members that should be in this array + "next_check": 0, # Unix timestamp of next check in seconds. Might be 0 if check_interval is 0 + "sync_action": "idle", # values: idle, resync, recover, check, repair, reshape, frozen + "level": "raid1", # values: basic, raid0, raid1, raid5, raid10 + "uuid": "dc8679f8-13f9-11ee-9106-38d547790df8", + "sysfs_state": "clear", # values: clear, inactive, suspended, readonly, read_auto, clean, active, write_pending, active_idle + "id": 0, + "sync_completed_pos": 0, # Current position of sync process + "members": [ + { + "total_bytes": 2000000000000, + "active_device": 1, + "id": 1000, + "corrected_read_errors": 0, + "array_id": 0, + "disk": { + "firmware": "0001", + "temp": 29, + "serial": "ZDZLBFHC", + "model": "ST2000LM015-2E8174", + }, + "role": "active", # values: active, faulty, spare, missing + "sct_erc_supported": False, + "sct_erc_enabled": False, + "dev_uuid": "fca8720e-13f9-11ee-9106-38d547790df8", + "device_location": "sata-internal-p2", + "set_name": "Freebox", + "set_uuid": "dc8679f8-13f9-11ee-9106-38d547790df8", + }, + { + "total_bytes": 2000000000000, + "active_device": 0, + "id": 2000, + "corrected_read_errors": 0, + "array_id": 0, + "disk": { + "firmware": "0001", + "temp": 30, + "serial": "ZDZLEJXE", + "model": "ST2000LM015-2E8174", + }, + "role": "active", + "sct_erc_supported": False, + "sct_erc_enabled": False, + "dev_uuid": "16bf00d6-13fa-11ee-9106-38d547790df8", + "device_location": "sata-internal-p0", + "set_name": "Freebox", + "set_uuid": "dc8679f8-13f9-11ee-9106-38d547790df8", + }, + ], + "array_size": 2000000000000, # Size of array in bytes + "state": "running", # stopped, running, error + "sync_speed": 0, # Sync speed in bytes per second + "name": "Freebox", + "check_interval": 0, # Check interval in seconds + "disk_id": 3000, + "last_check": 1682884357, # Unix timestamp of last check in seconds + "sync_completed_end": 0, # End position of sync process: total of bytes to sync + "sync_completed_percent": 0, # Percentage of sync completion + } +] + # switch WIFI_GET_GLOBAL_CONFIG = {"enabled": True, "mac_filter_state": "disabled"} diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py new file mode 100644 index 00000000000..ec504a514ad --- /dev/null +++ b/tests/components/freebox/test_binary_sensor.py @@ -0,0 +1,44 @@ +"""Tests for the Freebox sensors.""" +from copy import deepcopy +from datetime import timedelta +from unittest.mock import Mock + +from homeassistant.components.freebox.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from .const import DATA_STORAGE_GET_RAIDS, MOCK_HOST, MOCK_PORT + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_raid_array_degraded(hass: HomeAssistant, router: Mock) -> None: + """Test raid array degraded binary sensor.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + unique_id=MOCK_HOST, + ) + entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert ( + hass.states.get("binary_sensor.freebox_server_r2_raid_array_0_degraded").state + == "off" + ) + + # Now simulate we degraded + data_storage_get_raids_degraded = deepcopy(DATA_STORAGE_GET_RAIDS) + data_storage_get_raids_degraded[0]["degraded"] = True + router().storage.get_raids.return_value = data_storage_get_raids_degraded + # Simulate an update + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + # To execute the save + await hass.async_block_till_done() + assert ( + hass.states.get("binary_sensor.freebox_server_r2_raid_array_0_degraded").state + == "on" + ) diff --git a/tests/components/freedompro/test_binary_sensor.py b/tests/components/freedompro/test_binary_sensor.py index 8a3605782a2..5efa5ca96f7 100644 --- a/tests/components/freedompro/test_binary_sensor.py +++ b/tests/components/freedompro/test_binary_sensor.py @@ -56,7 +56,7 @@ async def test_binary_sensor_get_state( registry = er.async_get(hass) registry_device = dr.async_get(hass) - device = registry_device.async_get_device({("freedompro", uid)}) + device = registry_device.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} assert device.manufacturer == "Freedompro" diff --git a/tests/components/freedompro/test_climate.py b/tests/components/freedompro/test_climate.py index ae7c39ed4ba..41a550b3c50 100644 --- a/tests/components/freedompro/test_climate.py +++ b/tests/components/freedompro/test_climate.py @@ -33,7 +33,7 @@ async def test_climate_get_state(hass: HomeAssistant, init_integration) -> None: entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) - device = device_registry.async_get_device({("freedompro", uid)}) + device = device_registry.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} assert device.manufacturer == "Freedompro" diff --git a/tests/components/freedompro/test_cover.py b/tests/components/freedompro/test_cover.py index b29e6499fec..af54b1c2793 100644 --- a/tests/components/freedompro/test_cover.py +++ b/tests/components/freedompro/test_cover.py @@ -47,7 +47,7 @@ async def test_cover_get_state( registry = er.async_get(hass) registry_device = dr.async_get(hass) - device = registry_device.async_get_device({("freedompro", uid)}) + device = registry_device.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} assert device.manufacturer == "Freedompro" diff --git a/tests/components/freedompro/test_fan.py b/tests/components/freedompro/test_fan.py index 159b495e0f8..b5acf3e496a 100644 --- a/tests/components/freedompro/test_fan.py +++ b/tests/components/freedompro/test_fan.py @@ -27,7 +27,7 @@ async def test_fan_get_state(hass: HomeAssistant, init_integration) -> None: registry = er.async_get(hass) registry_device = dr.async_get(hass) - device = registry_device.async_get_device({("freedompro", uid)}) + device = registry_device.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} assert device.manufacturer == "Freedompro" diff --git a/tests/components/freedompro/test_lock.py b/tests/components/freedompro/test_lock.py index ae208194d2a..c9f75e6b594 100644 --- a/tests/components/freedompro/test_lock.py +++ b/tests/components/freedompro/test_lock.py @@ -26,7 +26,7 @@ async def test_lock_get_state(hass: HomeAssistant, init_integration) -> None: registry = er.async_get(hass) registry_device = dr.async_get(hass) - device = registry_device.async_get_device({("freedompro", uid)}) + device = registry_device.async_get_device(identifiers={("freedompro", uid)}) assert device is not None assert device.identifiers == {("freedompro", uid)} assert device.manufacturer == "Freedompro" diff --git a/tests/components/fritz/conftest.py b/tests/components/fritz/conftest.py index 66f4cf2b879..acb135d01bb 100644 --- a/tests/components/fritz/conftest.py +++ b/tests/components/fritz/conftest.py @@ -6,7 +6,12 @@ from fritzconnection.core.processor import Service from fritzconnection.lib.fritzhosts import FritzHosts import pytest -from .const import MOCK_FB_SERVICES, MOCK_MESH_DATA, MOCK_MODELNAME +from .const import ( + MOCK_FB_SERVICES, + MOCK_HOST_ATTRIBUTES_DATA, + MOCK_MESH_DATA, + MOCK_MODELNAME, +) LOGGER = logging.getLogger(__name__) @@ -75,6 +80,10 @@ class FritzHostMock(FritzHosts): """Retrurn mocked mesh data.""" return MOCK_MESH_DATA + def get_hosts_attributes(self): + """Retrurn mocked host attributes data.""" + return MOCK_HOST_ATTRIBUTES_DATA + @pytest.fixture(name="fc_data") def fc_data_mock(): diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index 7a89aab1af1..dc27e8aab96 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -29,7 +29,7 @@ MOCK_HOST = "fake_host" MOCK_IPS = {"fritz.box": "192.168.178.1", "printer": "192.168.178.2"} MOCK_MODELNAME = "FRITZ!Box 7530 AX" MOCK_FIRMWARE = "256.07.29" -MOCK_FIRMWARE_AVAILABLE = "256.07.50" +MOCK_FIRMWARE_AVAILABLE = "7.50" MOCK_FIRMWARE_RELEASE_URL = ( "http://download.avm.de/fritzbox/fritzbox-7530-ax/deutschland/fritz.os/info_de.txt" ) @@ -52,27 +52,8 @@ MOCK_FB_SERVICES: dict[str, dict] = { }, }, "Hosts1": { - "GetGenericHostEntry": [ - { - "NewIPAddress": MOCK_IPS["fritz.box"], - "NewAddressSource": "Static", - "NewLeaseTimeRemaining": 0, - "NewMACAddress": MOCK_MESH_MASTER_MAC, - "NewInterfaceType": "", - "NewActive": True, - "NewHostName": "fritz.box", - }, - { - "NewIPAddress": MOCK_IPS["printer"], - "NewAddressSource": "DHCP", - "NewLeaseTimeRemaining": 0, - "NewMACAddress": "AA:BB:CC:00:11:22", - "NewInterfaceType": "Ethernet", - "NewActive": True, - "NewHostName": "printer", - }, - ], "X_AVM-DE_GetMeshListPath": {}, + "X_AVM-DE_GetHostListPath": {}, }, "LANEthernetInterfaceConfig1": { "GetStatistics": { @@ -783,6 +764,58 @@ MOCK_MESH_DATA = { ], } +MOCK_HOST_ATTRIBUTES_DATA = [ + { + "Index": 1, + "IPAddress": MOCK_IPS["printer"], + "MACAddress": "AA:BB:CC:00:11:22", + "Active": True, + "HostName": "printer", + "InterfaceType": "Ethernet", + "X_AVM-DE_Port": 1, + "X_AVM-DE_Speed": 1000, + "X_AVM-DE_UpdateAvailable": False, + "X_AVM-DE_UpdateSuccessful": "unknown", + "X_AVM-DE_InfoURL": None, + "X_AVM-DE_MACAddressList": None, + "X_AVM-DE_Model": None, + "X_AVM-DE_URL": f"http://{MOCK_IPS['printer']}", + "X_AVM-DE_Guest": False, + "X_AVM-DE_RequestClient": "0", + "X_AVM-DE_VPN": False, + "X_AVM-DE_WANAccess": "granted", + "X_AVM-DE_Disallow": False, + "X_AVM-DE_IsMeshable": "0", + "X_AVM-DE_Priority": "0", + "X_AVM-DE_FriendlyName": "printer", + "X_AVM-DE_FriendlyNameIsWriteable": "1", + }, + { + "Index": 2, + "IPAddress": MOCK_IPS["fritz.box"], + "MACAddress": MOCK_MESH_MASTER_MAC, + "Active": True, + "HostName": "fritz.box", + "InterfaceType": None, + "X_AVM-DE_Port": 0, + "X_AVM-DE_Speed": 0, + "X_AVM-DE_UpdateAvailable": False, + "X_AVM-DE_UpdateSuccessful": "unknown", + "X_AVM-DE_InfoURL": None, + "X_AVM-DE_MACAddressList": f"{MOCK_MESH_MASTER_MAC},{MOCK_MESH_MASTER_WIFI1_MAC}", + "X_AVM-DE_Model": None, + "X_AVM-DE_URL": f"http://{MOCK_IPS['fritz.box']}", + "X_AVM-DE_Guest": False, + "X_AVM-DE_RequestClient": "0", + "X_AVM-DE_VPN": False, + "X_AVM-DE_WANAccess": "granted", + "X_AVM-DE_Disallow": False, + "X_AVM-DE_IsMeshable": "1", + "X_AVM-DE_Priority": "0", + "X_AVM-DE_FriendlyName": "fritz.box", + "X_AVM-DE_FriendlyNameIsWriteable": "0", + }, +] MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] MOCK_DEVICE_INFO = { diff --git a/tests/components/fritz/test_diagnostics.py b/tests/components/fritz/test_diagnostics.py index f7e5980720d..760b5f32d0c 100644 --- a/tests/components/fritz/test_diagnostics.py +++ b/tests/components/fritz/test_diagnostics.py @@ -50,7 +50,7 @@ async def test_entry_diagnostics( for _, device in avm_wrapper.devices.items() ], "connection_type": "WANPPPConnection", - "current_firmware": "256.07.29", + "current_firmware": "7.29", "discovered_services": [ "DeviceInfo1", "Hosts1", diff --git a/tests/components/fritz/test_update.py b/tests/components/fritz/test_update.py index 915a6bb6fd0..99ca7a3b6c5 100644 --- a/tests/components/fritz/test_update.py +++ b/tests/components/fritz/test_update.py @@ -9,7 +9,6 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from .const import ( - MOCK_FIRMWARE, MOCK_FIRMWARE_AVAILABLE, MOCK_FIRMWARE_RELEASE_URL, MOCK_USER_DATA, @@ -60,7 +59,7 @@ async def test_update_available( update = hass.states.get("update.mock_title_fritz_os") assert update is not None assert update.state == "on" - assert update.attributes.get("installed_version") == MOCK_FIRMWARE + assert update.attributes.get("installed_version") == "7.29" assert update.attributes.get("latest_version") == MOCK_FIRMWARE_AVAILABLE assert update.attributes.get("release_url") == MOCK_FIRMWARE_RELEASE_URL @@ -83,8 +82,8 @@ async def test_no_update_available( update = hass.states.get("update.mock_title_fritz_os") assert update is not None assert update.state == "off" - assert update.attributes.get("installed_version") == MOCK_FIRMWARE - assert update.attributes.get("latest_version") == MOCK_FIRMWARE + assert update.attributes.get("installed_version") == "7.29" + assert update.attributes.get("latest_version") == "7.29" async def test_available_update_can_be_installed( diff --git a/tests/components/fritzbox/conftest.py b/tests/components/fritzbox/conftest.py index 50fca4581b3..1fbaf48de4b 100644 --- a/tests/components/fritzbox/conftest.py +++ b/tests/components/fritzbox/conftest.py @@ -10,4 +10,5 @@ def fritz_fixture() -> Mock: with patch("homeassistant.components.fritzbox.Fritzhome") as fritz, patch( "homeassistant.components.fritzbox.config_flow.Fritzhome" ): + fritz.return_value.get_prefixed_host.return_value = "http://1.2.3.4" yield fritz diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index bd70604398d..4d11291508b 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -59,7 +59,7 @@ def mock_responses( ) aioclient_mock.get( f"{host}/solar_api/v1/GetInverterInfo.cgi", - text=load_fixture(f"{fixture_set}/GetInverterInfo.json", "fronius"), + text=load_fixture(f"{fixture_set}/GetInverterInfo{_night}.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetLoggerInfo.cgi", diff --git a/tests/components/fronius/fixtures/igplus_v2/GetAPIVersion.json b/tests/components/fronius/fixtures/igplus_v2/GetAPIVersion.json new file mode 100644 index 00000000000..28b2077691c --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetAPIVersion.json @@ -0,0 +1,5 @@ +{ + "APIVersion": 1, + "BaseURL": "/solar_api/v1/", + "CompatibilityRange": "1.5-18" +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetInverterInfo.json b/tests/components/fronius/fixtures/igplus_v2/GetInverterInfo.json new file mode 100644 index 00000000000..844fcff89e4 --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetInverterInfo.json @@ -0,0 +1,24 @@ +{ + "Body": { + "Data": { + "1": { + "CustomName": "IG Plus 70 V-2", + "DT": 174, + "ErrorCode": 0, + "PVPower": 6500, + "Show": 1, + "StatusCode": 7, + "UniqueID": "203200" + } + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-07-14T17:19:20+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetInverterInfo_night.json b/tests/components/fronius/fixtures/igplus_v2/GetInverterInfo_night.json new file mode 100644 index 00000000000..e65784e7971 --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetInverterInfo_night.json @@ -0,0 +1,14 @@ +{ + "Body": { + "Data": {} + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-06-27T21:48:52+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetInverterRealtimeData_Device_1.json b/tests/components/fronius/fixtures/igplus_v2/GetInverterRealtimeData_Device_1.json new file mode 100644 index 00000000000..150ea901a0c --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetInverterRealtimeData_Device_1.json @@ -0,0 +1,64 @@ +{ + "Body": { + "Data": { + "DAY_ENERGY": { + "Unit": "Wh", + "Value": 42000 + }, + "DeviceStatus": { + "ErrorCode": 0, + "LEDColor": 2, + "LEDState": 0, + "MgmtTimerRemainingTime": -1, + "StateToReset": false, + "StatusCode": 7 + }, + "FAC": { + "Unit": "Hz", + "Value": 49.960000000000001 + }, + "IAC": { + "Unit": "A", + "Value": 9.0299999999999994 + }, + "IDC": { + "Unit": "A", + "Value": 6.46 + }, + "PAC": { + "Unit": "W", + "Value": 2096 + }, + "TOTAL_ENERGY": { + "Unit": "Wh", + "Value": 81809000 + }, + "UAC": { + "Unit": "V", + "Value": 232 + }, + "UDC": { + "Unit": "V", + "Value": 345 + }, + "YEAR_ENERGY": { + "Unit": "Wh", + "Value": 4927000 + } + } + }, + "Head": { + "RequestArguments": { + "DataCollection": "CommonInverterData", + "DeviceClass": "Inverter", + "DeviceId": "1", + "Scope": "Device" + }, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-07-14T17:21:42+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetInverterRealtimeData_Device_1_night.json b/tests/components/fronius/fixtures/igplus_v2/GetInverterRealtimeData_Device_1_night.json new file mode 100644 index 00000000000..e65784e7971 --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetInverterRealtimeData_Device_1_night.json @@ -0,0 +1,14 @@ +{ + "Body": { + "Data": {} + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-06-27T21:48:52+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetLoggerInfo.json b/tests/components/fronius/fixtures/igplus_v2/GetLoggerInfo.json new file mode 100644 index 00000000000..0ebeb823def --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetLoggerInfo.json @@ -0,0 +1,29 @@ +{ + "Body": { + "LoggerInfo": { + "CO2Factor": 0.52999997138977051, + "CO2Unit": "kg", + "CashCurrency": "EUR", + "CashFactor": 0.07700000643730164, + "DefaultLanguage": "en", + "DeliveryFactor": 0.25, + "HWVersion": "2.4D", + "PlatformID": "wilma", + "ProductID": "fronius-datamanager-card", + "SWVersion": "3.26.1-3", + "TimezoneLocation": "Berlin", + "TimezoneName": "CEST", + "UTCOffset": 7200, + "UniqueID": "123.4567890" + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-07-14T17:23:22+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetMeterRealtimeData.json b/tests/components/fronius/fixtures/igplus_v2/GetMeterRealtimeData.json new file mode 100644 index 00000000000..30de1a1fa98 --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetMeterRealtimeData.json @@ -0,0 +1,17 @@ +{ + "Body": { + "Data": {} + }, + "Head": { + "RequestArguments": { + "DeviceClass": "Meter", + "Scope": "System" + }, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-07-14T17:28:05+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetOhmPilotRealtimeData.json b/tests/components/fronius/fixtures/igplus_v2/GetOhmPilotRealtimeData.json new file mode 100644 index 00000000000..e77b751db3b --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetOhmPilotRealtimeData.json @@ -0,0 +1,17 @@ +{ + "Body": { + "Data": {} + }, + "Head": { + "RequestArguments": { + "DeviceClass": "OhmPilot", + "Scope": "System" + }, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-07-14T17:29:16+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetPowerFlowRealtimeData.json b/tests/components/fronius/fixtures/igplus_v2/GetPowerFlowRealtimeData.json new file mode 100644 index 00000000000..a8ae2fc6d86 --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetPowerFlowRealtimeData.json @@ -0,0 +1,38 @@ +{ + "Body": { + "Data": { + "Inverters": { + "1": { + "DT": 174, + "E_Day": 43000, + "E_Total": 1230000, + "E_Year": 12345, + "P": 2241 + } + }, + "Site": { + "E_Day": 43000, + "E_Total": 1230000, + "E_Year": 12345, + "Meter_Location": "unknown", + "Mode": "produce-only", + "P_Akku": null, + "P_Grid": null, + "P_Load": null, + "P_PV": 2241, + "rel_Autonomy": null, + "rel_SelfConsumption": null + }, + "Version": "12" + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-07-14T17:29:55+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetPowerFlowRealtimeData_night.json b/tests/components/fronius/fixtures/igplus_v2/GetPowerFlowRealtimeData_night.json new file mode 100644 index 00000000000..1da28803195 --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetPowerFlowRealtimeData_night.json @@ -0,0 +1,32 @@ +{ + "Body": { + "Data": { + "Inverters": {}, + "Site": { + "E_Day": null, + "E_Total": null, + "E_Year": null, + "Meter_Location": "unknown", + "Mode": "produce-only", + "P_Akku": null, + "P_Grid": null, + "P_Load": null, + "P_PV": null, + "rel_Autonomy": null, + "rel_SelfConsumption": null + }, + "Version": "12" + } + }, + "Head": { + "RequestArguments": { + "humanreadable": "false" + }, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2023-07-13T22:04:44+02:00" + } +} diff --git a/tests/components/fronius/fixtures/igplus_v2/GetStorageRealtimeData.json b/tests/components/fronius/fixtures/igplus_v2/GetStorageRealtimeData.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/tests/components/fronius/fixtures/igplus_v2/GetStorageRealtimeData.json @@ -0,0 +1 @@ +{} diff --git a/tests/components/fronius/fixtures/symo/GetInverterInfo_night.json b/tests/components/fronius/fixtures/symo/GetInverterInfo_night.json new file mode 100644 index 00000000000..5b2676c3a3f --- /dev/null +++ b/tests/components/fronius/fixtures/symo/GetInverterInfo_night.json @@ -0,0 +1,24 @@ +{ + "Body": { + "Data": { + "1": { + "CustomName": "Symo 20", + "DT": 121, + "ErrorCode": 0, + "PVPower": 23100, + "Show": 1, + "StatusCode": 7, + "UniqueID": "1234567" + } + } + }, + "Head": { + "RequestArguments": {}, + "Status": { + "Code": 0, + "Reason": "", + "UserMessage": "" + }, + "Timestamp": "2021-10-07T13:41:00+02:00" + } +} diff --git a/tests/components/fronius/test_init.py b/tests/components/fronius/test_init.py index 0e8b405da44..d46c60c3cb3 100644 --- a/tests/components/fronius/test_init.py +++ b/tests/components/fronius/test_init.py @@ -1,14 +1,18 @@ """Test the Fronius integration.""" +from datetime import timedelta from unittest.mock import patch from pyfronius import FroniusError -from homeassistant.components.fronius.const import DOMAIN +from homeassistant.components.fronius.const import DOMAIN, SOLAR_NET_RESCAN_TIMER from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.util import dt as dt_util from . import mock_responses, setup_fronius_integration +from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -53,3 +57,82 @@ async def test_inverter_error( ): config_entry = await setup_fronius_integration(hass) assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_inverter_night_rescan( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test dynamic adding of an inverter discovered automatically after a Home Assistant reboot during the night.""" + mock_responses(aioclient_mock, fixture_set="igplus_v2", night=True) + config_entry = await setup_fronius_integration(hass, is_logger=True) + assert config_entry.state is ConfigEntryState.LOADED + + # Only expect logger during the night + fronius_entries = hass.config_entries.async_entries(DOMAIN) + assert len(fronius_entries) == 1 + + # Switch to daytime + mock_responses(aioclient_mock, fixture_set="igplus_v2", night=False) + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER) + ) + await hass.async_block_till_done() + + # We expect our inverter to be present now + device_registry = dr.async_get(hass) + inverter_1 = device_registry.async_get_device(identifiers={(DOMAIN, "203200")}) + assert inverter_1.manufacturer == "Fronius" + + # After another re-scan we still only expect this inverter + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER * 2) + ) + await hass.async_block_till_done() + inverter_1 = device_registry.async_get_device(identifiers={(DOMAIN, "203200")}) + assert inverter_1.manufacturer == "Fronius" + + +async def test_inverter_rescan_interruption( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test interruption of re-scan during runtime to process further.""" + mock_responses(aioclient_mock, fixture_set="igplus_v2", night=True) + config_entry = await setup_fronius_integration(hass, is_logger=True) + assert config_entry.state is ConfigEntryState.LOADED + device_registry = dr.async_get(hass) + # Expect 1 devices during the night, logger + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 1 + ) + + with patch( + "pyfronius.Fronius.inverter_info", + side_effect=FroniusError, + ): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER) + ) + await hass.async_block_till_done() + + # No increase of devices expected because of a FroniusError + assert ( + len( + dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + ) + == 1 + ) + + # Next re-scan will pick up the new inverter. Expect 2 devices now. + mock_responses(aioclient_mock, fixture_set="igplus_v2", night=False) + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(minutes=SOLAR_NET_RESCAN_TIMER * 2) + ) + await hass.async_block_till_done() + + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 2 + ) diff --git a/tests/components/fully_kiosk/test_diagnostics.py b/tests/components/fully_kiosk/test_diagnostics.py index ebd4a028f8c..b1b30bda669 100644 --- a/tests/components/fully_kiosk/test_diagnostics.py +++ b/tests/components/fully_kiosk/test_diagnostics.py @@ -24,7 +24,7 @@ async def test_diagnostics( """Test Fully Kiosk diagnostics.""" device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, "abcdef-123456")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, "abcdef-123456")}) diagnostics = await get_diagnostics_for_device( hass, hass_client, init_integration, device diff --git a/tests/components/gardena_bluetooth/__init__.py b/tests/components/gardena_bluetooth/__init__.py new file mode 100644 index 00000000000..7de0780e129 --- /dev/null +++ b/tests/components/gardena_bluetooth/__init__.py @@ -0,0 +1,83 @@ +"""Tests for the Gardena Bluetooth integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.common import MockConfigEntry +from tests.components.bluetooth import ( + inject_bluetooth_service_info, +) + +WATER_TIMER_SERVICE_INFO = BluetoothServiceInfo( + name="Timer", + address="00000000-0000-0000-0000-000000000001", + rssi=-63, + service_data={}, + manufacturer_data={ + 1062: b"\x02\x07d\x02\x05\x01\x02\x08\x00\x02\t\x01\x04\x06\x12\x00\x01" + }, + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], + source="local", +) + +WATER_TIMER_UNNAMED_SERVICE_INFO = BluetoothServiceInfo( + name=None, + address="00000000-0000-0000-0000-000000000002", + rssi=-63, + service_data={}, + manufacturer_data={ + 1062: b"\x02\x07d\x02\x05\x01\x02\x08\x00\x02\t\x01\x04\x06\x12\x00\x01" + }, + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], + source="local", +) + +MISSING_SERVICE_SERVICE_INFO = BluetoothServiceInfo( + name="Missing Service Info", + address="00000000-0000-0000-0001-000000000000", + rssi=-63, + service_data={}, + manufacturer_data={ + 1062: b"\x02\x07d\x02\x05\x01\x02\x08\x00\x02\t\x01\x04\x06\x12\x00\x01" + }, + service_uuids=[], + source="local", +) + +MISSING_MANUFACTURER_DATA_SERVICE_INFO = BluetoothServiceInfo( + name="Missing Manufacturer Data", + address="00000000-0000-0000-0001-000000000001", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], + source="local", +) + +UNSUPPORTED_GROUP_SERVICE_INFO = BluetoothServiceInfo( + name="Unsupported Group", + address="00000000-0000-0000-0001-000000000002", + rssi=-63, + service_data={}, + manufacturer_data={ + 1062: b"\x02\x07d\x02\x05\x01\x02\x08\x00\x02\t\x01\x04\x06\x10\x00\x01" + }, + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], + source="local", +) + + +async def setup_entry( + hass: HomeAssistant, mock_entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Make sure the device is available.""" + + inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO) + + with patch("homeassistant.components.gardena_bluetooth.PLATFORMS", platforms): + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/gardena_bluetooth/conftest.py b/tests/components/gardena_bluetooth/conftest.py new file mode 100644 index 00000000000..98ae41d195b --- /dev/null +++ b/tests/components/gardena_bluetooth/conftest.py @@ -0,0 +1,117 @@ +"""Common fixtures for the Gardena Bluetooth tests.""" +from collections.abc import Awaitable, Callable, Generator +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +from freezegun import freeze_time +from gardena_bluetooth.client import Client +from gardena_bluetooth.const import DeviceInformation +from gardena_bluetooth.exceptions import CharacteristicNotFound +from gardena_bluetooth.parse import Characteristic +import pytest + +from homeassistant.components.gardena_bluetooth.const import DOMAIN +from homeassistant.components.gardena_bluetooth.coordinator import SCAN_INTERVAL +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from . import WATER_TIMER_SERVICE_INFO + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.fixture +def mock_entry(): + """Create hass config fixture.""" + return MockConfigEntry( + domain=DOMAIN, data={CONF_ADDRESS: WATER_TIMER_SERVICE_INFO.address} + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.gardena_bluetooth.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_read_char_raw(): + """Mock data on device.""" + return { + DeviceInformation.firmware_version.uuid: b"1.2.3", + DeviceInformation.model_number.uuid: b"Mock Model", + } + + +@pytest.fixture +async def scan_step( + hass: HomeAssistant, +) -> Generator[None, None, Callable[[], Awaitable[None]]]: + """Step system time forward.""" + + with freeze_time("2023-01-01", tz_offset=1) as frozen_time: + + async def delay(): + """Trigger delay in system.""" + frozen_time.tick(delta=SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + yield delay + + +@pytest.fixture(autouse=True) +def mock_client( + enable_bluetooth: None, scan_step, mock_read_char_raw: dict[str, Any] +) -> None: + """Auto mock bluetooth.""" + + client = Mock(spec_set=Client) + + SENTINEL = object() + + def _read_char(char: Characteristic, default: Any = SENTINEL): + try: + return char.decode(mock_read_char_raw[char.uuid]) + except KeyError: + if default is SENTINEL: + raise CharacteristicNotFound from KeyError + return default + + def _read_char_raw(uuid: str, default: Any = SENTINEL): + try: + val = mock_read_char_raw[uuid] + if isinstance(val, Exception): + raise val + return val + except KeyError: + if default is SENTINEL: + raise CharacteristicNotFound from KeyError + return default + + def _all_char(): + return set(mock_read_char_raw.keys()) + + client.read_char.side_effect = _read_char + client.read_char_raw.side_effect = _read_char_raw + client.get_all_characteristics_uuid.side_effect = _all_char + + with patch( + "homeassistant.components.gardena_bluetooth.config_flow.Client", + return_value=client, + ), patch("homeassistant.components.gardena_bluetooth.Client", return_value=client): + yield client + + +@pytest.fixture(autouse=True) +def enable_all_entities(): + """Make sure all entities are enabled.""" + with patch( + "homeassistant.components.gardena_bluetooth.coordinator.GardenaBluetoothEntity.entity_registry_enabled_default", + new=Mock(return_value=True), + ): + yield diff --git a/tests/components/gardena_bluetooth/snapshots/test_binary_sensor.ambr b/tests/components/gardena_bluetooth/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..8a2600dcbb1 --- /dev/null +++ b/tests/components/gardena_bluetooth/snapshots/test_binary_sensor.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_setup[98bd0f12-0b0e-421a-84e5-ddbf75dc6de4-raw0-binary_sensor.mock_title_valve_connection] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Mock Title Valve connection', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_title_valve_connection', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup[98bd0f12-0b0e-421a-84e5-ddbf75dc6de4-raw0-binary_sensor.mock_title_valve_connection].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Mock Title Valve connection', + }), + 'context': , + 'entity_id': 'binary_sensor.mock_title_valve_connection', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/gardena_bluetooth/snapshots/test_button.ambr b/tests/components/gardena_bluetooth/snapshots/test_button.ambr new file mode 100644 index 00000000000..b9cdca0e03c --- /dev/null +++ b/tests/components/gardena_bluetooth/snapshots/test_button.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_setup + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Factory reset', + }), + 'context': , + 'entity_id': 'button.mock_title_factory_reset', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Factory reset', + }), + 'context': , + 'entity_id': 'button.mock_title_factory_reset', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..fde70b60a01 --- /dev/null +++ b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr @@ -0,0 +1,258 @@ +# serializer version: 1 +# name: test_bluetooth + FlowResultSnapshot({ + 'data_schema': None, + 'description_placeholders': dict({ + 'name': 'Timer', + }), + 'errors': None, + 'flow_id': , + 'handler': 'gardena_bluetooth', + 'last_step': None, + 'step_id': 'confirm', + 'type': , + }) +# --- +# name: test_bluetooth.1 + FlowResultSnapshot({ + 'context': dict({ + 'confirm_only': True, + 'source': 'bluetooth', + 'title_placeholders': dict({ + 'name': 'Timer', + }), + 'unique_id': '00000000-0000-0000-0000-000000000001', + }), + 'data': dict({ + 'address': '00000000-0000-0000-0000-000000000001', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'gardena_bluetooth', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'address': '00000000-0000-0000-0000-000000000001', + }), + 'disabled_by': None, + 'domain': 'gardena_bluetooth', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'bluetooth', + 'title': 'Timer', + 'unique_id': '00000000-0000-0000-0000-000000000001', + 'version': 1, + }), + 'title': 'Timer', + 'type': , + 'version': 1, + }) +# --- +# name: test_bluetooth_invalid + FlowResultSnapshot({ + 'description_placeholders': None, + 'flow_id': , + 'handler': 'gardena_bluetooth', + 'reason': 'no_devices_found', + 'type': , + }) +# --- +# name: test_bluetooth_lost + FlowResultSnapshot({ + 'data_schema': None, + 'description_placeholders': dict({ + 'name': 'Timer', + }), + 'errors': None, + 'flow_id': , + 'handler': 'gardena_bluetooth', + 'last_step': None, + 'step_id': 'confirm', + 'type': , + }) +# --- +# name: test_bluetooth_lost.1 + FlowResultSnapshot({ + 'context': dict({ + 'confirm_only': True, + 'source': 'bluetooth', + 'title_placeholders': dict({ + 'name': 'Timer', + }), + 'unique_id': '00000000-0000-0000-0000-000000000001', + }), + 'data': dict({ + 'address': '00000000-0000-0000-0000-000000000001', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'gardena_bluetooth', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'address': '00000000-0000-0000-0000-000000000001', + }), + 'disabled_by': None, + 'domain': 'gardena_bluetooth', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'bluetooth', + 'title': 'Timer', + 'unique_id': '00000000-0000-0000-0000-000000000001', + 'version': 1, + }), + 'title': 'Timer', + 'type': , + 'version': 1, + }) +# --- +# name: test_failed_connect + FlowResultSnapshot({ + 'data_schema': list([ + dict({ + 'name': 'address', + 'options': list([ + tuple( + '00000000-0000-0000-0000-000000000001', + 'Timer', + ), + ]), + 'required': True, + 'type': 'select', + }), + ]), + 'description_placeholders': None, + 'errors': None, + 'flow_id': , + 'handler': 'gardena_bluetooth', + 'last_step': None, + 'step_id': 'user', + 'type': , + }) +# --- +# name: test_failed_connect.1 + FlowResultSnapshot({ + 'data_schema': None, + 'description_placeholders': dict({ + 'name': 'Timer', + }), + 'errors': None, + 'flow_id': , + 'handler': 'gardena_bluetooth', + 'last_step': None, + 'step_id': 'confirm', + 'type': , + }) +# --- +# name: test_failed_connect.2 + FlowResultSnapshot({ + 'description_placeholders': dict({ + 'error': 'something went wrong', + }), + 'flow_id': , + 'handler': 'gardena_bluetooth', + 'reason': 'cannot_connect', + 'type': , + }) +# --- +# name: test_no_devices + FlowResultSnapshot({ + 'description_placeholders': None, + 'flow_id': , + 'handler': 'gardena_bluetooth', + 'reason': 'no_devices_found', + 'type': , + }) +# --- +# name: test_user_selection + FlowResultSnapshot({ + 'data_schema': list([ + dict({ + 'name': 'address', + 'options': list([ + tuple( + '00000000-0000-0000-0000-000000000001', + 'Timer', + ), + tuple( + '00000000-0000-0000-0000-000000000002', + 'Gardena Device', + ), + ]), + 'required': True, + 'type': 'select', + }), + ]), + 'description_placeholders': None, + 'errors': None, + 'flow_id': , + 'handler': 'gardena_bluetooth', + 'last_step': None, + 'step_id': 'user', + 'type': , + }) +# --- +# name: test_user_selection.1 + FlowResultSnapshot({ + 'data_schema': None, + 'description_placeholders': dict({ + 'name': 'Timer', + }), + 'errors': None, + 'flow_id': , + 'handler': 'gardena_bluetooth', + 'last_step': None, + 'step_id': 'confirm', + 'type': , + }) +# --- +# name: test_user_selection.2 + FlowResultSnapshot({ + 'context': dict({ + 'confirm_only': True, + 'source': 'user', + 'title_placeholders': dict({ + 'name': 'Timer', + }), + 'unique_id': '00000000-0000-0000-0000-000000000001', + }), + 'data': dict({ + 'address': '00000000-0000-0000-0000-000000000001', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'gardena_bluetooth', + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'address': '00000000-0000-0000-0000-000000000001', + }), + 'disabled_by': None, + 'domain': 'gardena_bluetooth', + 'entry_id': , + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Timer', + 'unique_id': '00000000-0000-0000-0000-000000000001', + 'version': 1, + }), + 'title': 'Timer', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/gardena_bluetooth/snapshots/test_init.ambr b/tests/components/gardena_bluetooth/snapshots/test_init.ambr new file mode 100644 index 00000000000..a3ecff80a46 --- /dev/null +++ b/tests/components/gardena_bluetooth/snapshots/test_init.ambr @@ -0,0 +1,28 @@ +# serializer version: 1 +# name: test_setup + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'gardena_bluetooth', + '00000000-0000-0000-0000-000000000001', + ), + }), + 'is_new': False, + 'manufacturer': None, + 'model': 'Mock Model', + 'name': 'Mock Title', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.2.3', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/gardena_bluetooth/snapshots/test_number.ambr b/tests/components/gardena_bluetooth/snapshots/test_number.ambr new file mode 100644 index 00000000000..0c464f7cbc1 --- /dev/null +++ b/tests/components/gardena_bluetooth/snapshots/test_number.ambr @@ -0,0 +1,188 @@ +# serializer version: 1 +# name: test_bluetooth_error_unavailable + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Remaining open time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_remaining_open_time', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_bluetooth_error_unavailable.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Manual watering time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_manual_watering_time', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_bluetooth_error_unavailable.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Remaining open time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_remaining_open_time', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_bluetooth_error_unavailable.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Manual watering time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_manual_watering_time', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Remaining open time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_remaining_open_time', + 'last_changed': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Remaining open time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_remaining_open_time', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Remaining open time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_remaining_open_time', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Remaining open time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_remaining_open_time', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw2-number.mock_title_open_for] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Open for', + 'max': 1440, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.mock_title_open_for', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[98bd0f14-0b0e-421a-84e5-ddbf75dc6de4-raw0-number.mock_title_manual_watering_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Manual watering time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_manual_watering_time', + 'last_changed': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_setup[98bd0f14-0b0e-421a-84e5-ddbf75dc6de4-raw0-number.mock_title_manual_watering_time].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Manual watering time', + 'max': 86400, + 'min': 0.0, + 'mode': , + 'step': 60, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_title_manual_watering_time', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- diff --git a/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..14135cb390c --- /dev/null +++ b/tests/components/gardena_bluetooth/snapshots/test_sensor.ambr @@ -0,0 +1,70 @@ +# serializer version: 1 +# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-sensor.mock_title_valve_closing] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Valve closing', + }), + 'context': , + 'entity_id': 'sensor.mock_title_valve_closing', + 'last_changed': , + 'last_updated': , + 'state': '2023-01-01T01:01:40+00:00', + }) +# --- +# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-sensor.mock_title_valve_closing].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Valve closing', + }), + 'context': , + 'entity_id': 'sensor.mock_title_valve_closing', + 'last_changed': , + 'last_updated': , + 'state': '2023-01-01T01:01:10+00:00', + }) +# --- +# name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-sensor.mock_title_valve_closing].2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Mock Title Valve closing', + }), + 'context': , + 'entity_id': 'sensor.mock_title_valve_closing', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[98bd2a19-0b0e-421a-84e5-ddbf75dc6de4-raw0-sensor.mock_title_battery] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Mock Title Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_title_battery', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_setup[98bd2a19-0b0e-421a-84e5-ddbf75dc6de4-raw0-sensor.mock_title_battery].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Mock Title Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_title_battery', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- diff --git a/tests/components/gardena_bluetooth/snapshots/test_switch.ambr b/tests/components/gardena_bluetooth/snapshots/test_switch.ambr new file mode 100644 index 00000000000..37dae0bff75 --- /dev/null +++ b/tests/components/gardena_bluetooth/snapshots/test_switch.ambr @@ -0,0 +1,25 @@ +# serializer version: 1 +# name: test_setup + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Open', + }), + 'context': , + 'entity_id': 'switch.mock_title_open', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_setup.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Title Open', + }), + 'context': , + 'entity_id': 'switch.mock_title_open', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/gardena_bluetooth/test_binary_sensor.py b/tests/components/gardena_bluetooth/test_binary_sensor.py new file mode 100644 index 00000000000..d12f825b1a7 --- /dev/null +++ b/tests/components/gardena_bluetooth/test_binary_sensor.py @@ -0,0 +1,47 @@ +"""Test Gardena Bluetooth binary sensor.""" + + +from collections.abc import Awaitable, Callable + +from gardena_bluetooth.const import Valve +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_entry + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("uuid", "raw", "entity_id"), + [ + ( + Valve.connected_state.uuid, + [b"\x01", b"\x00"], + "binary_sensor.mock_title_valve_connection", + ), + ], +) +async def test_setup( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_read_char_raw: dict[str, bytes], + scan_step: Callable[[], Awaitable[None]], + uuid: str, + raw: list[bytes], + entity_id: str, +) -> None: + """Test setup creates expected entities.""" + + mock_read_char_raw[uuid] = raw[0] + await setup_entry(hass, mock_entry, [Platform.BINARY_SENSOR]) + assert hass.states.get(entity_id) == snapshot + + for char_raw in raw[1:]: + mock_read_char_raw[uuid] = char_raw + await scan_step() + assert hass.states.get(entity_id) == snapshot diff --git a/tests/components/gardena_bluetooth/test_button.py b/tests/components/gardena_bluetooth/test_button.py new file mode 100644 index 00000000000..52fa3d4b00e --- /dev/null +++ b/tests/components/gardena_bluetooth/test_button.py @@ -0,0 +1,69 @@ +"""Test Gardena Bluetooth sensor.""" + + +from collections.abc import Awaitable, Callable +from unittest.mock import Mock, call + +from gardena_bluetooth.const import Reset +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ( + ATTR_ENTITY_ID, + Platform, +) +from homeassistant.core import HomeAssistant + +from . import setup_entry + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_switch_chars(mock_read_char_raw): + """Mock data on device.""" + mock_read_char_raw[Reset.factory_reset.uuid] = b"\x00" + return mock_read_char_raw + + +async def test_setup( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_switch_chars: dict[str, bytes], + scan_step: Callable[[], Awaitable[None]], +) -> None: + """Test setup creates expected entities.""" + + entity_id = "button.mock_title_factory_reset" + await setup_entry(hass, mock_entry, [Platform.BUTTON]) + assert hass.states.get(entity_id) == snapshot + + mock_switch_chars[Reset.factory_reset.uuid] = b"\x01" + await scan_step() + assert hass.states.get(entity_id) == snapshot + + +async def test_switching( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_switch_chars: dict[str, bytes], +) -> None: + """Test switching makes correct calls.""" + + entity_id = "button.mock_title_factory_reset" + await setup_entry(hass, mock_entry, [Platform.BUTTON]) + assert hass.states.get(entity_id) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_client.write_char.mock_calls == [ + call(Reset.factory_reset, True), + ] diff --git a/tests/components/gardena_bluetooth/test_config_flow.py b/tests/components/gardena_bluetooth/test_config_flow.py new file mode 100644 index 00000000000..0f0e297c4d7 --- /dev/null +++ b/tests/components/gardena_bluetooth/test_config_flow.py @@ -0,0 +1,134 @@ +"""Test the Gardena Bluetooth config flow.""" +from unittest.mock import Mock + +from gardena_bluetooth.exceptions import CharacteristicNotFound +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant import config_entries +from homeassistant.components.gardena_bluetooth.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import ( + MISSING_MANUFACTURER_DATA_SERVICE_INFO, + MISSING_SERVICE_SERVICE_INFO, + UNSUPPORTED_GROUP_SERVICE_INFO, + WATER_TIMER_SERVICE_INFO, + WATER_TIMER_UNNAMED_SERVICE_INFO, +) + +from tests.components.bluetooth import ( + inject_bluetooth_service_info, +) + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_user_selection( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test we can select a device.""" + + inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO) + inject_bluetooth_service_info(hass, WATER_TIMER_UNNAMED_SERVICE_INFO) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result == snapshot + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "00000000-0000-0000-0000-000000000001"}, + ) + assert result == snapshot + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result == snapshot + + +async def test_failed_connect( + hass: HomeAssistant, + mock_client: Mock, + snapshot: SnapshotAssertion, +) -> None: + """Test we can select a device.""" + + inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result == snapshot + + mock_client.read_char.side_effect = CharacteristicNotFound("something went wrong") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": "00000000-0000-0000-0000-000000000001"}, + ) + assert result == snapshot + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result == snapshot + + +async def test_no_devices( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test missing device.""" + + inject_bluetooth_service_info(hass, MISSING_MANUFACTURER_DATA_SERVICE_INFO) + inject_bluetooth_service_info(hass, MISSING_SERVICE_SERVICE_INFO) + inject_bluetooth_service_info(hass, UNSUPPORTED_GROUP_SERVICE_INFO) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result == snapshot + + +async def test_bluetooth( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test bluetooth device discovery.""" + + inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=WATER_TIMER_SERVICE_INFO, + ) + assert result == snapshot + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result == snapshot + + +async def test_bluetooth_invalid( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test bluetooth device discovery with invalid data.""" + + inject_bluetooth_service_info(hass, UNSUPPORTED_GROUP_SERVICE_INFO) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=UNSUPPORTED_GROUP_SERVICE_INFO, + ) + assert result == snapshot diff --git a/tests/components/gardena_bluetooth/test_init.py b/tests/components/gardena_bluetooth/test_init.py new file mode 100644 index 00000000000..b09d2177c22 --- /dev/null +++ b/tests/components/gardena_bluetooth/test_init.py @@ -0,0 +1,62 @@ +"""Test the Gardena Bluetooth setup.""" + +from datetime import timedelta +from unittest.mock import Mock + +from gardena_bluetooth.const import Battery +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.gardena_bluetooth import DeviceUnavailable +from homeassistant.components.gardena_bluetooth.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.util import utcnow + +from . import WATER_TIMER_SERVICE_INFO + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_setup( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_read_char_raw: dict[str, bytes], + snapshot: SnapshotAssertion, +) -> None: + """Test setup creates expected devices.""" + + mock_read_char_raw[Battery.battery_level.uuid] = Battery.battery_level.encode(100) + + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert mock_entry.state is ConfigEntryState.LOADED + + device_registry = dr.async_get(hass) + device = device_registry.async_get_device( + identifiers={(DOMAIN, WATER_TIMER_SERVICE_INFO.address)} + ) + assert device == snapshot + + +async def test_setup_retry( + hass: HomeAssistant, mock_entry: MockConfigEntry, mock_client: Mock +) -> None: + """Test setup creates expected devices.""" + + original_read_char = mock_client.read_char.side_effect + mock_client.read_char.side_effect = DeviceUnavailable + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + assert mock_entry.state is ConfigEntryState.SETUP_RETRY + + mock_client.read_char.side_effect = original_read_char + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + + assert mock_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/gardena_bluetooth/test_number.py b/tests/components/gardena_bluetooth/test_number.py new file mode 100644 index 00000000000..3b04d0cc818 --- /dev/null +++ b/tests/components/gardena_bluetooth/test_number.py @@ -0,0 +1,154 @@ +"""Test Gardena Bluetooth sensor.""" + + +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import Mock, call + +from gardena_bluetooth.const import Valve +from gardena_bluetooth.exceptions import ( + CharacteristicNoAccess, + GardenaBluetoothException, +) +from gardena_bluetooth.parse import Characteristic +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + Platform, +) +from homeassistant.core import HomeAssistant + +from . import setup_entry + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("uuid", "raw", "entity_id"), + [ + ( + Valve.manual_watering_time.uuid, + [ + Valve.manual_watering_time.encode(100), + Valve.manual_watering_time.encode(10), + ], + "number.mock_title_manual_watering_time", + ), + ( + Valve.remaining_open_time.uuid, + [ + Valve.remaining_open_time.encode(100), + Valve.remaining_open_time.encode(10), + CharacteristicNoAccess("Test for no access"), + GardenaBluetoothException("Test for errors on bluetooth"), + ], + "number.mock_title_remaining_open_time", + ), + ( + Valve.remaining_open_time.uuid, + [Valve.remaining_open_time.encode(100)], + "number.mock_title_open_for", + ), + ], +) +async def test_setup( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_read_char_raw: dict[str, bytes], + scan_step: Callable[[], Awaitable[None]], + uuid: str, + raw: list[bytes], + entity_id: str, +) -> None: + """Test setup creates expected entities.""" + + mock_read_char_raw[uuid] = raw[0] + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + assert hass.states.get(entity_id) == snapshot + + for char_raw in raw[1:]: + mock_read_char_raw[uuid] = char_raw + await scan_step() + assert hass.states.get(entity_id) == snapshot + + +@pytest.mark.parametrize( + ("char", "value", "expected", "entity_id"), + [ + ( + Valve.manual_watering_time, + 100, + 100, + "number.mock_title_manual_watering_time", + ), + ( + Valve.remaining_open_time, + 100, + 100 * 60, + "number.mock_title_open_for", + ), + ], +) +async def test_config( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_read_char_raw: dict[str, bytes], + mock_client: Mock, + char: Characteristic, + value: Any, + expected: Any, + entity_id: str, +) -> None: + """Test setup creates expected entities.""" + + mock_read_char_raw[char.uuid] = char.encode(value) + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + assert hass.states.get(entity_id) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + assert mock_client.write_char.mock_calls == [ + call(char, expected), + ] + + +async def test_bluetooth_error_unavailable( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_read_char_raw: dict[str, bytes], + scan_step: Callable[[], Awaitable[None]], +) -> None: + """Verify that a connectivity error makes all entities unavailable.""" + + mock_read_char_raw[ + Valve.manual_watering_time.uuid + ] = Valve.manual_watering_time.encode(0) + mock_read_char_raw[ + Valve.remaining_open_time.uuid + ] = Valve.remaining_open_time.encode(0) + + await setup_entry(hass, mock_entry, [Platform.NUMBER]) + assert hass.states.get("number.mock_title_remaining_open_time") == snapshot + assert hass.states.get("number.mock_title_manual_watering_time") == snapshot + + mock_read_char_raw[Valve.manual_watering_time.uuid] = GardenaBluetoothException( + "Test for errors on bluetooth" + ) + + await scan_step() + assert hass.states.get("number.mock_title_remaining_open_time") == snapshot + assert hass.states.get("number.mock_title_manual_watering_time") == snapshot diff --git a/tests/components/gardena_bluetooth/test_sensor.py b/tests/components/gardena_bluetooth/test_sensor.py new file mode 100644 index 00000000000..307a9467f00 --- /dev/null +++ b/tests/components/gardena_bluetooth/test_sensor.py @@ -0,0 +1,54 @@ +"""Test Gardena Bluetooth sensor.""" +from collections.abc import Awaitable, Callable + +from gardena_bluetooth.const import Battery, Valve +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_entry + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("uuid", "raw", "entity_id"), + [ + ( + Battery.battery_level.uuid, + [Battery.battery_level.encode(100), Battery.battery_level.encode(10)], + "sensor.mock_title_battery", + ), + ( + Valve.remaining_open_time.uuid, + [ + Valve.remaining_open_time.encode(100), + Valve.remaining_open_time.encode(10), + Valve.remaining_open_time.encode(0), + ], + "sensor.mock_title_valve_closing", + ), + ], +) +async def test_setup( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_read_char_raw: dict[str, bytes], + scan_step: Callable[[], Awaitable[None]], + uuid: str, + raw: list[bytes], + entity_id: str, +) -> None: + """Test setup creates expected entities.""" + + mock_read_char_raw[uuid] = raw[0] + await setup_entry(hass, mock_entry, [Platform.SENSOR]) + assert hass.states.get(entity_id) == snapshot + + for char_raw in raw[1:]: + mock_read_char_raw[uuid] = char_raw + await scan_step() + assert hass.states.get(entity_id) == snapshot diff --git a/tests/components/gardena_bluetooth/test_switch.py b/tests/components/gardena_bluetooth/test_switch.py new file mode 100644 index 00000000000..40e8c148335 --- /dev/null +++ b/tests/components/gardena_bluetooth/test_switch.py @@ -0,0 +1,86 @@ +"""Test Gardena Bluetooth sensor.""" + + +from collections.abc import Awaitable, Callable +from unittest.mock import Mock, call + +from gardena_bluetooth.const import Valve +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) +from homeassistant.core import HomeAssistant + +from . import setup_entry + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_switch_chars(mock_read_char_raw): + """Mock data on device.""" + mock_read_char_raw[Valve.state.uuid] = b"\x00" + mock_read_char_raw[ + Valve.remaining_open_time.uuid + ] = Valve.remaining_open_time.encode(0) + mock_read_char_raw[ + Valve.manual_watering_time.uuid + ] = Valve.manual_watering_time.encode(1000) + return mock_read_char_raw + + +async def test_setup( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_switch_chars: dict[str, bytes], + scan_step: Callable[[], Awaitable[None]], +) -> None: + """Test setup creates expected entities.""" + + entity_id = "switch.mock_title_open" + await setup_entry(hass, mock_entry, [Platform.SWITCH]) + assert hass.states.get(entity_id) == snapshot + + mock_switch_chars[Valve.state.uuid] = b"\x01" + await scan_step() + assert hass.states.get(entity_id) == snapshot + + +async def test_switching( + hass: HomeAssistant, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_switch_chars: dict[str, bytes], +) -> None: + """Test switching makes correct calls.""" + + entity_id = "switch.mock_title_open" + await setup_entry(hass, mock_entry, [Platform.SWITCH]) + assert hass.states.get(entity_id) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert mock_client.write_char.mock_calls == [ + call(Valve.remaining_open_time, 1000), + call(Valve.remaining_open_time, 0), + ] diff --git a/tests/components/gdacs/test_geo_location.py b/tests/components/gdacs/test_geo_location.py index fc20ecd406c..d279fe981d4 100644 --- a/tests/components/gdacs/test_geo_location.py +++ b/tests/components/gdacs/test_geo_location.py @@ -58,8 +58,8 @@ async def test_setup(hass: HomeAssistant) -> None: alert_level="Alert Level 1", country="Country 1", attribution="Attribution 1", - from_date=datetime.datetime(2020, 1, 10, 8, 0, tzinfo=datetime.timezone.utc), - to_date=datetime.datetime(2020, 1, 20, 8, 0, tzinfo=datetime.timezone.utc), + from_date=datetime.datetime(2020, 1, 10, 8, 0, tzinfo=datetime.UTC), + to_date=datetime.datetime(2020, 1, 20, 8, 0, tzinfo=datetime.UTC), duration_in_week=1, population="Population 1", severity="Severity 1", @@ -120,12 +120,8 @@ async def test_setup(hass: HomeAssistant) -> None: ATTR_DESCRIPTION: "Description 1", ATTR_COUNTRY: "Country 1", ATTR_ATTRIBUTION: "Attribution 1", - ATTR_FROM_DATE: datetime.datetime( - 2020, 1, 10, 8, 0, tzinfo=datetime.timezone.utc - ), - ATTR_TO_DATE: datetime.datetime( - 2020, 1, 20, 8, 0, tzinfo=datetime.timezone.utc - ), + ATTR_FROM_DATE: datetime.datetime(2020, 1, 10, 8, 0, tzinfo=datetime.UTC), + ATTR_TO_DATE: datetime.datetime(2020, 1, 20, 8, 0, tzinfo=datetime.UTC), ATTR_DURATION_IN_WEEK: 1, ATTR_ALERT_LEVEL: "Alert Level 1", ATTR_POPULATION: "Population 1", diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index e7668bdc3ff..54a9c5c0796 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -34,8 +34,8 @@ from homeassistant.const import ( CONF_VERIFY_SSL, HTTP_BASIC_AUTHENTICATION, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import entity_registry as er, issue_registry as ir from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator @@ -769,6 +769,13 @@ async def test_import(hass: HomeAssistant, fakeimg_png) -> None: assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["title"] == "Yaml Defined Name" await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_generic" + ) + assert issue.translation_key == "deprecated_yaml" + # Any name defined in yaml should end up as the entity id. assert hass.states.get("camera.yaml_defined_name") assert result2["type"] == data_entry_flow.FlowResultType.ABORT diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index 341571fe9ad..e3fb26ffe22 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -121,6 +121,7 @@ async def test_humidifier_input_boolean(hass: HomeAssistant, setup_comp_1) -> No await hass.async_block_till_done() assert hass.states.get(humidifier_switch).state == STATE_ON + assert hass.states.get(ENTITY).attributes.get("action") == "humidifying" async def test_humidifier_switch( @@ -165,6 +166,7 @@ async def test_humidifier_switch( await hass.async_block_till_done() assert hass.states.get(humidifier_switch).state == STATE_ON + assert hass.states.get(ENTITY).attributes.get("action") == "humidifying" def _setup_sensor(hass, humidity): @@ -277,6 +279,7 @@ async def test_default_setup_params(hass: HomeAssistant, setup_comp_2) -> None: assert state.attributes.get("min_humidity") == 0 assert state.attributes.get("max_humidity") == 100 assert state.attributes.get("humidity") == 0 + assert state.attributes.get("action") == "idle" async def test_default_setup_params_dehumidifier( @@ -287,6 +290,7 @@ async def test_default_setup_params_dehumidifier( assert state.attributes.get("min_humidity") == 0 assert state.attributes.get("max_humidity") == 100 assert state.attributes.get("humidity") == 100 + assert state.attributes.get("action") == "idle" async def test_get_modes(hass: HomeAssistant, setup_comp_2) -> None: @@ -648,6 +652,7 @@ async def test_set_target_humidity_dry_off(hass: HomeAssistant, setup_comp_3) -> assert call.domain == HASS_DOMAIN assert call.service == SERVICE_TURN_OFF assert call.data["entity_id"] == ENT_SWITCH + assert hass.states.get(ENTITY).attributes.get("action") == "drying" async def test_turn_away_mode_on_drying(hass: HomeAssistant, setup_comp_3) -> None: @@ -799,6 +804,7 @@ async def test_running_when_operating_mode_is_off_2( assert call.domain == HASS_DOMAIN assert call.service == SERVICE_TURN_OFF assert call.data["entity_id"] == ENT_SWITCH + assert hass.states.get(ENTITY).attributes.get("action") == "off" async def test_no_state_change_when_operation_mode_off_2( @@ -818,6 +824,7 @@ async def test_no_state_change_when_operation_mode_off_2( _setup_sensor(hass, 45) await hass.async_block_till_done() assert len(calls) == 0 + assert hass.states.get(ENTITY).attributes.get("action") == "off" @pytest.fixture @@ -862,9 +869,7 @@ async def test_humidity_change_dry_trigger_on_long_enough( hass: HomeAssistant, setup_comp_4 ) -> None: """Test if humidity change turn dry on.""" - fake_changed = datetime.datetime( - 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc - ) + fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=datetime.UTC) with freeze_time(fake_changed): calls = await _setup_switch(hass, False) _setup_sensor(hass, 35) @@ -898,9 +903,7 @@ async def test_humidity_change_dry_trigger_off_long_enough( hass: HomeAssistant, setup_comp_4 ) -> None: """Test if humidity change turn dry on.""" - fake_changed = datetime.datetime( - 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc - ) + fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=datetime.UTC) with freeze_time(fake_changed): calls = await _setup_switch(hass, True) _setup_sensor(hass, 45) @@ -1024,9 +1027,7 @@ async def test_humidity_change_humidifier_trigger_on_long_enough( hass: HomeAssistant, setup_comp_6 ) -> None: """Test if humidity change turn humidifier on after min cycle.""" - fake_changed = datetime.datetime( - 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc - ) + fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=datetime.UTC) with freeze_time(fake_changed): calls = await _setup_switch(hass, False) _setup_sensor(hass, 45) @@ -1046,9 +1047,7 @@ async def test_humidity_change_humidifier_trigger_off_long_enough( hass: HomeAssistant, setup_comp_6 ) -> None: """Test if humidity change turn humidifier off after min cycle.""" - fake_changed = datetime.datetime( - 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc - ) + fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=datetime.UTC) with freeze_time(fake_changed): calls = await _setup_switch(hass, True) _setup_sensor(hass, 35) diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index 19c85be0d9d..bfe94bbf304 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -48,7 +48,7 @@ async def test_setup(hass: HomeAssistant) -> None: (38.0, -3.0), locality="Locality 1", attribution="Attribution 1", - time=datetime.datetime(2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc), + time=datetime.datetime(2018, 9, 22, 8, 0, tzinfo=datetime.UTC), magnitude=5.7, mmi=5, depth=10.5, @@ -93,9 +93,7 @@ async def test_setup(hass: HomeAssistant) -> None: ATTR_FRIENDLY_NAME: "Title 1", ATTR_LOCALITY: "Locality 1", ATTR_ATTRIBUTION: "Attribution 1", - ATTR_TIME: datetime.datetime( - 2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc - ), + ATTR_TIME: datetime.datetime(2018, 9, 22, 8, 0, tzinfo=datetime.UTC), ATTR_MAGNITUDE: 5.7, ATTR_DEPTH: 10.5, ATTR_MMI: 5, diff --git a/tests/components/geonetnz_quakes/test_sensor.py b/tests/components/geonetnz_quakes/test_sensor.py index 253d44ee9ee..27f67dad322 100644 --- a/tests/components/geonetnz_quakes/test_sensor.py +++ b/tests/components/geonetnz_quakes/test_sensor.py @@ -41,7 +41,7 @@ async def test_setup(hass: HomeAssistant) -> None: (38.0, -3.0), locality="Locality 1", attribution="Attribution 1", - time=datetime.datetime(2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc), + time=datetime.datetime(2018, 9, 22, 8, 0, tzinfo=datetime.UTC), magnitude=5.7, mmi=5, depth=10.5, diff --git a/tests/components/goalzero/test_init.py b/tests/components/goalzero/test_init.py index 2603f0bf93a..287af75c9cd 100644 --- a/tests/components/goalzero/test_init.py +++ b/tests/components/goalzero/test_init.py @@ -72,7 +72,7 @@ async def test_device_info( entry = await async_init_integration(hass, aioclient_mock) device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) assert device.connections == {("mac", "12:34:56:78:90:12")} assert device.identifiers == {(DOMAIN, entry.entry_id)} diff --git a/tests/components/goalzero/test_sensor.py b/tests/components/goalzero/test_sensor.py index 47fbb29915b..90b1489803a 100644 --- a/tests/components/goalzero/test_sensor.py +++ b/tests/components/goalzero/test_sensor.py @@ -66,7 +66,7 @@ async def test_sensors( assert state.state == "1330" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.WATT_HOUR - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL state = hass.states.get(f"sensor.{DEFAULT_NAME}_volts") assert state.state == "12.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE diff --git a/tests/components/gogogate2/test_cover.py b/tests/components/gogogate2/test_cover.py index 576cf16044e..00cc0057d7c 100644 --- a/tests/components/gogogate2/test_cover.py +++ b/tests/components/gogogate2/test_cover.py @@ -334,7 +334,7 @@ async def test_device_info_ismartgate( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - device = device_registry.async_get_device({(DOMAIN, "xyz")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, "xyz")}) assert device assert device.manufacturer == MANUFACTURER assert device.name == "mycontroller" @@ -369,7 +369,7 @@ async def test_device_info_gogogate2( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - device = device_registry.async_get_device({(DOMAIN, "xyz")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, "xyz")}) assert device assert device.manufacturer == MANUFACTURER assert device.name == "mycontroller" diff --git a/tests/components/google_assistant/test_button.py b/tests/components/google_assistant/test_button.py index d16d406999e..d3c5665b945 100644 --- a/tests/components/google_assistant/test_button.py +++ b/tests/components/google_assistant/test_button.py @@ -24,7 +24,7 @@ async def test_sync_button(hass: HomeAssistant, hass_owner_user: MockUser) -> No await hass.async_block_till_done() - state = hass.states.get("button.synchronize_devices") + state = hass.states.get("button.google_assistant_synchronize_devices") assert state config_entry = hass.config_entries.async_entries("google_assistant")[0] @@ -36,7 +36,7 @@ async def test_sync_button(hass: HomeAssistant, hass_owner_user: MockUser) -> No await hass.services.async_call( "button", "press", - {"entity_id": "button.synchronize_devices"}, + {"entity_id": "button.google_assistant_synchronize_devices"}, blocking=True, context=context, ) @@ -48,7 +48,7 @@ async def test_sync_button(hass: HomeAssistant, hass_owner_user: MockUser) -> No await hass.services.async_call( "button", "press", - {"entity_id": "button.synchronize_devices"}, + {"entity_id": "button.google_assistant_synchronize_devices"}, blocking=True, context=context, ) diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 849f9e38a68..f471e6f862c 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1,7 +1,7 @@ """Test Google Smart Home.""" import asyncio from types import SimpleNamespace -from unittest.mock import ANY, call, patch +from unittest.mock import ANY, patch import pytest from pytest_unordered import unordered @@ -488,76 +488,41 @@ async def test_execute( events = async_capture_events(hass, EVENT_COMMAND_RECEIVED) service_events = async_capture_events(hass, EVENT_CALL_SERVICE) - with patch.object( - hass.services, "async_call", wraps=hass.services.async_call - ) as call_service_mock: - result = await sh.async_handle_message( - hass, - MockConfig(should_report_state=report_state), - None, - { - "requestId": REQ_ID, - "inputs": [ - { - "intent": "action.devices.EXECUTE", - "payload": { - "commands": [ - { - "devices": [ - {"id": "light.non_existing"}, - {"id": "light.ceiling_lights"}, - {"id": "light.kitchen_lights"}, - ], - "execution": [ - { - "command": "action.devices.commands.OnOff", - "params": {"on": True}, - }, - { - "command": "action.devices.commands.BrightnessAbsolute", - "params": {"brightness": 20}, - }, - ], - } - ] - }, - } - ], - }, - const.SOURCE_CLOUD, - ) - assert call_service_mock.call_count == 4 - expected_calls = [ - call( - "light", - "turn_on", - {"entity_id": "light.ceiling_lights"}, - blocking=not report_state, - context=ANY, - ), - call( - "light", - "turn_on", - {"entity_id": "light.kitchen_lights"}, - blocking=not report_state, - context=ANY, - ), - call( - "light", - "turn_on", - {"entity_id": "light.ceiling_lights", "brightness_pct": 20}, - blocking=not report_state, - context=ANY, - ), - call( - "light", - "turn_on", - {"entity_id": "light.kitchen_lights", "brightness_pct": 20}, - blocking=not report_state, - context=ANY, - ), - ] - call_service_mock.assert_has_awaits(expected_calls, any_order=True) + result = await sh.async_handle_message( + hass, + MockConfig(should_report_state=report_state), + None, + { + "requestId": REQ_ID, + "inputs": [ + { + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [ + { + "devices": [ + {"id": "light.non_existing"}, + {"id": "light.ceiling_lights"}, + {"id": "light.kitchen_lights"}, + ], + "execution": [ + { + "command": "action.devices.commands.OnOff", + "params": {"on": True}, + }, + { + "command": "action.devices.commands.BrightnessAbsolute", + "params": {"brightness": 20}, + }, + ], + } + ] + }, + } + ], + }, + const.SOURCE_CLOUD, + ) await hass.async_block_till_done() assert result == { @@ -682,11 +647,7 @@ async def test_execute_times_out( # Make DemoLigt.async_turn_on hang waiting for the turn_on_wait event await turn_on_wait.wait() - with patch.object( - hass.services, "async_call", wraps=hass.services.async_call - ) as call_service_mock, patch.object( - DemoLight, "async_turn_on", wraps=slow_turn_on - ): + with patch.object(DemoLight, "async_turn_on", wraps=slow_turn_on): result = await sh.async_handle_message( hass, MockConfig(should_report_state=report_state), @@ -722,51 +683,10 @@ async def test_execute_times_out( }, const.SOURCE_CLOUD, ) - # Only the two first calls are executed - assert call_service_mock.call_count == 2 - expected_calls = [ - call( - "light", - "turn_on", - {"entity_id": "light.ceiling_lights"}, - blocking=not report_state, - context=ANY, - ), - call( - "light", - "turn_on", - {"entity_id": "light.kitchen_lights"}, - blocking=not report_state, - context=ANY, - ), - ] - call_service_mock.assert_has_awaits(expected_calls, any_order=True) turn_on_wait.set() await hass.async_block_till_done() await hass.async_block_till_done() - # The remaining two calls should now have executed - assert call_service_mock.call_count == 4 - expected_calls.extend( - [ - call( - "light", - "turn_on", - {"entity_id": "light.ceiling_lights", "brightness_pct": 20}, - blocking=not report_state, - context=ANY, - ), - call( - "light", - "turn_on", - {"entity_id": "light.kitchen_lights", "brightness_pct": 20}, - blocking=not report_state, - context=ANY, - ), - ] - ) - call_service_mock.assert_has_awaits(expected_calls, any_order=True) - await hass.async_block_till_done() assert result == { "requestId": REQ_ID, diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index 99f264e4a3a..3cb64a9a441 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -326,7 +326,6 @@ async def test_conversation_agent( assert entry.state is ConfigEntryState.LOADED agent = await conversation._get_agent_manager(hass).async_get_agent(entry.entry_id) - assert agent.attribution.keys() == {"name", "url"} assert agent.supported_languages == SUPPORTED_LANGUAGE_CODES text1 = "tell me a joke" diff --git a/tests/components/google_mail/test_init.py b/tests/components/google_mail/test_init.py index 9580430621b..a069ae0807b 100644 --- a/tests/components/google_mail/test_init.py +++ b/tests/components/google_mail/test_init.py @@ -123,7 +123,7 @@ async def test_device_info( device_registry = dr.async_get(hass) entry = hass.config_entries.async_entries(DOMAIN)[0] - device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) assert device.entry_type is dr.DeviceEntryType.SERVICE assert device.identifiers == {(DOMAIN, entry.entry_id)} diff --git a/tests/components/google_pubsub/test_init.py b/tests/components/google_pubsub/test_init.py index 38c3da79524..0a1d4741268 100644 --- a/tests/components/google_pubsub/test_init.py +++ b/tests/components/google_pubsub/test_init.py @@ -8,8 +8,7 @@ import pytest import homeassistant.components.google_pubsub as google_pubsub from homeassistant.components.google_pubsub import DateTimeJSONEncoder as victim -from homeassistant.const import EVENT_STATE_CHANGED -from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component GOOGLE_PUBSUB_PATH = "homeassistant.components.google_pubsub" @@ -60,9 +59,8 @@ def mock_is_file_fixture(): @pytest.fixture(autouse=True) -def mock_bus_and_json(hass, monkeypatch): +def mock_json(hass, monkeypatch): """Mock the event bus listener and os component.""" - hass.bus.listen = mock.MagicMock() monkeypatch.setattr( f"{GOOGLE_PUBSUB_PATH}.json.dumps", mock.Mock(return_value=mock.MagicMock()) ) @@ -80,8 +78,6 @@ async def test_minimal_config(hass: HomeAssistant, mock_client) -> None: } assert await async_setup_component(hass, google_pubsub.DOMAIN, config) await hass.async_block_till_done() - assert hass.bus.listen.called - assert hass.bus.listen.call_args_list[0][0][0] == EVENT_STATE_CHANGED assert mock_client.from_service_account_json.call_count == 1 assert mock_client.from_service_account_json.call_args[0][0] == os.path.join( hass.config.config_dir, "creds" @@ -107,27 +103,12 @@ async def test_full_config(hass: HomeAssistant, mock_client) -> None: } assert await async_setup_component(hass, google_pubsub.DOMAIN, config) await hass.async_block_till_done() - assert hass.bus.listen.called - assert hass.bus.listen.call_args_list[0][0][0] == EVENT_STATE_CHANGED assert mock_client.from_service_account_json.call_count == 1 assert mock_client.from_service_account_json.call_args[0][0] == os.path.join( hass.config.config_dir, "creds" ) -def make_event(entity_id): - """Make a mock event for test.""" - domain = split_entity_id(entity_id)[0] - state = mock.MagicMock( - state="not blank", - domain=domain, - entity_id=entity_id, - object_id="entity", - attributes={}, - ) - return mock.MagicMock(data={"new_state": state}, time_fired=12345) - - async def _setup(hass, filter_config): """Shared set up for filtering tests.""" config = { @@ -140,12 +121,11 @@ async def _setup(hass, filter_config): } assert await async_setup_component(hass, google_pubsub.DOMAIN, config) await hass.async_block_till_done() - return hass.bus.listen.call_args_list[0][0][1] async def test_allowlist(hass: HomeAssistant, mock_client) -> None: """Test an allowlist only config.""" - handler_method = await _setup( + await _setup( hass, { "include_domains": ["light"], @@ -165,8 +145,8 @@ async def test_allowlist(hass: HomeAssistant, mock_client) -> None: ] for test in tests: - event = make_event(test.id) - handler_method(event) + hass.states.async_set(test.id, "not blank") + await hass.async_block_till_done() was_called = publish_client.publish.call_count == 1 assert test.should_pass == was_called @@ -175,7 +155,7 @@ async def test_allowlist(hass: HomeAssistant, mock_client) -> None: async def test_denylist(hass: HomeAssistant, mock_client) -> None: """Test a denylist only config.""" - handler_method = await _setup( + await _setup( hass, { "exclude_domains": ["climate"], @@ -195,8 +175,8 @@ async def test_denylist(hass: HomeAssistant, mock_client) -> None: ] for test in tests: - event = make_event(test.id) - handler_method(event) + hass.states.async_set(test.id, "not blank") + await hass.async_block_till_done() was_called = publish_client.publish.call_count == 1 assert test.should_pass == was_called @@ -205,7 +185,7 @@ async def test_denylist(hass: HomeAssistant, mock_client) -> None: async def test_filtered_allowlist(hass: HomeAssistant, mock_client) -> None: """Test an allowlist config with a filtering denylist.""" - handler_method = await _setup( + await _setup( hass, { "include_domains": ["light"], @@ -226,8 +206,8 @@ async def test_filtered_allowlist(hass: HomeAssistant, mock_client) -> None: ] for test in tests: - event = make_event(test.id) - handler_method(event) + hass.states.async_set(test.id, "not blank") + await hass.async_block_till_done() was_called = publish_client.publish.call_count == 1 assert test.should_pass == was_called @@ -236,7 +216,7 @@ async def test_filtered_allowlist(hass: HomeAssistant, mock_client) -> None: async def test_filtered_denylist(hass: HomeAssistant, mock_client) -> None: """Test a denylist config with a filtering allowlist.""" - handler_method = await _setup( + await _setup( hass, { "include_entities": ["climate.included", "sensor.excluded_test"], @@ -257,8 +237,8 @@ async def test_filtered_denylist(hass: HomeAssistant, mock_client) -> None: ] for test in tests: - event = make_event(test.id) - handler_method(event) + hass.states.async_set(test.id, "not blank") + await hass.async_block_till_done() was_called = publish_client.publish.call_count == 1 assert test.should_pass == was_called diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index f50d4486b39..539a8c61414 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -1,5 +1,4 @@ """The tests for the Group Light platform.""" -import unittest.mock from unittest.mock import MagicMock, patch import async_timeout @@ -16,7 +15,6 @@ from homeassistant.components.light import ( ATTR_COLOR_TEMP_KELVIN, ATTR_EFFECT, ATTR_EFFECT_LIST, - ATTR_FLASH, ATTR_HS_COLOR, ATTR_MAX_COLOR_TEMP_KELVIN, ATTR_MIN_COLOR_TEMP_KELVIN, @@ -26,7 +24,6 @@ from homeassistant.components.light import ( ATTR_SUPPORTED_COLOR_MODES, ATTR_TRANSITION, ATTR_WHITE, - ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -39,16 +36,17 @@ from homeassistant.components.light import ( from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + EVENT_CALL_SERVICE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from tests.common import get_fixture_path +from tests.common import async_capture_events, get_fixture_path async def test_default_state(hass: HomeAssistant) -> None: @@ -1443,6 +1441,7 @@ async def test_invalid_service_calls(hass: HomeAssistant) -> None: await group.async_setup_platform( hass, {"name": "test", "entities": ["light.test1", "light.test2"]}, add_entities ) + await async_setup_component(hass, "light", {}) await hass.async_block_till_done() await hass.async_start() await hass.async_block_till_done() @@ -1451,35 +1450,38 @@ async def test_invalid_service_calls(hass: HomeAssistant) -> None: grouped_light = add_entities.call_args[0][0][0] grouped_light.hass = hass - with unittest.mock.patch.object(hass.services, "async_call") as mock_call: - await grouped_light.async_turn_on(brightness=150, four_oh_four="404") - data = {ATTR_ENTITY_ID: ["light.test1", "light.test2"], ATTR_BRIGHTNESS: 150} - mock_call.assert_called_once_with( - LIGHT_DOMAIN, SERVICE_TURN_ON, data, blocking=True, context=None - ) - mock_call.reset_mock() + service_call_events = async_capture_events(hass, EVENT_CALL_SERVICE) - await grouped_light.async_turn_off(transition=4, four_oh_four="404") - data = {ATTR_ENTITY_ID: ["light.test1", "light.test2"], ATTR_TRANSITION: 4} - mock_call.assert_called_once_with( - LIGHT_DOMAIN, SERVICE_TURN_OFF, data, blocking=True, context=None - ) - mock_call.reset_mock() + await grouped_light.async_turn_on(brightness=150, four_oh_four="404") + data = {ATTR_ENTITY_ID: ["light.test1", "light.test2"], ATTR_BRIGHTNESS: 150} + assert len(service_call_events) == 1 + service_event_call: Event = service_call_events[0] + assert service_event_call.data["domain"] == LIGHT_DOMAIN + assert service_event_call.data["service"] == SERVICE_TURN_ON + assert service_event_call.data["service_data"] == data + service_call_events.clear() - data = { - ATTR_BRIGHTNESS: 150, - ATTR_XY_COLOR: (0.5, 0.42), - ATTR_RGB_COLOR: (80, 120, 50), - ATTR_COLOR_TEMP_KELVIN: 1234, - ATTR_EFFECT: "Sunshine", - ATTR_TRANSITION: 4, - ATTR_FLASH: "long", - } - await grouped_light.async_turn_on(**data) - data[ATTR_ENTITY_ID] = ["light.test1", "light.test2"] - mock_call.assert_called_once_with( - LIGHT_DOMAIN, SERVICE_TURN_ON, data, blocking=True, context=None - ) + await grouped_light.async_turn_off(transition=4, four_oh_four="404") + data = {ATTR_ENTITY_ID: ["light.test1", "light.test2"], ATTR_TRANSITION: 4} + assert len(service_call_events) == 1 + service_event_call: Event = service_call_events[0] + assert service_event_call.data["domain"] == LIGHT_DOMAIN + assert service_event_call.data["service"] == SERVICE_TURN_OFF + assert service_event_call.data["service_data"] == data + service_call_events.clear() + + data = { + ATTR_BRIGHTNESS: 150, + ATTR_COLOR_TEMP_KELVIN: 1234, + ATTR_TRANSITION: 4, + } + await grouped_light.async_turn_on(**data) + data[ATTR_ENTITY_ID] = ["light.test1", "light.test2"] + service_event_call: Event = service_call_events[0] + assert service_event_call.data["domain"] == LIGHT_DOMAIN + assert service_event_call.data["service"] == SERVICE_TURN_ON + assert service_event_call.data["service_data"] == data + service_call_events.clear() async def test_reload(hass: HomeAssistant) -> None: diff --git a/tests/components/group/test_media_player.py b/tests/components/group/test_media_player.py index 4549a7f5fec..2a1a2a05e4e 100644 --- a/tests/components/group/test_media_player.py +++ b/tests/components/group/test_media_player.py @@ -191,7 +191,11 @@ async def test_supported_features(hass: HomeAssistant) -> None: | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP ) - play_media = MediaPlayerEntityFeature.PLAY_MEDIA + play_media = ( + MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.MEDIA_ANNOUNCE + | MediaPlayerEntityFeature.MEDIA_ENQUEUE + ) volume = ( MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_SET diff --git a/tests/components/hassio/test_ingress.py b/tests/components/hassio/test_ingress.py index 06b7523614c..3eda10b1514 100644 --- a/tests/components/hassio/test_ingress.py +++ b/tests/components/hassio/test_ingress.py @@ -348,3 +348,156 @@ async def test_forwarding_paths_as_requested( "/api/hassio_ingress/mock-token/hello/%252e./world", ) assert await resp.text() == "test" + + +@pytest.mark.parametrize( + "build_type", + [ + ("a3_vl", "test/beer/ping?index=1"), + ("core", "index.html"), + ("local", "panel/config"), + ("jk_921", "editor.php?idx=3&ping=5"), + ("fsadjf10312", ""), + ], +) +async def test_ingress_request_get_compressed( + hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker +) -> None: + """Test ingress compressed.""" + body = "this_is_long_enough_to_be_compressed" * 100 + aioclient_mock.get( + f"http://127.0.0.1/ingress/{build_type[0]}/{build_type[1]}", + text=body, + headers={"Content-Length": len(body)}, + ) + + resp = await hassio_noauth_client.get( + f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", + headers={"X-Test-Header": "beer", "Accept-Encoding": "gzip, deflate"}, + ) + + # Check we got right response + assert resp.status == HTTPStatus.OK + body = await resp.text() + assert body == body + assert resp.headers["Content-Encoding"] == "deflate" + + # Check we forwarded command + assert len(aioclient_mock.mock_calls) == 1 + assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3] + assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress" + assert ( + aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] + == f"/api/hassio_ingress/{build_type[0]}" + ) + assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer" + assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR] + assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST] + assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO] + + +@pytest.mark.parametrize( + "content_type", + [ + "image/png", + "image/jpeg", + "font/woff2", + "video/mp4", + ], +) +async def test_ingress_request_not_compressed( + hassio_noauth_client, content_type: str, aioclient_mock: AiohttpClientMocker +) -> None: + """Test ingress does not compress images.""" + body = b"this_is_long_enough_to_be_compressed" * 100 + aioclient_mock.get( + "http://127.0.0.1/ingress/core/x.any", + data=body, + headers={"Content-Length": len(body), "Content-Type": content_type}, + ) + + resp = await hassio_noauth_client.get( + "/api/hassio_ingress/core/x.any", + headers={"X-Test-Header": "beer", "Accept-Encoding": "gzip, deflate"}, + ) + + # Check we got right response + assert resp.status == HTTPStatus.OK + assert resp.headers["Content-Type"] == content_type + assert "Content-Encoding" not in resp.headers + + +@pytest.mark.parametrize( + "content_type", + [ + "image/svg+xml", + "text/html", + "application/javascript", + "text/plain", + ], +) +async def test_ingress_request_compressed( + hassio_noauth_client, content_type: str, aioclient_mock: AiohttpClientMocker +) -> None: + """Test ingress compresses text.""" + body = b"this_is_long_enough_to_be_compressed" * 100 + aioclient_mock.get( + "http://127.0.0.1/ingress/core/x.any", + data=body, + headers={"Content-Length": len(body), "Content-Type": content_type}, + ) + + resp = await hassio_noauth_client.get( + "/api/hassio_ingress/core/x.any", + headers={"X-Test-Header": "beer", "Accept-Encoding": "gzip, deflate"}, + ) + + # Check we got right response + assert resp.status == HTTPStatus.OK + assert resp.headers["Content-Type"] == content_type + assert resp.headers["Content-Encoding"] == "deflate" + + +@pytest.mark.parametrize( + "build_type", + [ + ("a3_vl", "test/beer/ping?index=1"), + ("core", "index.html"), + ("local", "panel/config"), + ("jk_921", "editor.php?idx=3&ping=5"), + ("fsadjf10312", ""), + ], +) +async def test_ingress_request_get_not_changed( + hassio_noauth_client, build_type, aioclient_mock: AiohttpClientMocker +) -> None: + """Test ingress compressed and not modified.""" + aioclient_mock.get( + f"http://127.0.0.1/ingress/{build_type[0]}/{build_type[1]}", + text="test", + status=HTTPStatus.NOT_MODIFIED, + ) + + resp = await hassio_noauth_client.get( + f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", + headers={"X-Test-Header": "beer", "Accept-Encoding": "gzip, deflate"}, + ) + + # Check we got right response + assert resp.status == HTTPStatus.NOT_MODIFIED + body = await resp.text() + assert body == "" + assert "Content-Encoding" not in resp.headers # too small to compress + + # Check we forwarded command + assert len(aioclient_mock.mock_calls) == 1 + assert X_AUTH_TOKEN not in aioclient_mock.mock_calls[-1][3] + assert aioclient_mock.mock_calls[-1][3]["X-Hass-Source"] == "core.ingress" + assert ( + aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] + == f"/api/hassio_ingress/{build_type[0]}" + ) + assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer" + assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR] + assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST] + assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO] diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 0dff261d864..b394d439654 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -265,6 +265,7 @@ async def test_setup_api_panel( "title": None, "url_path": "hassio", "require_admin": True, + "config_panel_domain": None, "config": { "_panel_custom": { "embed_iframe": True, diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py index c429c8b46a8..1784ba83446 100644 --- a/tests/components/heos/test_media_player.py +++ b/tests/components/heos/test_media_player.py @@ -263,7 +263,7 @@ async def test_updates_from_players_changed_new_ids( event = asyncio.Event() # Assert device registry matches current id - assert device_registry.async_get_device({(DOMAIN, 1)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, 1)}) # Assert entity registry matches current id assert ( entity_registry.async_get_entity_id(MEDIA_PLAYER_DOMAIN, DOMAIN, "1") @@ -284,7 +284,7 @@ async def test_updates_from_players_changed_new_ids( # Assert device registry identifiers were updated assert len(device_registry.devices) == 2 - assert device_registry.async_get_device({(DOMAIN, 101)}) + assert device_registry.async_get_device(identifiers={(DOMAIN, 101)}) # Assert entity registry unique id was updated assert len(entity_registry.entities) == 2 assert ( diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 28e24b587aa..bb4b5b275d2 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -334,7 +334,7 @@ async def test_measure_multiple(recorder_mock: Recorder, hass: HomeAssistant) -> await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() - assert hass.states.get("sensor.sensor1").state == "0.5" + assert round(float(hass.states.get("sensor.sensor1").state), 3) == 0.5 assert hass.states.get("sensor.sensor2").state == "0.0" assert hass.states.get("sensor.sensor3").state == "2" assert hass.states.get("sensor.sensor4").state == "50.0" @@ -386,6 +386,7 @@ async def test_measure(recorder_mock: Recorder, hass: HomeAssistant) -> None: "start": "{{ as_timestamp(utcnow()) - 3600 }}", "end": "{{ utcnow() }}", "type": "time", + "unique_id": "6b1f54e3-4065-43ca-8492-d0d4506a573a", }, { "platform": "history_stats", @@ -414,7 +415,7 @@ async def test_measure(recorder_mock: Recorder, hass: HomeAssistant) -> None: await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "0.83" - assert hass.states.get("sensor.sensor2").state == "0.83" + assert hass.states.get("sensor.sensor2").state == "0.833333333333333" assert hass.states.get("sensor.sensor3").state == "2" assert hass.states.get("sensor.sensor4").state == "83.3" @@ -724,7 +725,17 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_sin "start": "{{ utcnow().replace(hour=0, minute=0, second=0) }}", "end": "{{ utcnow() }}", "type": "time", - } + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": "sensor2", + "state": "on", + "start": "{{ utcnow().replace(hour=0, minute=0, second=0) }}", + "end": "{{ utcnow() }}", + "type": "time", + "unique_id": "6b1f54e3-4065-43ca-8492-d0d4506a573a", + }, ] }, ) @@ -734,6 +745,7 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_sin await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "0.0" + assert hass.states.get("sensor.sensor2").state == "0.0" one_hour_in = start_time + timedelta(minutes=60) with freeze_time(one_hour_in): @@ -741,6 +753,7 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_sin await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "1.0" + assert hass.states.get("sensor.sensor2").state == "1.0" turn_off_time = start_time + timedelta(minutes=90) with freeze_time(turn_off_time): @@ -750,6 +763,7 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_sin await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "1.5" + assert hass.states.get("sensor.sensor2").state == "1.5" turn_back_on_time = start_time + timedelta(minutes=105) with freeze_time(turn_back_on_time): @@ -757,12 +771,14 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_sin await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "1.5" + assert hass.states.get("sensor.sensor2").state == "1.5" with freeze_time(turn_back_on_time): hass.states.async_set("binary_sensor.state", "on") await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "1.5" + assert hass.states.get("sensor.sensor2").state == "1.5" next_update_time = start_time + timedelta(minutes=107) with freeze_time(next_update_time): @@ -770,6 +786,7 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_sin await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "1.53" + assert hass.states.get("sensor.sensor2").state == "1.53333333333333" end_time = start_time + timedelta(minutes=120) with freeze_time(end_time): @@ -777,6 +794,7 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_sin await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "1.75" + assert hass.states.get("sensor.sensor2").state == "1.75" async def test_async_start_from_history_and_switch_to_watching_state_changes_multiple( @@ -960,7 +978,17 @@ async def test_does_not_work_into_the_future( "start": "{{ utcnow().replace(hour=23, minute=0, second=0) }}", "duration": {"hours": 1}, "type": "time", - } + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.state", + "name": "sensor2", + "state": "on", + "start": "{{ utcnow().replace(hour=23, minute=0, second=0) }}", + "duration": {"hours": 1}, + "type": "time", + "unique_id": "6b1f54e3-4065-43ca-8492-d0d4506a573a", + }, ] }, ) @@ -969,6 +997,7 @@ async def test_does_not_work_into_the_future( await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == STATE_UNKNOWN + assert hass.states.get("sensor.sensor2").state == STATE_UNKNOWN one_hour_in = start_time + timedelta(minutes=60) with freeze_time(one_hour_in): @@ -976,6 +1005,7 @@ async def test_does_not_work_into_the_future( await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == STATE_UNKNOWN + assert hass.states.get("sensor.sensor2").state == STATE_UNKNOWN turn_off_time = start_time + timedelta(minutes=90) with freeze_time(turn_off_time): @@ -985,6 +1015,7 @@ async def test_does_not_work_into_the_future( await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == STATE_UNKNOWN + assert hass.states.get("sensor.sensor2").state == STATE_UNKNOWN turn_back_on_time = start_time + timedelta(minutes=105) with freeze_time(turn_back_on_time): @@ -992,12 +1023,14 @@ async def test_does_not_work_into_the_future( await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == STATE_UNKNOWN + assert hass.states.get("sensor.sensor2").state == STATE_UNKNOWN with freeze_time(turn_back_on_time): hass.states.async_set("binary_sensor.state", "on") await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == STATE_UNKNOWN + assert hass.states.get("sensor.sensor2").state == STATE_UNKNOWN end_time = start_time + timedelta(minutes=120) with freeze_time(end_time): @@ -1005,6 +1038,7 @@ async def test_does_not_work_into_the_future( await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == STATE_UNKNOWN + assert hass.states.get("sensor.sensor2").state == STATE_UNKNOWN in_the_window = start_time + timedelta(hours=23, minutes=5) with freeze_time(in_the_window): @@ -1012,6 +1046,7 @@ async def test_does_not_work_into_the_future( await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "0.08" + assert hass.states.get("sensor.sensor2").state == "0.0833333333333333" past_the_window = start_time + timedelta(hours=25) with patch( @@ -1143,6 +1178,7 @@ async def test_measure_sliding_window( "start": "{{ as_timestamp(now()) - 3600 }}", "end": "{{ as_timestamp(now()) + 3600 }}", "type": "time", + "unique_id": "6b1f54e3-4065-43ca-8492-d0d4506a573a", }, { "platform": "history_stats", @@ -1176,7 +1212,7 @@ async def test_measure_sliding_window( await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "0.83" - assert hass.states.get("sensor.sensor2").state == "0.83" + assert hass.states.get("sensor.sensor2").state == "0.833333333333333" assert hass.states.get("sensor.sensor3").state == "2" assert hass.states.get("sensor.sensor4").state == "41.7" @@ -1189,7 +1225,7 @@ async def test_measure_sliding_window( await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "0.83" - assert hass.states.get("sensor.sensor2").state == "0.83" + assert hass.states.get("sensor.sensor2").state == "0.833333333333333" assert hass.states.get("sensor.sensor3").state == "2" assert hass.states.get("sensor.sensor4").state == "41.7" @@ -1242,6 +1278,7 @@ async def test_measure_from_end_going_backwards( "duration": {"hours": 1}, "end": "{{ utcnow() }}", "type": "time", + "unique_id": "6b1f54e3-4065-43ca-8492-d0d4506a573a", }, { "platform": "history_stats", @@ -1270,7 +1307,7 @@ async def test_measure_from_end_going_backwards( await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "0.83" - assert hass.states.get("sensor.sensor2").state == "0.83" + assert hass.states.get("sensor.sensor2").state == "0.833333333333333" assert hass.states.get("sensor.sensor3").state == "2" assert hass.states.get("sensor.sensor4").state == "83.3" @@ -1283,7 +1320,7 @@ async def test_measure_from_end_going_backwards( await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "0.83" - assert hass.states.get("sensor.sensor2").state == "0.83" + assert hass.states.get("sensor.sensor2").state == "0.833333333333333" assert hass.states.get("sensor.sensor3").state == "2" assert hass.states.get("sensor.sensor4").state == "83.3" @@ -1335,6 +1372,7 @@ async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None "start": "{{ as_timestamp(utcnow()) - 3600 }}", "end": "{{ utcnow() }}", "type": "time", + "unique_id": "6b1f54e3-4065-43ca-8492-d0d4506a573a", }, { "platform": "history_stats", @@ -1363,7 +1401,7 @@ async def test_measure_cet(recorder_mock: Recorder, hass: HomeAssistant) -> None await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "0.83" - assert hass.states.get("sensor.sensor2").state == "0.83" + assert hass.states.get("sensor.sensor2").state == "0.833333333333333" assert hass.states.get("sensor.sensor3").state == "2" assert hass.states.get("sensor.sensor4").state == "83.3" @@ -1425,6 +1463,16 @@ async def test_end_time_with_microseconds_zeroed( "end": "{{ now().replace(microsecond=0) }}", "type": "time", }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.heatpump_compressor_state", + "name": "heatpump_compressor_today2", + "state": "on", + "start": "{{ now().replace(hour=0, minute=0, second=0, microsecond=0) }}", + "end": "{{ now().replace(microsecond=0) }}", + "type": "time", + "unique_id": "6b1f54e3-4065-43ca-8492-d0d4506a573a", + }, ] }, ) @@ -1432,9 +1480,18 @@ async def test_end_time_with_microseconds_zeroed( await async_update_entity(hass, "sensor.heatpump_compressor_today") await hass.async_block_till_done() assert hass.states.get("sensor.heatpump_compressor_today").state == "1.83" + assert ( + hass.states.get("sensor.heatpump_compressor_today2").state + == "1.83333333333333" + ) + async_fire_time_changed(hass, time_200) await hass.async_block_till_done() assert hass.states.get("sensor.heatpump_compressor_today").state == "1.83" + assert ( + hass.states.get("sensor.heatpump_compressor_today2").state + == "1.83333333333333" + ) hass.states.async_set("binary_sensor.heatpump_compressor_state", "off") await hass.async_block_till_done() @@ -1443,6 +1500,10 @@ async def test_end_time_with_microseconds_zeroed( async_fire_time_changed(hass, time_400) await hass.async_block_till_done() assert hass.states.get("sensor.heatpump_compressor_today").state == "1.83" + assert ( + hass.states.get("sensor.heatpump_compressor_today2").state + == "1.83333333333333" + ) hass.states.async_set("binary_sensor.heatpump_compressor_state", "on") await async_wait_recording_done(hass) time_600 = start_of_today + timedelta(hours=6) @@ -1450,6 +1511,10 @@ async def test_end_time_with_microseconds_zeroed( async_fire_time_changed(hass, time_600) await hass.async_block_till_done() assert hass.states.get("sensor.heatpump_compressor_today").state == "3.83" + assert ( + hass.states.get("sensor.heatpump_compressor_today2").state + == "3.83333333333333" + ) rolled_to_next_day = start_of_today + timedelta(days=1) assert rolled_to_next_day.hour == 0 @@ -1461,6 +1526,7 @@ async def test_end_time_with_microseconds_zeroed( async_fire_time_changed(hass, rolled_to_next_day) await hass.async_block_till_done() assert hass.states.get("sensor.heatpump_compressor_today").state == "0.0" + assert hass.states.get("sensor.heatpump_compressor_today2").state == "0.0" rolled_to_next_day_plus_12 = start_of_today + timedelta( days=1, hours=12, microseconds=0 @@ -1469,6 +1535,7 @@ async def test_end_time_with_microseconds_zeroed( async_fire_time_changed(hass, rolled_to_next_day_plus_12) await hass.async_block_till_done() assert hass.states.get("sensor.heatpump_compressor_today").state == "12.0" + assert hass.states.get("sensor.heatpump_compressor_today2").state == "12.0" rolled_to_next_day_plus_14 = start_of_today + timedelta( days=1, hours=14, microseconds=0 @@ -1477,6 +1544,7 @@ async def test_end_time_with_microseconds_zeroed( async_fire_time_changed(hass, rolled_to_next_day_plus_14) await hass.async_block_till_done() assert hass.states.get("sensor.heatpump_compressor_today").state == "14.0" + assert hass.states.get("sensor.heatpump_compressor_today2").state == "14.0" rolled_to_next_day_plus_16_860000 = start_of_today + timedelta( days=1, hours=16, microseconds=860000 @@ -1492,6 +1560,10 @@ async def test_end_time_with_microseconds_zeroed( async_fire_time_changed(hass, rolled_to_next_day_plus_18) await hass.async_block_till_done() assert hass.states.get("sensor.heatpump_compressor_today").state == "16.0" + assert ( + hass.states.get("sensor.heatpump_compressor_today2").state + == "16.0002388888929" + ) async def test_device_classes(recorder_mock: Recorder, hass: HomeAssistant) -> None: diff --git a/tests/components/home_plus_control/test_switch.py b/tests/components/home_plus_control/test_switch.py index 9c7736e2b8e..ead1f83cb94 100644 --- a/tests/components/home_plus_control/test_switch.py +++ b/tests/components/home_plus_control/test_switch.py @@ -55,7 +55,7 @@ def one_entity_state(hass, device_uid): entity_reg = er.async_get(hass) device_reg = dr.async_get(hass) - device_id = device_reg.async_get_device({(DOMAIN, device_uid)}).id + device_id = device_reg.async_get_device(identifiers={(DOMAIN, device_uid)}).id entity_entries = er.async_entries_for_device(entity_reg, device_id) assert len(entity_entries) == 1 diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 0a41df17c8d..b4554f1a4e6 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -102,6 +102,7 @@ async def test_if_fires_using_at_input_datetime( }, blocking=True, ) + await hass.async_block_till_done() time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) @@ -148,6 +149,7 @@ async def test_if_fires_using_at_input_datetime( }, blocking=True, ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -556,6 +558,7 @@ async def test_datetime_in_past_on_load(hass: HomeAssistant, calls) -> None: }, blocking=True, ) + await hass.async_block_till_done() assert await async_setup_component( hass, @@ -587,6 +590,7 @@ async def test_datetime_in_past_on_load(hass: HomeAssistant, calls) -> None: }, blocking=True, ) + await hass.async_block_till_done() async_fire_time_changed(hass, future + timedelta(seconds=1)) await hass.async_block_till_done() diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 83702adcc3a..a956214c098 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -27,6 +27,7 @@ from tests.common import ( ) TEST_DOMAIN = "test" +TEST_DOMAIN_2 = "test_2" class FakeConfigFlow(ConfigFlow): @@ -456,7 +457,7 @@ async def test_option_flow_addon_installed_other_device( @pytest.mark.parametrize( ("configured_channel", "suggested_channel"), [(None, "15"), (11, "11")] ) -async def test_option_flow_addon_installed_same_device_reconfigure( +async def test_option_flow_addon_installed_same_device_reconfigure_unexpected_users( hass: HomeAssistant, addon_info, addon_store_info, @@ -465,7 +466,7 @@ async def test_option_flow_addon_installed_same_device_reconfigure( configured_channel: int | None, suggested_channel: int, ) -> None: - """Test installing the multi pan addon.""" + """Test reconfiguring the multi pan addon.""" mock_integration(hass, MockModule("hassio")) addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" @@ -494,7 +495,11 @@ async def test_option_flow_addon_installed_same_device_reconfigure( {"next_step_id": "reconfigure_addon"}, ) assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "reconfigure_addon" + assert result["step_id"] == "notify_unknown_multipan_user" + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "change_channel" assert get_suggested(result["data_schema"].schema, "channel") == suggested_channel result = await hass.config_entries.options.async_configure( @@ -508,6 +513,79 @@ async def test_option_flow_addon_installed_same_device_reconfigure( assert result["type"] == FlowResultType.CREATE_ENTRY assert mock_multiprotocol_platform.change_channel_calls == [(14, 300)] + assert multipan_manager._channel == 14 + + +@pytest.mark.parametrize( + ("configured_channel", "suggested_channel"), [(None, "15"), (11, "11")] +) +async def test_option_flow_addon_installed_same_device_reconfigure_expected_users( + hass: HomeAssistant, + addon_info, + addon_store_info, + addon_installed, + configured_channel: int | None, + suggested_channel: int, +) -> None: + """Test reconfiguring the multi pan addon.""" + mock_integration(hass, MockModule("hassio")) + addon_info.return_value["options"]["device"] = "/dev/ttyTEST123" + + multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + multipan_manager._channel = configured_channel + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=TEST_DOMAIN, + options={}, + title="Test HW", + ) + config_entry.add_to_hass(hass) + + mock_multiprotocol_platforms = {} + for domain in ["otbr", "zha"]: + mock_multiprotocol_platform = MockMultiprotocolPlatform() + mock_multiprotocol_platforms[domain] = mock_multiprotocol_platform + mock_multiprotocol_platform.channel = configured_channel + mock_multiprotocol_platform.using_multipan = True + + hass.config.components.add(domain) + mock_platform( + hass, f"{domain}.silabs_multiprotocol", mock_multiprotocol_platform + ) + hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: domain}) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", + side_effect=Mock(return_value=True), + ): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "addon_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "reconfigure_addon"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "change_channel" + assert get_suggested(result["data_schema"].schema, "channel") == suggested_channel + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"channel": "14"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "notify_channel_change" + assert result["description_placeholders"] == {"delay_minutes": "5"} + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.CREATE_ENTRY + + for domain in ["otbr", "zha"]: + assert mock_multiprotocol_platforms[domain].change_channel_calls == [(14, 300)] + assert multipan_manager._channel == 14 async def test_option_flow_addon_installed_same_device_uninstall( @@ -1007,3 +1085,39 @@ async def test_load_preferences(hass: HomeAssistant) -> None: await multipan_manager2.async_setup() assert multipan_manager._channel == multipan_manager2._channel + + +@pytest.mark.parametrize( + ( + "multipan_platforms", + "active_platforms", + ), + [ + ({}, []), + ({TEST_DOMAIN: False}, []), + ({TEST_DOMAIN: True}, [TEST_DOMAIN]), + ({TEST_DOMAIN: True, TEST_DOMAIN_2: False}, [TEST_DOMAIN]), + ({TEST_DOMAIN: True, TEST_DOMAIN_2: True}, [TEST_DOMAIN, TEST_DOMAIN_2]), + ], +) +async def test_active_plaforms( + hass: HomeAssistant, + multipan_platforms: dict[str, bool], + active_platforms: list[str], +) -> None: + """Test async_active_platforms.""" + multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + + for domain, platform_using_multipan in multipan_platforms.items(): + mock_multiprotocol_platform = MockMultiprotocolPlatform() + mock_multiprotocol_platform.channel = 11 + mock_multiprotocol_platform.using_multipan = platform_using_multipan + + hass.config.components.add(domain) + mock_platform( + hass, f"{domain}.silabs_multiprotocol", mock_multiprotocol_platform + ) + hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: domain}) + + await hass.async_block_till_done() + assert await multipan_manager.async_active_platforms() == active_platforms diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 112c138a843..109f4205901 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -744,7 +744,7 @@ async def test_homekit_start( assert device_registry.async_get(bridge_with_wrong_mac.id) is None device = device_registry.async_get_device( - {(DOMAIN, entry.entry_id, BRIDGE_SERIAL_NUMBER)} + identifiers={(DOMAIN, entry.entry_id, BRIDGE_SERIAL_NUMBER)} ) assert device formatted_mac = dr.format_mac(homekit.driver.state.mac) @@ -760,7 +760,7 @@ async def test_homekit_start( await homekit.async_start() device = device_registry.async_get_device( - {(DOMAIN, entry.entry_id, BRIDGE_SERIAL_NUMBER)} + identifiers={(DOMAIN, entry.entry_id, BRIDGE_SERIAL_NUMBER)} ) assert device formatted_mac = dr.format_mac(homekit.driver.state.mac) @@ -953,7 +953,7 @@ async def test_homekit_unpair( formatted_mac = dr.format_mac(state.mac) hk_bridge_dev = device_registry.async_get_device( - {}, {(dr.CONNECTION_NETWORK_MAC, formatted_mac)} + connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)} ) await hass.services.async_call( diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 19e1b738aed..0c27e0a3648 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -325,9 +325,7 @@ async def assert_devices_and_entities_created( # we have detected broken serial numbers (and serial number is not used as an identifier). device = device_registry.async_get_device( - { - (IDENTIFIER_ACCESSORY_ID, expected.unique_id), - } + identifiers={(IDENTIFIER_ACCESSORY_ID, expected.unique_id)} ) logger.debug("Comparing device %r to %r", device, expected) diff --git a/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py b/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py index d1c9398bb24..0091fc098de 100644 --- a/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py +++ b/tests/components/homekit_controller/specific_devices/test_airversa_ap2.py @@ -60,8 +60,8 @@ async def test_airversa_ap2_setup(hass: HomeAssistant) -> None: capabilities={"state_class": SensorStateClass.MEASUREMENT}, ), EntityTestInfo( - entity_id="sensor.airversa_ap2_1808_filter_life", - friendly_name="Airversa AP2 1808 Filter Life", + entity_id="sensor.airversa_ap2_1808_filter_lifetime", + friendly_name="Airversa AP2 1808 Filter lifetime", unique_id="00:00:00:00:00:00_1_32896_32900", state="100.0", capabilities={"state_class": SensorStateClass.MEASUREMENT}, diff --git a/tests/components/homekit_controller/test_event.py b/tests/components/homekit_controller/test_event.py new file mode 100644 index 00000000000..9731f429eaf --- /dev/null +++ b/tests/components/homekit_controller/test_event.py @@ -0,0 +1,183 @@ +"""Test homekit_controller stateless triggers.""" +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + +from homeassistant.components.event import EventDeviceClass +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import setup_test_component + + +def create_remote(accessory): + """Define characteristics for a button (that is inn a group).""" + service_label = accessory.add_service(ServicesTypes.SERVICE_LABEL) + + char = service_label.add_char(CharacteristicsTypes.SERVICE_LABEL_NAMESPACE) + char.value = 1 + + for i in range(4): + button = accessory.add_service(ServicesTypes.STATELESS_PROGRAMMABLE_SWITCH) + button.linked.append(service_label) + + char = button.add_char(CharacteristicsTypes.INPUT_EVENT) + char.value = 0 + char.perms = ["pw", "pr", "ev"] + + char = button.add_char(CharacteristicsTypes.NAME) + char.value = f"Button {i + 1}" + + char = button.add_char(CharacteristicsTypes.SERVICE_LABEL_INDEX) + char.value = i + + battery = accessory.add_service(ServicesTypes.BATTERY_SERVICE) + battery.add_char(CharacteristicsTypes.BATTERY_LEVEL) + + +def create_button(accessory): + """Define a button (that is not in a group).""" + button = accessory.add_service(ServicesTypes.STATELESS_PROGRAMMABLE_SWITCH) + + char = button.add_char(CharacteristicsTypes.INPUT_EVENT) + char.value = 0 + char.perms = ["pw", "pr", "ev"] + + char = button.add_char(CharacteristicsTypes.NAME) + char.value = "Button 1" + + battery = accessory.add_service(ServicesTypes.BATTERY_SERVICE) + battery.add_char(CharacteristicsTypes.BATTERY_LEVEL) + + +def create_doorbell(accessory): + """Define a button (that is not in a group).""" + button = accessory.add_service(ServicesTypes.DOORBELL) + + char = button.add_char(CharacteristicsTypes.INPUT_EVENT) + char.value = 0 + char.perms = ["pw", "pr", "ev"] + + char = button.add_char(CharacteristicsTypes.NAME) + char.value = "Doorbell" + + battery = accessory.add_service(ServicesTypes.BATTERY_SERVICE) + battery.add_char(CharacteristicsTypes.BATTERY_LEVEL) + + +async def test_remote(hass: HomeAssistant, utcnow) -> None: + """Test that remote is supported.""" + helper = await setup_test_component(hass, create_remote) + + entities = [ + ("event.testdevice_button_1", "Button 1"), + ("event.testdevice_button_2", "Button 2"), + ("event.testdevice_button_3", "Button 3"), + ("event.testdevice_button_4", "Button 4"), + ] + + entity_registry = er.async_get(hass) + + for entity_id, service in entities: + button = entity_registry.async_get(entity_id) + + assert button.original_device_class == EventDeviceClass.BUTTON + assert button.capabilities["event_types"] == [ + "single_press", + "double_press", + "long_press", + ] + + helper.pairing.testing.update_named_service( + service, {CharacteristicsTypes.INPUT_EVENT: 0} + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["event_type"] == "single_press" + + helper.pairing.testing.update_named_service( + service, {CharacteristicsTypes.INPUT_EVENT: 1} + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["event_type"] == "double_press" + + helper.pairing.testing.update_named_service( + service, {CharacteristicsTypes.INPUT_EVENT: 2} + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["event_type"] == "long_press" + + +async def test_button(hass: HomeAssistant, utcnow) -> None: + """Test that a button is correctly enumerated.""" + helper = await setup_test_component(hass, create_button) + entity_id = "event.testdevice_button_1" + + entity_registry = er.async_get(hass) + button = entity_registry.async_get(entity_id) + + assert button.original_device_class == EventDeviceClass.BUTTON + assert button.capabilities["event_types"] == [ + "single_press", + "double_press", + "long_press", + ] + + helper.pairing.testing.update_named_service( + "Button 1", {CharacteristicsTypes.INPUT_EVENT: 0} + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["event_type"] == "single_press" + + helper.pairing.testing.update_named_service( + "Button 1", {CharacteristicsTypes.INPUT_EVENT: 1} + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["event_type"] == "double_press" + + helper.pairing.testing.update_named_service( + "Button 1", {CharacteristicsTypes.INPUT_EVENT: 2} + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["event_type"] == "long_press" + + +async def test_doorbell(hass: HomeAssistant, utcnow) -> None: + """Test that doorbell service is handled.""" + helper = await setup_test_component(hass, create_doorbell) + entity_id = "event.testdevice_doorbell" + + entity_registry = er.async_get(hass) + doorbell = entity_registry.async_get(entity_id) + + assert doorbell.original_device_class == EventDeviceClass.DOORBELL + assert doorbell.capabilities["event_types"] == [ + "single_press", + "double_press", + "long_press", + ] + + helper.pairing.testing.update_named_service( + "Doorbell", {CharacteristicsTypes.INPUT_EVENT: 0} + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["event_type"] == "single_press" + + helper.pairing.testing.update_named_service( + "Doorbell", {CharacteristicsTypes.INPUT_EVENT: 1} + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["event_type"] == "double_press" + + helper.pairing.testing.update_named_service( + "Doorbell", {CharacteristicsTypes.INPUT_EVENT: 2} + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes["event_type"] == "long_press" diff --git a/tests/components/hue/const.py b/tests/components/hue/const.py index 01b9c7f84b8..415fe1324b7 100644 --- a/tests/components/hue/const.py +++ b/tests/components/hue/const.py @@ -18,6 +18,7 @@ FAKE_DEVICE = { {"rid": "fake_zigbee_connectivity_id_1", "rtype": "zigbee_connectivity"}, {"rid": "fake_temperature_sensor_id_1", "rtype": "temperature"}, {"rid": "fake_motion_sensor_id_1", "rtype": "motion"}, + {"rid": "fake_relative_rotary", "rtype": "relative_rotary"}, ], "type": "device", } @@ -95,3 +96,20 @@ FAKE_SCENE = { "auto_dynamic": False, "type": "scene", } + +FAKE_ROTARY = { + "id": "fake_relative_rotary", + "id_v1": "/sensors/1", + "owner": {"rid": "fake_device_id_1", "rtype": "device"}, + "relative_rotary": { + "last_event": { + "action": "start", + "rotation": { + "direction": "clock_wise", + "steps": 0, + "duration": 0, + }, + } + }, + "type": "relative_rotary", +} diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index a7ad7ec1a00..371975e12a5 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -475,6 +475,10 @@ { "rid": "db50a5d9-8cc7-486f-be06-c0b8f0d26c69", "rtype": "zigbee_connectivity" + }, + { + "rid": "2f029c7b-868b-49e9-aa01-a0bbc595990d", + "rtype": "relative_rotary" } ], "type": "device" diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index ef309849faa..28aa8626c42 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -51,9 +51,16 @@ async def test_bridge_setup_v2(hass: HomeAssistant, mock_api_v2) -> None: assert hue_bridge.api is mock_api_v2 assert isinstance(hue_bridge.api, HueBridgeV2) assert hue_bridge.api_version == 2 - assert len(mock_forward.mock_calls) == 5 + assert len(mock_forward.mock_calls) == 6 forward_entries = {c[1][1] for c in mock_forward.mock_calls} - assert forward_entries == {"light", "binary_sensor", "sensor", "switch", "scene"} + assert forward_entries == { + "light", + "binary_sensor", + "event", + "sensor", + "switch", + "scene", + } async def test_bridge_setup_invalid_api_key(hass: HomeAssistant) -> None: diff --git a/tests/components/hue/test_device_trigger_v1.py b/tests/components/hue/test_device_trigger_v1.py index aea91c06e88..3be150f0269 100644 --- a/tests/components/hue/test_device_trigger_v1.py +++ b/tests/components/hue/test_device_trigger_v1.py @@ -29,7 +29,7 @@ async def test_get_triggers( # Get triggers for specific tap switch hue_tap_device = device_reg.async_get_device( - {(hue.DOMAIN, "00:00:00:00:00:44:23:08")} + identifiers={(hue.DOMAIN, "00:00:00:00:00:44:23:08")} ) triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, hue_tap_device.id @@ -50,7 +50,7 @@ async def test_get_triggers( # Get triggers for specific dimmer switch hue_dimmer_device = device_reg.async_get_device( - {(hue.DOMAIN, "00:17:88:01:10:3e:3a:dc")} + identifiers={(hue.DOMAIN, "00:17:88:01:10:3e:3a:dc")} ) hue_bat_sensor = entity_registry.async_get( "sensor.hue_dimmer_switch_1_battery_level" @@ -95,7 +95,7 @@ async def test_if_fires_on_state_change( # Set an automation with a specific tap switch trigger hue_tap_device = device_reg.async_get_device( - {(hue.DOMAIN, "00:00:00:00:00:44:23:08")} + identifiers={(hue.DOMAIN, "00:00:00:00:00:44:23:08")} ) assert await async_setup_component( hass, diff --git a/tests/components/hue/test_device_trigger_v2.py b/tests/components/hue/test_device_trigger_v2.py index 26c323617d2..e89f53af73a 100644 --- a/tests/components/hue/test_device_trigger_v2.py +++ b/tests/components/hue/test_device_trigger_v2.py @@ -60,7 +60,7 @@ async def test_get_triggers( # Get triggers for `Wall switch with 2 controls` hue_wall_switch_device = device_reg.async_get_device( - {(hue.DOMAIN, "3ff06175-29e8-44a8-8fe7-af591b0025da")} + identifiers={(hue.DOMAIN, "3ff06175-29e8-44a8-8fe7-af591b0025da")} ) hue_bat_sensor = entity_registry.async_get( "sensor.wall_switch_with_2_controls_battery" @@ -92,9 +92,9 @@ async def test_get_triggers( } for event_type in ( ButtonEvent.INITIAL_PRESS, - ButtonEvent.LONG_PRESS, ButtonEvent.LONG_RELEASE, ButtonEvent.REPEAT, + ButtonEvent.LONG_PRESS, ButtonEvent.SHORT_RELEASE, ) for control_id, resource_id in ( diff --git a/tests/components/hue/test_event.py b/tests/components/hue/test_event.py new file mode 100644 index 00000000000..4dbb104357d --- /dev/null +++ b/tests/components/hue/test_event.py @@ -0,0 +1,101 @@ +"""Philips Hue Event platform tests for V2 bridge/api.""" +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + ATTR_EVENT_TYPES, +) +from homeassistant.core import HomeAssistant + +from .conftest import setup_platform +from .const import FAKE_DEVICE, FAKE_ROTARY, FAKE_ZIGBEE_CONNECTIVITY + + +async def test_event( + hass: HomeAssistant, mock_bridge_v2, v2_resources_test_data +) -> None: + """Test event entity for Hue integration.""" + await mock_bridge_v2.api.load_test_data(v2_resources_test_data) + await setup_platform(hass, mock_bridge_v2, "event") + # 7 entities should be created from test data + assert len(hass.states.async_all()) == 7 + + # pick one of the remote buttons + state = hass.states.get("event.hue_dimmer_switch_with_4_controls_button_1") + assert state + assert state.state == "unknown" + assert state.name == "Hue Dimmer switch with 4 controls Button 1" + # check event_types + assert state.attributes[ATTR_EVENT_TYPES] == [ + "initial_press", + "repeat", + "short_release", + "long_press", + "long_release", + ] + # trigger firing 'initial_press' event from the device + btn_event = { + "button": {"last_event": "initial_press"}, + "id": "f92aa267-1387-4f02-9950-210fb7ca1f5a", + "metadata": {"control_id": 1}, + "type": "button", + } + mock_bridge_v2.api.emit_event("update", btn_event) + await hass.async_block_till_done() + state = hass.states.get("event.hue_dimmer_switch_with_4_controls_button_1") + assert state.attributes[ATTR_EVENT_TYPE] == "initial_press" + # trigger firing 'long_release' event from the device + btn_event = { + "button": {"last_event": "long_release"}, + "id": "f92aa267-1387-4f02-9950-210fb7ca1f5a", + "metadata": {"control_id": 1}, + "type": "button", + } + mock_bridge_v2.api.emit_event("update", btn_event) + await hass.async_block_till_done() + state = hass.states.get("event.hue_dimmer_switch_with_4_controls_button_1") + assert state.attributes[ATTR_EVENT_TYPE] == "long_release" + + +async def test_sensor_add_update(hass: HomeAssistant, mock_bridge_v2) -> None: + """Test Event entity for newly added Relative Rotary resource.""" + await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY]) + await setup_platform(hass, mock_bridge_v2, "event") + + test_entity_id = "event.hue_mocked_device_relative_rotary" + + # verify entity does not exist before we start + assert hass.states.get(test_entity_id) is None + + # Add new fake relative_rotary entity by emitting event + mock_bridge_v2.api.emit_event("add", FAKE_ROTARY) + await hass.async_block_till_done() + + # the entity should now be available + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == "unknown" + assert state.name == "Hue mocked device Relative Rotary" + # check event_types + assert state.attributes[ATTR_EVENT_TYPES] == ["clock_wise", "counter_clock_wise"] + + # test update of entity works on incoming event + btn_event = { + "id": "fake_relative_rotary", + "relative_rotary": { + "last_event": { + "action": "repeat", + "rotation": { + "direction": "counter_clock_wise", + "steps": 60, + "duration": 400, + }, + } + }, + "type": "relative_rotary", + } + mock_bridge_v2.api.emit_event("update", btn_event) + await hass.async_block_till_done() + state = hass.states.get(test_entity_id) + assert state.attributes[ATTR_EVENT_TYPE] == "counter_clock_wise" + assert state.attributes["action"] == "repeat" + assert state.attributes["steps"] == 60 + assert state.attributes["duration"] == 400 diff --git a/tests/components/hue/test_logbook.py b/tests/components/hue/test_logbook.py deleted file mode 100644 index 3f49efcdeb7..00000000000 --- a/tests/components/hue/test_logbook.py +++ /dev/null @@ -1,107 +0,0 @@ -"""The tests for hue logbook.""" -from homeassistant.components.hue.const import ATTR_HUE_EVENT, CONF_SUBTYPE, DOMAIN -from homeassistant.components.hue.v1.hue_event import CONF_LAST_UPDATED -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_EVENT, - CONF_ID, - CONF_TYPE, - CONF_UNIQUE_ID, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr -from homeassistant.setup import async_setup_component - -from .conftest import setup_platform - -from tests.components.logbook.common import MockRow, mock_humanify - -# v1 event -SAMPLE_V1_EVENT = { - CONF_DEVICE_ID: "fe346f17a9f8c15be633f9cc3f3d6631", - CONF_EVENT: 18, - CONF_ID: "hue_tap", - CONF_LAST_UPDATED: "2019-12-28T22:58:03", - CONF_UNIQUE_ID: "00:00:00:00:00:44:23:08-f2", -} -# v2 event -SAMPLE_V2_EVENT = { - CONF_DEVICE_ID: "f974028e7933aea703a2199a855bc4a3", - CONF_ID: "wall_switch_with_2_controls_button", - CONF_SUBTYPE: 1, - CONF_TYPE: "initial_press", - CONF_UNIQUE_ID: "c658d3d8-a013-4b81-8ac6-78b248537e70", -} - - -async def test_humanify_hue_events( - hass: HomeAssistant, mock_bridge_v2, device_registry: dr.DeviceRegistry -) -> None: - """Test hue events when the devices are present in the registry.""" - await setup_platform(hass, mock_bridge_v2, "sensor") - hass.config.components.add("recorder") - assert await async_setup_component(hass, "logbook", {}) - await hass.async_block_till_done() - entry: ConfigEntry = hass.config_entries.async_entries(DOMAIN)[0] - - v1_device = device_registry.async_get_or_create( - identifiers={(DOMAIN, "v1")}, name="Remote 1", config_entry_id=entry.entry_id - ) - v2_device = device_registry.async_get_or_create( - identifiers={(DOMAIN, "v2")}, name="Remote 2", config_entry_id=entry.entry_id - ) - - (v1_event, v2_event) = mock_humanify( - hass, - [ - MockRow( - ATTR_HUE_EVENT, - {**SAMPLE_V1_EVENT, CONF_DEVICE_ID: v1_device.id}, - ), - MockRow( - ATTR_HUE_EVENT, - {**SAMPLE_V2_EVENT, CONF_DEVICE_ID: v2_device.id}, - ), - ], - ) - - assert v1_event["name"] == "Remote 1" - assert v1_event["domain"] == DOMAIN - assert v1_event["message"] == "Event 18" - - assert v2_event["name"] == "Remote 2" - assert v2_event["domain"] == DOMAIN - assert v2_event["message"] == "first button pressed initially" - - -async def test_humanify_hue_events_devices_removed( - hass: HomeAssistant, mock_bridge_v2 -) -> None: - """Test hue events when the devices have been removed from the registry.""" - await setup_platform(hass, mock_bridge_v2, "sensor") - hass.config.components.add("recorder") - assert await async_setup_component(hass, "logbook", {}) - await hass.async_block_till_done() - - (v1_event, v2_event) = mock_humanify( - hass, - [ - MockRow( - ATTR_HUE_EVENT, - SAMPLE_V1_EVENT, - ), - MockRow( - ATTR_HUE_EVENT, - SAMPLE_V2_EVENT, - ), - ], - ) - - assert v1_event["name"] == "hue_tap" - assert v1_event["domain"] == DOMAIN - assert v1_event["message"] == "Event 18" - - assert v2_event["name"] == "wall_switch_with_2_controls_button" - assert v2_event["domain"] == DOMAIN - assert v2_event["message"] == "first button pressed initially" diff --git a/tests/components/hue/test_sensor_v1.py b/tests/components/hue/test_sensor_v1.py index f7f08188036..d5ac8406f24 100644 --- a/tests/components/hue/test_sensor_v1.py +++ b/tests/components/hue/test_sensor_v1.py @@ -460,7 +460,7 @@ async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> No assert len(events) == 0 hue_tap_device = device_reg.async_get_device( - {(hue.DOMAIN, "00:00:00:00:00:44:23:08")} + identifiers={(hue.DOMAIN, "00:00:00:00:00:44:23:08")} ) mock_bridge_v1.api.sensors["7"].last_event = {"type": "button"} @@ -492,7 +492,7 @@ async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> No } hue_dimmer_device = device_reg.async_get_device( - {(hue.DOMAIN, "00:17:88:01:10:3e:3a:dc")} + identifiers={(hue.DOMAIN, "00:17:88:01:10:3e:3a:dc")} ) new_sensor_response = dict(new_sensor_response) @@ -595,7 +595,7 @@ async def test_hue_events(hass: HomeAssistant, mock_bridge_v1, device_reg) -> No await hass.async_block_till_done() hue_aurora_device = device_reg.async_get_device( - {(hue.DOMAIN, "ff:ff:00:0f:e7:fd:bc:b7")} + identifiers={(hue.DOMAIN, "ff:ff:00:0f:e7:fd:bc:b7")} ) assert len(mock_bridge_v1.mock_requests) == 6 diff --git a/tests/components/hyperion/__init__.py b/tests/components/hyperion/__init__.py index 382a168bc44..3714e58479b 100644 --- a/tests/components/hyperion/__init__.py +++ b/tests/components/hyperion/__init__.py @@ -114,6 +114,7 @@ def create_mock_client() -> Mock: mock_client.instances = [ {"friendly_name": "Test instance 1", "instance": 0, "running": True} ] + mock_client.remote_url = f"http://{TEST_HOST}:{TEST_PORT_UI}" return mock_client diff --git a/tests/components/hyperion/test_camera.py b/tests/components/hyperion/test_camera.py index f83ed9c7e78..a6234f34593 100644 --- a/tests/components/hyperion/test_camera.py +++ b/tests/components/hyperion/test_camera.py @@ -192,7 +192,7 @@ async def test_device_info(hass: HomeAssistant) -> None: device_id = get_hyperion_device_id(TEST_SYSINFO_ID, TEST_INSTANCE) device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, device_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device assert device.config_entries == {TEST_CONFIG_ENTRY_ID} assert device.identifiers == {(DOMAIN, device_id)} diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index 667c73a20ac..6c4cc4e512e 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -775,7 +775,7 @@ async def test_device_info(hass: HomeAssistant) -> None: device_id = get_hyperion_device_id(TEST_SYSINFO_ID, TEST_INSTANCE) device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, device_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) assert device assert device.config_entries == {TEST_CONFIG_ENTRY_ID} assert device.identifiers == {(DOMAIN, device_id)} diff --git a/tests/components/hyperion/test_switch.py b/tests/components/hyperion/test_switch.py index 49338c72c5d..dcdd86f0902 100644 --- a/tests/components/hyperion/test_switch.py +++ b/tests/components/hyperion/test_switch.py @@ -164,7 +164,7 @@ async def test_device_info(hass: HomeAssistant) -> None: device_identifer = get_hyperion_device_id(TEST_SYSINFO_ID, TEST_INSTANCE) device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, device_identifer)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, device_identifer)}) assert device assert device.config_entries == {TEST_CONFIG_ENTRY_ID} assert device.identifiers == {(DOMAIN, device_identifer)} diff --git a/tests/components/ibeacon/test_init.py b/tests/components/ibeacon/test_init.py index 2437c7c1351..2e3aafb4984 100644 --- a/tests/components/ibeacon/test_init.py +++ b/tests/components/ibeacon/test_init.py @@ -49,13 +49,12 @@ async def test_device_remove_devices( device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device( - { + identifiers={ ( DOMAIN, "426c7565-4368-6172-6d42-6561636f6e73_3838_4949_61DE521B-F0BF-9F44-64D4-75BBE1738105", ) }, - {}, ) assert ( await remove_device(await hass_ws_client(hass), device_entry.id, entry.entry_id) diff --git a/tests/components/ign_sismologia/test_geo_location.py b/tests/components/ign_sismologia/test_geo_location.py index 4769da29019..02a11b3fe7a 100644 --- a/tests/components/ign_sismologia/test_geo_location.py +++ b/tests/components/ign_sismologia/test_geo_location.py @@ -81,7 +81,7 @@ async def test_setup(hass: HomeAssistant) -> None: (38.0, -3.0), region="Region 1", attribution="Attribution 1", - published=datetime.datetime(2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc), + published=datetime.datetime(2018, 9, 22, 8, 0, tzinfo=datetime.UTC), magnitude=5.7, image_url="http://image.url/map.jpg", ) @@ -125,7 +125,7 @@ async def test_setup(hass: HomeAssistant) -> None: ATTR_REGION: "Region 1", ATTR_ATTRIBUTION: "Attribution 1", ATTR_PUBLICATION_DATE: datetime.datetime( - 2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc + 2018, 9, 22, 8, 0, tzinfo=datetime.UTC ), ATTR_IMAGE_URL: "http://image.url/map.jpg", ATTR_MAGNITUDE: 5.7, diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index fb4347b08a7..efb505cda77 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -401,9 +401,9 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("advanced_options", "assert_result"), [ - ({"max_message_size": "8192"}, data_entry_flow.FlowResultType.CREATE_ENTRY), - ({"max_message_size": "1024"}, data_entry_flow.FlowResultType.FORM), - ({"max_message_size": "65536"}, data_entry_flow.FlowResultType.FORM), + ({"max_message_size": 8192}, data_entry_flow.FlowResultType.CREATE_ENTRY), + ({"max_message_size": 1024}, data_entry_flow.FlowResultType.FORM), + ({"max_message_size": 65536}, data_entry_flow.FlowResultType.FORM), ( {"custom_event_data_template": "{{ subject }}"}, data_entry_flow.FlowResultType.CREATE_ENTRY, @@ -412,6 +412,8 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None: {"custom_event_data_template": "{{ invalid_syntax"}, data_entry_flow.FlowResultType.FORM, ), + ({"enable_push": True}, data_entry_flow.FlowResultType.CREATE_ENTRY), + ({"enable_push": False}, data_entry_flow.FlowResultType.CREATE_ENTRY), ], ids=[ "valid_message_size", @@ -419,6 +421,8 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None: "invalid_message_size_high", "valid_template", "invalid_template", + "enable_push_true", + "enable_push_false", ], ) async def test_advanced_options_form( @@ -459,7 +463,7 @@ async def test_advanced_options_form( else: # Check if entry was updated for key, value in new_config.items(): - assert str(entry.data[key]) == value + assert entry.data[key] == value except vol.MultipleInvalid: # Check if form was expected with these options assert assert_result == data_entry_flow.FlowResultType.FORM diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 055f8fd82bc..b9512da0278 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -2,7 +2,7 @@ import asyncio from datetime import datetime, timedelta, timezone from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, call, patch from aioimaplib import AUTH, NONAUTH, SELECTED, AioImapException, Response import pytest @@ -36,13 +36,17 @@ from tests.common import MockConfigEntry, async_capture_events, async_fire_time_ @pytest.mark.parametrize( - ("cipher_list", "verify_ssl"), + ("cipher_list", "verify_ssl", "enable_push"), [ - (None, None), - ("python_default", True), - ("python_default", False), - ("modern", True), - ("intermediate", True), + (None, None, None), + ("python_default", True, None), + ("python_default", False, None), + ("modern", True, None), + ("intermediate", True, None), + (None, None, False), + (None, None, True), + ("python_default", True, False), + ("python_default", False, True), ], ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) @@ -51,6 +55,7 @@ async def test_entry_startup_and_unload( mock_imap_protocol: MagicMock, cipher_list: str | None, verify_ssl: bool | None, + enable_push: bool | None, ) -> None: """Test imap entry startup and unload with push and polling coordinator and alternate ciphers.""" config = MOCK_CONFIG.copy() @@ -58,6 +63,8 @@ async def test_entry_startup_and_unload( config["ssl_cipher_list"] = cipher_list if verify_ssl is not None: config["verify_ssl"] = verify_ssl + if enable_push is not None: + config["enable_push"] = enable_push config_entry = MockConfigEntry(domain=DOMAIN, data=config) config_entry.add_to_hass(hass) @@ -660,3 +667,58 @@ async def test_custom_template( assert data["text"] assert data["custom"] == result assert error in caplog.text if error is not None else True + + +@pytest.mark.parametrize( + ("imap_search", "imap_fetch"), + [(TEST_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_TEXT_PLAIN)], +) +@pytest.mark.parametrize( + ("imap_has_capability", "enable_push", "should_poll"), + [ + (True, False, True), + (False, False, True), + (True, True, False), + (False, True, True), + ], + ids=["enforce_poll", "poll", "auto_push", "auto_poll"], +) +async def test_enforce_polling( + hass: HomeAssistant, + mock_imap_protocol: MagicMock, + enable_push: bool, + should_poll: True, +) -> None: + """Test enforce polling.""" + event_called = async_capture_events(hass, "imap_content") + config = MOCK_CONFIG.copy() + config["enable_push"] = enable_push + + config_entry = MockConfigEntry(domain=DOMAIN, data=config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have received one message + assert state is not None + assert state.state == "1" + assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT + + # we should have received one event + assert len(event_called) == 1 + data: dict[str, Any] = event_called[0].data + assert data["server"] == "imap.server.com" + assert data["username"] == "email@email.com" + assert data["search"] == "UnSeen UnDeleted" + assert data["folder"] == "INBOX" + assert data["sender"] == "john.doe@example.com" + assert data["subject"] == "Test subject" + assert data["text"] + + if should_poll: + mock_imap_protocol.wait_server_push.assert_not_called() + else: + mock_imap_protocol.assert_has_calls([call.wait_server_push]) diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py index 683c69807b2..a1234b7a470 100644 --- a/tests/components/influxdb/test_init.py +++ b/tests/components/influxdb/test_init.py @@ -2,14 +2,13 @@ from dataclasses import dataclass import datetime from http import HTTPStatus -from unittest.mock import MagicMock, Mock, call, patch +from unittest.mock import ANY, MagicMock, Mock, call, patch import pytest import homeassistant.components.influxdb as influxdb from homeassistant.components.influxdb.const import DEFAULT_BUCKET from homeassistant.const import ( - EVENT_STATE_CHANGED, PERCENTAGE, STATE_OFF, STATE_ON, @@ -39,7 +38,6 @@ class FilterTest: @pytest.fixture(autouse=True) def mock_batch_timeout(hass, monkeypatch): """Mock the event bus listener and the batch timeout for tests.""" - hass.bus.listen = MagicMock() monkeypatch.setattr( f"{INFLUX_PATH}.InfluxThread.batch_timeout", Mock(return_value=0), @@ -129,8 +127,6 @@ async def test_setup_config_full( assert await async_setup_component(hass, influxdb.DOMAIN, config) await hass.async_block_till_done() - assert hass.bus.listen.called - assert hass.bus.listen.call_args_list[0][0][0] == EVENT_STATE_CHANGED assert get_write_api(mock_client).call_count == 1 @@ -263,8 +259,6 @@ async def test_setup_config_ssl( assert await async_setup_component(hass, influxdb.DOMAIN, config) await hass.async_block_till_done() - assert hass.bus.listen.called - assert hass.bus.listen.call_args_list[0][0][0] == EVENT_STATE_CHANGED assert expected_client_args.items() <= mock_client.call_args.kwargs.items() @@ -285,8 +279,6 @@ async def test_setup_minimal_config( assert await async_setup_component(hass, influxdb.DOMAIN, config) await hass.async_block_till_done() - assert hass.bus.listen.called - assert hass.bus.listen.call_args_list[0][0][0] == EVENT_STATE_CHANGED assert get_write_api(mock_client).call_count == 1 @@ -347,7 +339,6 @@ async def _setup(hass, mock_influx_client, config_ext, get_write_api): # A call is made to the write API during setup to test the connection. # Therefore we reset the write API mock here before the test begins. get_write_api(mock_influx_client).reset_mock() - return hass.bus.listen.call_args_list[0][0][1] @pytest.mark.parametrize( @@ -372,7 +363,7 @@ async def test_event_listener( hass: HomeAssistant, mock_client, config_ext, get_write_api, get_mock_call ) -> None: """Test the event listener.""" - handler_method = await _setup(hass, mock_client, config_ext, get_write_api) + await _setup(hass, mock_client, config_ext, get_write_api) # map of HA State to valid influxdb [state, value] fields valid = { @@ -394,19 +385,11 @@ async def test_event_listener( "updated_at": datetime.datetime(2017, 1, 1, 0, 0), "multi_periods": "0.120.240.2023873", } - state = MagicMock( - state=in_, - domain="fake", - entity_id="fake.entity-id", - object_id="entity", - attributes=attrs, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) body = [ { "measurement": "foobars", - "tags": {"domain": "fake", "entity_id": "entity"}, - "time": 12345, + "tags": {"domain": "fake", "entity_id": "entity_id"}, + "time": ANY, "fields": { "longitude": 1.1, "latitude": 2.2, @@ -427,7 +410,8 @@ async def test_event_listener( if out[1] is not None: body[0]["fields"]["value"] = out[1] - handler_method(event) + hass.states.async_set("fake.entity_id", in_, attrs) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api = get_write_api(mock_client) @@ -458,30 +442,23 @@ async def test_event_listener_no_units( hass: HomeAssistant, mock_client, config_ext, get_write_api, get_mock_call ) -> None: """Test the event listener for missing units.""" - handler_method = await _setup(hass, mock_client, config_ext, get_write_api) + await _setup(hass, mock_client, config_ext, get_write_api) - for unit in (None, ""): + for unit in ("",): if unit: attrs = {"unit_of_measurement": unit} else: attrs = {} - state = MagicMock( - state=1, - domain="fake", - entity_id="fake.entity-id", - object_id="entity", - attributes=attrs, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) body = [ { - "measurement": "fake.entity-id", - "tags": {"domain": "fake", "entity_id": "entity"}, - "time": 12345, + "measurement": "fake.entity_id", + "tags": {"domain": "fake", "entity_id": "entity_id"}, + "time": ANY, "fields": {"value": 1}, } ] - handler_method(event) + hass.states.async_set("fake.entity_id", 1, attrs) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api = get_write_api(mock_client) @@ -512,26 +489,19 @@ async def test_event_listener_inf( hass: HomeAssistant, mock_client, config_ext, get_write_api, get_mock_call ) -> None: """Test the event listener with large or invalid numbers.""" - handler_method = await _setup(hass, mock_client, config_ext, get_write_api) + await _setup(hass, mock_client, config_ext, get_write_api) attrs = {"bignumstring": "9" * 999, "nonumstring": "nan"} - state = MagicMock( - state=8, - domain="fake", - entity_id="fake.entity-id", - object_id="entity", - attributes=attrs, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) body = [ { - "measurement": "fake.entity-id", - "tags": {"domain": "fake", "entity_id": "entity"}, - "time": 12345, + "measurement": "fake.entity_id", + "tags": {"domain": "fake", "entity_id": "entity_id"}, + "time": ANY, "fields": {"value": 8}, } ] - handler_method(event) + hass.states.async_set("fake.entity_id", 8, attrs) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api = get_write_api(mock_client) @@ -561,26 +531,19 @@ async def test_event_listener_states( hass: HomeAssistant, mock_client, config_ext, get_write_api, get_mock_call ) -> None: """Test the event listener against ignored states.""" - handler_method = await _setup(hass, mock_client, config_ext, get_write_api) + await _setup(hass, mock_client, config_ext, get_write_api) - for state_state in (1, "unknown", "", "unavailable", None): - state = MagicMock( - state=state_state, - domain="fake", - entity_id="fake.entity-id", - object_id="entity", - attributes={}, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) + for state_state in (1, "unknown", "", "unavailable"): body = [ { - "measurement": "fake.entity-id", - "tags": {"domain": "fake", "entity_id": "entity"}, - "time": 12345, + "measurement": "fake.entity_id", + "tags": {"domain": "fake", "entity_id": "entity_id"}, + "time": ANY, "fields": {"value": 1}, } ] - handler_method(event) + hass.states.async_set("fake.entity_id", state_state) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api = get_write_api(mock_client) @@ -592,27 +555,20 @@ async def test_event_listener_states( write_api.reset_mock() -def execute_filter_test(hass, tests, handler_method, write_api, get_mock_call): +async def execute_filter_test(hass: HomeAssistant, tests, write_api, get_mock_call): """Execute all tests for a given filtering test.""" for test in tests: domain, entity_id = split_entity_id(test.id) - state = MagicMock( - state=1, - domain=domain, - entity_id=test.id, - object_id=entity_id, - attributes={}, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) body = [ { "measurement": test.id, "tags": {"domain": domain, "entity_id": entity_id}, - "time": 12345, + "time": ANY, "fields": {"value": 1}, } ] - handler_method(event) + hass.states.async_set(test.id, 1) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() if test.should_pass: @@ -647,14 +603,14 @@ async def test_event_listener_denylist( """Test the event listener against a denylist.""" config = {"exclude": {"entities": ["fake.denylisted"]}, "include": {}} config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) + await _setup(hass, mock_client, config, get_write_api) write_api = get_write_api(mock_client) tests = [ FilterTest("fake.ok", True), FilterTest("fake.denylisted", False), ] - execute_filter_test(hass, tests, handler_method, write_api, get_mock_call) + await execute_filter_test(hass, tests, write_api, get_mock_call) @pytest.mark.parametrize( @@ -681,14 +637,14 @@ async def test_event_listener_denylist_domain( """Test the event listener against a domain denylist.""" config = {"exclude": {"domains": ["another_fake"]}, "include": {}} config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) + await _setup(hass, mock_client, config, get_write_api) write_api = get_write_api(mock_client) tests = [ FilterTest("fake.ok", True), FilterTest("another_fake.denylisted", False), ] - execute_filter_test(hass, tests, handler_method, write_api, get_mock_call) + await execute_filter_test(hass, tests, write_api, get_mock_call) @pytest.mark.parametrize( @@ -715,14 +671,14 @@ async def test_event_listener_denylist_glob( """Test the event listener against a glob denylist.""" config = {"exclude": {"entity_globs": ["*.excluded_*"]}, "include": {}} config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) + await _setup(hass, mock_client, config, get_write_api) write_api = get_write_api(mock_client) tests = [ FilterTest("fake.ok", True), FilterTest("fake.excluded_entity", False), ] - execute_filter_test(hass, tests, handler_method, write_api, get_mock_call) + await execute_filter_test(hass, tests, write_api, get_mock_call) @pytest.mark.parametrize( @@ -749,14 +705,14 @@ async def test_event_listener_allowlist( """Test the event listener against an allowlist.""" config = {"include": {"entities": ["fake.included"]}, "exclude": {}} config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) + await _setup(hass, mock_client, config, get_write_api) write_api = get_write_api(mock_client) tests = [ FilterTest("fake.included", True), FilterTest("fake.excluded", False), ] - execute_filter_test(hass, tests, handler_method, write_api, get_mock_call) + await execute_filter_test(hass, tests, write_api, get_mock_call) @pytest.mark.parametrize( @@ -783,14 +739,14 @@ async def test_event_listener_allowlist_domain( """Test the event listener against a domain allowlist.""" config = {"include": {"domains": ["fake"]}, "exclude": {}} config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) + await _setup(hass, mock_client, config, get_write_api) write_api = get_write_api(mock_client) tests = [ FilterTest("fake.ok", True), FilterTest("another_fake.excluded", False), ] - execute_filter_test(hass, tests, handler_method, write_api, get_mock_call) + await execute_filter_test(hass, tests, write_api, get_mock_call) @pytest.mark.parametrize( @@ -817,14 +773,14 @@ async def test_event_listener_allowlist_glob( """Test the event listener against a glob allowlist.""" config = {"include": {"entity_globs": ["*.included_*"]}, "exclude": {}} config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) + await _setup(hass, mock_client, config, get_write_api) write_api = get_write_api(mock_client) tests = [ FilterTest("fake.included_entity", True), FilterTest("fake.denied", False), ] - execute_filter_test(hass, tests, handler_method, write_api, get_mock_call) + await execute_filter_test(hass, tests, write_api, get_mock_call) @pytest.mark.parametrize( @@ -862,7 +818,7 @@ async def test_event_listener_filtered_allowlist( }, } config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) + await _setup(hass, mock_client, config, get_write_api) write_api = get_write_api(mock_client) tests = [ @@ -874,7 +830,7 @@ async def test_event_listener_filtered_allowlist( FilterTest("fake.excluded_entity", False), FilterTest("another_fake.included_entity", True), ] - execute_filter_test(hass, tests, handler_method, write_api, get_mock_call) + await execute_filter_test(hass, tests, write_api, get_mock_call) @pytest.mark.parametrize( @@ -904,7 +860,7 @@ async def test_event_listener_filtered_denylist( "exclude": {"domains": ["another_fake"], "entity_globs": "*.excluded_*"}, } config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) + await _setup(hass, mock_client, config, get_write_api) write_api = get_write_api(mock_client) tests = [ @@ -914,7 +870,7 @@ async def test_event_listener_filtered_denylist( FilterTest("another_fake.denied", False), FilterTest("fake.excluded_entity", False), ] - execute_filter_test(hass, tests, handler_method, write_api, get_mock_call) + await execute_filter_test(hass, tests, write_api, get_mock_call) @pytest.mark.parametrize( @@ -939,7 +895,7 @@ async def test_event_listener_invalid_type( hass: HomeAssistant, mock_client, config_ext, get_write_api, get_mock_call ) -> None: """Test the event listener when an attribute has an invalid type.""" - handler_method = await _setup(hass, mock_client, config_ext, get_write_api) + await _setup(hass, mock_client, config_ext, get_write_api) # map of HA State to valid influxdb [state, value] fields valid = { @@ -957,19 +913,11 @@ async def test_event_listener_invalid_type( "latitude": "2.2", "invalid_attribute": ["value1", "value2"], } - state = MagicMock( - state=in_, - domain="fake", - entity_id="fake.entity-id", - object_id="entity", - attributes=attrs, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) body = [ { "measurement": "foobars", - "tags": {"domain": "fake", "entity_id": "entity"}, - "time": 12345, + "tags": {"domain": "fake", "entity_id": "entity_id"}, + "time": ANY, "fields": { "longitude": 1.1, "latitude": 2.2, @@ -982,7 +930,8 @@ async def test_event_listener_invalid_type( if out[1] is not None: body[0]["fields"]["value"] = out[1] - handler_method(event) + hass.states.async_set("fake.entity_id", in_, attrs) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api = get_write_api(mock_client) @@ -1015,25 +964,17 @@ async def test_event_listener_default_measurement( """Test the event listener with a default measurement.""" config = {"default_measurement": "state"} config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) - - state = MagicMock( - state=1, - domain="fake", - entity_id="fake.ok", - object_id="ok", - attributes={}, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) + await _setup(hass, mock_client, config, get_write_api) body = [ { "measurement": "state", "tags": {"domain": "fake", "entity_id": "ok"}, - "time": 12345, + "time": ANY, "fields": {"value": 1}, } ] - handler_method(event) + hass.states.async_set("fake.ok", 1) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api = get_write_api(mock_client) @@ -1065,26 +1006,19 @@ async def test_event_listener_unit_of_measurement_field( """Test the event listener for unit of measurement field.""" config = {"override_measurement": "state"} config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) + await _setup(hass, mock_client, config, get_write_api) attrs = {"unit_of_measurement": "foobars"} - state = MagicMock( - state="foo", - domain="fake", - entity_id="fake.entity-id", - object_id="entity", - attributes=attrs, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) body = [ { "measurement": "state", - "tags": {"domain": "fake", "entity_id": "entity"}, - "time": 12345, + "tags": {"domain": "fake", "entity_id": "entity_id"}, + "time": ANY, "fields": {"state": "foo", "unit_of_measurement_str": "foobars"}, } ] - handler_method(event) + hass.states.async_set("fake.entity_id", "foo", attrs) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api = get_write_api(mock_client) @@ -1116,17 +1050,9 @@ async def test_event_listener_tags_attributes( """Test the event listener when some attributes should be tags.""" config = {"tags_attributes": ["friendly_fake"]} config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) + await _setup(hass, mock_client, config, get_write_api) attrs = {"friendly_fake": "tag_str", "field_fake": "field_str"} - state = MagicMock( - state=1, - domain="fake", - entity_id="fake.something", - object_id="something", - attributes=attrs, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) body = [ { "measurement": "fake.something", @@ -1135,11 +1061,12 @@ async def test_event_listener_tags_attributes( "entity_id": "something", "friendly_fake": "tag_str", }, - "time": 12345, + "time": ANY, "fields": {"value": 1, "field_fake_str": "field_str"}, } ] - handler_method(event) + hass.states.async_set("fake.something", 1, attrs) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api = get_write_api(mock_client) @@ -1179,7 +1106,7 @@ async def test_event_listener_component_override_measurement( "component_config_domain": {"climate": {"override_measurement": "hvac"}}, } config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) + await _setup(hass, mock_client, config, get_write_api) test_components = [ {"domain": "sensor", "id": "fake_humidity", "res": "humidity"}, @@ -1188,23 +1115,16 @@ async def test_event_listener_component_override_measurement( {"domain": "other", "id": "just_fake", "res": "other.just_fake"}, ] for comp in test_components: - state = MagicMock( - state=1, - domain=comp["domain"], - entity_id=f"{comp['domain']}.{comp['id']}", - object_id=comp["id"], - attributes={}, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) body = [ { "measurement": comp["res"], "tags": {"domain": comp["domain"], "entity_id": comp["id"]}, - "time": 12345, + "time": ANY, "fields": {"value": 1}, } ] - handler_method(event) + hass.states.async_set(f"{comp['domain']}.{comp['id']}", 1) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api = get_write_api(mock_client) @@ -1246,7 +1166,7 @@ async def test_event_listener_component_measurement_attr( "component_config_domain": {"climate": {"override_measurement": "hvac"}}, } config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) + await _setup(hass, mock_client, config, get_write_api) test_components = [ { @@ -1261,23 +1181,16 @@ async def test_event_listener_component_measurement_attr( {"domain": "other", "id": "just_fake", "attrs": {}, "res": "other"}, ] for comp in test_components: - state = MagicMock( - state=1, - domain=comp["domain"], - entity_id=f"{comp['domain']}.{comp['id']}", - object_id=comp["id"], - attributes=comp["attrs"], - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) body = [ { "measurement": comp["res"], "tags": {"domain": comp["domain"], "entity_id": comp["id"]}, - "time": 12345, + "time": ANY, "fields": {"value": 1}, } ] - handler_method(event) + hass.states.async_set(f"{comp['domain']}.{comp['id']}", 1, comp["attrs"]) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api = get_write_api(mock_client) @@ -1321,7 +1234,7 @@ async def test_event_listener_ignore_attributes( }, } config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) + await _setup(hass, mock_client, config, get_write_api) test_components = [ { @@ -1342,30 +1255,27 @@ async def test_event_listener_ignore_attributes( ] for comp in test_components: entity_id = f"{comp['domain']}.{comp['id']}" - state = MagicMock( - state=1, - domain=comp["domain"], - entity_id=entity_id, - object_id=comp["id"], - attributes={ - "ignore": 1, - "id_ignore": 1, - "glob_ignore": 1, - "domain_ignore": 1, - }, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) fields = {"value": 1} fields.update(comp["attrs"]) body = [ { "measurement": entity_id, "tags": {"domain": comp["domain"], "entity_id": comp["id"]}, - "time": 12345, + "time": ANY, "fields": fields, } ] - handler_method(event) + hass.states.async_set( + entity_id, + 1, + { + "ignore": 1, + "id_ignore": 1, + "glob_ignore": 1, + "domain_ignore": 1, + }, + ) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api = get_write_api(mock_client) @@ -1401,25 +1311,17 @@ async def test_event_listener_ignore_attributes_overlapping_entities( "component_config_domain": {"sensor": {"ignore_attributes": ["ignore"]}}, } config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) - - state = MagicMock( - state=1, - domain="sensor", - entity_id="sensor.fake", - object_id="fake", - attributes={"ignore": 1}, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) + await _setup(hass, mock_client, config, get_write_api) body = [ { "measurement": "units", "tags": {"domain": "sensor", "entity_id": "fake"}, - "time": 12345, + "time": ANY, "fields": {"value": 1}, } ] - handler_method(event) + hass.states.async_set("sensor.fake", 1, {"ignore": 1}) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api = get_write_api(mock_client) @@ -1452,22 +1354,14 @@ async def test_event_listener_scheduled_write( """Test the event listener retries after a write failure.""" config = {"max_retries": 1} config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) - - state = MagicMock( - state=1, - domain="fake", - entity_id="entity.id", - object_id="entity", - attributes={}, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) + await _setup(hass, mock_client, config, get_write_api) write_api = get_write_api(mock_client) write_api.side_effect = OSError("foo") # Write fails with patch.object(influxdb.time, "sleep") as mock_sleep: - handler_method(event) + hass.states.async_set("entity.entity_id", 1) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() assert mock_sleep.called assert write_api.call_count == 2 @@ -1475,7 +1369,8 @@ async def test_event_listener_scheduled_write( # Write works again write_api.side_effect = None with patch.object(influxdb.time, "sleep") as mock_sleep: - handler_method(event) + hass.states.async_set("entity.entity_id", "2") + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() assert not mock_sleep.called assert write_api.call_count == 3 @@ -1503,16 +1398,7 @@ async def test_event_listener_backlog_full( hass: HomeAssistant, mock_client, config_ext, get_write_api, get_mock_call ) -> None: """Test the event listener drops old events when backlog gets full.""" - handler_method = await _setup(hass, mock_client, config_ext, get_write_api) - - state = MagicMock( - state=1, - domain="fake", - entity_id="entity.id", - object_id="entity", - attributes={}, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) + await _setup(hass, mock_client, config_ext, get_write_api) monotonic_time = 0 @@ -1523,7 +1409,8 @@ async def test_event_listener_backlog_full( return monotonic_time with patch("homeassistant.components.influxdb.time.monotonic", new=fast_monotonic): - handler_method(event) + hass.states.async_set("entity.id", 1) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() assert get_write_api(mock_client).call_count == 0 @@ -1551,26 +1438,17 @@ async def test_event_listener_attribute_name_conflict( hass: HomeAssistant, mock_client, config_ext, get_write_api, get_mock_call ) -> None: """Test the event listener when an attribute conflicts with another field.""" - handler_method = await _setup(hass, mock_client, config_ext, get_write_api) - - attrs = {"value": "value_str"} - state = MagicMock( - state=1, - domain="fake", - entity_id="fake.something", - object_id="something", - attributes=attrs, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) + await _setup(hass, mock_client, config_ext, get_write_api) body = [ { "measurement": "fake.something", "tags": {"domain": "fake", "entity_id": "something"}, - "time": 12345, + "time": ANY, "fields": {"value": 1, "value__str": "value_str"}, } ] - handler_method(event) + hass.states.async_set("fake.something", 1, {"value": "value_str"}) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api = get_write_api(mock_client) @@ -1642,7 +1520,6 @@ async def test_connection_failure_on_startup( == 1 ) event_helper.call_later.assert_called_once() - hass.bus.listen.assert_not_called() @pytest.mark.parametrize( @@ -1686,21 +1563,14 @@ async def test_invalid_inputs_error( But Influx is an external service so there may be edge cases that haven't been encountered yet. """ - handler_method = await _setup(hass, mock_client, config_ext, get_write_api) + await _setup(hass, mock_client, config_ext, get_write_api) write_api = get_write_api(mock_client) write_api.side_effect = test_exception - state = MagicMock( - state=1, - domain="fake", - entity_id="fake.something", - object_id="something", - attributes={}, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) with patch(f"{INFLUX_PATH}.time.sleep") as sleep: - handler_method(event) + hass.states.async_set("fake.something", 1) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api.assert_called_once() @@ -1786,29 +1656,25 @@ async def test_precision( "precision": precision, } config.update(config_ext) - handler_method = await _setup(hass, mock_client, config, get_write_api) + await _setup(hass, mock_client, config, get_write_api) value = "1.9" - attrs = { - "unit_of_measurement": "foobars", - } - state = MagicMock( - state=value, - domain="fake", - entity_id="fake.entity-id", - object_id="entity", - attributes=attrs, - ) - event = MagicMock(data={"new_state": state}, time_fired=12345) body = [ { "measurement": "foobars", - "tags": {"domain": "fake", "entity_id": "entity"}, - "time": 12345, + "tags": {"domain": "fake", "entity_id": "entity_id"}, + "time": ANY, "fields": {"value": float(value)}, } ] - handler_method(event) + hass.states.async_set( + "fake.entity_id", + value, + { + "unit_of_measurement": "foobars", + }, + ) + await hass.async_block_till_done() hass.data[influxdb.DOMAIN].block_till_done() write_api = get_write_api(mock_client) diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py index e9f9458611a..940d0ff6c55 100644 --- a/tests/components/input_datetime/test_init.py +++ b/tests/components/input_datetime/test_init.py @@ -736,7 +736,7 @@ async def test_timestamp(hass: HomeAssistant) -> None: assert ( dt_util.as_local( datetime.datetime.fromtimestamp( - state_without_tz.attributes[ATTR_TIMESTAMP], datetime.timezone.utc + state_without_tz.attributes[ATTR_TIMESTAMP], datetime.UTC ) ).strftime(FORMAT_DATETIME) == "2020-12-13 10:00:00" diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py index feda6554210..f66630b2a69 100644 --- a/tests/components/ipp/__init__.py +++ b/tests/components/ipp/__init__.py @@ -1,20 +1,8 @@ """Tests for the IPP integration.""" -import aiohttp -from pyipp import IPPConnectionUpgradeRequired, IPPError from homeassistant.components import zeroconf -from homeassistant.components.ipp.const import CONF_BASE_PATH, DOMAIN -from homeassistant.const import ( - CONF_HOST, - CONF_PORT, - CONF_SSL, - CONF_UUID, - CONF_VERIFY_SSL, -) -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry, get_fixture_path -from tests.test_util.aiohttp import AiohttpClientMocker +from homeassistant.components.ipp.const import CONF_BASE_PATH +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL ATTR_HOSTNAME = "hostname" ATTR_PROPERTIES = "properties" @@ -59,99 +47,3 @@ MOCK_ZEROCONF_IPPS_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( port=ZEROCONF_PORT, properties={"rp": ZEROCONF_RP}, ) - - -def load_fixture_binary(filename): - """Load a binary fixture.""" - return get_fixture_path(filename, "ipp").read_bytes() - - -def mock_connection( - aioclient_mock: AiohttpClientMocker, - host: str = HOST, - port: int = PORT, - ssl: bool = False, - base_path: str = BASE_PATH, - conn_error: bool = False, - conn_upgrade_error: bool = False, - ipp_error: bool = False, - no_unique_id: bool = False, - parse_error: bool = False, - version_not_supported: bool = False, -): - """Mock the IPP connection.""" - scheme = "https" if ssl else "http" - ipp_url = f"{scheme}://{host}:{port}" - - if ipp_error: - aioclient_mock.post(f"{ipp_url}{base_path}", exc=IPPError) - return - - if conn_error: - aioclient_mock.post(f"{ipp_url}{base_path}", exc=aiohttp.ClientError) - return - - if conn_upgrade_error: - aioclient_mock.post(f"{ipp_url}{base_path}", exc=IPPConnectionUpgradeRequired) - return - - fixture = "get-printer-attributes.bin" - if no_unique_id: - fixture = "get-printer-attributes-success-nodata.bin" - elif version_not_supported: - fixture = "get-printer-attributes-error-0x0503.bin" - - if parse_error: - content = "BAD" - else: - content = load_fixture_binary(fixture) - - aioclient_mock.post( - f"{ipp_url}{base_path}", - content=content, - headers={"Content-Type": "application/ipp"}, - ) - - -async def init_integration( - hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, - skip_setup: bool = False, - host: str = HOST, - port: int = PORT, - ssl: bool = False, - base_path: str = BASE_PATH, - uuid: str = "cfe92100-67c4-11d4-a45f-f8d027761251", - unique_id: str = "cfe92100-67c4-11d4-a45f-f8d027761251", - conn_error: bool = False, -) -> MockConfigEntry: - """Set up the IPP integration in Home Assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id=unique_id, - data={ - CONF_HOST: host, - CONF_PORT: port, - CONF_SSL: ssl, - CONF_VERIFY_SSL: True, - CONF_BASE_PATH: base_path, - CONF_UUID: uuid, - }, - ) - - entry.add_to_hass(hass) - - mock_connection( - aioclient_mock, - host=host, - port=port, - ssl=ssl, - base_path=base_path, - conn_error=conn_error, - ) - - if not skip_setup: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - return entry diff --git a/tests/components/ipp/conftest.py b/tests/components/ipp/conftest.py new file mode 100644 index 00000000000..de3f1e0e73c --- /dev/null +++ b/tests/components/ipp/conftest.py @@ -0,0 +1,99 @@ +"""Fixtures for IPP integration tests.""" +from collections.abc import Generator +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from pyipp import Printer +import pytest + +from homeassistant.components.ipp.const import CONF_BASE_PATH, DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_UUID, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="IPP Printer", + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.31", + CONF_PORT: 631, + CONF_SSL: False, + CONF_VERIFY_SSL: True, + CONF_BASE_PATH: "/ipp/print", + CONF_UUID: "cfe92100-67c4-11d4-a45f-f8d027761251", + }, + unique_id="cfe92100-67c4-11d4-a45f-f8d027761251", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.ipp.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +async def mock_printer( + request: pytest.FixtureRequest, +) -> Printer: + """Return the mocked printer.""" + fixture: str = "ipp/printer.json" + if hasattr(request, "param") and request.param: + fixture = request.param + + return Printer.from_dict(json.loads(load_fixture(fixture))) + + +@pytest.fixture +def mock_ipp_config_flow( + mock_printer: Printer, +) -> Generator[None, MagicMock, None]: + """Return a mocked IPP client.""" + + with patch( + "homeassistant.components.ipp.config_flow.IPP", autospec=True + ) as ipp_mock: + client = ipp_mock.return_value + client.printer.return_value = mock_printer + yield client + + +@pytest.fixture +def mock_ipp( + request: pytest.FixtureRequest, mock_printer: Printer +) -> Generator[None, MagicMock, None]: + """Return a mocked IPP client.""" + + with patch( + "homeassistant.components.ipp.coordinator.IPP", autospec=True + ) as ipp_mock: + client = ipp_mock.return_value + client.printer.return_value = mock_printer + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_ipp: MagicMock +) -> MockConfigEntry: + """Set up the IPP integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/ipp/fixtures/get-printer-attributes-error-0x0503.bin b/tests/components/ipp/fixtures/get-printer-attributes-error-0x0503.bin deleted file mode 100644 index c92134b9e3b..00000000000 Binary files a/tests/components/ipp/fixtures/get-printer-attributes-error-0x0503.bin and /dev/null differ diff --git a/tests/components/ipp/fixtures/get-printer-attributes-success-nodata.bin b/tests/components/ipp/fixtures/get-printer-attributes-success-nodata.bin deleted file mode 100644 index e6061adaccd..00000000000 Binary files a/tests/components/ipp/fixtures/get-printer-attributes-success-nodata.bin and /dev/null differ diff --git a/tests/components/ipp/fixtures/get-printer-attributes.bin b/tests/components/ipp/fixtures/get-printer-attributes.bin deleted file mode 100644 index 24b903efc5d..00000000000 Binary files a/tests/components/ipp/fixtures/get-printer-attributes.bin and /dev/null differ diff --git a/tests/components/ipp/fixtures/printer.json b/tests/components/ipp/fixtures/printer.json new file mode 100644 index 00000000000..6c3f9cd0545 --- /dev/null +++ b/tests/components/ipp/fixtures/printer.json @@ -0,0 +1,36 @@ +{ + "printer-uuid": "urn:uuid:cfe92100-67c4-11d4-a45f-f8d027761251", + "printer-state": "idle", + "printer-name": "Test Printer", + "printer-location": null, + "printer-make-and-model": "Test HA-1000 Series", + "printer-device-id": "MFG:TEST;CMD:ESCPL2,BDC,D4,D4PX,ESCPR7,END4,GENEP,URF;MDL:HA-1000 Series;CLS:PRINTER;DES:TEST HA-1000 Series;CID:EpsonRGB;FID:FXN,DPA,WFA,ETN,AFN,DAN,WRA;RID:20;DDS:022500;ELG:1000;SN:555534593035345555;URF:CP1,PQ4-5,OB9,OFU0,RS360,SRGB24,W8,DM3,IS1-7-6,V1.4,MT1-3-7-8-10-11-12;", + "printer-uri-supported": [ + "ipps://192.168.1.31:631/ipp/print", + "ipp://192.168.1.31:631/ipp/print" + ], + "uri-authentication-supported": ["none", "none"], + "uri-security-supported": ["tls", "none"], + "printer-info": "Test HA-1000 Series", + "printer-up-time": 30, + "printer-firmware-string-version": "20.23.06HA", + "printer-more-info": "http://192.168.1.31:80/PRESENTATION/BONJOUR", + "marker-names": [ + "Black ink", + "Photo black ink", + "Cyan ink", + "Yellow ink", + "Magenta ink" + ], + "marker-types": [ + "ink-cartridge", + "ink-cartridge", + "ink-cartridge", + "ink-cartridge", + "ink-cartridge" + ], + "marker-colors": ["#000000", "#000000", "#00FFFF", "#FFFF00", "#FF00FF"], + "marker-levels": [58, 98, 91, 95, 73], + "marker-low-levels": [10, 10, 10, 10, 10], + "marker-high-levels": [100, 100, 100, 100, 100] +} diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py index 214488d49a0..69a2bb9287a 100644 --- a/tests/components/ipp/test_config_flow.py +++ b/tests/components/ipp/test_config_flow.py @@ -1,6 +1,15 @@ """Tests for the IPP config flow.""" import dataclasses -from unittest.mock import patch +from unittest.mock import MagicMock + +from pyipp import ( + IPPConnectionError, + IPPConnectionUpgradeRequired, + IPPError, + IPPParseError, + IPPVersionNotSupportedError, +) +import pytest from homeassistant.components.ipp.const import CONF_BASE_PATH, DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF @@ -12,11 +21,11 @@ from . import ( MOCK_USER_INPUT, MOCK_ZEROCONF_IPP_SERVICE_INFO, MOCK_ZEROCONF_IPPS_SERVICE_INFO, - init_integration, - mock_connection, ) -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_show_user_form(hass: HomeAssistant) -> None: @@ -31,11 +40,10 @@ async def test_show_user_form(hass: HomeAssistant) -> None: async def test_show_zeroconf_form( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test that the zeroconf confirmation form is served.""" - mock_connection(aioclient_mock) - discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -49,10 +57,11 @@ async def test_show_zeroconf_form( async def test_connection_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test we show user form on IPP connection error.""" - mock_connection(aioclient_mock, conn_error=True) + mock_ipp_config_flow.printer.side_effect = IPPConnectionError user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( @@ -67,10 +76,11 @@ async def test_connection_error( async def test_zeroconf_connection_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test we abort zeroconf flow on IPP connection error.""" - mock_connection(aioclient_mock, conn_error=True) + mock_ipp_config_flow.printer.side_effect = IPPConnectionError discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( @@ -84,10 +94,11 @@ async def test_zeroconf_connection_error( async def test_zeroconf_confirm_connection_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test we abort zeroconf flow on IPP connection error.""" - mock_connection(aioclient_mock, conn_error=True) + mock_ipp_config_flow.printer.side_effect = IPPConnectionError discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( @@ -99,10 +110,11 @@ async def test_zeroconf_confirm_connection_error( async def test_user_connection_upgrade_required( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test we show the user form if connection upgrade required by server.""" - mock_connection(aioclient_mock, conn_upgrade_error=True) + mock_ipp_config_flow.printer.side_effect = IPPConnectionUpgradeRequired user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( @@ -117,10 +129,11 @@ async def test_user_connection_upgrade_required( async def test_zeroconf_connection_upgrade_required( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test we abort zeroconf flow on IPP connection error.""" - mock_connection(aioclient_mock, conn_upgrade_error=True) + mock_ipp_config_flow.printer.side_effect = IPPConnectionUpgradeRequired discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( @@ -134,10 +147,11 @@ async def test_zeroconf_connection_upgrade_required( async def test_user_parse_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test we abort user flow on IPP parse error.""" - mock_connection(aioclient_mock, parse_error=True) + mock_ipp_config_flow.printer.side_effect = IPPParseError user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( @@ -151,10 +165,11 @@ async def test_user_parse_error( async def test_zeroconf_parse_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test we abort zeroconf flow on IPP parse error.""" - mock_connection(aioclient_mock, parse_error=True) + mock_ipp_config_flow.printer.side_effect = IPPParseError discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( @@ -168,10 +183,11 @@ async def test_zeroconf_parse_error( async def test_user_ipp_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test we abort the user flow on IPP error.""" - mock_connection(aioclient_mock, ipp_error=True) + mock_ipp_config_flow.printer.side_effect = IPPError user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( @@ -185,10 +201,11 @@ async def test_user_ipp_error( async def test_zeroconf_ipp_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test we abort zeroconf flow on IPP error.""" - mock_connection(aioclient_mock, ipp_error=True) + mock_ipp_config_flow.printer.side_effect = IPPError discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( @@ -202,10 +219,11 @@ async def test_zeroconf_ipp_error( async def test_user_ipp_version_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test we abort user flow on IPP version not supported error.""" - mock_connection(aioclient_mock, version_not_supported=True) + mock_ipp_config_flow.printer.side_effect = IPPVersionNotSupportedError user_input = {**MOCK_USER_INPUT} result = await hass.config_entries.flow.async_init( @@ -219,10 +237,11 @@ async def test_user_ipp_version_error( async def test_zeroconf_ipp_version_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test we abort zeroconf flow on IPP version not supported error.""" - mock_connection(aioclient_mock, version_not_supported=True) + mock_ipp_config_flow.printer.side_effect = IPPVersionNotSupportedError discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( @@ -236,10 +255,12 @@ async def test_zeroconf_ipp_version_error( async def test_user_device_exists_abort( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ipp_config_flow: MagicMock, ) -> None: """Test we abort user flow if printer already configured.""" - await init_integration(hass, aioclient_mock, skip_setup=True) + mock_config_entry.add_to_hass(hass) user_input = MOCK_USER_INPUT.copy() result = await hass.config_entries.flow.async_init( @@ -253,10 +274,12 @@ async def test_user_device_exists_abort( async def test_zeroconf_device_exists_abort( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ipp_config_flow: MagicMock, ) -> None: """Test we abort zeroconf flow if printer already configured.""" - await init_integration(hass, aioclient_mock, skip_setup=True) + mock_config_entry.add_to_hass(hass) discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( @@ -270,10 +293,12 @@ async def test_zeroconf_device_exists_abort( async def test_zeroconf_with_uuid_device_exists_abort( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ipp_config_flow: MagicMock, ) -> None: """Test we abort zeroconf flow if printer already configured.""" - await init_integration(hass, aioclient_mock, skip_setup=True) + mock_config_entry.add_to_hass(hass) discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) discovery_info.properties = { @@ -292,10 +317,12 @@ async def test_zeroconf_with_uuid_device_exists_abort( async def test_zeroconf_empty_unique_id( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test zeroconf flow if printer lacks (empty) unique identification.""" - mock_connection(aioclient_mock, no_unique_id=True) + printer = mock_ipp_config_flow.printer.return_value + printer.unique_id = None discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) discovery_info.properties = { @@ -312,10 +339,12 @@ async def test_zeroconf_empty_unique_id( async def test_zeroconf_no_unique_id( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test zeroconf flow if printer lacks unique identification.""" - mock_connection(aioclient_mock, no_unique_id=True) + printer = mock_ipp_config_flow.printer.return_value + printer.unique_id = None discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( @@ -328,11 +357,10 @@ async def test_zeroconf_no_unique_id( async def test_full_user_flow_implementation( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test the full manual user flow from start to finish.""" - mock_connection(aioclient_mock) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -341,11 +369,10 @@ async def test_full_user_flow_implementation( assert result["step_id"] == "user" assert result["type"] == FlowResultType.FORM - with patch("homeassistant.components.ipp.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_HOST: "192.168.1.31", CONF_BASE_PATH: "/ipp/print"}, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.31", CONF_BASE_PATH: "/ipp/print"}, + ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "192.168.1.31" @@ -359,11 +386,10 @@ async def test_full_user_flow_implementation( async def test_full_zeroconf_flow_implementation( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test the full manual user flow from start to finish.""" - mock_connection(aioclient_mock) - discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -374,10 +400,9 @@ async def test_full_zeroconf_flow_implementation( assert result["step_id"] == "zeroconf_confirm" assert result["type"] == FlowResultType.FORM - with patch("homeassistant.components.ipp.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "EPSON XP-6000 Series" @@ -393,11 +418,10 @@ async def test_full_zeroconf_flow_implementation( async def test_full_zeroconf_tls_flow_implementation( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_ipp_config_flow: MagicMock, ) -> None: """Test the full manual user flow from start to finish.""" - mock_connection(aioclient_mock, ssl=True) - discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPPS_SERVICE_INFO) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -409,10 +433,9 @@ async def test_full_zeroconf_tls_flow_implementation( assert result["type"] == FlowResultType.FORM assert result["description_placeholders"] == {CONF_NAME: "EPSON XP-6000 Series"} - with patch("homeassistant.components.ipp.async_setup_entry", return_value=True): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "EPSON XP-6000 Series" diff --git a/tests/components/ipp/test_init.py b/tests/components/ipp/test_init.py index 32060b4df86..f502c30068c 100644 --- a/tests/components/ipp/test_init.py +++ b/tests/components/ipp/test_init.py @@ -1,33 +1,45 @@ """Tests for the IPP integration.""" +from unittest.mock import AsyncMock, MagicMock, patch + +from pyipp import IPPConnectionError + from homeassistant.components.ipp.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import init_integration - -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry +@patch( + "homeassistant.components.ipp.coordinator.IPP._request", + side_effect=IPPConnectionError, +) async def test_config_entry_not_ready( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + mock_request: MagicMock, hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test the IPP configuration entry not ready.""" - entry = await init_integration(hass, aioclient_mock, conn_error=True) - assert entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_unload_config_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test the IPP configuration entry unloading.""" - entry = await init_integration(hass, aioclient_mock) - - assert hass.data[DOMAIN] - assert entry.entry_id in hass.data[DOMAIN] - assert entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(entry.entry_id) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert entry.entry_id not in hass.data[DOMAIN] - assert entry.state is ConfigEntryState.NOT_LOADED + assert mock_request.call_count == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ipp: AsyncMock, +) -> None: + """Test the IPP configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.entry_id in hass.data[DOMAIN] + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.entry_id not in hass.data[DOMAIN] + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/ipp/test_sensor.py b/tests/components/ipp/test_sensor.py index f8dd94ffc72..ebebd18bc72 100644 --- a/tests/components/ipp/test_sensor.py +++ b/tests/components/ipp/test_sensor.py @@ -1,119 +1,105 @@ """Tests for the IPP sensor platform.""" -from datetime import datetime -from unittest.mock import patch +from unittest.mock import AsyncMock -from homeassistant.components.ipp.const import DOMAIN -from homeassistant.components.sensor import ( - ATTR_OPTIONS as SENSOR_ATTR_OPTIONS, - DOMAIN as SENSOR_DOMAIN, -) +import pytest + +from homeassistant.components.sensor import ATTR_OPTIONS from homeassistant.const import ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt as dt_util -from . import init_integration, mock_connection - -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import MockConfigEntry +@pytest.mark.freeze_time("2019-11-11 09:10:32+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensors( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, ) -> None: """Test the creation and values of the IPP sensors.""" - mock_connection(aioclient_mock) - - entry = await init_integration(hass, aioclient_mock, skip_setup=True) - registry = er.async_get(hass) - - # Pre-create registry entries for disabled by default sensors - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "cfe92100-67c4-11d4-a45f-f8d027761251_uptime", - suggested_object_id="epson_xp_6000_series_uptime", - disabled_by=None, - ) - - test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=dt_util.UTC) - with patch("homeassistant.components.ipp.sensor.utcnow", return_value=test_time): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("sensor.epson_xp_6000_series") + state = hass.states.get("sensor.test_ha_1000_series") assert state assert state.attributes.get(ATTR_ICON) == "mdi:printer" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - assert state.attributes.get(SENSOR_ATTR_OPTIONS) == ["idle", "printing", "stopped"] + assert state.attributes.get(ATTR_OPTIONS) == ["idle", "printing", "stopped"] - entry = registry.async_get("sensor.epson_xp_6000_series") + entry = entity_registry.async_get("sensor.test_ha_1000_series") assert entry assert entry.translation_key == "printer" - state = hass.states.get("sensor.epson_xp_6000_series_black_ink") + state = hass.states.get("sensor.test_ha_1000_series_black_ink") assert state assert state.attributes.get(ATTR_ICON) == "mdi:water" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is PERCENTAGE assert state.state == "58" - state = hass.states.get("sensor.epson_xp_6000_series_photo_black_ink") + state = hass.states.get("sensor.test_ha_1000_series_photo_black_ink") assert state assert state.attributes.get(ATTR_ICON) == "mdi:water" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is PERCENTAGE assert state.state == "98" - state = hass.states.get("sensor.epson_xp_6000_series_cyan_ink") + state = hass.states.get("sensor.test_ha_1000_series_cyan_ink") assert state assert state.attributes.get(ATTR_ICON) == "mdi:water" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is PERCENTAGE assert state.state == "91" - state = hass.states.get("sensor.epson_xp_6000_series_yellow_ink") + state = hass.states.get("sensor.test_ha_1000_series_yellow_ink") assert state assert state.attributes.get(ATTR_ICON) == "mdi:water" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is PERCENTAGE assert state.state == "95" - state = hass.states.get("sensor.epson_xp_6000_series_magenta_ink") + state = hass.states.get("sensor.test_ha_1000_series_magenta_ink") assert state assert state.attributes.get(ATTR_ICON) == "mdi:water" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is PERCENTAGE assert state.state == "73" - state = hass.states.get("sensor.epson_xp_6000_series_uptime") + state = hass.states.get("sensor.test_ha_1000_series_uptime") assert state assert state.attributes.get(ATTR_ICON) == "mdi:clock-outline" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None - assert state.state == "2019-10-26T15:37:00+00:00" + assert state.state == "2019-11-11T09:10:02+00:00" - entry = registry.async_get("sensor.epson_xp_6000_series_uptime") + entry = entity_registry.async_get("sensor.test_ha_1000_series_uptime") assert entry assert entry.unique_id == "cfe92100-67c4-11d4-a45f-f8d027761251_uptime" async def test_disabled_by_default_sensors( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + init_integration: MockConfigEntry, ) -> None: """Test the disabled by default IPP sensors.""" - await init_integration(hass, aioclient_mock) registry = er.async_get(hass) - state = hass.states.get("sensor.epson_xp_6000_series_uptime") + state = hass.states.get("sensor.test_ha_1000_series_uptime") assert state is None - entry = registry.async_get("sensor.epson_xp_6000_series_uptime") + entry = registry.async_get("sensor.test_ha_1000_series_uptime") assert entry assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION async def test_missing_entry_unique_id( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ipp: AsyncMock, ) -> None: """Test the unique_id of IPP sensor when printer is missing identifiers.""" - entry = await init_integration(hass, aioclient_mock, uuid=None, unique_id=None) + mock_config_entry.unique_id = None + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + registry = er.async_get(hass) - entity = registry.async_get("sensor.epson_xp_6000_series") + entity = registry.async_get("sensor.test_ha_1000_series") assert entity - assert entity.unique_id == f"{entry.entry_id}_printer" + assert entity.unique_id == f"{mock_config_entry.entry_id}_printer" diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index 3b291c9973d..a5b9b9c8a8d 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -5,9 +5,7 @@ from freezegun import freeze_time import pytest from homeassistant.components.islamic_prayer_times.const import DOMAIN -from homeassistant.components.islamic_prayer_times.sensor import SENSOR_TYPES from homeassistant.core import HomeAssistant -from homeassistant.util import slugify import homeassistant.util.dt as dt_util from . import NOW, PRAYER_TIMES, PRAYER_TIMES_TIMESTAMPS @@ -21,7 +19,21 @@ def set_utc(hass: HomeAssistant) -> None: hass.config.set_time_zone("UTC") -async def test_islamic_prayer_times_sensors(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("key", "sensor_name"), + [ + ("Fajr", "sensor.islamic_prayer_times_fajr_prayer"), + ("Sunrise", "sensor.islamic_prayer_times_sunrise_time"), + ("Dhuhr", "sensor.islamic_prayer_times_dhuhr_prayer"), + ("Asr", "sensor.islamic_prayer_times_asr_prayer"), + ("Maghrib", "sensor.islamic_prayer_times_maghrib_prayer"), + ("Isha", "sensor.islamic_prayer_times_isha_prayer"), + ("Midnight", "sensor.islamic_prayer_times_midnight_time"), + ], +) +async def test_islamic_prayer_times_sensors( + hass: HomeAssistant, key: str, sensor_name: str +) -> None: """Test minimum Islamic prayer times configuration.""" entry = MockConfigEntry(domain=DOMAIN, data={}) entry.add_to_hass(hass) @@ -33,10 +45,7 @@ async def test_islamic_prayer_times_sensors(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - for prayer in SENSOR_TYPES: - assert ( - hass.states.get(f"sensor.{DOMAIN}_{slugify(prayer.name)}").state - == PRAYER_TIMES_TIMESTAMPS[prayer.key] - .astimezone(dt_util.UTC) - .isoformat() - ) + assert ( + hass.states.get(sensor_name).state + == PRAYER_TIMES_TIMESTAMPS[key].astimezone(dt_util.UTC).isoformat() + ) diff --git a/tests/components/jellyfin/conftest.py b/tests/components/jellyfin/conftest.py index 423e4ad3950..671c9881ae0 100644 --- a/tests/components/jellyfin/conftest.py +++ b/tests/components/jellyfin/conftest.py @@ -74,6 +74,8 @@ def mock_api() -> MagicMock: jf_api.sessions.return_value = load_json_fixture("sessions.json") jf_api.artwork.side_effect = api_artwork_side_effect + jf_api.audio_url.side_effect = api_audio_url_side_effect + jf_api.video_url.side_effect = api_video_url_side_effect jf_api.user_items.side_effect = api_user_items_side_effect jf_api.get_item.side_effect = api_get_item_side_effect jf_api.get_media_folders.return_value = load_json_fixture("get-media-folders.json") @@ -86,7 +88,7 @@ def mock_api() -> MagicMock: def mock_config() -> MagicMock: """Return a mocked JellyfinClient.""" jf_config = create_autospec(Config) - jf_config.data = {} + jf_config.data = {"auth.server": "http://localhost"} return jf_config @@ -138,6 +140,18 @@ def api_artwork_side_effect(*args, **kwargs): return f"http://localhost/Items/{item_id}/Images/{art}.{ext}" +def api_audio_url_side_effect(*args, **kwargs): + """Handle variable responses for audio_url method.""" + item_id = args[0] + return f"http://localhost/Audio/{item_id}/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000" + + +def api_video_url_side_effect(*args, **kwargs): + """Handle variable responses for video_url method.""" + item_id = args[0] + return f"http://localhost/Videos/{item_id}/stream?static=true,DeviceId=TEST-UUID,api_key=TEST-API-KEY" + + def api_get_item_side_effect(*args): """Handle variable responses for get_item method.""" return load_json_fixture("get-item-collection.json") diff --git a/tests/components/jellyfin/fixtures/album.json b/tests/components/jellyfin/fixtures/album.json new file mode 100644 index 00000000000..b748b125e4a --- /dev/null +++ b/tests/components/jellyfin/fixtures/album.json @@ -0,0 +1,12 @@ +{ + "AlbumArtist": "ARTIST", + "AlbumArtists": [{ "Id": "ARTIST-UUID", "Name": "ARTIST" }], + "Artists": ["ARTIST"], + "Id": "ALBUM-UUID", + "ImageTags": {}, + "IsFolder": true, + "Name": "ALBUM", + "PrimaryImageAspectRatio": 1, + "ServerId": "ServerId", + "Type": "MusicAlbum" +} diff --git a/tests/components/jellyfin/fixtures/albums.json b/tests/components/jellyfin/fixtures/albums.json new file mode 100644 index 00000000000..e557018a89e --- /dev/null +++ b/tests/components/jellyfin/fixtures/albums.json @@ -0,0 +1,16 @@ +{ + "Items": [ + { + "AlbumArtist": "ARTIST", + "AlbumArtists": [{ "Id": "ARTIST-UUID", "Name": "ARTIST" }], + "Artists": ["ARTIST"], + "Id": "ALBUM-UUID", + "ImageTags": {}, + "IsFolder": true, + "Name": "ALBUM", + "PrimaryImageAspectRatio": 1, + "ServerId": "ServerId", + "Type": "MusicAlbum" + } + ] +} diff --git a/tests/components/jellyfin/fixtures/artist.json b/tests/components/jellyfin/fixtures/artist.json new file mode 100644 index 00000000000..95e59d33820 --- /dev/null +++ b/tests/components/jellyfin/fixtures/artist.json @@ -0,0 +1,15 @@ +{ + "AlbumCount": 1, + "Id": "ARTIST-UUID", + "ImageTags": { + "Logo": "string", + "Primary": "string" + }, + "IsFolder": true, + "Name": "ARTIST", + "ParentId": "MUSIC-COLLECTION-FOLDER-UUID", + "Path": "/media/music/artist", + "PrimaryImageAspectRatio": 1, + "ServerId": "string", + "Type": "MusicArtist" +} diff --git a/tests/components/jellyfin/fixtures/artists.json b/tests/components/jellyfin/fixtures/artists.json new file mode 100644 index 00000000000..bb57ef451a2 --- /dev/null +++ b/tests/components/jellyfin/fixtures/artists.json @@ -0,0 +1,19 @@ +{ + "Items": [ + { + "AlbumCount": 1, + "Id": "ARTIST-UUID", + "ImageTags": { + "Logo": "string", + "Primary": "string" + }, + "IsFolder": true, + "Name": "ARTIST", + "ParentId": "MUSIC-COLLECTION-FOLDER-UUID", + "Path": "/media/music/artist", + "PrimaryImageAspectRatio": 1, + "ServerId": "string", + "Type": "MusicArtist" + } + ] +} diff --git a/tests/components/jellyfin/fixtures/episode.json b/tests/components/jellyfin/fixtures/episode.json new file mode 100644 index 00000000000..49f30434eac --- /dev/null +++ b/tests/components/jellyfin/fixtures/episode.json @@ -0,0 +1,504 @@ +{ + "Name": "EPISODE", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "EPISODE-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "/media/tvshows/Series/Season 01/S01E01.mp4", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": false, + "ParentId": "FOLDER-UUID", + "Type": "Episode", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "property1": "string", + "property2": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} +} diff --git a/tests/components/jellyfin/fixtures/episodes.json b/tests/components/jellyfin/fixtures/episodes.json new file mode 100644 index 00000000000..31b2fe76558 --- /dev/null +++ b/tests/components/jellyfin/fixtures/episodes.json @@ -0,0 +1,509 @@ +{ + "Items": [ + { + "Name": "EPISODE", + "OriginalTitle": "string", + "ServerId": "SERVER-UUID", + "Id": "EPISODE-UUID", + "Etag": "string", + "SourceType": "string", + "PlaylistItemId": "string", + "DateCreated": "2019-08-24T14:15:22Z", + "DateLastMediaAdded": "2019-08-24T14:15:22Z", + "ExtraType": "string", + "AirsBeforeSeasonNumber": 0, + "AirsAfterSeasonNumber": 0, + "AirsBeforeEpisodeNumber": 0, + "CanDelete": true, + "CanDownload": true, + "HasSubtitles": true, + "PreferredMetadataLanguage": "string", + "PreferredMetadataCountryCode": "string", + "SupportsSync": true, + "Container": "string", + "SortName": "string", + "ForcedSortName": "string", + "Video3DFormat": "HalfSideBySide", + "PremiereDate": "2019-08-24T14:15:22Z", + "ExternalUrls": [ + { + "Name": "string", + "Url": "string" + } + ], + "MediaSources": [ + { + "Protocol": "File", + "Id": "string", + "Path": "/media/tvshows/Series/Season 01/S01E01.mp4", + "EncoderPath": "string", + "EncoderProtocol": "File", + "Type": "Default", + "Container": "string", + "Size": 0, + "Name": "string", + "IsRemote": true, + "ETag": "string", + "RunTimeTicks": 0, + "ReadAtNativeFramerate": true, + "IgnoreDts": true, + "IgnoreIndex": true, + "GenPtsInput": true, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": true, + "RequiresOpening": true, + "OpenToken": "string", + "RequiresClosing": true, + "LiveStreamId": "string", + "BufferMs": 0, + "RequiresLooping": true, + "SupportsProbing": true, + "VideoType": "VideoFile", + "IsoType": "Dvd", + "Video3DFormat": "HalfSideBySide", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "MediaAttachments": [ + { + "Codec": "string", + "CodecTag": "string", + "Comment": "string", + "Index": 0, + "FileName": "string", + "MimeType": "string", + "DeliveryUrl": "string" + } + ], + "Formats": ["string"], + "Bitrate": 0, + "Timestamp": "None", + "RequiredHttpHeaders": { + "property1": "string", + "property2": "string" + }, + "TranscodingUrl": "string", + "TranscodingSubProtocol": "string", + "TranscodingContainer": "string", + "AnalyzeDurationMs": 0, + "DefaultAudioStreamIndex": 0, + "DefaultSubtitleStreamIndex": 0 + } + ], + "CriticRating": 0, + "ProductionLocations": ["string"], + "Path": "string", + "EnableMediaSourceDisplay": true, + "OfficialRating": "string", + "CustomRating": "string", + "ChannelId": "04b0b2a5-93cb-474d-8ea9-3df0f84eb0ff", + "ChannelName": "string", + "Overview": "string", + "Taglines": ["string"], + "Genres": ["string"], + "CommunityRating": 0, + "CumulativeRunTimeTicks": 0, + "RunTimeTicks": 0, + "PlayAccess": "Full", + "AspectRatio": "string", + "ProductionYear": 0, + "IsPlaceHolder": true, + "Number": "string", + "ChannelNumber": "string", + "IndexNumber": 0, + "IndexNumberEnd": 0, + "ParentIndexNumber": 0, + "RemoteTrailers": [ + { + "Url": "string", + "Name": "string" + } + ], + "ProviderIds": { + "property1": "string", + "property2": "string" + }, + "IsHD": true, + "IsFolder": false, + "ParentId": "FOLDER-UUID", + "Type": "Episode", + "People": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43", + "Role": "string", + "Type": "string", + "PrimaryImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + } + } + ], + "Studios": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "GenreItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "ParentLogoItemId": "c78d400f-de5c-421e-8714-4fb05d387233", + "ParentBackdropItemId": "c22fd826-17fc-44f4-9b04-1eb3e8fb9173", + "ParentBackdropImageTags": ["string"], + "LocalTrailerCount": 0, + "UserData": { + "Rating": 0, + "PlayedPercentage": 0, + "UnplayedItemCount": 0, + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": true, + "Likes": true, + "LastPlayedDate": "2019-08-24T14:15:22Z", + "Played": true, + "Key": "string", + "ItemId": "string" + }, + "RecursiveItemCount": 0, + "ChildCount": 0, + "SeriesName": "string", + "SeriesId": "c7b70af4-4902-4a7e-95ab-28349b6c7afc", + "SeasonId": "badb6463-e5b7-45c5-8141-71204420ec8f", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "string", + "Status": "string", + "AirTime": "string", + "AirDays": ["Sunday"], + "Tags": ["string"], + "PrimaryImageAspectRatio": 0, + "Artists": ["string"], + "ArtistItems": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "Album": "string", + "CollectionType": "string", + "DisplayOrder": "string", + "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", + "AlbumPrimaryImageTag": "string", + "SeriesPrimaryImageTag": "string", + "AlbumArtist": "string", + "AlbumArtists": [ + { + "Name": "string", + "Id": "38a5a5bb-dc30-49a2-b175-1de0d1488c43" + } + ], + "SeasonName": "string", + "MediaStreams": [ + { + "Codec": "string", + "CodecTag": "string", + "Language": "string", + "ColorRange": "string", + "ColorSpace": "string", + "ColorTransfer": "string", + "ColorPrimaries": "string", + "DvVersionMajor": 0, + "DvVersionMinor": 0, + "DvProfile": 0, + "DvLevel": 0, + "RpuPresentFlag": 0, + "ElPresentFlag": 0, + "BlPresentFlag": 0, + "DvBlSignalCompatibilityId": 0, + "Comment": "string", + "TimeBase": "string", + "CodecTimeBase": "string", + "Title": "string", + "VideoRange": "string", + "VideoRangeType": "string", + "VideoDoViTitle": "string", + "LocalizedUndefined": "string", + "LocalizedDefault": "string", + "LocalizedForced": "string", + "LocalizedExternal": "string", + "DisplayTitle": "string", + "NalLengthSize": "string", + "IsInterlaced": true, + "IsAVC": true, + "ChannelLayout": "string", + "BitRate": 0, + "BitDepth": 0, + "RefFrames": 0, + "PacketLength": 0, + "Channels": 0, + "SampleRate": 0, + "IsDefault": true, + "IsForced": true, + "Height": 0, + "Width": 0, + "AverageFrameRate": 0, + "RealFrameRate": 0, + "Profile": "string", + "Type": "Audio", + "AspectRatio": "string", + "Index": 0, + "Score": 0, + "IsExternal": true, + "DeliveryMethod": "Encode", + "DeliveryUrl": "string", + "IsExternalUrl": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "string", + "PixelFormat": "string", + "Level": 0, + "IsAnamorphic": true + } + ], + "VideoType": "VideoFile", + "PartCount": 0, + "MediaSourceCount": 0, + "ImageTags": { + "Primary": "string" + }, + "BackdropImageTags": ["string"], + "ScreenshotImageTags": ["string"], + "ParentLogoImageTag": "string", + "ParentArtItemId": "10c1875b-b82c-48e8-bae9-939a5e68dc2f", + "ParentArtImageTag": "string", + "SeriesThumbImageTag": "string", + "ImageBlurHashes": { + "Primary": { + "property1": "string", + "property2": "string" + }, + "Art": { + "property1": "string", + "property2": "string" + }, + "Backdrop": { + "property1": "string", + "property2": "string" + }, + "Banner": { + "property1": "string", + "property2": "string" + }, + "Logo": { + "property1": "string", + "property2": "string" + }, + "Thumb": { + "property1": "string", + "property2": "string" + }, + "Disc": { + "property1": "string", + "property2": "string" + }, + "Box": { + "property1": "string", + "property2": "string" + }, + "Screenshot": { + "property1": "string", + "property2": "string" + }, + "Menu": { + "property1": "string", + "property2": "string" + }, + "Chapter": { + "property1": "string", + "property2": "string" + }, + "BoxRear": { + "property1": "string", + "property2": "string" + }, + "Profile": { + "property1": "string", + "property2": "string" + } + }, + "SeriesStudio": "string", + "ParentThumbItemId": "ae6ff707-333d-4994-be6d-b83ca1b35f46", + "ParentThumbImageTag": "string", + "ParentPrimaryImageItemId": "string", + "ParentPrimaryImageTag": "string", + "Chapters": [ + { + "StartPositionTicks": 0, + "Name": "string", + "ImagePath": "string", + "ImageDateModified": "2019-08-24T14:15:22Z", + "ImageTag": "string" + } + ], + "LocationType": "FileSystem", + "IsoType": "Dvd", + "MediaType": "string", + "EndDate": "2019-08-24T14:15:22Z", + "LockedFields": ["Cast"], + "TrailerCount": 0, + "MovieCount": 0, + "SeriesCount": 0, + "ProgramCount": 0, + "EpisodeCount": 0, + "SongCount": 0, + "AlbumCount": 0, + "ArtistCount": 0, + "MusicVideoCount": 0, + "LockData": true, + "Width": 0, + "Height": 0, + "CameraMake": "string", + "CameraModel": "string", + "Software": "string", + "ExposureTime": 0, + "FocalLength": 0, + "ImageOrientation": "TopLeft", + "Aperture": 0, + "ShutterSpeed": 0, + "Latitude": 0, + "Longitude": 0, + "Altitude": 0, + "IsoSpeedRating": 0, + "SeriesTimerId": "string", + "ProgramId": "string", + "ChannelPrimaryImageTag": "string", + "StartDate": "2019-08-24T14:15:22Z", + "CompletionPercentage": 0, + "IsRepeat": true, + "EpisodeTitle": "string", + "ChannelType": "TV", + "Audio": "Mono", + "IsMovie": true, + "IsSports": true, + "IsSeries": true, + "IsLive": true, + "IsNews": true, + "IsKids": true, + "IsPremiere": true, + "TimerId": "string", + "CurrentProgram": {} + } + ], + "TotalRecordCount": 1, + "StartIndex": 0 +} diff --git a/tests/components/jellyfin/fixtures/get-item-collection.json b/tests/components/jellyfin/fixtures/get-item-collection.json index 90ad63a39e4..c58074d999f 100644 --- a/tests/components/jellyfin/fixtures/get-item-collection.json +++ b/tests/components/jellyfin/fixtures/get-item-collection.json @@ -298,7 +298,7 @@ } ], "Album": "string", - "CollectionType": "string", + "CollectionType": "tvshows", "DisplayOrder": "string", "AlbumId": "21af9851-8e39-43a9-9c47-513d3b9e99fc", "AlbumPrimaryImageTag": "string", diff --git a/tests/components/jellyfin/fixtures/media-source-root.json b/tests/components/jellyfin/fixtures/media-source-root.json new file mode 100644 index 00000000000..9d8d2a8231a --- /dev/null +++ b/tests/components/jellyfin/fixtures/media-source-root.json @@ -0,0 +1,23 @@ +{ + "title": "Jellyfin", + "media_class": "directory", + "media_content_type": "", + "media_content_id": "media-source://jellyfin", + "children_media_class": "directory", + "can_play": false, + "can_expand": true, + "thumbnail": null, + "not_shown": 0, + "children": [ + { + "title": "COLLECTION FOLDER", + "media_class": "directory", + "media_content_type": "", + "media_content_id": "media-source://jellyfin/COLLECTION-FOLDER-UUID", + "children_media_class": null, + "can_play": false, + "can_expand": true, + "thumbnail": null + } + ] +} diff --git a/tests/components/jellyfin/fixtures/movie-collection.json b/tests/components/jellyfin/fixtures/movie-collection.json new file mode 100644 index 00000000000..1a3c262440d --- /dev/null +++ b/tests/components/jellyfin/fixtures/movie-collection.json @@ -0,0 +1,45 @@ +{ + "BackdropImageTags": [], + "CanDelete": false, + "CanDownload": false, + "ChannelId": "", + "ChildCount": 1, + "CollectionType": "movies", + "DateCreated": "string", + "DisplayPreferencesId": "string", + "EnableMediaSourceDisplay": true, + "Etag": "string", + "ExternalUrls": [], + "GenreItems": [], + "Genres": [], + "Id": "MOVIE-COLLECTION-FOLDER-UUID", + "ImageBlurHashes": { "Primary": { "string": "string" } }, + "ImageTags": { "Primary": "string" }, + "IsFolder": true, + "LocalTrailerCount": 0, + "LocationType": "FileSystem", + "LockData": false, + "LockedFields": [], + "Name": "Movies", + "ParentId": "string", + "Path": "string", + "People": [], + "PlayAccess": "Full", + "PrimaryImageAspectRatio": 1.7777777777777777, + "ProviderIds": {}, + "RemoteTrailers": [], + "ServerId": "string", + "SortName": "movies", + "SpecialFeatureCount": 0, + "Studios": [], + "Taglines": [], + "Tags": [], + "Type": "CollectionFolder", + "UserData": { + "IsFavorite": false, + "Key": "string", + "PlayCount": 0, + "PlaybackPositionTicks": 0, + "Played": false + } +} diff --git a/tests/components/jellyfin/fixtures/movie.json b/tests/components/jellyfin/fixtures/movie.json new file mode 100644 index 00000000000..47eaddd4cfc --- /dev/null +++ b/tests/components/jellyfin/fixtures/movie.json @@ -0,0 +1,153 @@ +{ + "BackdropImageTags": ["string"], + "CanDelete": true, + "CanDownload": true, + "ChannelId": "", + "Chapters": [], + "CommunityRating": 0, + "Container": "string", + "CriticRating": 0, + "DateCreated": "string", + "DisplayPreferencesId": "string", + "EnableMediaSourceDisplay": true, + "Etag": "string", + "ExternalUrls": [], + "GenreItems": [], + "Genres": ["string"], + "Height": 0, + "Id": "MOVIE-UUID", + "ImageBlurHashes": { + "Backdrop": { "string": "string" }, + "Primary": { "string": "string" } + }, + "ImageTags": { "Primary": "string" }, + "IsFolder": false, + "IsHD": true, + "LocalTrailerCount": 0, + "LocationType": "FileSystem", + "LockData": false, + "LockedFields": [], + "MediaSources": [ + { + "Bitrate": 0, + "Container": "string", + "DefaultAudioStreamIndex": 1, + "ETag": "string", + "Formats": [], + "GenPtsInput": false, + "Id": "string", + "IgnoreDts": false, + "IgnoreIndex": false, + "IsInfiniteStream": false, + "IsRemote": false, + "MediaAttachments": [], + "MediaStreams": [ + { + "AspectRatio": "string", + "AverageFrameRate": 0, + "BitRate": 0, + "Codec": "string", + "CodecTimeBase": "string", + "ColorPrimaries": "string", + "ColorTransfer": "string", + "DisplayTitle": "string", + "Height": 0, + "Index": 0, + "IsDefault": true, + "IsExternal": false, + "IsForced": false, + "IsInterlaced": false, + "IsTextSubtitleStream": false, + "Level": 0, + "PixelFormat": "string", + "Profile": "Main", + "RealFrameRate": 0, + "RefFrames": 0, + "SupportsExternalStream": false, + "TimeBase": "string", + "Type": "Video", + "VideoRange": "string", + "VideoRangeType": "string", + "Width": 0 + } + ], + "Name": "MOVIE", + "Path": "/media/movies/MOVIE/MOVIE.mp4", + "Protocol": "File", + "ReadAtNativeFramerate": false, + "RequiredHttpHeaders": {}, + "RequiresClosing": false, + "RequiresLooping": false, + "RequiresOpening": false, + "RunTimeTicks": 0, + "Size": 0, + "SupportsDirectPlay": true, + "SupportsDirectStream": true, + "SupportsProbing": true, + "SupportsTranscoding": false, + "Type": "Default", + "VideoType": "VideoFile" + } + ], + "MediaStreams": [ + { + "AspectRatio": "string", + "AverageFrameRate": 0, + "BitRate": 0, + "Codec": "string", + "CodecTimeBase": "string", + "ColorPrimaries": "string", + "ColorTransfer": "string", + "DisplayTitle": "string", + "Height": 0, + "Index": 0, + "IsDefault": true, + "IsExternal": false, + "IsForced": false, + "IsInterlaced": false, + "IsTextSubtitleStream": false, + "Level": 0, + "PixelFormat": "string", + "Profile": "string", + "RealFrameRate": 0, + "RefFrames": 0, + "SupportsExternalStream": false, + "TimeBase": "string", + "Type": "Video", + "VideoRange": "string", + "VideoRangeType": "string", + "Width": 0 + } + ], + "MediaType": "Video", + "Name": "MOVIE", + "OfficialRating": "string", + "OriginalTitle": "MOVIE", + "Overview": "string", + "Path": "/media/movies/MOVIE/MOVIE.mp4", + "People": [], + "PlayAccess": "string", + "PremiereDate": "string", + "PrimaryImageAspectRatio": 0, + "ProductionLocations": ["string"], + "ProductionYear": 0, + "ProviderIds": { "Imdb": "string", "Tmdb": "string" }, + "RemoteTrailers": [], + "RunTimeTicks": 0, + "ServerId": "string", + "SortName": "string", + "SpecialFeatureCount": 0, + "Studios": [], + "Taglines": ["string"], + "Tags": [], + "Type": "Movie", + "UserData": { + "IsFavorite": false, + "Key": "0", + "PlayCount": 0, + "PlaybackPositionTicks": 0, + "Played": false + }, + "VideoType": "VideoFile", + "Width": 0 +} diff --git a/tests/components/jellyfin/fixtures/movies.json b/tests/components/jellyfin/fixtures/movies.json new file mode 100644 index 00000000000..78706456b9b --- /dev/null +++ b/tests/components/jellyfin/fixtures/movies.json @@ -0,0 +1,159 @@ +{ + "Items": [ + { + "BackdropImageTags": ["string"], + "CanDelete": true, + "CanDownload": true, + "ChannelId": "", + "Chapters": [], + "CommunityRating": 0, + "Container": "string", + "CriticRating": 0, + "DateCreated": "string", + "DisplayPreferencesId": "string", + "EnableMediaSourceDisplay": true, + "Etag": "string", + "ExternalUrls": [], + "GenreItems": [], + "Genres": ["string"], + "Height": 0, + "Id": "MOVIE-UUID", + "ImageBlurHashes": { + "Backdrop": { "string": "string" }, + "Primary": { "string": "string" } + }, + "ImageTags": { "Primary": "string" }, + "IsFolder": false, + "IsHD": true, + "LocalTrailerCount": 0, + "LocationType": "FileSystem", + "LockData": false, + "LockedFields": [], + "MediaSources": [ + { + "Bitrate": 0, + "Container": "string", + "DefaultAudioStreamIndex": 1, + "ETag": "string", + "Formats": [], + "GenPtsInput": false, + "Id": "string", + "IgnoreDts": false, + "IgnoreIndex": false, + "IsInfiniteStream": false, + "IsRemote": false, + "MediaAttachments": [], + "MediaStreams": [ + { + "AspectRatio": "string", + "AverageFrameRate": 0, + "BitRate": 0, + "Codec": "string", + "CodecTimeBase": "string", + "ColorPrimaries": "string", + "ColorTransfer": "string", + "DisplayTitle": "string", + "Height": 0, + "Index": 0, + "IsDefault": true, + "IsExternal": false, + "IsForced": false, + "IsInterlaced": false, + "IsTextSubtitleStream": false, + "Level": 0, + "PixelFormat": "string", + "Profile": "Main", + "RealFrameRate": 0, + "RefFrames": 0, + "SupportsExternalStream": false, + "TimeBase": "string", + "Type": "Video", + "VideoRange": "string", + "VideoRangeType": "string", + "Width": 0 + } + ], + "Name": "MOVIE", + "Path": "/media/movies/MOVIE/MOVIE.mp4", + "Protocol": "File", + "ReadAtNativeFramerate": false, + "RequiredHttpHeaders": {}, + "RequiresClosing": false, + "RequiresLooping": false, + "RequiresOpening": false, + "RunTimeTicks": 0, + "Size": 0, + "SupportsDirectPlay": true, + "SupportsDirectStream": true, + "SupportsProbing": true, + "SupportsTranscoding": false, + "Type": "Default", + "VideoType": "VideoFile" + } + ], + "MediaStreams": [ + { + "AspectRatio": "string", + "AverageFrameRate": 0, + "BitRate": 0, + "Codec": "string", + "CodecTimeBase": "string", + "ColorPrimaries": "string", + "ColorTransfer": "string", + "DisplayTitle": "string", + "Height": 0, + "Index": 0, + "IsDefault": true, + "IsExternal": false, + "IsForced": false, + "IsInterlaced": false, + "IsTextSubtitleStream": false, + "Level": 0, + "PixelFormat": "string", + "Profile": "string", + "RealFrameRate": 0, + "RefFrames": 0, + "SupportsExternalStream": false, + "TimeBase": "string", + "Type": "Video", + "VideoRange": "string", + "VideoRangeType": "string", + "Width": 0 + } + ], + "MediaType": "Video", + "Name": "MOVIE", + "OfficialRating": "string", + "OriginalTitle": "MOVIE", + "Overview": "string", + "Path": "/media/movies/MOVIE/MOVIE.mp4", + "People": [], + "PlayAccess": "string", + "PremiereDate": "string", + "PrimaryImageAspectRatio": 0, + "ProductionLocations": ["string"], + "ProductionYear": 0, + "ProviderIds": { "Imdb": "string", "Tmdb": "string" }, + "RemoteTrailers": [], + "RunTimeTicks": 0, + "ServerId": "string", + "SortName": "string", + "SpecialFeatureCount": 0, + "Studios": [], + "Taglines": ["string"], + "Tags": [], + "Type": "Movie", + "UserData": { + "IsFavorite": false, + "Key": "0", + "PlayCount": 0, + "PlaybackPositionTicks": 0, + "Played": false + }, + "VideoType": "VideoFile", + "Width": 0 + } + ], + "StartIndex": 0, + "TotalRecordCount": 1 +} diff --git a/tests/components/jellyfin/fixtures/music-collection.json b/tests/components/jellyfin/fixtures/music-collection.json new file mode 100644 index 00000000000..0ae91d7badd --- /dev/null +++ b/tests/components/jellyfin/fixtures/music-collection.json @@ -0,0 +1,45 @@ +{ + "BackdropImageTags": [], + "CanDelete": false, + "CanDownload": false, + "ChannelId": "", + "ChildCount": 0, + "CollectionType": "music", + "DateCreated": "string", + "DisplayPreferencesId": "string", + "EnableMediaSourceDisplay": true, + "Etag": "string", + "ExternalUrls": [], + "GenreItems": [], + "Genres": [], + "Id": "MUSIC-COLLECTION-FOLDER-UUID", + "ImageBlurHashes": { "Primary": { "string": "string" } }, + "ImageTags": { "Primary": "string" }, + "IsFolder": true, + "LocalTrailerCount": 0, + "LocationType": "FileSystem", + "LockData": false, + "LockedFields": [], + "Name": "Music", + "ParentId": "string", + "Path": "string", + "People": [], + "PlayAccess": "Full", + "PrimaryImageAspectRatio": 1.7777777777777777, + "ProviderIds": {}, + "RemoteTrailers": [], + "ServerId": "string", + "SortName": "music", + "SpecialFeatureCount": 0, + "Studios": [], + "Taglines": [], + "Tags": [], + "Type": "CollectionFolder", + "UserData": { + "IsFavorite": false, + "Key": "string", + "PlayCount": 0, + "PlaybackPositionTicks": 0, + "Played": false + } +} diff --git a/tests/components/jellyfin/fixtures/season.json b/tests/components/jellyfin/fixtures/season.json new file mode 100644 index 00000000000..b8fb80042f3 --- /dev/null +++ b/tests/components/jellyfin/fixtures/season.json @@ -0,0 +1,23 @@ +{ + "BackdropImageTags": [], + "ChannelId": "string", + "Id": "SEASON-UUID", + "ImageBlurHashes": {}, + "ImageTags": {}, + "IndexNumber": 0, + "IsFolder": true, + "LocationType": "FileSystem", + "Name": "SEASON", + "SeriesId": "SERIES-UUID", + "SeriesName": "SERIES", + "ServerId": "SEASON-UUID", + "Type": "Season", + "UserData": { + "IsFavorite": false, + "Key": "string", + "PlayCount": 0, + "PlaybackPositionTicks": 0, + "Played": false, + "UnplayedItemCount": 0 + } +} diff --git a/tests/components/jellyfin/fixtures/seasons.json b/tests/components/jellyfin/fixtures/seasons.json new file mode 100644 index 00000000000..dc070d78352 --- /dev/null +++ b/tests/components/jellyfin/fixtures/seasons.json @@ -0,0 +1,29 @@ +{ + "Items": [ + { + "BackdropImageTags": [], + "ChannelId": "string", + "Id": "SEASON-UUID", + "ImageBlurHashes": {}, + "ImageTags": {}, + "IndexNumber": 0, + "IsFolder": true, + "LocationType": "FileSystem", + "Name": "SEASON", + "SeriesId": "SERIES-UUID", + "SeriesName": "SERIES", + "ServerId": "SEASON-UUID", + "Type": "Season", + "UserData": { + "IsFavorite": false, + "Key": "string", + "PlayCount": 0, + "PlaybackPositionTicks": 0, + "Played": false, + "UnplayedItemCount": 0 + } + } + ], + "StartIndex": 0, + "TotalRecordCount": 1 +} diff --git a/tests/components/jellyfin/fixtures/series-list.json b/tests/components/jellyfin/fixtures/series-list.json new file mode 100644 index 00000000000..3209ccfb2c4 --- /dev/null +++ b/tests/components/jellyfin/fixtures/series-list.json @@ -0,0 +1,34 @@ +{ + "Items": [ + { + "AirDays": ["string"], + "AirTime": "string", + "BackdropImageTags": [], + "ChannelId": "string", + "CommunityRating": 0, + "EndDate": "string", + "Id": "SERIES-UUID", + "ImageBlurHashes": { "Banner": { "string": "string" } }, + "ImageTags": { "Banner": "string" }, + "IsFolder": true, + "LocationType": "FileSystem", + "Name": "SERIES", + "PremiereDate": "string", + "ProductionYear": 0, + "RunTimeTicks": 0, + "ServerId": "string", + "Status": "string", + "Type": "Series", + "UserData": { + "IsFavorite": false, + "Key": "string", + "PlayCount": 0, + "PlaybackPositionTicks": 0, + "Played": false, + "UnplayedItemCount": 0 + } + } + ], + "TotalRecordCount": 1, + "StartIndex": 0 +} diff --git a/tests/components/jellyfin/fixtures/series.json b/tests/components/jellyfin/fixtures/series.json new file mode 100644 index 00000000000..879680ec591 --- /dev/null +++ b/tests/components/jellyfin/fixtures/series.json @@ -0,0 +1,28 @@ +{ + "AirDays": ["string"], + "AirTime": "string", + "BackdropImageTags": [], + "ChannelId": "string", + "CommunityRating": 0, + "EndDate": "string", + "Id": "SERIES-UUID", + "ImageBlurHashes": { "Banner": { "string": "string" } }, + "ImageTags": { "Banner": "string" }, + "IsFolder": true, + "LocationType": "FileSystem", + "Name": "SERIES", + "PremiereDate": "string", + "ProductionYear": 0, + "RunTimeTicks": 0, + "ServerId": "string", + "Status": "string", + "Type": "Series", + "UserData": { + "IsFavorite": false, + "Key": "string", + "PlayCount": 0, + "PlaybackPositionTicks": 0, + "Played": false, + "UnplayedItemCount": 0 + } +} diff --git a/tests/components/jellyfin/fixtures/track.json b/tests/components/jellyfin/fixtures/track.json new file mode 100644 index 00000000000..e9297549387 --- /dev/null +++ b/tests/components/jellyfin/fixtures/track.json @@ -0,0 +1,91 @@ +{ + "Album": "ALBUM_NAME", + "AlbumArtist": "ARTIST", + "AlbumArtists": [{ "Id": "ARTIST-UUID", "Name": "ARTIST" }], + "AlbumId": "ALBUM-UUID", + "AlbumPrimaryImageTag": "string", + "ArtistItems": [{ "Id": "ARTIST-UUID", "Name": "ARTIST" }], + "Artists": ["ARTIST"], + "Id": "TRACK-UUID", + "ImageTags": { "Primary": "string" }, + "IndexNumber": 1, + "IsFolder": false, + "MediaSources": [ + { + "Bitrate": 1, + "Container": "flac", + "DefaultAudioStreamIndex": 0, + "Formats": [], + "GenPtsInput": false, + "Id": "string", + "IgnoreDts": false, + "IgnoreIndex": false, + "IsInfiniteStream": false, + "IsRemote": false, + "MediaAttachments": [], + "MediaStreams": [ + { + "BitDepth": 16, + "ChannelLayout": "stereo", + "Channels": 2, + "Codec": "flac", + "CodecTimeBase": "1/44100", + "DisplayTitle": "FLAC - Stereo", + "Index": 0, + "IsDefault": false, + "IsExternal": false, + "IsForced": false, + "IsInterlaced": false, + "IsTextSubtitleStream": false, + "Level": 0, + "SampleRate": 44100, + "SupportsExternalStream": false, + "TimeBase": "1/44100", + "Type": "Audio" + } + ], + "Name": "string", + "Path": "/media/music/MockArtist/MockAlbum/01 - Track - MockAlbum - MockArtist.flac", + "Protocol": "string", + "ReadAtNativeFramerate": false, + "RequiredHttpHeaders": {}, + "RequiresClosing": false, + "RequiresLooping": false, + "RequiresOpening": false, + "RunTimeTicks": 2954933248, + "Size": 30074476, + "SupportsDirectPlay": true, + "SupportsDirectStream": true, + "SupportsProbing": true, + "SupportsTranscoding": true, + "Type": "Default" + } + ], + "MediaStreams": [ + { + "BitDepth": 16, + "ChannelLayout": "stereo", + "Channels": 2, + "Codec": "flac", + "CodecTimeBase": "1/44100", + "DisplayTitle": "FLAC - Stereo", + "Index": 0, + "IsDefault": false, + "IsExternal": false, + "IsForced": false, + "IsInterlaced": false, + "IsTextSubtitleStream": false, + "Level": 0, + "SampleRate": 44100, + "SupportsExternalStream": false, + "TimeBase": "1/44100", + "Type": "Audio" + } + ], + "MediaType": "Audio", + "Name": "TRACK", + "ParentId": "ALBUM-UUID", + "Path": "/media/music/MockArtist/MockAlbum/01 - Track - MockAlbum - MockArtist.flac", + "ServerId": "string", + "Type": "Audio" +} diff --git a/tests/components/jellyfin/fixtures/tracks-nopath.json b/tests/components/jellyfin/fixtures/tracks-nopath.json new file mode 100644 index 00000000000..75e87e1a05b --- /dev/null +++ b/tests/components/jellyfin/fixtures/tracks-nopath.json @@ -0,0 +1,93 @@ +{ + "Items": [ + { + "Album": "ALBUM_NAME", + "AlbumArtist": "ARTIST", + "AlbumArtists": [{ "Id": "ARTIST-UUID", "Name": "ARTIST" }], + "AlbumId": "ALBUM-UUID", + "AlbumPrimaryImageTag": "string", + "ArtistItems": [{ "Id": "ARTIST-UUID", "Name": "ARTIST" }], + "Artists": ["ARTIST"], + "Id": "TRACK-UUID", + "ImageTags": { "Primary": "string" }, + "IndexNumber": 1, + "IsFolder": false, + "MediaSources": [ + { + "Bitrate": 1, + "Container": "flac", + "DefaultAudioStreamIndex": 0, + "Formats": [], + "GenPtsInput": false, + "Id": "string", + "IgnoreDts": false, + "IgnoreIndex": false, + "IsInfiniteStream": false, + "IsRemote": false, + "MediaAttachments": [], + "MediaStreams": [ + { + "BitDepth": 16, + "ChannelLayout": "stereo", + "Channels": 2, + "Codec": "flac", + "CodecTimeBase": "1/44100", + "DisplayTitle": "FLAC - Stereo", + "Index": 0, + "IsDefault": false, + "IsExternal": false, + "IsForced": false, + "IsInterlaced": false, + "IsTextSubtitleStream": false, + "Level": 0, + "SampleRate": 44100, + "SupportsExternalStream": false, + "TimeBase": "1/44100", + "Type": "Audio" + } + ], + "Name": "string", + "Protocol": "string", + "ReadAtNativeFramerate": false, + "RequiredHttpHeaders": {}, + "RequiresClosing": false, + "RequiresLooping": false, + "RequiresOpening": false, + "RunTimeTicks": 2954933248, + "Size": 30074476, + "SupportsDirectPlay": true, + "SupportsDirectStream": true, + "SupportsProbing": true, + "SupportsTranscoding": true, + "Type": "Default" + } + ], + "MediaStreams": [ + { + "BitDepth": 16, + "ChannelLayout": "stereo", + "Channels": 2, + "Codec": "flac", + "CodecTimeBase": "1/44100", + "DisplayTitle": "FLAC - Stereo", + "Index": 0, + "IsDefault": false, + "IsExternal": false, + "IsForced": false, + "IsInterlaced": false, + "IsTextSubtitleStream": false, + "Level": 0, + "SampleRate": 44100, + "SupportsExternalStream": false, + "TimeBase": "1/44100", + "Type": "Audio" + } + ], + "MediaType": "Audio", + "Name": "TRACK", + "ParentId": "ALBUM-UUID", + "ServerId": "string", + "Type": "Audio" + } + ] +} diff --git a/tests/components/jellyfin/fixtures/tracks-nosource.json b/tests/components/jellyfin/fixtures/tracks-nosource.json new file mode 100644 index 00000000000..02509f13196 --- /dev/null +++ b/tests/components/jellyfin/fixtures/tracks-nosource.json @@ -0,0 +1,23 @@ +{ + "Items": [ + { + "Album": "ALBUM_NAME", + "AlbumArtist": "ARTIST", + "AlbumArtists": [{ "Id": "ARTIST-UUID", "Name": "ARTIST" }], + "AlbumId": "ALBUM-UUID", + "AlbumPrimaryImageTag": "string", + "ArtistItems": [{ "Id": "ARTIST-UUID", "Name": "ARTIST" }], + "Artists": ["ARTIST"], + "Id": "TRACK-UUID", + "ImageTags": { "Primary": "string" }, + "IndexNumber": 1, + "IsFolder": false, + "MediaType": "Audio", + "Name": "TRACK", + "ParentId": "ALBUM-UUID", + "Path": "/media/music/MockArtist/MockAlbum/01 - Track - MockAlbum - MockArtist.flac", + "ServerId": "string", + "Type": "Audio" + } + ] +} diff --git a/tests/components/jellyfin/fixtures/tracks-unknown-extension.json b/tests/components/jellyfin/fixtures/tracks-unknown-extension.json new file mode 100644 index 00000000000..b3beaa1d758 --- /dev/null +++ b/tests/components/jellyfin/fixtures/tracks-unknown-extension.json @@ -0,0 +1,95 @@ +{ + "Items": [ + { + "Album": "ALBUM_NAME", + "AlbumArtist": "ARTIST", + "AlbumArtists": [{ "Id": "ARTIST-UUID", "Name": "ARTIST" }], + "AlbumId": "ALBUM-UUID", + "AlbumPrimaryImageTag": "string", + "ArtistItems": [{ "Id": "ARTIST-UUID", "Name": "ARTIST" }], + "Artists": ["ARTIST"], + "Id": "TRACK-UUID", + "ImageTags": { "Primary": "string" }, + "IndexNumber": 1, + "IsFolder": false, + "MediaSources": [ + { + "Bitrate": 1, + "Container": "flac", + "DefaultAudioStreamIndex": 0, + "Formats": [], + "GenPtsInput": false, + "Id": "string", + "IgnoreDts": false, + "IgnoreIndex": false, + "IsInfiniteStream": false, + "IsRemote": false, + "MediaAttachments": [], + "MediaStreams": [ + { + "BitDepth": 16, + "ChannelLayout": "stereo", + "Channels": 2, + "Codec": "flac", + "CodecTimeBase": "1/44100", + "DisplayTitle": "FLAC - Stereo", + "Index": 0, + "IsDefault": false, + "IsExternal": false, + "IsForced": false, + "IsInterlaced": false, + "IsTextSubtitleStream": false, + "Level": 0, + "SampleRate": 44100, + "SupportsExternalStream": false, + "TimeBase": "1/44100", + "Type": "Audio" + } + ], + "Name": "string", + "Path": "/media/music/MockArtist/MockAlbum/01 - Track - MockAlbum - MockArtist.uke", + "Protocol": "string", + "ReadAtNativeFramerate": false, + "RequiredHttpHeaders": {}, + "RequiresClosing": false, + "RequiresLooping": false, + "RequiresOpening": false, + "RunTimeTicks": 2954933248, + "Size": 30074476, + "SupportsDirectPlay": true, + "SupportsDirectStream": true, + "SupportsProbing": true, + "SupportsTranscoding": true, + "Type": "Default" + } + ], + "MediaStreams": [ + { + "BitDepth": 16, + "ChannelLayout": "stereo", + "Channels": 2, + "Codec": "flac", + "CodecTimeBase": "1/44100", + "DisplayTitle": "FLAC - Stereo", + "Index": 0, + "IsDefault": false, + "IsExternal": false, + "IsForced": false, + "IsInterlaced": false, + "IsTextSubtitleStream": false, + "Level": 0, + "SampleRate": 44100, + "SupportsExternalStream": false, + "TimeBase": "1/44100", + "Type": "Audio" + } + ], + "MediaType": "Audio", + "Name": "TRACK", + "ParentId": "ALBUM-UUID", + "Path": "/media/music/MockArtist/MockAlbum/01 - Track - MockAlbum - MockArtist.uke", + "ServerId": "string", + "Type": "Audio" + } + ] +} diff --git a/tests/components/jellyfin/fixtures/tracks.json b/tests/components/jellyfin/fixtures/tracks.json new file mode 100644 index 00000000000..63a0fd9deaf --- /dev/null +++ b/tests/components/jellyfin/fixtures/tracks.json @@ -0,0 +1,95 @@ +{ + "Items": [ + { + "Album": "ALBUM_NAME", + "AlbumArtist": "ARTIST", + "AlbumArtists": [{ "Id": "ARTIST-UUID", "Name": "ARTIST" }], + "AlbumId": "ALBUM-UUID", + "AlbumPrimaryImageTag": "string", + "ArtistItems": [{ "Id": "ARTIST-UUID", "Name": "ARTIST" }], + "Artists": ["ARTIST"], + "Id": "TRACK-UUID", + "ImageTags": { "Primary": "string" }, + "IndexNumber": 1, + "IsFolder": false, + "MediaSources": [ + { + "Bitrate": 1, + "Container": "flac", + "DefaultAudioStreamIndex": 0, + "Formats": [], + "GenPtsInput": false, + "Id": "string", + "IgnoreDts": false, + "IgnoreIndex": false, + "IsInfiniteStream": false, + "IsRemote": false, + "MediaAttachments": [], + "MediaStreams": [ + { + "BitDepth": 16, + "ChannelLayout": "stereo", + "Channels": 2, + "Codec": "flac", + "CodecTimeBase": "1/44100", + "DisplayTitle": "FLAC - Stereo", + "Index": 0, + "IsDefault": false, + "IsExternal": false, + "IsForced": false, + "IsInterlaced": false, + "IsTextSubtitleStream": false, + "Level": 0, + "SampleRate": 44100, + "SupportsExternalStream": false, + "TimeBase": "1/44100", + "Type": "Audio" + } + ], + "Name": "string", + "Path": "/media/music/MockArtist/MockAlbum/01 - Track - MockAlbum - MockArtist.flac", + "Protocol": "string", + "ReadAtNativeFramerate": false, + "RequiredHttpHeaders": {}, + "RequiresClosing": false, + "RequiresLooping": false, + "RequiresOpening": false, + "RunTimeTicks": 2954933248, + "Size": 30074476, + "SupportsDirectPlay": true, + "SupportsDirectStream": true, + "SupportsProbing": true, + "SupportsTranscoding": true, + "Type": "Default" + } + ], + "MediaStreams": [ + { + "BitDepth": 16, + "ChannelLayout": "stereo", + "Channels": 2, + "Codec": "flac", + "CodecTimeBase": "1/44100", + "DisplayTitle": "FLAC - Stereo", + "Index": 0, + "IsDefault": false, + "IsExternal": false, + "IsForced": false, + "IsInterlaced": false, + "IsTextSubtitleStream": false, + "Level": 0, + "SampleRate": 44100, + "SupportsExternalStream": false, + "TimeBase": "1/44100", + "Type": "Audio" + } + ], + "MediaType": "Audio", + "Name": "TRACK", + "ParentId": "ALBUM-UUID", + "Path": "/media/music/MockArtist/MockAlbum/01 - Track - MockAlbum - MockArtist.flac", + "ServerId": "string", + "Type": "Audio" + } + ] +} diff --git a/tests/components/jellyfin/fixtures/tv-collection.json b/tests/components/jellyfin/fixtures/tv-collection.json new file mode 100644 index 00000000000..0817352edae --- /dev/null +++ b/tests/components/jellyfin/fixtures/tv-collection.json @@ -0,0 +1,45 @@ +{ + "BackdropImageTags": [], + "CanDelete": false, + "CanDownload": false, + "ChannelId": "", + "ChildCount": 0, + "CollectionType": "tvshows", + "DateCreated": "string", + "DisplayPreferencesId": "string", + "EnableMediaSourceDisplay": true, + "Etag": "string", + "ExternalUrls": [], + "GenreItems": [], + "Genres": [], + "Id": "TV-COLLECTION-FOLDER-UUID", + "ImageBlurHashes": { "Primary": { "string": "string" } }, + "ImageTags": { "Primary": "string" }, + "IsFolder": true, + "LocalTrailerCount": 0, + "LocationType": "FileSystem", + "LockData": false, + "LockedFields": [], + "Name": "TVShows", + "ParentId": "string", + "Path": "string", + "People": [], + "PlayAccess": "Full", + "PrimaryImageAspectRatio": 1.7777777777777777, + "ProviderIds": {}, + "RemoteTrailers": [], + "ServerId": "string", + "SortName": "music", + "SpecialFeatureCount": 0, + "Studios": [], + "Taglines": [], + "Tags": [], + "Type": "CollectionFolder", + "UserData": { + "IsFavorite": false, + "Key": "string", + "PlayCount": 0, + "PlaybackPositionTicks": 0, + "Played": false + } +} diff --git a/tests/components/jellyfin/fixtures/unsupported-item.json b/tests/components/jellyfin/fixtures/unsupported-item.json new file mode 100644 index 00000000000..5d97447808a --- /dev/null +++ b/tests/components/jellyfin/fixtures/unsupported-item.json @@ -0,0 +1,5 @@ +{ + "Id": "Unsupported-UUID", + "Type": "Unsupported", + "MediaType": "Unsupported" +} diff --git a/tests/components/jellyfin/snapshots/test_media_source.ambr b/tests/components/jellyfin/snapshots/test_media_source.ambr new file mode 100644 index 00000000000..6d629f245a0 --- /dev/null +++ b/tests/components/jellyfin/snapshots/test_media_source.ambr @@ -0,0 +1,135 @@ +# serializer version: 1 +# name: test_movie_library + dict({ + 'can_expand': False, + 'can_play': True, + 'children': None, + 'children_media_class': None, + 'domain': 'jellyfin', + 'identifier': 'MOVIE-UUID', + 'media_class': , + 'media_content_id': 'media-source://jellyfin/MOVIE-UUID', + 'media_content_type': 'video/mp4', + 'not_shown': 0, + 'thumbnail': 'http://localhost/Items/MOVIE-UUID/Images/Primary.jpg', + 'title': 'MOVIE', + }) +# --- +# name: test_music_library + dict({ + 'can_expand': True, + 'can_play': False, + 'children': None, + 'children_media_class': None, + 'domain': 'jellyfin', + 'identifier': 'ALBUM-UUID', + 'media_class': , + 'media_content_id': 'media-source://jellyfin/ALBUM-UUID', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'ALBUM', + }) +# --- +# name: test_music_library.1 + dict({ + 'can_expand': True, + 'can_play': False, + 'children': None, + 'children_media_class': None, + 'domain': 'jellyfin', + 'identifier': 'ALBUM-UUID', + 'media_class': , + 'media_content_id': 'media-source://jellyfin/ALBUM-UUID', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'ALBUM', + }) +# --- +# name: test_music_library.2 + dict({ + 'can_expand': False, + 'can_play': True, + 'children': None, + 'children_media_class': None, + 'domain': 'jellyfin', + 'identifier': 'TRACK-UUID', + 'media_class': , + 'media_content_id': 'media-source://jellyfin/TRACK-UUID', + 'media_content_type': 'audio/flac', + 'not_shown': 0, + 'thumbnail': 'http://localhost/Items/TRACK-UUID/Images/Primary.jpg', + 'title': 'TRACK', + }) +# --- +# name: test_resolve + 'http://localhost/Audio/TRACK-UUID/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000' +# --- +# name: test_resolve.1 + 'http://localhost/Videos/MOVIE-UUID/stream?static=true,DeviceId=TEST-UUID,api_key=TEST-API-KEY' +# --- +# name: test_root + dict({ + 'can_expand': True, + 'can_play': False, + 'children': None, + 'children_media_class': None, + 'domain': 'jellyfin', + 'identifier': 'COLLECTION-FOLDER-UUID', + 'media_class': , + 'media_content_id': 'media-source://jellyfin/COLLECTION-FOLDER-UUID', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'COLLECTION FOLDER', + }) +# --- +# name: test_tv_library + dict({ + 'can_expand': True, + 'can_play': False, + 'children': None, + 'children_media_class': None, + 'domain': 'jellyfin', + 'identifier': 'SERIES-UUID', + 'media_class': , + 'media_content_id': 'media-source://jellyfin/SERIES-UUID', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'SERIES', + }) +# --- +# name: test_tv_library.1 + dict({ + 'can_expand': True, + 'can_play': False, + 'children': None, + 'children_media_class': None, + 'domain': 'jellyfin', + 'identifier': 'SEASON-UUID', + 'media_class': , + 'media_content_id': 'media-source://jellyfin/SEASON-UUID', + 'media_content_type': '', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'SEASON', + }) +# --- +# name: test_tv_library.2 + dict({ + 'can_expand': False, + 'can_play': True, + 'children': None, + 'children_media_class': None, + 'domain': 'jellyfin', + 'identifier': 'EPISODE-UUID', + 'media_class': , + 'media_content_id': 'media-source://jellyfin/EPISODE-UUID', + 'media_content_type': 'video/mp4', + 'not_shown': 0, + 'thumbnail': 'http://localhost/Items/EPISODE-UUID/Images/Primary.jpg', + 'title': 'EPISODE', + }) +# --- diff --git a/tests/components/jellyfin/test_init.py b/tests/components/jellyfin/test_init.py index 542be0736c7..9af73391d18 100644 --- a/tests/components/jellyfin/test_init.py +++ b/tests/components/jellyfin/test_init.py @@ -4,10 +4,29 @@ from unittest.mock import MagicMock from homeassistant.components.jellyfin.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component from . import async_load_json_fixture from tests.common import MockConfigEntry +from tests.typing import MockHAClientWebSocket, WebSocketGenerator + + +async def remove_device( + ws_client: MockHAClientWebSocket, device_id: str, config_entry_id: str +) -> bool: + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 1, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] async def test_config_entry_not_ready( @@ -29,6 +48,26 @@ async def test_config_entry_not_ready( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test the Jellyfin integration handling invalid credentials.""" + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + + async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -46,3 +85,44 @@ async def test_load_unload_config_entry( await hass.async_block_till_done() assert mock_config_entry.entry_id not in hass.data[DOMAIN] assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_device_remove_devices( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entry = device_registry.async_get_device( + identifiers={ + ( + DOMAIN, + "DEVICE-UUID", + ) + }, + ) + assert ( + await remove_device( + await hass_ws_client(hass), device_entry.id, mock_config_entry.entry_id + ) + is False + ) + old_device_entry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "OLD-DEVICE-UUID")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), old_device_entry.id, mock_config_entry.entry_id + ) + is True + ) diff --git a/tests/components/jellyfin/test_media_source.py b/tests/components/jellyfin/test_media_source.py new file mode 100644 index 00000000000..5f8871e6242 --- /dev/null +++ b/tests/components/jellyfin/test_media_source.py @@ -0,0 +1,303 @@ +"""Tests for the Jellyfin media_player platform.""" +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.jellyfin.const import DOMAIN +from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_source import ( + DOMAIN as MEDIA_SOURCE_DOMAIN, + URI_SCHEME, + async_browse_media, + async_resolve_media, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import load_json_fixture + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +async def setup_component(hass: HomeAssistant) -> None: + """Set up component.""" + assert await async_setup_component(hass, MEDIA_SOURCE_DOMAIN, {}) + + +async def test_resolve( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test resolving Jellyfin media items.""" + + # Test resolving a track + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = load_json_fixture("track.json") + + play_media = await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/TRACK-UUID") + + assert play_media.mime_type == "audio/flac" + assert play_media.url == snapshot + + # Test resolving a movie + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = load_json_fixture("movie.json") + + play_media = await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/MOVIE-UUID") + + assert play_media.mime_type == "video/mp4" + assert play_media.url == snapshot + + # Test resolving an unsupported item + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = load_json_fixture("unsupported-item.json") + + with pytest.raises(BrowseError): + await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/UNSUPPORTED-ITEM-UUID") + + +async def test_root( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing the Jellyfin root.""" + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + + assert browse.domain == DOMAIN + assert browse.identifier is None + assert browse.title == "Jellyfin" + assert vars(browse.children[0]) == snapshot + + +async def test_tv_library( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing a Jellyfin TV Library.""" + + # Test browsing an empty tv library + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = load_json_fixture("tv-collection.json") + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = {"Items": []} + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/TV-COLLECTION-FOLDER-UUID" + ) + + assert browse.domain == DOMAIN + assert browse.identifier == "TV-COLLECTION-FOLDER-UUID" + assert browse.title == "TVShows" + assert browse.children == [] + + # Test browsing a tv library containing series + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = load_json_fixture("series-list.json") + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/TV-COLLECTION-FOLDER-UUID" + ) + + assert browse.domain == DOMAIN + assert browse.identifier == "TV-COLLECTION-FOLDER-UUID" + assert browse.title == "TVShows" + assert vars(browse.children[0]) == snapshot + + # Test browsing a series + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = load_json_fixture("series.json") + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = load_json_fixture("seasons.json") + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/SERIES-UUID") + + assert browse.domain == DOMAIN + assert browse.identifier == "SERIES-UUID" + assert browse.title == "SERIES" + assert vars(browse.children[0]) == snapshot + + # Test browsing a season + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = load_json_fixture("season.json") + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = load_json_fixture("episodes.json") + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/SEASON-UUID") + + assert browse.domain == DOMAIN + assert browse.identifier == "SEASON-UUID" + assert browse.title == "SEASON" + assert vars(browse.children[0]) == snapshot + + +async def test_movie_library( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing a Jellyfin Movie Library.""" + + # Test empty movie library + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = load_json_fixture("movie-collection.json") + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = {"Items": []} + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/MOVIE-COLLECTION-FOLDER-UUID" + ) + + assert browse.domain == DOMAIN + assert browse.identifier == "MOVIE-COLLECTION-FOLDER-UUID" + assert browse.title == "Movies" + assert browse.children == [] + + # Test browsing a movie library containing movies + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = load_json_fixture("movies.json") + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/MOVIE-COLLECTION-FOLDER-UUID" + ) + + assert browse.domain == DOMAIN + assert browse.identifier == "MOVIE-COLLECTION-FOLDER-UUID" + assert browse.title == "Movies" + assert vars(browse.children[0]) == snapshot + + +async def test_music_library( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test browsing a Jellyfin Music Library.""" + + # Test browsinng an empty music library + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = load_json_fixture("music-collection.json") + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = {"Items": []} + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/MUSIC-COLLECTION-FOLDER-UUID" + ) + + assert browse.domain == DOMAIN + assert browse.identifier == "MUSIC-COLLECTION-FOLDER-UUID" + assert browse.title == "Music" + assert browse.children == [] + + # Test browsing a music library containing albums + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = load_json_fixture("albums.json") + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/MUSIC-COLLECTION-FOLDER-UUID" + ) + + assert browse.domain == DOMAIN + assert browse.identifier == "MUSIC-COLLECTION-FOLDER-UUID" + assert browse.title == "Music" + assert vars(browse.children[0]) == snapshot + + # Test browsing an artist + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = load_json_fixture("artist.json") + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = load_json_fixture("albums.json") + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/ARTIST-UUID") + + assert browse.domain == DOMAIN + assert browse.identifier == "ARTIST-UUID" + assert browse.title == "ARTIST" + assert vars(browse.children[0]) == snapshot + + # Test browsing an album + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = load_json_fixture("album.json") + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = load_json_fixture("tracks.json") + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/ALBUM-UUID") + + assert browse.domain == DOMAIN + assert browse.identifier == "ALBUM-UUID" + assert browse.title == "ALBUM" + assert vars(browse.children[0]) == snapshot + + # Test browsing an album with a track with no source + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = load_json_fixture("tracks-nosource.json") + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/ALBUM-UUID") + + assert browse.domain == DOMAIN + assert browse.identifier == "ALBUM-UUID" + assert browse.title == "ALBUM" + + assert browse.children == [] + + # Test browsing an album with a track with no path + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = load_json_fixture("tracks-nopath.json") + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/ALBUM-UUID") + + assert browse.domain == DOMAIN + assert browse.identifier == "ALBUM-UUID" + assert browse.title == "ALBUM" + + assert browse.children == [] + + # Test browsing an album with a track with an unknown file extension + mock_api.user_items.side_effect = None + mock_api.user_items.return_value = load_json_fixture( + "tracks-unknown-extension.json" + ) + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/ALBUM-UUID") + + assert browse.domain == DOMAIN + assert browse.identifier == "ALBUM-UUID" + assert browse.title == "ALBUM" + + assert browse.children == [] + + +async def test_browse_unsupported( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test browsing an unsupported item.""" + + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = load_json_fixture("unsupported-item.json") + + with pytest.raises(BrowseError): + await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/UNSUPPORTED-ITEM-UUID") diff --git a/tests/components/kaleidescape/test_sensor.py b/tests/components/kaleidescape/test_sensor.py index 0ae2dc15619..3fbff29e3e9 100644 --- a/tests/components/kaleidescape/test_sensor.py +++ b/tests/components/kaleidescape/test_sensor.py @@ -27,7 +27,7 @@ async def test_sensors( assert entity assert entity.state == "none" assert ( - entity.attributes.get(ATTR_FRIENDLY_NAME) == f"{FRIENDLY_NAME} Media Location" + entity.attributes.get(ATTR_FRIENDLY_NAME) == f"{FRIENDLY_NAME} Media location" ) assert entry assert entry.unique_id == f"{MOCK_SERIAL}-media_location" @@ -36,7 +36,7 @@ async def test_sensors( entry = er.async_get(hass).async_get(f"{ENTITY_ID}_play_status") assert entity assert entity.state == "none" - assert entity.attributes.get(ATTR_FRIENDLY_NAME) == f"{FRIENDLY_NAME} Play Status" + assert entity.attributes.get(ATTR_FRIENDLY_NAME) == f"{FRIENDLY_NAME} Play status" assert entry assert entry.unique_id == f"{MOCK_SERIAL}-play_status" diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index ca804176ee9..5463892a3ef 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -910,7 +910,7 @@ async def test_form_with_automatic_connection_handling( CONF_KNX_ROUTE_BACK: False, CONF_KNX_TUNNEL_ENDPOINT_IA: None, CONF_KNX_STATE_UPDATER: True, - CONF_KNX_TELEGRAM_LOG_SIZE: 50, + CONF_KNX_TELEGRAM_LOG_SIZE: 200, } knx_setup.assert_called_once() @@ -1210,7 +1210,7 @@ async def test_options_flow_connection_type( CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_USER_ID: None, CONF_KNX_SECURE_USER_PASSWORD: None, - CONF_KNX_TELEGRAM_LOG_SIZE: 50, + CONF_KNX_TELEGRAM_LOG_SIZE: 200, } diff --git a/tests/components/knx/test_cover.py b/tests/components/knx/test_cover.py index 4ee9bd04eee..2d2b72e9015 100644 --- a/tests/components/knx/test_cover.py +++ b/tests/components/knx/test_cover.py @@ -19,8 +19,6 @@ async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: CoverSchema.CONF_MOVE_SHORT_ADDRESS: "1/0/1", CoverSchema.CONF_POSITION_STATE_ADDRESS: "1/0/2", CoverSchema.CONF_POSITION_ADDRESS: "1/0/3", - CoverSchema.CONF_ANGLE_STATE_ADDRESS: "1/0/4", - CoverSchema.CONF_ANGLE_ADDRESS: "1/0/5", } } ) @@ -28,10 +26,8 @@ async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: # read position state address and angle state address await knx.assert_read("1/0/2") - await knx.assert_read("1/0/4") # StateUpdater initialize state await knx.receive_response("1/0/2", (0x0F,)) - await knx.receive_response("1/0/4", (0x30,)) events.clear() # open cover @@ -82,6 +78,32 @@ async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: assert len(events) == 1 events.pop() + +async def test_cover_tilt_absolute(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX cover tilt.""" + await knx.setup_integration( + { + CoverSchema.PLATFORM: { + CONF_NAME: "test", + CoverSchema.CONF_MOVE_LONG_ADDRESS: "1/0/0", + CoverSchema.CONF_MOVE_SHORT_ADDRESS: "1/0/1", + CoverSchema.CONF_POSITION_STATE_ADDRESS: "1/0/2", + CoverSchema.CONF_POSITION_ADDRESS: "1/0/3", + CoverSchema.CONF_ANGLE_STATE_ADDRESS: "1/0/4", + CoverSchema.CONF_ANGLE_ADDRESS: "1/0/5", + } + } + ) + events = async_capture_events(hass, "state_changed") + + # read position state address and angle state address + await knx.assert_read("1/0/2") + await knx.assert_read("1/0/4") + # StateUpdater initialize state + await knx.receive_response("1/0/2", (0x0F,)) + await knx.receive_response("1/0/4", (0x30,)) + events.clear() + # set cover tilt position await hass.services.async_call( "cover", @@ -102,7 +124,7 @@ async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: await hass.services.async_call( "cover", "close_cover_tilt", target={"entity_id": "cover.test"}, blocking=True ) - await knx.assert_write("1/0/1", True) + await knx.assert_write("1/0/5", (0xFF,)) assert len(events) == 1 events.pop() @@ -111,4 +133,29 @@ async def test_cover_basic(hass: HomeAssistant, knx: KNXTestKit) -> None: await hass.services.async_call( "cover", "open_cover_tilt", target={"entity_id": "cover.test"}, blocking=True ) - await knx.assert_write("1/0/1", False) + await knx.assert_write("1/0/5", (0x00,)) + + +async def test_cover_tilt_move_short(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX cover tilt.""" + await knx.setup_integration( + { + CoverSchema.PLATFORM: { + CONF_NAME: "test", + CoverSchema.CONF_MOVE_LONG_ADDRESS: "1/0/0", + CoverSchema.CONF_MOVE_SHORT_ADDRESS: "1/0/1", + } + } + ) + + # close cover tilt + await hass.services.async_call( + "cover", "close_cover_tilt", target={"entity_id": "cover.test"}, blocking=True + ) + await knx.assert_write("1/0/1", 1) + + # open cover tilt + await hass.services.async_call( + "cover", "open_cover_tilt", target={"entity_id": "cover.test"}, blocking=True + ) + await knx.assert_write("1/0/1", 0) diff --git a/tests/components/knx/test_date.py b/tests/components/knx/test_date.py new file mode 100644 index 00000000000..bfde519f3c0 --- /dev/null +++ b/tests/components/knx/test_date.py @@ -0,0 +1,86 @@ +"""Test KNX date.""" +from homeassistant.components.date import ATTR_DATE, DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.schema import DateSchema +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, State + +from .conftest import KNXTestKit + +from tests.common import mock_restore_cache + + +async def test_date(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX date.""" + test_address = "1/1/1" + await knx.setup_integration( + { + DateSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + } + } + ) + # set value + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {"entity_id": "date.test", ATTR_DATE: "1999-03-31"}, + blocking=True, + ) + await knx.assert_write( + test_address, + (0x1F, 0x03, 0x63), + ) + state = hass.states.get("date.test") + assert state.state == "1999-03-31" + + # update from KNX + await knx.receive_write( + test_address, + (0x01, 0x02, 0x03), + ) + state = hass.states.get("date.test") + assert state.state == "2003-02-01" + + +async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX date with passive_address, restoring state and respond_to_read.""" + test_address = "1/1/1" + test_passive_address = "3/3/3" + + fake_state = State("date.test", "2023-07-24") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + DateSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: [test_address, test_passive_address], + CONF_RESPOND_TO_READ: True, + } + } + ) + # restored state - doesn't send telegram + state = hass.states.get("date.test") + assert state.state == "2023-07-24" + await knx.assert_telegram_count(0) + + # respond with restored state + await knx.receive_read(test_address) + await knx.assert_response( + test_address, + (0x18, 0x07, 0x17), + ) + + # don't respond to passive address + await knx.receive_read(test_passive_address) + await knx.assert_no_telegram() + + # update from KNX passive address + await knx.receive_write( + test_passive_address, + (0x18, 0x02, 0x18), + ) + state = hass.states.get("date.test") + assert state.state == "2024-02-24" diff --git a/tests/components/knx/test_datetime.py b/tests/components/knx/test_datetime.py new file mode 100644 index 00000000000..f9d9f039367 --- /dev/null +++ b/tests/components/knx/test_datetime.py @@ -0,0 +1,89 @@ +"""Test KNX date.""" +from homeassistant.components.datetime import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.schema import DateTimeSchema +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, State + +from .conftest import KNXTestKit + +from tests.common import mock_restore_cache + +# KNX DPT 19.001 doesn't provide timezone information so we send local time + + +async def test_datetime(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX datetime.""" + # default timezone in tests is US/Pacific + test_address = "1/1/1" + await knx.setup_integration( + { + DateTimeSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + } + } + ) + # set value + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {"entity_id": "datetime.test", ATTR_DATETIME: "2020-01-02T03:04:05+00:00"}, + blocking=True, + ) + await knx.assert_write( + test_address, + (0x78, 0x01, 0x01, 0x73, 0x04, 0x05, 0x20, 0x80), + ) + state = hass.states.get("datetime.test") + assert state.state == "2020-01-02T03:04:05+00:00" + + # update from KNX + await knx.receive_write( + test_address, + (0x7B, 0x07, 0x19, 0x49, 0x28, 0x08, 0x00, 0x00), + ) + state = hass.states.get("datetime.test") + assert state.state == "2023-07-25T16:40:08+00:00" + + +async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX datetime with passive_address, restoring state and respond_to_read.""" + hass.config.set_time_zone("Europe/Vienna") + test_address = "1/1/1" + test_passive_address = "3/3/3" + fake_state = State("datetime.test", "2022-03-03T03:04:05+00:00") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + DateTimeSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: [test_address, test_passive_address], + CONF_RESPOND_TO_READ: True, + } + } + ) + # restored state - doesn't send telegram + state = hass.states.get("datetime.test") + assert state.state == "2022-03-03T03:04:05+00:00" + await knx.assert_telegram_count(0) + + # respond with restored state + await knx.receive_read(test_address) + await knx.assert_response( + test_address, + (0x7A, 0x03, 0x03, 0x84, 0x04, 0x05, 0x20, 0x80), + ) + + # don't respond to passive address + await knx.receive_read(test_passive_address) + await knx.assert_no_telegram() + + # update from KNX passive address + await knx.receive_write( + test_passive_address, + (0x78, 0x01, 0x01, 0x73, 0x04, 0x05, 0x20, 0x80), + ) + state = hass.states.get("datetime.test") + assert state.state == "2020-01-01T18:04:05+00:00" diff --git a/tests/components/knx/test_init.py b/tests/components/knx/test_init.py index 785ff9d8317..a5d3d0f3263 100644 --- a/tests/components/knx/test_init.py +++ b/tests/components/knx/test_init.py @@ -280,7 +280,7 @@ async def test_async_remove_entry( "pathlib.Path.rmdir" ) as rmdir_mock: assert await hass.config_entries.async_remove(config_entry.entry_id) - unlink_mock.assert_called_once() + assert unlink_mock.call_count == 3 rmdir_mock.assert_called_once() await hass.async_block_till_done() diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py index 9fb21b9f9b4..12ae0ac7d0e 100644 --- a/tests/components/knx/test_interface_device.py +++ b/tests/components/knx/test_interface_device.py @@ -99,11 +99,11 @@ async def test_removed_entity( hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry ) -> None: """Test unregister callback when entity is removed.""" - await knx.setup_integration({}) - - with patch.object( - knx.xknx.connection_manager, "unregister_connection_state_changed_cb" + with patch( + "xknx.core.connection_manager.ConnectionManager.unregister_connection_state_changed_cb" ) as unregister_mock: + await knx.setup_integration({}) + entity_registry.async_update_entity( "sensor.knx_interface_connection_established", disabled_by=er.RegistryEntryDisabler.USER, diff --git a/tests/components/knx/test_telegrams.py b/tests/components/knx/test_telegrams.py new file mode 100644 index 00000000000..964b9ea2a11 --- /dev/null +++ b/tests/components/knx/test_telegrams.py @@ -0,0 +1,114 @@ +"""KNX Telegrams Tests.""" +from copy import copy +from datetime import datetime +from typing import Any + +import pytest + +from homeassistant.components.knx import DOMAIN +from homeassistant.components.knx.const import CONF_KNX_TELEGRAM_LOG_SIZE +from homeassistant.components.knx.telegrams import TelegramDict +from homeassistant.core import HomeAssistant + +from .conftest import KNXTestKit + +MOCK_TIMESTAMP = "2023-07-02T14:51:24.045162-07:00" +MOCK_TELEGRAMS = [ + { + "destination": "1/3/4", + "destination_name": "", + "direction": "Incoming", + "dpt_main": None, + "dpt_sub": None, + "dpt_name": None, + "payload": True, + "source": "1.2.3", + "source_name": "", + "telegramtype": "GroupValueWrite", + "timestamp": MOCK_TIMESTAMP, + "unit": None, + "value": None, + }, + { + "destination": "2/2/2", + "destination_name": "", + "direction": "Outgoing", + "dpt_main": None, + "dpt_sub": None, + "dpt_name": None, + "payload": [1, 2, 3, 4], + "source": "0.0.0", + "source_name": "", + "telegramtype": "GroupValueWrite", + "timestamp": MOCK_TIMESTAMP, + "unit": None, + "value": None, + }, +] + + +def assert_telegram_history(telegrams: list[TelegramDict]) -> bool: + """Assert that the mock telegrams are equal to the given telegrams. Omitting timestamp.""" + assert len(telegrams) == len(MOCK_TELEGRAMS) + for index in range(len(telegrams)): + test_telegram = copy(telegrams[index]) # don't modify the original + comp_telegram = MOCK_TELEGRAMS[index] + assert datetime.fromisoformat(test_telegram["timestamp"]) + if isinstance(test_telegram["payload"], tuple): + # JSON encodes tuples to lists + test_telegram["payload"] = list(test_telegram["payload"]) + assert test_telegram | {"timestamp": MOCK_TIMESTAMP} == comp_telegram + return True + + +async def test_store_telegam_history( + hass: HomeAssistant, + knx: KNXTestKit, + hass_storage: dict[str, Any], +): + """Test storing telegram history.""" + await knx.setup_integration({}) + + await knx.receive_write("1/3/4", True) + await hass.services.async_call( + "knx", "send", {"address": "2/2/2", "payload": [1, 2, 3, 4]}, blocking=True + ) + await knx.assert_write("2/2/2", (1, 2, 3, 4)) + + assert len(hass.data[DOMAIN].telegrams.recent_telegrams) == 2 + with pytest.raises(KeyError): + hass_storage["knx/telegrams_history.json"] + + await hass.config_entries.async_unload(knx.mock_config_entry.entry_id) + saved_telegrams = hass_storage["knx/telegrams_history.json"]["data"] + assert assert_telegram_history(saved_telegrams) + + +async def test_load_telegam_history( + hass: HomeAssistant, + knx: KNXTestKit, + hass_storage: dict[str, Any], +): + """Test telegram history restoration.""" + hass_storage["knx/telegrams_history.json"] = {"version": 1, "data": MOCK_TELEGRAMS} + await knx.setup_integration({}) + loaded_telegrams = hass.data[DOMAIN].telegrams.recent_telegrams + assert assert_telegram_history(loaded_telegrams) + # TelegramDict "payload" is a tuple, this shall be restored when loading from JSON + assert isinstance(loaded_telegrams[1]["payload"], tuple) + + +async def test_remove_telegam_history( + hass: HomeAssistant, + knx: KNXTestKit, + hass_storage: dict[str, Any], +): + """Test telegram history removal when configured to size 0.""" + hass_storage["knx/telegrams_history.json"] = {"version": 1, "data": MOCK_TELEGRAMS} + knx.mock_config_entry.data = knx.mock_config_entry.data | { + CONF_KNX_TELEGRAM_LOG_SIZE: 0 + } + await knx.setup_integration({}) + # Store.async_remove() is mocked by hass_storage - check that data was removed. + assert "knx/telegrams_history.json" not in hass_storage + assert not hass.data[DOMAIN].telegrams.recent_telegrams diff --git a/tests/components/lastfm/__init__.py b/tests/components/lastfm/__init__.py index dde914d51cc..7e6bb6500b2 100644 --- a/tests/components/lastfm/__init__.py +++ b/tests/components/lastfm/__init__.py @@ -75,7 +75,7 @@ class MockUser: def get_image(self) -> str: """Get mock image.""" - return "" + return "image" def get_recent_tracks(self, limit: int) -> list[MockLastTrack]: """Get mock recent tracks.""" diff --git a/tests/components/lastfm/conftest.py b/tests/components/lastfm/conftest.py index 119d4796f57..c7cada9ba0a 100644 --- a/tests/components/lastfm/conftest.py +++ b/tests/components/lastfm/conftest.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable from unittest.mock import patch -from pylast import Track +from pylast import Track, WSError import pytest from homeassistant.components.lastfm.const import CONF_MAIN_USER, CONF_USERS, DOMAIN @@ -36,6 +36,20 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture(name="imported_config_entry") +def mock_imported_config_entry() -> MockConfigEntry: + """Create LastFM entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: None, + CONF_USERS: [USERNAME_1, USERNAME_2], + }, + ) + + @pytest.fixture(name="setup_integration") async def mock_setup_integration( hass: HomeAssistant, @@ -54,6 +68,17 @@ async def mock_setup_integration( @pytest.fixture(name="default_user") def mock_default_user() -> MockUser: """Return default mock user.""" + return MockUser( + now_playing_result=Track("artist", "title", MockNetwork("lastfm")), + top_tracks=[Track("artist", "title", MockNetwork("lastfm"))], + recent_tracks=[Track("artist", "title", MockNetwork("lastfm"))], + friends=[MockUser()], + ) + + +@pytest.fixture(name="default_user_no_friends") +def mock_default_user_no_friends() -> MockUser: + """Return default mock user without friends.""" return MockUser( now_playing_result=Track("artist", "title", MockNetwork("lastfm")), top_tracks=[Track("artist", "title", MockNetwork("lastfm"))], @@ -65,3 +90,9 @@ def mock_default_user() -> MockUser: def mock_first_time_user() -> MockUser: """Return first time mock user.""" return MockUser(now_playing_result=None, top_tracks=[], recent_tracks=[]) + + +@pytest.fixture(name="not_found_user") +def mock_not_found_user() -> MockUser: + """Return not found mock user.""" + return MockUser(thrown_error=WSError("network", "status", "User not found")) diff --git a/tests/components/lastfm/snapshots/test_sensor.ambr b/tests/components/lastfm/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..e64cf6b2629 --- /dev/null +++ b/tests/components/lastfm/snapshots/test_sensor.ambr @@ -0,0 +1,40 @@ +# serializer version: 1 +# name: test_sensors[default_user] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Last.fm', + 'entity_picture': 'image', + 'friendly_name': 'testaccount1', + 'icon': 'mdi:radio-fm', + 'last_played': 'artist - title', + 'play_count': 1, + 'top_played': 'artist - title', + }), + 'context': , + 'entity_id': 'sensor.testaccount1', + 'last_changed': , + 'last_updated': , + 'state': 'artist - title', + }) +# --- +# name: test_sensors[first_time_user] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Last.fm', + 'entity_picture': 'image', + 'friendly_name': 'testaccount1', + 'icon': 'mdi:radio-fm', + 'last_played': None, + 'play_count': 0, + 'top_played': None, + }), + 'context': , + 'entity_id': 'sensor.testaccount1', + 'last_changed': , + 'last_updated': , + 'state': 'Not Scrobbling', + }) +# --- +# name: test_sensors[not_found_user] + None +# --- diff --git a/tests/components/lastfm/test_config_flow.py b/tests/components/lastfm/test_config_flow.py index ce28638c3f3..07e96afaced 100644 --- a/tests/components/lastfm/test_config_flow.py +++ b/tests/components/lastfm/test_config_flow.py @@ -139,10 +139,12 @@ async def test_flow_friends_invalid_username( async def test_flow_friends_no_friends( - hass: HomeAssistant, default_user: MockUser + hass: HomeAssistant, default_user_no_friends: MockUser ) -> None: """Test options is empty when user has no friends.""" - with patch("pylast.User", return_value=default_user), patch_setup_entry(): + with patch( + "pylast.User", return_value=default_user_no_friends + ), patch_setup_entry(): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -177,11 +179,11 @@ async def test_import_flow_success(hass: HomeAssistant, default_user: MockUser) async def test_import_flow_already_exist( hass: HomeAssistant, setup_integration: ComponentSetup, - config_entry: MockConfigEntry, + imported_config_entry: MockConfigEntry, default_user: MockUser, ) -> None: """Test import of yaml already exist.""" - await setup_integration(config_entry, default_user) + await setup_integration(imported_config_entry, default_user) with patch("pylast.User", return_value=default_user): result = await hass.config_entries.flow.async_init( @@ -275,12 +277,12 @@ async def test_options_flow_incorrect_username( async def test_options_flow_from_import( hass: HomeAssistant, setup_integration: ComponentSetup, - config_entry: MockConfigEntry, - default_user: MockUser, + imported_config_entry: MockConfigEntry, + default_user_no_friends: MockUser, ) -> None: """Test updating options gained from import.""" - await setup_integration(config_entry, default_user) - with patch("pylast.User", return_value=default_user): + await setup_integration(imported_config_entry, default_user_no_friends) + with patch("pylast.User", return_value=default_user_no_friends): entry = hass.config_entries.async_entries(DOMAIN)[0] result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() @@ -294,11 +296,11 @@ async def test_options_flow_without_friends( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry: MockConfigEntry, - default_user: MockUser, + default_user_no_friends: MockUser, ) -> None: """Test updating options for someone without friends.""" - await setup_integration(config_entry, default_user) - with patch("pylast.User", return_value=default_user): + await setup_integration(config_entry, default_user_no_friends) + with patch("pylast.User", return_value=default_user_no_friends): entry = hass.config_entries.async_entries(DOMAIN)[0] result = await hass.config_entries.options.async_init(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/lastfm/test_sensor.py b/tests/components/lastfm/test_sensor.py index e46cf99ffdc..049f2a74250 100644 --- a/tests/components/lastfm/test_sensor.py +++ b/tests/components/lastfm/test_sensor.py @@ -1,15 +1,12 @@ """Tests for the lastfm sensor.""" from unittest.mock import patch -from pylast import WSError +import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.lastfm.const import ( - ATTR_LAST_PLAYED, - ATTR_PLAY_COUNT, - ATTR_TOP_PLAYED, CONF_USERS, DOMAIN, - STATE_NOT_SCROBBLING, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, Platform @@ -31,7 +28,7 @@ LEGACY_CONFIG = { async def test_legacy_migration(hass: HomeAssistant) -> None: """Test migration from yaml to config flow.""" - with patch("pylast.User", return_value=None): + with patch("pylast.User", return_value=MockUser()): assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) await hass.async_block_till_done() entries = hass.config_entries.async_entries(DOMAIN) @@ -41,73 +38,28 @@ async def test_legacy_migration(hass: HomeAssistant) -> None: assert len(issue_registry.issues) == 1 -async def test_user_unavailable( +@pytest.mark.parametrize( + ("fixture"), + [ + ("not_found_user"), + ("first_time_user"), + ("default_user"), + ], +) +async def test_sensors( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + fixture: str, + request: pytest.FixtureRequest, ) -> None: - """Test update when user can't be fetched.""" - await setup_integration( - config_entry, - MockUser(thrown_error=WSError("network", "status", "User not found")), - ) + """Test sensors.""" + user = request.getfixturevalue(fixture) + await setup_integration(config_entry, user) entity_id = "sensor.testaccount1" state = hass.states.get(entity_id) - assert state.state == "unavailable" - - -async def test_first_time_user( - hass: HomeAssistant, - setup_integration: ComponentSetup, - config_entry: MockConfigEntry, - first_time_user: MockUser, -) -> None: - """Test first time user.""" - await setup_integration(config_entry, first_time_user) - - entity_id = "sensor.testaccount1" - - state = hass.states.get(entity_id) - - assert state.state == STATE_NOT_SCROBBLING - assert state.attributes[ATTR_LAST_PLAYED] is None - assert state.attributes[ATTR_TOP_PLAYED] is None - assert state.attributes[ATTR_PLAY_COUNT] == 0 - - -async def test_update_not_playing( - hass: HomeAssistant, - setup_integration: ComponentSetup, - config_entry: MockConfigEntry, - first_time_user: MockUser, -) -> None: - """Test update when no playing song.""" - await setup_integration(config_entry, first_time_user) - - entity_id = "sensor.testaccount1" - - state = hass.states.get(entity_id) - - assert state.state == STATE_NOT_SCROBBLING - - -async def test_update_playing( - hass: HomeAssistant, - setup_integration: ComponentSetup, - config_entry: MockConfigEntry, - default_user: MockUser, -) -> None: - """Test update when playing a song.""" - await setup_integration(config_entry, default_user) - - entity_id = "sensor.testaccount1" - - state = hass.states.get(entity_id) - - assert state.state == "artist - title" - assert state.attributes[ATTR_LAST_PLAYED] == "artist - title" - assert state.attributes[ATTR_TOP_PLAYED] == "artist - title" - assert state.attributes[ATTR_PLAY_COUNT] == 1 + assert state == snapshot diff --git a/tests/components/lcn/conftest.py b/tests/components/lcn/conftest.py index cf52263e69d..a6bcdf63950 100644 --- a/tests/components/lcn/conftest.py +++ b/tests/components/lcn/conftest.py @@ -128,6 +128,6 @@ def get_device(hass, entry, address): """Get LCN device for specified address.""" device_registry = dr.async_get(hass) identifiers = {(DOMAIN, generate_unique_id(entry.entry_id, address))} - device = device_registry.async_get_device(identifiers) + device = device_registry.async_get_device(identifiers=identifiers) assert device return device diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py index 637aeec1b0b..47287fbd1d2 100644 --- a/tests/components/lcn/test_device_trigger.py +++ b/tests/components/lcn/test_device_trigger.py @@ -55,10 +55,12 @@ async def test_get_triggers_non_module_device( not_included_types = ("transmitter", "transponder", "fingerprint", "send_keys") device_registry = dr.async_get(hass) - host_device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + host_device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) group_device = get_device(hass, entry, (0, 5, True)) resource_device = device_registry.async_get_device( - {(DOMAIN, f"{entry.entry_id}-m000007-output1")} + identifiers={(DOMAIN, f"{entry.entry_id}-m000007-output1")} ) for device in (host_device, group_device, resource_device): diff --git a/tests/components/lidarr/test_init.py b/tests/components/lidarr/test_init.py index 2a217bebd5f..5d6961e57c3 100644 --- a/tests/components/lidarr/test_init.py +++ b/tests/components/lidarr/test_init.py @@ -52,7 +52,7 @@ async def test_device_info( entry = hass.config_entries.async_entries(DOMAIN)[0] device_registry = dr.async_get(hass) await hass.async_block_till_done() - device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) assert device.configuration_url == "http://127.0.0.1:8668" assert device.identifiers == {(DOMAIN, entry.entry_id)} diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index f64af98c9b5..70a5a89a3ae 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -100,7 +100,7 @@ async def test_light_unique_id(hass: HomeAssistant) -> None: device_registry = dr.async_get(hass) device = device_registry.async_get_device( - identifiers=set(), connections={(dr.CONNECTION_NETWORK_MAC, SERIAL)} + connections={(dr.CONNECTION_NETWORK_MAC, SERIAL)} ) assert device.identifiers == {(DOMAIN, SERIAL)} @@ -123,7 +123,6 @@ async def test_light_unique_id_new_firmware(hass: HomeAssistant) -> None: assert entity_registry.async_get(entity_id).unique_id == SERIAL device_registry = dr.async_get(hass) device = device_registry.async_get_device( - identifiers=set(), connections={(dr.CONNECTION_NETWORK_MAC, MAC_ADDRESS)}, ) assert device.identifiers == {(DOMAIN, SERIAL)} diff --git a/tests/components/local_calendar/test_init.py b/tests/components/local_calendar/test_init.py new file mode 100644 index 00000000000..e5ca209e8a6 --- /dev/null +++ b/tests/components/local_calendar/test_init.py @@ -0,0 +1,18 @@ +"""Tests for init platform of local calendar.""" + +from unittest.mock import patch + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_remove_config_entry( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test removing a config entry.""" + + with patch("homeassistant.components.local_calendar.Path.unlink") as unlink_mock: + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + unlink_mock.assert_called_once() diff --git a/tests/components/logentries/test_init.py b/tests/components/logentries/test_init.py index 0101356e3ed..98b171c813f 100644 --- a/tests/components/logentries/test_init.py +++ b/tests/components/logentries/test_init.py @@ -1,10 +1,10 @@ """The tests for the Logentries component.""" -from unittest.mock import MagicMock, call, patch +from unittest.mock import ANY, call, patch import pytest import homeassistant.components.logentries as logentries -from homeassistant.const import EVENT_STATE_CHANGED, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -12,19 +12,23 @@ from homeassistant.setup import async_setup_component async def test_setup_config_full(hass: HomeAssistant) -> None: """Test setup with all data.""" config = {"logentries": {"token": "secret"}} - hass.bus.listen = MagicMock() assert await async_setup_component(hass, logentries.DOMAIN, config) - assert hass.bus.listen.called - assert hass.bus.listen.call_args_list[0][0][0] == EVENT_STATE_CHANGED + + with patch("homeassistant.components.logentries.requests.post") as mock_post: + hass.states.async_set("fake.entity", STATE_ON) + await hass.async_block_till_done() + assert len(mock_post.mock_calls) == 1 async def test_setup_config_defaults(hass: HomeAssistant) -> None: """Test setup with defaults.""" config = {"logentries": {"token": "token"}} - hass.bus.listen = MagicMock() assert await async_setup_component(hass, logentries.DOMAIN, config) - assert hass.bus.listen.called - assert hass.bus.listen.call_args_list[0][0][0] == EVENT_STATE_CHANGED + + with patch("homeassistant.components.logentries.requests.post") as mock_post: + hass.states.async_set("fake.entity", STATE_ON) + await hass.async_block_till_done() + assert len(mock_post.mock_calls) == 1 @pytest.fixture @@ -47,28 +51,24 @@ async def test_event_listener(hass: HomeAssistant, mock_dump, mock_requests) -> mock_post = mock_requests.post mock_requests.exceptions.RequestException = Exception config = {"logentries": {"token": "token"}} - hass.bus.listen = MagicMock() assert await async_setup_component(hass, logentries.DOMAIN, config) - handler_method = hass.bus.listen.call_args_list[0][0][1] valid = {"1": 1, "1.0": 1.0, STATE_ON: 1, STATE_OFF: 0, "foo": "foo"} for in_, out in valid.items(): - state = MagicMock(state=in_, domain="fake", object_id="entity", attributes={}) - event = MagicMock(data={"new_state": state}, time_fired=12345) - body = [ - { - "domain": "fake", - "entity_id": "entity", - "attributes": {}, - "time": "12345", - "value": out, - } - ] payload = { "host": "https://webhook.logentries.com/noformat/logs/token", - "event": body, + "event": [ + { + "domain": "fake", + "entity_id": "entity", + "attributes": {}, + "time": ANY, + "value": out, + } + ], } - handler_method(event) + hass.states.async_set("fake.entity", in_) + await hass.async_block_till_done() assert mock_post.call_count == 1 assert mock_post.call_args == call(payload["host"], data=payload, timeout=10) mock_post.reset_mock() diff --git a/tests/components/loqed/conftest.py b/tests/components/loqed/conftest.py index da7009a5744..616c0cb0552 100644 --- a/tests/components/loqed/conftest.py +++ b/tests/components/loqed/conftest.py @@ -9,6 +9,7 @@ from loqedAPI import loqed import pytest from homeassistant.components.loqed import DOMAIN +from homeassistant.components.loqed.const import CONF_CLOUDHOOK_URL from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -39,6 +40,31 @@ def config_entry_fixture() -> MockConfigEntry: ) +@pytest.fixture(name="cloud_config_entry") +def cloud_config_entry_fixture() -> MockConfigEntry: + """Mock config entry.""" + + config = load_fixture("loqed/integration_config.json") + webhooks_fixture = json.loads(load_fixture("loqed/get_all_webhooks.json")) + json_config = json.loads(config) + return MockConfigEntry( + version=1, + domain=DOMAIN, + data={ + "id": "Foo", + "bridge_ip": json_config["bridge_ip"], + "bridge_mdns_hostname": json_config["bridge_mdns_hostname"], + "bridge_key": json_config["bridge_key"], + "lock_key_local_id": int(json_config["lock_key_local_id"]), + "lock_key_key": json_config["lock_key_key"], + CONF_WEBHOOK_ID: "Webhook_id", + CONF_API_TOKEN: "Token", + CONF_NAME: "Home", + CONF_CLOUDHOOK_URL: webhooks_fixture[0]["url"], + }, + ) + + @pytest.fixture(name="lock") def lock_fixture() -> loqed.Lock: """Set up a mock implementation of a Lock.""" @@ -48,6 +74,7 @@ def lock_fixture() -> loqed.Lock: mock_lock.name = "LOQED smart lock" mock_lock.getWebhooks = AsyncMock(return_value=webhooks_fixture) mock_lock.bolt_state = "locked" + mock_lock.battery_percentage = 90 return mock_lock @@ -63,9 +90,6 @@ async def integration_fixture( with patch("loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=lock), patch( "loqedAPI.loqed.LoqedAPI.async_get_lock_details", return_value=lock_status - ), patch( - "homeassistant.components.webhook.async_generate_url", - return_value="http://hook_id", ): await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() diff --git a/tests/components/loqed/fixtures/get_all_webhooks.json b/tests/components/loqed/fixtures/get_all_webhooks.json index cf53fcf56a9..e42c39b60f5 100644 --- a/tests/components/loqed/fixtures/get_all_webhooks.json +++ b/tests/components/loqed/fixtures/get_all_webhooks.json @@ -1,7 +1,7 @@ [ { "id": 1, - "url": "http://hook_id", + "url": "http://10.10.10.10:8123/api/webhook/Webhook_id", "trigger_state_changed_open": 1, "trigger_state_changed_latch": 1, "trigger_state_changed_night_lock": 1, diff --git a/tests/components/loqed/test_init.py b/tests/components/loqed/test_init.py index 960ad9def6b..057061f5915 100644 --- a/tests/components/loqed/test_init.py +++ b/tests/components/loqed/test_init.py @@ -10,6 +10,7 @@ from homeassistant.components.loqed.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant +from homeassistant.helpers.network import get_url from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture @@ -50,14 +51,63 @@ async def test_setup_webhook_in_bridge( with patch("loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=lock), patch( "loqedAPI.loqed.LoqedAPI.async_get_lock_details", return_value=lock_status - ), patch( - "homeassistant.components.webhook.async_generate_url", - return_value="http://hook_id", ): await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() - lock.registerWebhook.assert_called_with("http://hook_id") + lock.registerWebhook.assert_called_with(f"{get_url(hass)}/api/webhook/Webhook_id") + + +async def test_setup_cloudhook_in_bridge( + hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock +): + """Test webhook setup in loqed bridge.""" + config: dict[str, Any] = {DOMAIN: {}} + config_entry.add_to_hass(hass) + + lock_status = json.loads(load_fixture("loqed/status_ok.json")) + webhooks_fixture = json.loads(load_fixture("loqed/get_all_webhooks.json")) + lock.getWebhooks = AsyncMock(side_effect=[[], webhooks_fixture]) + + with patch("loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=lock), patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock_details", return_value=lock_status + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value=webhooks_fixture[0]["url"], + ): + await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + lock.registerWebhook.assert_called_with(f"{get_url(hass)}/api/webhook/Webhook_id") + + +async def test_setup_cloudhook_from_entry_in_bridge( + hass: HomeAssistant, cloud_config_entry: MockConfigEntry, lock: loqed.Lock +): + """Test webhook setup in loqed bridge.""" + webhooks_fixture = json.loads(load_fixture("loqed/get_all_webhooks.json")) + + config: dict[str, Any] = {DOMAIN: {}} + cloud_config_entry.add_to_hass(hass) + + lock_status = json.loads(load_fixture("loqed/status_ok.json")) + + lock.getWebhooks = AsyncMock(side_effect=[[], webhooks_fixture]) + + with patch("loqedAPI.loqed.LoqedAPI.async_get_lock", return_value=lock), patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock_details", return_value=lock_status + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value=webhooks_fixture[0]["url"], + ): + await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + lock.registerWebhook.assert_called_with(f"{get_url(hass)}/api/webhook/Webhook_id") async def test_unload_entry(hass, integration: MockConfigEntry, lock: loqed.Lock): diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index e4af252fccb..6a14148585a 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -5,12 +5,15 @@ import asyncio from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, MagicMock, patch +from matter_server.client.models.node import MatterNode from matter_server.common.const import SCHEMA_VERSION from matter_server.common.models import ServerInfoMessage import pytest from homeassistant.core import HomeAssistant +from .common import setup_integration_with_node_fixture + from tests.common import MockConfigEntry MOCK_FABRIC_ID = 12341234 @@ -210,3 +213,21 @@ def update_addon_fixture() -> Generator[AsyncMock, None, None]: "homeassistant.components.hassio.addon_manager.async_update_addon" ) as update_addon: yield update_addon + + +@pytest.fixture(name="door_lock") +async def door_lock_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a door lock node.""" + return await setup_integration_with_node_fixture(hass, "door-lock", matter_client) + + +@pytest.fixture(name="eve_contact_sensor_node") +async def eve_contact_sensor_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a contact sensor node.""" + return await setup_integration_with_node_fixture( + hass, "eve-contact-sensor", matter_client + ) diff --git a/tests/components/matter/fixtures/nodes/eve-contact-sensor.json b/tests/components/matter/fixtures/nodes/eve-contact-sensor.json new file mode 100644 index 00000000000..b0eacfb621c --- /dev/null +++ b/tests/components/matter/fixtures/nodes/eve-contact-sensor.json @@ -0,0 +1,343 @@ +{ + "node_id": 1, + "date_commissioned": "2023-07-02T14:06:45.190550", + "last_interview": "2023-07-02T14:06:45.190553", + "interview_version": 4, + "available": true, + "is_bridge": false, + "attributes": { + "0/53/65532": 15, + "0/53/11": 26, + "0/53/3": 4895, + "0/53/47": 0, + "0/53/8": [ + { + "extAddress": 12872547289273451492, + "rloc16": 1024, + "routerId": 1, + "nextHop": 0, + "pathCost": 0, + "LQIIn": 3, + "LQIOut": 3, + "age": 142, + "allocated": true, + "linkEstablished": true + } + ], + "0/53/29": 1556, + "0/53/9": 2040160480, + "0/53/15": 1, + "0/53/40": 519, + "0/53/7": [ + { + "extAddress": 12872547289273451492, + "age": 654, + "rloc16": 1024, + "linkFrameCounter": 738, + "mleFrameCounter": 418, + "lqi": 3, + "averageRssi": -50, + "lastRssi": -51, + "frameErrorRate": 5, + "messageErrorRate": 0, + "rxOnWhenIdle": true, + "fullThreadDevice": true, + "fullNetworkData": true, + "isChild": false + } + ], + "0/53/33": 66, + "0/53/18": 1, + "0/53/45": 0, + "0/53/21": 0, + "0/53/36": 0, + "0/53/44": 0, + "0/53/50": 0, + "0/53/60": "AB//wA==", + "0/53/10": 68, + "0/53/53": 0, + "0/53/65528": [], + "0/53/4": 5980345540157460411, + "0/53/19": 1, + "0/53/62": [0, 0, 0, 0], + "0/53/54": 2, + "0/53/49": 0, + "0/53/23": 2597, + "0/53/20": 0, + "0/53/28": 1059, + "0/53/24": 17, + "0/53/22": 2614, + "0/53/17": 0, + "0/53/32": 0, + "0/53/14": 1, + "0/53/26": 2597, + "0/53/37": 0, + "0/53/65529": [0], + "0/53/34": 1, + "0/53/2": "MyHome1425454932", + "0/53/6": 0, + "0/53/43": 0, + "0/53/25": 2597, + "0/53/30": 0, + "0/53/41": 1, + "0/53/55": 4, + "0/53/42": 520, + "0/53/52": 0, + "0/53/61": { + "activeTimestampPresent": true, + "pendingTimestampPresent": false, + "masterKeyPresent": true, + "networkNamePresent": true, + "extendedPanIdPresent": true, + "meshLocalPrefixPresent": true, + "delayPresent": false, + "panIdPresent": true, + "channelPresent": true, + "pskcPresent": true, + "securityPolicyPresent": true, + "channelMaskPresent": true + }, + "0/53/48": 3, + "0/53/39": 529, + "0/53/35": 0, + "0/53/38": 0, + "0/53/31": 0, + "0/53/51": 0, + "0/53/65533": 1, + "0/53/59": { + "rotationTime": 672, + "flags": 8335 + }, + "0/53/46": 0, + "0/53/5": "QP1S/nSVYwAA", + "0/53/13": 1, + "0/53/27": 17, + "0/53/1": 2, + "0/53/0": 25, + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 59, + 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/53/12": 121, + "0/53/16": 0, + "0/42/0": [ + { + "providerNodeID": 1773685588, + "endpoint": 0, + "fabricIndex": 1 + } + ], + "0/42/65528": [], + "0/42/65533": 1, + "0/42/1": true, + "0/42/2": 1, + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/42/3": null, + "0/42/65532": 0, + "0/42/65529": [0], + "0/48/65532": 0, + "0/48/65528": [1, 3, 5], + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/4": true, + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/48/2": 0, + "0/48/0": 0, + "0/48/3": 0, + "0/48/65529": [0, 2, 4], + "0/48/65533": 1, + "0/31/4": 3, + "0/31/65529": [], + "0/31/3": 3, + "0/31/65533": 1, + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/31/1": [], + "0/31/0": [ + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 1 + }, + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 2 + }, + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 3 + } + ], + "0/31/65532": 0, + "0/31/65528": [], + "0/31/2": 4, + "0/49/2": 10, + "0/49/65528": [1, 5, 7], + "0/49/65533": 1, + "0/49/1": [ + { + "networkID": "Uv50lWMtT7s=", + "connected": true + } + ], + "0/49/3": 20, + "0/49/7": null, + "0/49/0": 1, + "0/49/6": null, + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/5": 0, + "0/49/4": true, + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/49/65532": 2, + "0/63/0": [], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/63/65528": [2, 5], + "0/63/1": [], + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65529": [0, 1, 3, 4], + "0/63/2": 3, + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/29/65529": [], + "0/29/65532": 0, + "0/29/3": [1], + "0/29/2": [41], + "0/29/65533": 1, + "0/29/0": [ + { + "deviceType": 22, + "revision": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 46, 48, 49, 51, 53, 60, 62, 63], + "0/29/65528": [], + "0/51/65531": [0, 1, 2, 3, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "ieee802154", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "YtmXHFJ/dhk=", + "IPv4Addresses": [], + "IPv6Addresses": [ + "/RG+U41GAABynlpPU50e5g==", + "/oAAAAAAAABg2ZccUn92GQ==", + "/VL+dJVjAAB1cwmi02rvTA==" + ], + "type": 4 + } + ], + "0/51/65529": [0], + "0/51/7": [], + "0/51/3": 0, + "0/51/65533": 1, + "0/51/2": 653, + "0/51/6": [], + "0/51/1": 1, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65528": [], + "0/51/5": [], + "0/40/9": 6650, + "0/40/65529": [], + "0/40/4": 77, + "0/40/1": "Eve Systems", + "0/40/5": "", + "0/40/15": "QV26L1A16199", + "0/40/8": "1.1", + "0/40/6": "**REDACTED**", + "0/40/3": "Eve Door", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/2": 4874, + "0/40/65532": 0, + "0/40/65528": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/40/7": 1, + "0/40/10": "3.2.1", + "0/40/0": 1, + "0/40/65533": 1, + "0/40/18": "4D97F6015F8E39C1", + "0/46/65529": [], + "0/46/0": [1], + "0/46/65528": [], + "0/46/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/46/65532": 0, + "0/46/65533": 1, + "0/60/65532": 0, + "0/60/0": 0, + "0/60/65528": [], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/60/2": null, + "0/60/65529": [0, 1, 2], + "0/60/1": null, + "0/60/65533": 1, + "1/69/65529": [], + "1/69/65528": [], + "1/69/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/69/65533": 1, + "1/69/65532": 0, + "1/69/0": false, + "1/29/65529": [], + "1/29/1": [3, 29, 47, 69, 319486977], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/29/65533": 1, + "1/29/0": [ + { + "deviceType": 21, + "revision": 1 + } + ], + "1/29/65528": [], + "1/29/65532": 0, + "1/29/2": [], + "1/29/3": [], + "1/47/65531": [ + 0, 1, 2, 11, 12, 14, 15, 16, 18, 19, 25, 65528, 65529, 65531, 65532, 65533 + ], + "1/47/15": false, + "1/47/25": 1, + "1/47/2": "Battery", + "1/47/18": [], + "1/47/1": 0, + "1/47/14": 0, + "1/47/65533": 1, + "1/47/12": 200, + "1/47/19": "", + "1/47/11": 3558, + "1/47/65528": [], + "1/47/65529": [], + "1/47/0": 1, + "1/47/16": 2, + "1/47/65532": 10, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/1": 2, + "1/3/0": 0, + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/3/65532": 0, + "1/3/65533": 4 + }, + "attribute_subscriptions": [ + [1, 69, 0], + [1, 47, 12] + ] +} diff --git a/tests/components/matter/fixtures/nodes/generic-switch-multi.json b/tests/components/matter/fixtures/nodes/generic-switch-multi.json new file mode 100644 index 00000000000..15c93825307 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/generic-switch-multi.json @@ -0,0 +1,117 @@ +{ + "node_id": 1, + "date_commissioned": "2023-07-06T11:13:20.917394", + "last_interview": "2023-07-06T11:13:20.917401", + "interview_version": 2, + "attributes": { + "0/29/0": [ + { + "deviceType": 22, + "revision": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63, + 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock GenericSwitch", + "0/40/4": 32768, + "0/40/5": "Mock Generic Switch", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "prerelease", + "0/40/11": "20230707", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "TEST_SN", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-generic-switch", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "deviceType": 15, + "revision": 1 + } + ], + "1/29/1": [3, 29, 59], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/59/65529": [], + "1/59/0": 2, + "1/59/65533": 1, + "1/59/1": 0, + "1/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/59/65532": 14, + "1/59/65528": [], + "1/64/0": [ + { + "label": "Label", + "value": "1" + } + ], + + "2/3/65529": [0, 64], + "2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "deviceType": 15, + "revision": 1 + } + ], + "2/29/1": [3, 29, 59], + "2/29/2": [], + "2/29/3": [], + "2/29/65532": 0, + "2/29/65533": 1, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/59/65529": [], + "2/59/0": 2, + "2/59/65533": 1, + "2/59/1": 0, + "2/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "2/59/65532": 14, + "2/59/65528": [], + "2/64/0": [ + { + "label": "Label", + "value": "Fancy Button" + } + ] + }, + "available": true, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/fixtures/nodes/contact-sensor.json b/tests/components/matter/fixtures/nodes/generic-switch.json similarity index 64% rename from tests/components/matter/fixtures/nodes/contact-sensor.json rename to tests/components/matter/fixtures/nodes/generic-switch.json index 909f7be2ebe..30763c88e5b 100644 --- a/tests/components/matter/fixtures/nodes/contact-sensor.json +++ b/tests/components/matter/fixtures/nodes/generic-switch.json @@ -1,7 +1,7 @@ { "node_id": 1, - "date_commissioned": "2022-11-29T21:23:48.485051", - "last_interview": "2022-11-29T21:23:48.485057", + "date_commissioned": "2023-07-06T11:13:20.917394", + "last_interview": "2023-07-06T11:13:20.917401", "interview_version": 2, "attributes": { "0/29/0": [ @@ -24,22 +24,22 @@ "0/40/0": 1, "0/40/1": "Nabu Casa", "0/40/2": 65521, - "0/40/3": "Mock ContactSensor", + "0/40/3": "Mock GenericSwitch", "0/40/4": 32768, - "0/40/5": "Mock Contact sensor", + "0/40/5": "Mock Generic Switch", "0/40/6": "XX", "0/40/7": 0, "0/40/8": "v1.0", "0/40/9": 1, - "0/40/10": "v1.0", - "0/40/11": "20221206", + "0/40/10": "prerelease", + "0/40/11": "20230707", "0/40/12": "", "0/40/13": "", "0/40/14": "", "0/40/15": "TEST_SN", "0/40/16": false, "0/40/17": true, - "0/40/18": "mock-contact-sensor", + "0/40/18": "mock-generic-switch", "0/40/19": { "caseSessionsPerFabric": 3, "subscriptionsPerFabric": 3 @@ -52,25 +52,15 @@ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 65528, 65529, 65531, 65532, 65533 ], - "1/3/0": 0, - "1/3/1": 2, - "1/3/65532": 0, - "1/3/65533": 4, - "1/3/65528": [], "1/3/65529": [0, 64], "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], "1/29/0": [ { - "deviceType": 21, + "deviceType": 15, "revision": 1 } ], - "1/29/1": [ - 3, 4, 5, 6, 7, 8, 15, 29, 30, 37, 47, 59, 64, 65, 69, 80, 257, 258, 259, - 512, 513, 514, 516, 768, 1024, 1026, 1027, 1028, 1029, 1030, 1283, 1284, - 1285, 1286, 1287, 1288, 1289, 1290, 1291, 1292, 1293, 1294, 2820, - 4294048773 - ], + "1/29/1": [3, 29, 59], "1/29/2": [], "1/29/3": [], "1/29/65532": 0, @@ -78,12 +68,13 @@ "1/29/65528": [], "1/29/65529": [], "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], - "1/69/0": true, - "1/69/65532": 0, - "1/69/65533": 1, - "1/69/65528": [], - "1/69/65529": [], - "1/69/65531": [0, 65528, 65529, 65531, 65532, 65533] + "1/59/65529": [], + "1/59/0": 2, + "1/59/65533": 1, + "1/59/1": 0, + "1/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/59/65532": 30, + "1/59/65528": [] }, "available": true, "attribute_subscriptions": [] diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 62ed847bf28..8ed309f61df 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -41,7 +41,9 @@ async def test_device_registry_single_node_device( dev_reg = dr.async_get(hass) entry = dev_reg.async_get_device( - {(DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice")} + identifiers={ + (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") + } ) assert entry is not None @@ -70,7 +72,9 @@ async def test_device_registry_single_node_device_alt( dev_reg = dr.async_get(hass) entry = dev_reg.async_get_device( - {(DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice")} + identifiers={ + (DOMAIN, "deviceid_00000000000004D2-0000000000000001-MatterNodeDevice") + } ) assert entry is not None @@ -96,7 +100,7 @@ async def test_device_registry_bridge( dev_reg = dr.async_get(hass) # Validate bridge - bridge_entry = dev_reg.async_get_device({(DOMAIN, "mock-hub-id")}) + bridge_entry = dev_reg.async_get_device(identifiers={(DOMAIN, "mock-hub-id")}) assert bridge_entry is not None assert bridge_entry.name == "My Mock Bridge" @@ -106,7 +110,9 @@ async def test_device_registry_bridge( assert bridge_entry.sw_version == "123.4.5" # Device 1 - device1_entry = dev_reg.async_get_device({(DOMAIN, "mock-id-kitchen-ceiling")}) + device1_entry = dev_reg.async_get_device( + identifiers={(DOMAIN, "mock-id-kitchen-ceiling")} + ) assert device1_entry is not None assert device1_entry.via_device_id == bridge_entry.id @@ -117,7 +123,9 @@ async def test_device_registry_bridge( assert device1_entry.sw_version == "67.8.9" # Device 2 - device2_entry = dev_reg.async_get_device({(DOMAIN, "mock-id-living-room-ceiling")}) + device2_entry = dev_reg.async_get_device( + identifiers={(DOMAIN, "mock-id-living-room-ceiling")} + ) assert device2_entry is not None assert device2_entry.via_device_id == bridge_entry.id diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index d7982e1d5ae..4dbb3b27b9c 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -1,10 +1,16 @@ """Test Matter binary sensors.""" -from unittest.mock import MagicMock +from collections.abc import Generator +from unittest.mock import MagicMock, patch from matter_server.client.models.node import MatterNode import pytest +from homeassistant.components.matter.binary_sensor import ( + DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS, +) +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .common import ( set_node_attribute, @@ -13,14 +19,16 @@ from .common import ( ) -@pytest.fixture(name="contact_sensor_node") -async def contact_sensor_node_fixture( - hass: HomeAssistant, matter_client: MagicMock -) -> MatterNode: - """Fixture for a contact sensor node.""" - return await setup_integration_with_node_fixture( - hass, "contact-sensor", matter_client - ) +@pytest.fixture(autouse=True) +def binary_sensor_platform() -> Generator[None, None, None]: + """Load only the binary sensor platform.""" + with patch( + "homeassistant.components.matter.discovery.DISCOVERY_SCHEMAS", + new={ + Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, + }, + ): + yield # This tests needs to be adjusted to remove lingering tasks @@ -28,22 +36,23 @@ async def contact_sensor_node_fixture( async def test_contact_sensor( hass: HomeAssistant, matter_client: MagicMock, - contact_sensor_node: MatterNode, + eve_contact_sensor_node: MatterNode, ) -> None: """Test contact sensor.""" - state = hass.states.get("binary_sensor.mock_contact_sensor_door") - assert state - assert state.state == "off" - - set_node_attribute(contact_sensor_node, 1, 69, 0, False) - await trigger_subscription_callback( - hass, matter_client, data=(contact_sensor_node.node_id, "1/69/0", False) - ) - - state = hass.states.get("binary_sensor.mock_contact_sensor_door") + entity_id = "binary_sensor.eve_door_door" + state = hass.states.get(entity_id) assert state assert state.state == "on" + set_node_attribute(eve_contact_sensor_node, 1, 69, 0, True) + await trigger_subscription_callback( + hass, matter_client, data=(eve_contact_sensor_node.node_id, "1/69/0", True) + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + @pytest.fixture(name="occupancy_sensor_node") async def occupancy_sensor_node_fixture( @@ -75,3 +84,32 @@ async def test_occupancy_sensor( state = hass.states.get("binary_sensor.mock_occupancy_sensor_occupancy") assert state assert state.state == "off" + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_battery_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + door_lock: MatterNode, +) -> None: + """Test battery sensor.""" + entity_id = "binary_sensor.mock_door_lock_battery" + state = hass.states.get(entity_id) + assert state + assert state.state == "off" + + set_node_attribute(door_lock, 1, 47, 14, 1) + await trigger_subscription_callback( + hass, matter_client, data=(door_lock.node_id, "1/47/14", 1) + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == "on" + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get(entity_id) + + assert entry + assert entry.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/matter/test_door_lock.py b/tests/components/matter/test_door_lock.py index 003bfa3cf39..3eba65dc8ab 100644 --- a/tests/components/matter/test_door_lock.py +++ b/tests/components/matter/test_door_lock.py @@ -16,19 +16,10 @@ from homeassistant.core import HomeAssistant from .common import ( set_node_attribute, - setup_integration_with_node_fixture, trigger_subscription_callback, ) -@pytest.fixture(name="door_lock") -async def door_lock_fixture( - hass: HomeAssistant, matter_client: MagicMock -) -> MatterNode: - """Fixture for a door lock node.""" - return await setup_integration_with_node_fixture(hass, "door-lock", matter_client) - - # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_lock( diff --git a/tests/components/matter/test_event.py b/tests/components/matter/test_event.py new file mode 100644 index 00000000000..911dd0fe389 --- /dev/null +++ b/tests/components/matter/test_event.py @@ -0,0 +1,128 @@ +"""Test Matter Event entities.""" +from unittest.mock import MagicMock + +from matter_server.client.models.node import MatterNode +from matter_server.common.models import EventType, MatterNodeEvent +import pytest + +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + ATTR_EVENT_TYPES, +) +from homeassistant.core import HomeAssistant + +from .common import ( + setup_integration_with_node_fixture, + trigger_subscription_callback, +) + + +@pytest.fixture(name="generic_switch_node") +async def switch_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a GenericSwitch node.""" + return await setup_integration_with_node_fixture( + hass, "generic-switch", matter_client + ) + + +@pytest.fixture(name="generic_switch_multi_node") +async def multi_switch_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for a GenericSwitch node with multiple buttons.""" + return await setup_integration_with_node_fixture( + hass, "generic-switch-multi", matter_client + ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_generic_switch_node( + hass: HomeAssistant, + matter_client: MagicMock, + generic_switch_node: MatterNode, +) -> None: + """Test event entity for a GenericSwitch node.""" + state = hass.states.get("event.mock_generic_switch") + assert state + assert state.state == "unknown" + # the switch endpoint has no label so the entity name should be the device itself + assert state.name == "Mock Generic Switch" + # check event_types from featuremap 30 + assert state.attributes[ATTR_EVENT_TYPES] == [ + "initial_press", + "short_release", + "long_press_ongoing", + "long_release", + "multi_press_ongoing", + "multi_press_complete", + ] + # trigger firing a new event from the device + await trigger_subscription_callback( + hass, + matter_client, + EventType.NODE_EVENT, + MatterNodeEvent( + node_id=generic_switch_node.node_id, + endpoint_id=1, + cluster_id=59, + event_id=1, + event_number=0, + priority=1, + timestamp=0, + timestamp_type=0, + data=None, + ), + ) + state = hass.states.get("event.mock_generic_switch") + assert state.attributes[ATTR_EVENT_TYPE] == "initial_press" + # trigger firing a multi press event + await trigger_subscription_callback( + hass, + matter_client, + EventType.NODE_EVENT, + MatterNodeEvent( + node_id=generic_switch_node.node_id, + endpoint_id=1, + cluster_id=59, + event_id=5, + event_number=0, + priority=1, + timestamp=0, + timestamp_type=0, + data={"NewPosition": 3}, + ), + ) + state = hass.states.get("event.mock_generic_switch") + assert state.attributes[ATTR_EVENT_TYPE] == "multi_press_ongoing" + assert state.attributes["NewPosition"] == 3 + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_generic_switch_multi_node( + hass: HomeAssistant, + matter_client: MagicMock, + generic_switch_multi_node: MatterNode, +) -> None: + """Test event entity for a GenericSwitch node with multiple buttons.""" + state_button_1 = hass.states.get("event.mock_generic_switch_button_1") + assert state_button_1 + assert state_button_1.state == "unknown" + # name should be 'DeviceName Button 1' due to the label set to just '1' + assert state_button_1.name == "Mock Generic Switch Button 1" + # check event_types from featuremap 14 + assert state_button_1.attributes[ATTR_EVENT_TYPES] == [ + "initial_press", + "short_release", + "long_press_ongoing", + "long_release", + ] + # check button 2 + state_button_1 = hass.states.get("event.mock_generic_switch_fancy_button") + assert state_button_1 + assert state_button_1.state == "unknown" + # name should be 'DeviceName Fancy Button' due to the label set to 'Fancy Button' + assert state_button_1.name == "Mock Generic Switch Fancy Button" diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index a2e97e188f6..2650f2b1a6f 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -4,7 +4,9 @@ from unittest.mock import MagicMock from matter_server.client.models.node import MatterNode import pytest +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .common import ( set_node_attribute, @@ -179,3 +181,30 @@ async def test_temperature_sensor( state = hass.states.get("sensor.mock_temperature_sensor_temperature") assert state assert state.state == "25.0" + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_battery_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + eve_contact_sensor_node: MatterNode, +) -> None: + """Test battery sensor.""" + entity_id = "sensor.eve_door_battery" + state = hass.states.get(entity_id) + assert state + assert state.state == "100" + + set_node_attribute(eve_contact_sensor_node, 1, 47, 12, 100) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.state == "50" + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get(entity_id) + + assert entry + assert entry.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/metoffice/test_init.py b/tests/components/metoffice/test_init.py index f21f3a1b26f..a9e286907d5 100644 --- a/tests/components/metoffice/test_init.py +++ b/tests/components/metoffice/test_init.py @@ -15,9 +15,7 @@ from .const import DOMAIN, METOFFICE_CONFIG_WAVERTREE, TEST_COORDINATES_WAVERTRE from tests.common import MockConfigEntry -@pytest.mark.freeze_time( - datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) -) +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) @pytest.mark.parametrize( ("old_unique_id", "new_unique_id", "migration_needed"), [ diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py index 28bf8eda997..6e40dd66efe 100644 --- a/tests/components/metoffice/test_sensor.py +++ b/tests/components/metoffice/test_sensor.py @@ -24,9 +24,7 @@ from .const import ( from tests.common import MockConfigEntry, load_fixture -@pytest.mark.freeze_time( - datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) -) +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_one_sensor_site_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -74,9 +72,7 @@ async def test_one_sensor_site_running( assert sensor.attributes.get("attribution") == ATTRIBUTION -@pytest.mark.freeze_time( - datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) -) +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_two_sensor_sites_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index 0e5a934c7d0..673475c0303 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -23,9 +23,7 @@ from .const import ( from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture -@pytest.mark.freeze_time( - datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) -) +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_site_cannot_connect( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -54,9 +52,7 @@ async def test_site_cannot_connect( assert sensor is None -@pytest.mark.freeze_time( - datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) -) +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_site_cannot_update( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -104,9 +100,7 @@ async def test_site_cannot_update( assert weather.state == STATE_UNAVAILABLE -@pytest.mark.freeze_time( - datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) -) +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_one_weather_site_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: @@ -189,9 +183,7 @@ async def test_one_weather_site_running( assert weather.attributes.get("forecast")[3]["wind_bearing"] == "SE" -@pytest.mark.freeze_time( - datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc) -) +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_two_weather_sites_running( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 25c0cec441b..ac5ae7dbc6e 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -4,7 +4,7 @@ import asyncio from unittest.mock import patch import aiodns -from mcstatus.pinger import PingResponse +from mcstatus.status_response import JavaStatusResponse from homeassistant.components.minecraft_server.const import ( DEFAULT_NAME, @@ -22,7 +22,7 @@ from tests.common import MockConfigEntry class QueryMock: """Mock for result of aiodns.DNSResolver.query.""" - def __init__(self): + def __init__(self) -> None: """Set up query result mock.""" self.host = "mc.dummyserver.com" self.port = 23456 @@ -31,7 +31,7 @@ class QueryMock: self.ttl = None -STATUS_RESPONSE_RAW = { +JAVA_STATUS_RESPONSE_RAW = { "description": {"text": "Dummy Description"}, "version": {"name": "Dummy Version", "protocol": 123}, "players": { @@ -103,8 +103,10 @@ async def test_same_host(hass: HomeAssistant) -> None: "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError, ), patch( - "mcstatus.server.MinecraftServer.status", - return_value=PingResponse(STATUS_RESPONSE_RAW), + "mcstatus.server.JavaServer.async_status", + return_value=JavaStatusResponse( + None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None + ), ): unique_id = "mc.dummyserver.com-25565" config_data = { @@ -158,7 +160,7 @@ async def test_connection_failed(hass: HomeAssistant) -> None: with patch( "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError, - ), patch("mcstatus.server.MinecraftServer.status", side_effect=OSError): + ), patch("mcstatus.server.JavaServer.async_status", side_effect=OSError): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) @@ -173,8 +175,10 @@ async def test_connection_succeeded_with_srv_record(hass: HomeAssistant) -> None "aiodns.DNSResolver.query", return_value=SRV_RECORDS, ), patch( - "mcstatus.server.MinecraftServer.status", - return_value=PingResponse(STATUS_RESPONSE_RAW), + "mcstatus.server.JavaServer.async_status", + return_value=JavaStatusResponse( + None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None + ), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_SRV @@ -192,8 +196,10 @@ async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError, ), patch( - "mcstatus.server.MinecraftServer.status", - return_value=PingResponse(STATUS_RESPONSE_RAW), + "mcstatus.server.JavaServer.async_status", + return_value=JavaStatusResponse( + None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None + ), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -211,8 +217,10 @@ async def test_connection_succeeded_with_ip4(hass: HomeAssistant) -> None: "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError, ), patch( - "mcstatus.server.MinecraftServer.status", - return_value=PingResponse(STATUS_RESPONSE_RAW), + "mcstatus.server.JavaServer.async_status", + return_value=JavaStatusResponse( + None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None + ), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV4 @@ -230,8 +238,10 @@ async def test_connection_succeeded_with_ip6(hass: HomeAssistant) -> None: "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError, ), patch( - "mcstatus.server.MinecraftServer.status", - return_value=PingResponse(STATUS_RESPONSE_RAW), + "mcstatus.server.JavaServer.async_status", + return_value=JavaStatusResponse( + None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None + ), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV6 diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index ce1dc19319a..4faf48e2118 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -857,7 +857,9 @@ async def test_webhook_handle_scan_tag( hass: HomeAssistant, create_registrations, webhook_client ) -> None: """Test that we can scan tags.""" - device = dr.async_get(hass).async_get_device({(DOMAIN, "mock-device-id")}) + device = dr.async_get(hass).async_get_device( + identifiers={(DOMAIN, "mock-device-id")} + ) assert device is not None events = async_capture_events(hass, EVENT_TAG_SCANNED) diff --git a/tests/components/motioneye/test_camera.py b/tests/components/motioneye/test_camera.py index 6972bee35d0..5f5c5f7854e 100644 --- a/tests/components/motioneye/test_camera.py +++ b/tests/components/motioneye/test_camera.py @@ -159,15 +159,17 @@ async def test_setup_camera_new_data_camera_removed(hass: HomeAssistant) -> None await hass.async_block_till_done() assert hass.states.get(TEST_CAMERA_ENTITY_ID) - assert device_registry.async_get_device({TEST_CAMERA_DEVICE_IDENTIFIER}) + assert device_registry.async_get_device(identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}) client.async_get_cameras = AsyncMock(return_value={KEY_CAMERAS: []}) async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) await hass.async_block_till_done() await hass.async_block_till_done() assert not hass.states.get(TEST_CAMERA_ENTITY_ID) - assert not device_registry.async_get_device({TEST_CAMERA_DEVICE_IDENTIFIER}) - assert not device_registry.async_get_device({(DOMAIN, old_device_id)}) + assert not device_registry.async_get_device( + identifiers={TEST_CAMERA_DEVICE_IDENTIFIER} + ) + assert not device_registry.async_get_device(identifiers={(DOMAIN, old_device_id)}) assert not entity_registry.async_get_entity_id( DOMAIN, "camera", old_entity_unique_id ) @@ -320,7 +322,7 @@ async def test_device_info(hass: HomeAssistant) -> None: device_identifier = get_motioneye_device_identifier(entry.entry_id, TEST_CAMERA_ID) device_registry = dr.async_get(hass) - device = device_registry.async_get_device({device_identifier}) + device = device_registry.async_get_device(identifiers={device_identifier}) assert device assert device.config_entries == {TEST_CONFIG_ENTRY_ID} assert device.identifiers == {device_identifier} diff --git a/tests/components/motioneye/test_sensor.py b/tests/components/motioneye/test_sensor.py index ea07834976b..5494e69d9e9 100644 --- a/tests/components/motioneye/test_sensor.py +++ b/tests/components/motioneye/test_sensor.py @@ -88,7 +88,7 @@ async def test_sensor_device_info(hass: HomeAssistant) -> None: ) device_registry = dr.async_get(hass) - device = device_registry.async_get_device({device_identifer}) + device = device_registry.async_get_device(identifiers={device_identifer}) assert device entity_registry = er.async_get(hass) diff --git a/tests/components/motioneye/test_switch.py b/tests/components/motioneye/test_switch.py index 03c39a4b542..f0fe4f1faba 100644 --- a/tests/components/motioneye/test_switch.py +++ b/tests/components/motioneye/test_switch.py @@ -193,7 +193,7 @@ async def test_switch_device_info(hass: HomeAssistant) -> None: ) device_registry = dr.async_get(hass) - device = device_registry.async_get_device({device_identifer}) + device = device_registry.async_get_device(identifiers={device_identifer}) assert device entity_registry = er.async_get(hass) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index ee32b7131c4..e69839e6b16 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -55,6 +55,7 @@ from .test_common import ( help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_entity_name, help_test_publishing_with_custom_encoding, help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, @@ -1096,7 +1097,11 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: @@ -1116,3 +1121,21 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + ("expected_friendly_name", "device_class"), + [("test", None)], +) +async def test_entity_name( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + expected_friendly_name: str | None, + device_class: str | None, +) -> None: + """Test the entity name setup.""" + domain = alarm_control_panel.DOMAIN + config = DEFAULT_CONFIG + await help_test_entity_name( + hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class + ) diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 921f46703c2..28bf5f558cb 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -41,6 +41,7 @@ from .test_common import ( help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_entity_name, help_test_reload_with_config, help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, @@ -1203,7 +1204,11 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( assert state.state == STATE_UNAVAILABLE -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: @@ -1223,3 +1228,21 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + ("expected_friendly_name", "device_class"), + [("test", None), ("Door", "door"), ("Battery", "battery"), ("Motion", "motion")], +) +async def test_entity_name( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + expected_friendly_name: str | None, + device_class: str | None, +) -> None: + """Test the entity name setup.""" + domain = binary_sensor.DOMAIN + config = DEFAULT_CONFIG + await help_test_entity_name( + hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class + ) diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index e99182323c8..481e98f0099 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -30,6 +30,7 @@ from .test_common import ( help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, + help_test_entity_name, help_test_publishing_with_custom_encoding, help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, @@ -545,7 +546,11 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: @@ -565,3 +570,26 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + ("expected_friendly_name", "device_class"), + [ + ("test", None), + ("Update", "update"), + ("Identify", "identify"), + ("Restart", "restart"), + ], +) +async def test_entity_name( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + expected_friendly_name: str | None, + device_class: str | None, +) -> None: + """Test the entity name setup.""" + domain = button.DOMAIN + config = DEFAULT_CONFIG + await help_test_entity_name( + hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class + ) diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 8bb21f5eb51..5552457c213 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -439,7 +439,11 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 4a6d1bf64d4..e717c04b317 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -2484,7 +2484,11 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index cd1cc7280c6..9d580da073e 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -682,7 +682,7 @@ async def help_test_discovery_update_attr( # Verify we are no longer subscribing to the old topic async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') state = hass.states.get(f"{domain}.test") - assert state and state.attributes.get("val") == "100" + assert state and state.attributes.get("val") != "50" # Verify we are subscribing to the new topic async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') @@ -1001,7 +1001,7 @@ async def help_test_entity_device_info_with_identifier( async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.identifiers == {("mqtt", "helloworld")} assert device.manufacturer == "Whatever" @@ -1036,7 +1036,7 @@ async def help_test_entity_device_info_with_connection( await hass.async_block_till_done() device = registry.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} + connections={(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} ) assert device is not None assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} @@ -1069,14 +1069,14 @@ async def help_test_entity_device_info_remove( async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() - device = dev_registry.async_get_device({("mqtt", "helloworld")}) + device = dev_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert ent_registry.async_get_entity_id(domain, mqtt.DOMAIN, "veryunique") async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", "") await hass.async_block_till_done() - device = dev_registry.async_get_device({("mqtt", "helloworld")}) + device = dev_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is None assert not ent_registry.async_get_entity_id(domain, mqtt.DOMAIN, "veryunique") @@ -1103,7 +1103,7 @@ async def help_test_entity_device_info_update( async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.name == "Beer" @@ -1112,11 +1112,50 @@ async def help_test_entity_device_info_update( async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.name == "Milk" +async def help_test_entity_name( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + domain: str, + config: ConfigType, + expected_friendly_name: str | None = None, + device_class: str | None = None, +) -> None: + """Test device name setup with and without a device_class set. + + This is a test helper for the _setup_common_attributes_from_config mixin. + """ + await mqtt_mock_entry() + # Add device settings to config + config = copy.deepcopy(config[mqtt.DOMAIN][domain]) + config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) + config["unique_id"] = "veryunique" + expected_entity_name = "test" + if device_class is not None: + config["device_class"] = device_class + # Do not set a name + config.pop("name") + expected_entity_name = device_class + + registry = dr.async_get(hass) + + data = json.dumps(config) + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device({("mqtt", "helloworld")}) + assert device is not None + + entity_id = f"{domain}.beer_{expected_entity_name}" + state = hass.states.get(entity_id) + assert state is not None + assert state.name == f"Beer {expected_friendly_name}" + + async def help_test_entity_id_update_subscriptions( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, @@ -1232,7 +1271,7 @@ async def help_test_entity_debug_info( async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -1272,7 +1311,7 @@ async def help_test_entity_debug_info_max_messages( async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -1352,7 +1391,7 @@ async def help_test_entity_debug_info_message( async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -1390,7 +1429,7 @@ async def help_test_entity_debug_info_message( with patch("homeassistant.util.dt.utcnow") as dt_utcnow: dt_utcnow.return_value = start_dt if service: - service_data = {ATTR_ENTITY_ID: f"{domain}.test"} + service_data = {ATTR_ENTITY_ID: f"{domain}.beer_test"} if service_parameters: service_data.update(service_parameters) @@ -1443,7 +1482,7 @@ async def help_test_entity_debug_info_remove( async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -1458,7 +1497,7 @@ async def help_test_entity_debug_info_remove( "subscriptions" ] assert len(debug_info_data["triggers"]) == 0 - assert debug_info_data["entities"][0]["entity_id"] == f"{domain}.test" + assert debug_info_data["entities"][0]["entity_id"] == f"{domain}.beer_test" entity_id = debug_info_data["entities"][0]["entity_id"] async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", "") @@ -1493,7 +1532,7 @@ async def help_test_entity_debug_info_update_entity_id( async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() - device = device_registry.async_get_device({("mqtt", "helloworld")}) + device = device_registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -1503,7 +1542,7 @@ async def help_test_entity_debug_info_update_entity_id( == f"homeassistant/{domain}/bla/config" ) assert debug_info_data["entities"][0]["discovery_data"]["payload"] == config - assert debug_info_data["entities"][0]["entity_id"] == f"{domain}.test" + assert debug_info_data["entities"][0]["entity_id"] == f"{domain}.beer_test" assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 assert {"topic": "test-topic", "messages": []} in debug_info_data["entities"][0][ "subscriptions" @@ -1511,7 +1550,7 @@ async def help_test_entity_debug_info_update_entity_id( assert len(debug_info_data["triggers"]) == 0 entity_registry.async_update_entity( - f"{domain}.test", new_entity_id=f"{domain}.milk" + f"{domain}.beer_test", new_entity_id=f"{domain}.milk" ) await hass.async_block_till_done() await hass.async_block_till_done() @@ -1529,7 +1568,7 @@ async def help_test_entity_debug_info_update_entity_id( "subscriptions" ] assert len(debug_info_data["triggers"]) == 0 - assert f"{domain}.test" not in hass.data["mqtt"].debug_info_entities + assert f"{domain}.beer_test" not in hass.data["mqtt"].debug_info_entities async def help_test_entity_disabled_by_default( @@ -1555,7 +1594,7 @@ async def help_test_entity_disabled_by_default( await hass.async_block_till_done() entity_id = ent_registry.async_get_entity_id(domain, mqtt.DOMAIN, "veryunique1") assert entity_id is not None and hass.states.get(entity_id) is None - assert dev_registry.async_get_device({("mqtt", "helloworld")}) + assert dev_registry.async_get_device(identifiers={("mqtt", "helloworld")}) # Discover an enabled entity, tied to the same device config["enabled_by_default"] = True @@ -1571,7 +1610,7 @@ async def help_test_entity_disabled_by_default( await hass.async_block_till_done() assert not ent_registry.async_get_entity_id(domain, mqtt.DOMAIN, "veryunique1") assert not ent_registry.async_get_entity_id(domain, mqtt.DOMAIN, "veryunique2") - assert not dev_registry.async_get_device({("mqtt", "helloworld")}) + assert not dev_registry.async_get_device(identifiers={("mqtt", "helloworld")}) async def help_test_entity_category( diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index c388ded6587..2eec5f8374b 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -3642,7 +3642,11 @@ async def test_encoding_subscribable_topics( ) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 3793902258d..ddce53bfca0 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -249,7 +249,7 @@ async def test_cleanup_device_tracker( await hass.async_block_till_done() # Verify device and registry entries are created - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is not None entity_entry = entity_registry.async_get("device_tracker.mqtt_unique") assert entity_entry is not None @@ -273,7 +273,7 @@ async def test_cleanup_device_tracker( await hass.async_block_till_done() # Verify device and registry entries are cleared - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is None entity_entry = entity_registry.async_get("device_tracker.mqtt_unique") assert entity_entry is None diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 9f3b7565332..485c2774f7b 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -64,7 +64,7 @@ async def test_get_triggers( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data1) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) expected_triggers = [ { "platform": "device", @@ -98,7 +98,7 @@ async def test_get_unknown_triggers( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data1) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert await async_setup_component( hass, @@ -145,7 +145,7 @@ async def test_get_non_existing_triggers( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data1) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) @@ -171,7 +171,7 @@ async def test_discover_bad_triggers( ) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data0) await hass.async_block_till_done() - assert device_registry.async_get_device({("mqtt", "0AFFD2")}) is None + assert device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) is None # Test sending correct data data1 = ( @@ -185,7 +185,7 @@ async def test_discover_bad_triggers( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data1) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) expected_triggers = [ { "platform": "device", @@ -235,7 +235,7 @@ async def test_update_remove_triggers( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data1) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) expected_triggers1 = [ { "platform": "device", @@ -268,7 +268,7 @@ async def test_update_remove_triggers( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", "") await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is None @@ -299,7 +299,7 @@ async def test_if_fires_on_mqtt_message( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert await async_setup_component( hass, @@ -380,7 +380,7 @@ async def test_if_fires_on_mqtt_message_template( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert await async_setup_component( hass, @@ -463,7 +463,7 @@ async def test_if_fires_on_mqtt_message_late_discover( ) async_fire_mqtt_message(hass, "homeassistant/sensor/bla0/config", data0) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert await async_setup_component( hass, @@ -543,7 +543,7 @@ async def test_if_fires_on_mqtt_message_after_update( ) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert await async_setup_component( hass, @@ -615,7 +615,7 @@ async def test_no_resubscribe_same_topic( ) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert await async_setup_component( hass, @@ -663,7 +663,7 @@ async def test_not_fires_on_mqtt_message_after_remove_by_mqtt( ) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert await async_setup_component( hass, @@ -735,7 +735,7 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( ) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert await async_setup_component( hass, @@ -801,7 +801,7 @@ async def test_attach_remove( ) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) calls = [] @@ -864,7 +864,7 @@ async def test_attach_remove_late( ) async_fire_mqtt_message(hass, "homeassistant/sensor/bla0/config", data0) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) calls = [] @@ -930,7 +930,7 @@ async def test_attach_remove_late2( ) async_fire_mqtt_message(hass, "homeassistant/sensor/bla0/config", data0) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) calls = [] @@ -999,7 +999,7 @@ async def test_entity_device_info_with_connection( await hass.async_block_till_done() device = registry.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} + connections={(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} ) assert device is not None assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} @@ -1036,7 +1036,7 @@ async def test_entity_device_info_with_identifier( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.identifiers == {("mqtt", "helloworld")} assert device.manufacturer == "Whatever" @@ -1072,7 +1072,7 @@ async def test_entity_device_info_update( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.name == "Beer" @@ -1081,7 +1081,7 @@ async def test_entity_device_info_update( async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.name == "Milk" @@ -1110,7 +1110,9 @@ async def test_cleanup_trigger( await hass.async_block_till_done() # Verify device registry entry is created - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None triggers = await async_get_device_automations( @@ -1134,7 +1136,9 @@ async def test_cleanup_trigger( await hass.async_block_till_done() # Verify device registry entry is cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is None # Verify retained discovery topic has been cleared @@ -1163,7 +1167,9 @@ async def test_cleanup_device( await hass.async_block_till_done() # Verify device registry entry is created - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None triggers = await async_get_device_automations( @@ -1175,7 +1181,9 @@ async def test_cleanup_device( await hass.async_block_till_done() # Verify device registry entry is cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is None @@ -1210,7 +1218,9 @@ async def test_cleanup_device_several_triggers( await hass.async_block_till_done() # Verify device registry entry is created - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None triggers = await async_get_device_automations( @@ -1224,7 +1234,9 @@ async def test_cleanup_device_several_triggers( await hass.async_block_till_done() # Verify device registry entry is not cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None triggers = await async_get_device_automations( @@ -1237,7 +1249,9 @@ async def test_cleanup_device_several_triggers( await hass.async_block_till_done() # Verify device registry entry is cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is None @@ -1274,7 +1288,9 @@ async def test_cleanup_device_with_entity1( await hass.async_block_till_done() # Verify device registry entry is created - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None triggers = await async_get_device_automations( @@ -1286,7 +1302,9 @@ async def test_cleanup_device_with_entity1( await hass.async_block_till_done() # Verify device registry entry is not cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None triggers = await async_get_device_automations( @@ -1298,7 +1316,9 @@ async def test_cleanup_device_with_entity1( await hass.async_block_till_done() # Verify device registry entry is cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is None @@ -1335,7 +1355,9 @@ async def test_cleanup_device_with_entity2( await hass.async_block_till_done() # Verify device registry entry is created - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None triggers = await async_get_device_automations( @@ -1347,7 +1369,9 @@ async def test_cleanup_device_with_entity2( await hass.async_block_till_done() # Verify device registry entry is not cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None triggers = await async_get_device_automations( @@ -1359,7 +1383,9 @@ async def test_cleanup_device_with_entity2( await hass.async_block_till_done() # Verify device registry entry is cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is None @@ -1404,7 +1430,7 @@ async def test_trigger_debug_info( await hass.async_block_till_done() device = registry.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} + connections={(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} ) assert device is not None @@ -1457,7 +1483,7 @@ async def test_unload_entry( ) async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert await async_setup_component( hass, diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index 81a86f1c61f..eb923ac2f07 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -75,12 +75,12 @@ async def test_entry_diagnostics( ) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) expected_debug_info = { "entities": [ { - "entity_id": "sensor.mqtt_sensor", + "entity_id": "sensor.none_mqtt_sensor", "subscriptions": [{"topic": "foobar/sensor", "messages": []}], "discovery_data": { "payload": config_sensor, @@ -109,13 +109,13 @@ async def test_entry_diagnostics( "disabled": False, "disabled_by": None, "entity_category": None, - "entity_id": "sensor.mqtt_sensor", + "entity_id": "sensor.none_mqtt_sensor", "icon": None, "original_device_class": None, "original_icon": None, "state": { "attributes": {"friendly_name": "MQTT Sensor"}, - "entity_id": "sensor.mqtt_sensor", + "entity_id": "sensor.none_mqtt_sensor", "last_changed": ANY, "last_updated": ANY, "state": "unknown", @@ -190,7 +190,7 @@ async def test_redact_diagnostics( async_fire_mqtt_message(hass, "attributes-topic", location_data) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) expected_debug_info = { "entities": [ diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index f35af9fb037..f51d469bde7 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -7,7 +7,6 @@ import re from unittest.mock import AsyncMock, call, patch import pytest -from voluptuous import MultipleInvalid from homeassistant import config_entries from homeassistant.components import mqtt @@ -728,12 +727,12 @@ async def test_cleanup_device( await hass.async_block_till_done() # Verify device and registry entries are created - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is not None - entity_entry = entity_registry.async_get("sensor.mqtt_sensor") + entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") assert entity_entry is not None - state = hass.states.get("sensor.mqtt_sensor") + state = hass.states.get("sensor.none_mqtt_sensor") assert state is not None # Remove MQTT from the device @@ -752,13 +751,13 @@ async def test_cleanup_device( await hass.async_block_till_done() # Verify device and registry entries are cleared - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is None - entity_entry = entity_registry.async_get("sensor.mqtt_sensor") + entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") assert entity_entry is None # Verify state is removed - state = hass.states.get("sensor.mqtt_sensor") + state = hass.states.get("sensor.none_mqtt_sensor") assert state is None await hass.async_block_till_done() @@ -787,12 +786,12 @@ async def test_cleanup_device_mqtt( await hass.async_block_till_done() # Verify device and registry entries are created - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is not None - entity_entry = entity_registry.async_get("sensor.mqtt_sensor") + entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") assert entity_entry is not None - state = hass.states.get("sensor.mqtt_sensor") + state = hass.states.get("sensor.none_mqtt_sensor") assert state is not None async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", "") @@ -800,13 +799,13 @@ async def test_cleanup_device_mqtt( await hass.async_block_till_done() # Verify device and registry entries are cleared - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is None - entity_entry = entity_registry.async_get("sensor.mqtt_sensor") + entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") assert entity_entry is None # Verify state is removed - state = hass.states.get("sensor.mqtt_sensor") + state = hass.states.get("sensor.none_mqtt_sensor") assert state is None await hass.async_block_till_done() @@ -867,17 +866,17 @@ async def test_cleanup_device_multiple_config_entries( # Verify device and registry entries are created device_entry = device_registry.async_get_device( - set(), {("mac", "12:34:56:AB:CD:EF")} + connections={("mac", "12:34:56:AB:CD:EF")} ) assert device_entry is not None assert device_entry.config_entries == { mqtt_config_entry.entry_id, config_entry.entry_id, } - entity_entry = entity_registry.async_get("sensor.mqtt_sensor") + entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") assert entity_entry is not None - state = hass.states.get("sensor.mqtt_sensor") + state = hass.states.get("sensor.none_mqtt_sensor") assert state is not None # Remove MQTT from the device @@ -898,15 +897,15 @@ async def test_cleanup_device_multiple_config_entries( # Verify device is still there but entity is cleared device_entry = device_registry.async_get_device( - set(), {("mac", "12:34:56:AB:CD:EF")} + connections={("mac", "12:34:56:AB:CD:EF")} ) assert device_entry is not None - entity_entry = entity_registry.async_get("sensor.mqtt_sensor") + entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") assert device_entry.config_entries == {config_entry.entry_id} assert entity_entry is None # Verify state is removed - state = hass.states.get("sensor.mqtt_sensor") + state = hass.states.get("sensor.none_mqtt_sensor") assert state is None await hass.async_block_till_done() @@ -967,17 +966,17 @@ async def test_cleanup_device_multiple_config_entries_mqtt( # Verify device and registry entries are created device_entry = device_registry.async_get_device( - set(), {("mac", "12:34:56:AB:CD:EF")} + connections={("mac", "12:34:56:AB:CD:EF")} ) assert device_entry is not None assert device_entry.config_entries == { mqtt_config_entry.entry_id, config_entry.entry_id, } - entity_entry = entity_registry.async_get("sensor.mqtt_sensor") + entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") assert entity_entry is not None - state = hass.states.get("sensor.mqtt_sensor") + state = hass.states.get("sensor.none_mqtt_sensor") assert state is not None # Send MQTT messages to remove @@ -990,15 +989,15 @@ async def test_cleanup_device_multiple_config_entries_mqtt( # Verify device is still there but entity is cleared device_entry = device_registry.async_get_device( - set(), {("mac", "12:34:56:AB:CD:EF")} + connections={("mac", "12:34:56:AB:CD:EF")} ) assert device_entry is not None - entity_entry = entity_registry.async_get("sensor.mqtt_sensor") + entity_entry = entity_registry.async_get("sensor.none_mqtt_sensor") assert device_entry.config_entries == {config_entry.entry_id} assert entity_entry is None # Verify state is removed - state = hass.states.get("sensor.mqtt_sensor") + state = hass.states.get("sensor.none_mqtt_sensor") assert state is None await hass.async_block_till_done() @@ -1305,9 +1304,7 @@ async def test_missing_discover_abbreviations( and match[0] not in ABBREVIATIONS_WHITE_LIST ): missing.append( - "{}: no abbreviation for {} ({})".format( - fil, match[1], match[0] - ) + f"{fil}: no abbreviation for {match[1]} ({match[0]})" ) assert not missing @@ -1475,13 +1472,12 @@ async def test_clear_config_topic_disabled_entity( mqtt_mock = await mqtt_mock_entry() # discover an entity that is not enabled by default config = { - "name": "sbfspot_12345", "state_topic": "homeassistant_test/sensor/sbfspot_0/sbfspot_12345/", "unique_id": "sbfspot_12345", "enabled_by_default": False, "device": { "identifiers": ["sbfspot_12345"], - "name": "sbfspot_12345", + "name": "abc123", "sw_version": "1.0", "connections": [["mac", "12:34:56:AB:CD:EF"]], }, @@ -1513,13 +1509,13 @@ async def test_clear_config_topic_disabled_entity( await hass.async_block_till_done() assert "Platform mqtt does not generate unique IDs" in caplog.text - assert hass.states.get("sensor.sbfspot_12345") is None # disabled - assert hass.states.get("sensor.sbfspot_12345_1") is not None # enabled - assert hass.states.get("sensor.sbfspot_12345_2") is None # not unique + assert hass.states.get("sensor.abc123_sbfspot_12345") is None # disabled + assert hass.states.get("sensor.abc123_sbfspot_12345_1") is not None # enabled + assert hass.states.get("sensor.abc123_sbfspot_12345_2") is None # not unique # Verify device is created device_entry = device_registry.async_get_device( - set(), {("mac", "12:34:56:AB:CD:EF")} + connections={("mac", "12:34:56:AB:CD:EF")} ) assert device_entry is not None @@ -1585,7 +1581,7 @@ async def test_clean_up_registry_monitoring( # Verify device is created device_entry = device_registry.async_get_device( - set(), {("mac", "12:34:56:AB:CD:EF")} + connections={("mac", "12:34:56:AB:CD:EF")} ) assert device_entry is not None @@ -1604,13 +1600,12 @@ async def test_unique_id_collission_has_priority( """Test the unique_id collision detection has priority over registry disabled items.""" await mqtt_mock_entry() config = { - "name": "sbfspot_12345", "state_topic": "homeassistant_test/sensor/sbfspot_0/sbfspot_12345/", "unique_id": "sbfspot_12345", "enabled_by_default": False, "device": { "identifiers": ["sbfspot_12345"], - "name": "sbfspot_12345", + "name": "abc123", "sw_version": "1.0", "connections": [["mac", "12:34:56:AB:CD:EF"]], }, @@ -1634,16 +1629,15 @@ async def test_unique_id_collission_has_priority( ) await hass.async_block_till_done() - assert hass.states.get("sensor.sbfspot_12345_1") is None # not enabled - assert hass.states.get("sensor.sbfspot_12345_2") is None # not unique + assert hass.states.get("sensor.abc123_sbfspot_12345_1") is None # not enabled + assert hass.states.get("sensor.abc123_sbfspot_12345_2") is None # not unique # Verify the first entity is created - assert entity_registry.async_get("sensor.sbfspot_12345_1") is not None + assert entity_registry.async_get("sensor.abc123_sbfspot_12345_1") is not None # Verify the second entity is not created because it is not unique - assert entity_registry.async_get("sensor.sbfspot_12345_2") is None + assert entity_registry.async_get("sensor.abc123_sbfspot_12345_2") is None -@pytest.mark.xfail(raises=MultipleInvalid) @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) async def test_update_with_bad_config_not_breaks_discovery( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py new file mode 100644 index 00000000000..bc7b8b43523 --- /dev/null +++ b/tests/components/mqtt/test_event.py @@ -0,0 +1,673 @@ +"""The tests for the MQTT event platform.""" +import copy +import json +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components import event, mqtt +from homeassistant.components.mqtt.event import MQTT_EVENT_ATTRIBUTES_BLOCKED +from homeassistant.const import ( + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .test_common import ( + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_list_payload, + help_test_default_availability_list_payload_all, + help_test_default_availability_list_payload_any, + help_test_default_availability_list_single, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update_attr, + help_test_discovery_update_availability, + help_test_entity_category, + help_test_entity_debug_info, + help_test_entity_debug_info_message, + help_test_entity_debug_info_remove, + help_test_entity_debug_info_update_entity_id, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_disabled_by_default, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, + help_test_entity_name, + help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import ( + async_fire_mqtt_message, +) +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient + +DEFAULT_CONFIG = { + mqtt.DOMAIN: { + event.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "event_types": ["press"], + } + } +} + + +@pytest.fixture(autouse=True) +def event_platform_only(): + """Only setup the event platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.EVENT]): + yield + + +@pytest.mark.freeze_time("2023-08-01 00:00:00+00:00") +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_setting_event_value_via_mqtt_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the an MQTT event with attributes.""" + await mqtt_mock_entry() + + async_fire_mqtt_message( + hass, "test-topic", '{"event_type": "press", "duration": "short" }' + ) + state = hass.states.get("event.test") + + assert state.state == "2023-08-01T00:00:00.000+00:00" + assert state.attributes.get("duration") == "short" + + +@pytest.mark.freeze_time("2023-08-01 00:00:00+00:00") +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + ("message", "log"), + [ + ( + '{"event_type": "press", "duration": "short" ', + "No valid JSON event payload detected", + ), + ('{"event_type": "invalid", "duration": "short" }', "Invalid event type"), + ('{"event_type": 2, "duration": "short" }', "Invalid event type"), + ('{"event_type": null, "duration": "short" }', "Invalid event type"), + ( + '{"event": "press", "duration": "short" }', + "`event_type` missing in JSON event payload", + ), + ], +) +async def test_setting_event_value_with_invalid_payload( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + message: str, + log: str, +) -> None: + """Test the an MQTT event with attributes.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test-topic", message) + state = hass.states.get("event.test") + + assert state is not None + assert state.state == STATE_UNKNOWN + assert log in caplog.text + + +@pytest.mark.freeze_time("2023-08-01 00:00:00+00:00") +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + event.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "event_types": ["press"], + "value_template": '{"event_type": "press", "val": "{{ value_json.val | is_defined }}", "par": "{{ value_json.par }}"}', + } + } + } + ], +) +async def test_setting_event_value_via_mqtt_json_message_and_default_current_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test processing an event via MQTT with fall back to current state.""" + await mqtt_mock_entry() + + async_fire_mqtt_message( + hass, "test-topic", '{ "val": "valcontent", "par": "parcontent" }' + ) + state = hass.states.get("event.test") + + assert state.state == "2023-08-01T00:00:00.000+00:00" + assert state.attributes.get("val") == "valcontent" + assert state.attributes.get("par") == "parcontent" + + freezer.move_to("2023-08-01 00:00:10+00:00") + + async_fire_mqtt_message(hass, "test-topic", '{ "par": "invalidcontent" }') + state = hass.states.get("event.test") + + assert state.state == "2023-08-01T00:00:00.000+00:00" + assert state.attributes.get("val") == "valcontent" + assert state.attributes.get("par") == "parcontent" + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_when_connection_lost( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock_entry, event.DOMAIN + ) + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_availability_without_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_list_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_list_payload( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_list_payload_all( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_list_payload_all( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_list_payload_any( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by default payload with defined topic.""" + await help_test_default_availability_list_payload_any( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_list_single( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test availability list and availability_topic are mutually exclusive.""" + await help_test_default_availability_list_single( + hass, caplog, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_custom_availability_payload( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_discovery_update_availability( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test availability discovery update.""" + await help_test_discovery_update_availability( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + event.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "event_types": ["press"], + "device_class": "foobarnotreal", + } + } + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device_class option with invalid value.""" + with pytest.raises(AssertionError): + await mqtt_mock_entry() + assert ( + "Invalid config for [mqtt]: expected EventDeviceClass or one of" in caplog.text + ) + + +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, + mqtt_mock_entry, + event.DOMAIN, + DEFAULT_CONFIG, + MQTT_EVENT_ATTRIBUTES_BLOCKED, + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry, + caplog, + event.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry, + caplog, + event.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry, + caplog, + event.DOMAIN, + DEFAULT_CONFIG, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + event.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "event_types": ["press"], + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "event_types": ["press"], + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique id option only creates one event per unique_id.""" + await help_test_unique_id(hass, mqtt_mock_entry, event.DOMAIN) + + +async def test_discovery_removal_event( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removal of discovered event.""" + data = '{ "name": "test", "state_topic": "test_topic", "event_types": ["press"] }' + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, event.DOMAIN, data) + + +async def test_discovery_update_event_template( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered mqtt event template.""" + await mqtt_mock_entry() + config = {"name": "test", "state_topic": "test_topic", "event_types": ["press"]} + config1 = copy.deepcopy(config) + config2 = copy.deepcopy(config) + config1["name"] = "Beer" + config2["name"] = "Milk" + config1["state_topic"] = "event/state1" + config2["state_topic"] = "event/state1" + config1[ + "value_template" + ] = '{"event_type": "press", "val": "{{ value_json.val | int }}"}' + config2[ + "value_template" + ] = '{"event_type": "press", "val": "{{ value_json.val | int * 2 }}"}' + + async_fire_mqtt_message(hass, "homeassistant/event/bla/config", json.dumps(config1)) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "event/state1", '{"val":100}') + await hass.async_block_till_done() + state = hass.states.get("event.beer") + assert state is not None + assert state.attributes.get("val") == "100" + + async_fire_mqtt_message(hass, "homeassistant/event/bla/config", json.dumps(config2)) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "event/state1", '{"val":100}') + await hass.async_block_till_done() + state = hass.states.get("event.beer") + assert state is not None + assert state.attributes.get("val") == "200" + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer", "state_topic": "test_topic#", "event_types": ["press"] }' + data2 = '{ "name": "Milk", "state_topic": "test_topic", "event_types": ["press"] }' + await help_test_discovery_broken( + hass, mqtt_mock_entry, caplog, event.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT event device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT event device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT subscriptions are managed when entity_id is updated.""" + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_hub( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT event device registry integration.""" + await mqtt_mock_entry() + registry = dr.async_get(hass) + hub = registry.async_get_or_create( + config_entry_id="123", + connections=set(), + identifiers={("mqtt", "hub-id")}, + manufacturer="manufacturer", + model="hub", + ) + + data = json.dumps( + { + "name": "Test 1", + "state_topic": "test-topic", + "event_types": ["press"], + "device": {"identifiers": ["helloworld"], "via_device": "hub-id"}, + "unique_id": "veryunique", + } + ) + async_fire_mqtt_message(hass, "homeassistant/event/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) + assert device is not None + assert device.via_device_id == hub.id + + +async def test_entity_debug_info( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT event debug info.""" + await help_test_entity_debug_info( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_entity_debug_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT event debug info.""" + await help_test_entity_debug_info_remove( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_update_entity_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT event debug info.""" + await help_test_entity_debug_info_update_entity_id( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_disabled_by_default( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test entity disabled by default.""" + await help_test_entity_disabled_by_default( + hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_entity_category( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test entity category.""" + await help_test_entity_category(hass, mqtt_mock_entry, event.DOMAIN, DEFAULT_CONFIG) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + event.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "event_types": ["press"], + "value_template": '{ "event_type": "press", "val": \ + {% if state_attr(entity_id, "friendly_name") == "test" %} \ + "{{ value | int + 1 }}" \ + {% else %} \ + "{{ value }}" \ + {% endif %}}', + } + } + } + ], +) +async def test_value_template_with_entity_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the access to attributes in value_template via the entity_id.""" + await mqtt_mock_entry() + + async_fire_mqtt_message(hass, "test-topic", "100") + state = hass.states.get("event.test") + + assert state.attributes.get("val") == "101" + + +async def test_reloadable( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test reloading the MQTT platform.""" + domain = event.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) +async def test_setup_manual_entity_from_yaml( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setup manual configured MQTT entity.""" + await mqtt_mock_entry() + platform = event.DOMAIN + assert hass.states.get(f"{platform}.test") + + +async def test_unload_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test unloading the config entry.""" + domain = event.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) + + +@pytest.mark.parametrize( + ("expected_friendly_name", "device_class"), + [("test", None), ("Doorbell", "doorbell"), ("Motion", "motion")], +) +async def test_entity_name( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + expected_friendly_name: str | None, + device_class: str | None, +) -> None: + """Test the entity name setup.""" + domain = event.DOMAIN + config = DEFAULT_CONFIG + await help_test_entity_name( + hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class + ) diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index c4181a3f885..803a0d74766 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -2220,7 +2220,11 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 1c386b28703..0cc4d936841 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -1545,7 +1545,11 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index eee1d006137..c0d7a94de5b 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2096,7 +2096,6 @@ async def test_setup_manual_mqtt_with_platform_key( @pytest.mark.parametrize("hass_config", [{mqtt.DOMAIN: {"light": {"name": "test"}}}]) -@pytest.mark.xfail(reason="Invalid config for [mqtt]: required key not provided") @patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_setup_manual_mqtt_with_invalid_config( hass: HomeAssistant, @@ -2107,8 +2106,8 @@ async def test_setup_manual_mqtt_with_invalid_config( with pytest.raises(AssertionError): await mqtt_mock_entry() assert ( - "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']." - " Got None. (See ?, line ?)" in caplog.text + "Invalid config for [mqtt]: required key not provided @ data['mqtt'][0]['light'][0]['command_topic']. " + "Got None. (See ?, line ?)" in caplog.text ) @@ -2605,7 +2604,7 @@ async def test_default_entry_setting_are_applied( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is not None @@ -2758,7 +2757,7 @@ async def test_mqtt_ws_remove_discovered_device( await hass.async_block_till_done() # Verify device entry is created - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is not None client = await hass_ws_client(hass) @@ -2775,7 +2774,7 @@ async def test_mqtt_ws_remove_discovered_device( assert response["success"] # Verify device entry is cleared - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is None @@ -2810,7 +2809,7 @@ async def test_mqtt_ws_get_device_debug_info( await hass.async_block_till_done() # Verify device entry is created - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is not None client = await hass_ws_client(hass) @@ -2822,7 +2821,7 @@ async def test_mqtt_ws_get_device_debug_info( expected_result = { "entities": [ { - "entity_id": "sensor.mqtt_sensor", + "entity_id": "sensor.none_mqtt_sensor", "subscriptions": [{"topic": "foobar/sensor", "messages": []}], "discovery_data": { "payload": config_sensor, @@ -2865,7 +2864,7 @@ async def test_mqtt_ws_get_device_debug_info_binary( await hass.async_block_till_done() # Verify device entry is created - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) assert device_entry is not None small_png = ( @@ -2885,7 +2884,7 @@ async def test_mqtt_ws_get_device_debug_info_binary( expected_result = { "entities": [ { - "entity_id": "camera.mqtt_camera", + "entity_id": "camera.none_mqtt_camera", "subscriptions": [ { "topic": "foobar/image", @@ -2972,7 +2971,7 @@ async def test_debug_info_multiple_devices( for dev in devices: domain = dev["domain"] id = dev["config"]["device"]["identifiers"][0] - device = registry.async_get_device({("mqtt", id)}) + device = registry.async_get_device(identifiers={("mqtt", id)}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -3053,7 +3052,7 @@ async def test_debug_info_multiple_entities_triggers( await hass.async_block_till_done() device_id = config[0]["config"]["device"]["identifiers"][0] - device = registry.async_get_device({("mqtt", device_id)}) + device = registry.async_get_device(identifiers={("mqtt", device_id)}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"]) == 2 @@ -3133,7 +3132,7 @@ async def test_debug_info_wildcard( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -3181,7 +3180,7 @@ async def test_debug_info_filter_same( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -3242,7 +3241,7 @@ async def test_debug_info_same_topic( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) @@ -3295,7 +3294,7 @@ async def test_debug_info_qos_retain( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None debug_info_data = debug_info.info_for_device(hass, device.id) diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 0297f4216c4..85e3bdd12b9 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -1,4 +1,8 @@ """The tests for the Legacy Mqtt vacuum platform.""" + +# The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 +# and will be removed with HA Core 2024.2.0 + from copy import deepcopy import json from typing import Any @@ -32,6 +36,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType from .test_common import ( @@ -123,6 +128,31 @@ def vacuum_platform_only(): yield +@pytest.mark.parametrize( + ("hass_config", "deprecated"), + [ + ({mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test", "schema": "legacy"}}}, True), + ({mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}}, True), + ({mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test", "schema": "state"}}}, False), + ], +) +async def test_deprecation( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + deprecated: bool, +) -> None: + """Test that the depration warning for the legacy schema works.""" + assert await mqtt_mock_entry() + entity = hass.states.get("vacuum.test") + assert entity is not None + + if deprecated: + assert "Deprecated `legacy` schema detected for MQTT vacuum" in caplog.text + else: + assert "Deprecated `legacy` schema detected for MQTT vacuum" not in caplog.text + + @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_default_supported_features( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator @@ -245,39 +275,83 @@ async def test_commands_without_supported_features( """Test commands which are not supported by the vacuum.""" mqtt_mock = await mqtt_mock_entry() - await common.async_turn_on(hass, "vacuum.mqtttest") + with pytest.raises(HomeAssistantError): + await common.async_turn_on(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() - await common.async_turn_off(hass, "vacuum.mqtttest") + with pytest.raises(HomeAssistantError): + await common.async_turn_off(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() - await common.async_stop(hass, "vacuum.mqtttest") + with pytest.raises(HomeAssistantError): + await common.async_stop(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() - await common.async_clean_spot(hass, "vacuum.mqtttest") + with pytest.raises(HomeAssistantError): + await common.async_clean_spot(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() - await common.async_locate(hass, "vacuum.mqtttest") + with pytest.raises(HomeAssistantError): + await common.async_locate(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() - await common.async_start_pause(hass, "vacuum.mqtttest") + with pytest.raises(HomeAssistantError): + await common.async_start_pause(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() - await common.async_return_to_base(hass, "vacuum.mqtttest") + with pytest.raises(HomeAssistantError): + await common.async_return_to_base(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() - await common.async_set_fan_speed(hass, "high", "vacuum.mqtttest") + with pytest.raises(HomeAssistantError): + await common.async_set_fan_speed(hass, "high", "vacuum.mqtttest") mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() - await common.async_send_command(hass, "44 FE 93", entity_id="vacuum.mqtttest") + with pytest.raises(HomeAssistantError): + await common.async_send_command(hass, "44 FE 93", entity_id="vacuum.mqtttest") + mqtt_mock.async_publish.assert_not_called() + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": { + "vacuum": { + "name": "test", + "schema": "legacy", + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + ALL_SERVICES, SERVICE_TO_STRING + ), + } + } + } + ], +) +async def test_command_without_command_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test commands which are not supported by the vacuum.""" + mqtt_mock = await mqtt_mock_entry() + + await common.async_turn_on(hass, "vacuum.test") + mqtt_mock.async_publish.assert_not_called() + mqtt_mock.async_publish.reset_mock() + + await common.async_set_fan_speed(hass, "low", "vacuum.test") + mqtt_mock.async_publish.assert_not_called() + mqtt_mock.async_publish.reset_mock() + + await common.async_send_command(hass, "some command", "vacuum.test") mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() @@ -1013,7 +1087,11 @@ async def test_encoding_subscribable_topics( ) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index ee4f170e8e6..08def9a923e 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -666,6 +666,12 @@ async def test_brightness_from_rgb_controlling_scale( assert state.attributes.get("brightness") == 128 assert state.attributes.get("rgb_color") == (255, 128, 64) + # Test zero rgb is ignored + async_fire_mqtt_message(hass, "test_scale_rgb/rgb/status", "0,0,0") + state = hass.states.get("light.test") + assert state.attributes.get("brightness") == 128 + assert state.attributes.get("rgb_color") == (255, 128, 64) + mqtt_mock.async_publish.reset_mock() await common.async_turn_on(hass, "light.test", brightness=191) await hass.async_block_till_done() @@ -3434,7 +3440,11 @@ async def test_sending_mqtt_xy_command_with_template( assert state.attributes["xy_color"] == (0.151, 0.343) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 5a7bedd91e6..7ff4ccbab85 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -2441,7 +2441,11 @@ async def test_encoding_subscribable_topics( ) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 4727caca2cc..0583a1176b6 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -1354,7 +1354,11 @@ async def test_encoding_subscribable_topics( ) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 2b77a573bad..bf7e1529a4e 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -1006,7 +1006,11 @@ async def test_encoding_subscribable_topics( ) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index c7285f0fa5f..18269eb6970 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -5,11 +5,20 @@ from unittest.mock import patch import pytest from homeassistant.components import mqtt, sensor -from homeassistant.const import EVENT_STATE_CHANGED, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.components.mqtt.sensor import DEFAULT_NAME as DEFAULT_SENSOR_NAME +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STARTED, + EVENT_STATE_CHANGED, + Platform, +) +from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.helpers import ( + device_registry as dr, + issue_registry as ir, +) -from tests.common import async_fire_mqtt_message -from tests.typing import MqttMockHAClientGenerator +from tests.common import MockConfigEntry, async_capture_events, async_fire_mqtt_message +from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient @pytest.mark.parametrize( @@ -73,3 +82,276 @@ async def test_availability_with_shared_state_topic( # The availability is changed but the topic is shared, # hence there the state will be written when the value is updated assert len(events) == 1 + + +@pytest.mark.parametrize( + ( + "hass_config", + "entity_id", + "friendly_name", + "device_name", + "assert_log", + "issue_events", + ), + [ + ( # default_entity_name_without_device_name + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "state_topic": "test-topic", + "unique_id": "veryunique", + "device": {"identifiers": ["helloworld"]}, + } + } + }, + "sensor.none_mqtt_sensor", + DEFAULT_SENSOR_NAME, + None, + True, + 0, + ), + ( # default_entity_name_with_device_name + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "state_topic": "test-topic", + "unique_id": "veryunique", + "device": {"name": "Test", "identifiers": ["helloworld"]}, + } + } + }, + "sensor.test_mqtt_sensor", + "Test MQTT Sensor", + "Test", + False, + 0, + ), + ( # name_follows_device_class + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "state_topic": "test-topic", + "unique_id": "veryunique", + "device_class": "humidity", + "device": {"name": "Test", "identifiers": ["helloworld"]}, + } + } + }, + "sensor.test_humidity", + "Test Humidity", + "Test", + False, + 0, + ), + ( # name_follows_device_class_without_device_name + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "state_topic": "test-topic", + "unique_id": "veryunique", + "device_class": "humidity", + "device": {"identifiers": ["helloworld"]}, + } + } + }, + "sensor.none_humidity", + "Humidity", + None, + True, + 0, + ), + ( # name_overrides_device_class + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "MySensor", + "state_topic": "test-topic", + "unique_id": "veryunique", + "device_class": "humidity", + "device": {"name": "Test", "identifiers": ["helloworld"]}, + } + } + }, + "sensor.test_mysensor", + "Test MySensor", + "Test", + False, + 0, + ), + ( # name_set_no_device_name_set + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "MySensor", + "state_topic": "test-topic", + "unique_id": "veryunique", + "device_class": "humidity", + "device": {"identifiers": ["helloworld"]}, + } + } + }, + "sensor.none_mysensor", + "MySensor", + None, + True, + 0, + ), + ( # none_entity_name_with_device_name + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": None, + "state_topic": "test-topic", + "unique_id": "veryunique", + "device_class": "humidity", + "device": {"name": "Test", "identifiers": ["helloworld"]}, + } + } + }, + "sensor.test", + "Test", + "Test", + False, + 0, + ), + ( # none_entity_name_without_device_name + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": None, + "state_topic": "test-topic", + "unique_id": "veryunique", + "device_class": "humidity", + "device": {"identifiers": ["helloworld"]}, + } + } + }, + "sensor.mqtt_veryunique", + "mqtt veryunique", + None, + True, + 0, + ), + ( # entity_name_and_device_name_the_same + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "Hello world", + "state_topic": "test-topic", + "unique_id": "veryunique", + "device_class": "humidity", + "device": { + "identifiers": ["helloworld"], + "name": "Hello world", + }, + } + } + }, + "sensor.hello_world", + "Hello world", + "Hello world", + False, + 1, + ), + ( # entity_name_startswith_device_name1 + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "World automation", + "state_topic": "test-topic", + "unique_id": "veryunique", + "device_class": "humidity", + "device": { + "identifiers": ["helloworld"], + "name": "World", + }, + } + } + }, + "sensor.world_automation", + "World automation", + "World", + False, + 1, + ), + ( # entity_name_startswith_device_name2 + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "world automation", + "state_topic": "test-topic", + "unique_id": "veryunique", + "device_class": "humidity", + "device": { + "identifiers": ["helloworld"], + "name": "world", + }, + } + } + }, + "sensor.world_automation", + "world automation", + "world", + False, + 1, + ), + ], + ids=[ + "default_entity_name_without_device_name", + "default_entity_name_with_device_name", + "name_follows_device_class", + "name_follows_device_class_without_device_name", + "name_overrides_device_class", + "name_set_no_device_name_set", + "none_entity_name_with_device_name", + "none_entity_name_without_device_name", + "entity_name_and_device_name_the_same", + "entity_name_startswith_device_name1", + "entity_name_startswith_device_name2", + ], +) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +async def test_default_entity_and_device_name( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_config_entry_data, + caplog: pytest.LogCaptureFixture, + entity_id: str, + friendly_name: str, + device_name: str | None, + assert_log: bool, + issue_events: int, +) -> None: + """Test device name setup with and without a device_class set. + + This is a test helper for the _setup_common_attributes_from_config mixin. + """ + # mqtt_mock = await mqtt_mock_entry() + + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + hass.state = CoreState.starting + await hass.async_block_till_done() + + entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "mock-broker"}) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + registry = dr.async_get(hass) + + device = registry.async_get_device({("mqtt", "helloworld")}) + assert device is not None + assert device.name == device_name + + state = hass.states.get(entity_id) + assert state is not None + assert state.name == friendly_name + + assert ( + "MQTT device information always needs to include a name" in caplog.text + ) is assert_log + + # Assert that an issues ware registered + assert len(events) == issue_events diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index f882209139c..dbdd373a659 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -48,6 +48,7 @@ from .test_common import ( help_test_entity_device_info_with_identifier, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_entity_name, help_test_publishing_with_custom_encoding, help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, @@ -1097,7 +1098,11 @@ async def test_encoding_subscribable_topics( ) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: @@ -1117,3 +1122,21 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + ("expected_friendly_name", "device_class"), + [("test", None), ("Humidity", "humidity"), ("Temperature", "temperature")], +) +async def test_entity_name( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + expected_friendly_name: str | None, + device_class: str | None, +) -> None: + """Test the entity name setup.""" + domain = number.DOMAIN + config = DEFAULT_CONFIG + await help_test_entity_name( + hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class + ) diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index 4da60d44bb7..141bfc526d3 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -1,5 +1,6 @@ """The tests for the MQTT scene platform.""" import copy +from typing import Any from unittest.mock import patch import pytest @@ -16,10 +17,23 @@ from .test_common import ( help_test_discovery_broken, help_test_discovery_removal, help_test_discovery_update, + help_test_discovery_update_attr, help_test_discovery_update_unchanged, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_publishing_with_custom_encoding, help_test_reloadable, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, help_test_unique_id, help_test_unload_config_entry_with_platform, + help_test_update_with_json_attrs_bad_json, + help_test_update_with_json_attrs_not_dict, ) from tests.common import mock_restore_cache @@ -241,6 +255,172 @@ async def test_discovery_broken( ) +async def test_setting_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, scene.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry, scene.DOMAIN, DEFAULT_CONFIG, None + ) + + +async def test_setting_attribute_with_template( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock_entry, scene.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, + mqtt_mock_entry, + caplog, + scene.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_update_with_json_attrs_bad_json( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_json( + hass, + mqtt_mock_entry, + caplog, + scene.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_discovery_update_attr( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, + mqtt_mock_entry, + caplog, + scene.DOMAIN, + DEFAULT_CONFIG, + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT button device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, scene.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT button device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, scene.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock_entry, scene.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock_entry, scene.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock_entry, scene.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, + mqtt_mock_entry, + scene.DOMAIN, + DEFAULT_CONFIG, + scene.SERVICE_TURN_ON, + command_payload="test-payload-on", + state_topic=None, + ) + + +@pytest.mark.parametrize( + ("service", "topic", "parameters", "payload", "template"), + [ + (scene.SERVICE_TURN_ON, "command_topic", None, "test-payload-on", None), + ], +) +async def test_publishing_with_custom_encoding( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + service: str, + topic: str, + parameters: dict[str, Any], + payload: str, + template: str | None, +) -> None: + """Test publishing MQTT payload with different encoding.""" + domain = scene.DOMAIN + config = DEFAULT_CONFIG + + await help_test_publishing_with_custom_encoding( + hass, + mqtt_mock_entry, + caplog, + domain, + config, + service, + topic, + parameters, + payload, + template, + ) + + async def test_reloadable( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -251,7 +431,11 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 583e65bc61c..f1903fa4c3c 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -762,7 +762,11 @@ async def test_encoding_subscribable_topics( ) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 8f2aa754bac..30eb0fd1939 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -53,6 +53,7 @@ from .test_common import ( help_test_entity_disabled_by_default, help_test_entity_id_update_discovery_update, help_test_entity_id_update_subscriptions, + help_test_entity_name, help_test_reload_with_config, help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, @@ -1142,7 +1143,7 @@ async def test_entity_device_info_with_hub( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.via_device_id == hub.id @@ -1385,7 +1386,11 @@ async def test_encoding_subscribable_topics( ) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: @@ -1405,3 +1410,21 @@ async def test_unload_entry( await help_test_unload_config_entry_with_platform( hass, mqtt_mock_entry, domain, config ) + + +@pytest.mark.parametrize( + ("expected_friendly_name", "device_class"), + [("test", None), ("Humidity", "humidity"), ("Temperature", "temperature")], +) +async def test_entity_name( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + expected_friendly_name: str | None, + device_class: str | None, +) -> None: + """Test the entity name setup.""" + domain = sensor.DOMAIN + config = DEFAULT_CONFIG + await help_test_entity_name( + hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class + ) diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 76a9cb9c6f6..7c448eba85e 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -1067,7 +1067,11 @@ async def test_encoding_subscribable_topics( ) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index 203f5d55b95..a24884941fc 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -11,7 +11,10 @@ from homeassistant.components.mqtt.const import CONF_COMMAND_TOPIC, CONF_STATE_T from homeassistant.components.mqtt.vacuum import CONF_SCHEMA, schema_state as mqttvacuum from homeassistant.components.mqtt.vacuum.const import MQTT_VACUUM_ATTRIBUTES_BLOCKED from homeassistant.components.mqtt.vacuum.schema import services_to_strings -from homeassistant.components.mqtt.vacuum.schema_state import SERVICE_TO_STRING +from homeassistant.components.mqtt.vacuum.schema_state import ( + ALL_SERVICES, + SERVICE_TO_STRING, +) from homeassistant.components.vacuum import ( ATTR_BATTERY_ICON, ATTR_BATTERY_LEVEL, @@ -29,6 +32,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .test_common import ( help_custom_config, @@ -112,7 +116,7 @@ async def test_default_supported_features( entity = hass.states.get("vacuum.mqtttest") entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( - ["start", "stop", "return_home", "battery", "status", "clean_spot"] + ["start", "stop", "return_home", "battery", "clean_spot"] ) @@ -242,16 +246,53 @@ async def test_commands_without_supported_features( mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() - await common.async_set_fan_speed(hass, "medium", "vacuum.mqtttest") + with pytest.raises(HomeAssistantError): + await common.async_set_fan_speed(hass, "medium", "vacuum.mqtttest") mqtt_mock.async_publish.assert_not_called() mqtt_mock.async_publish.reset_mock() - await common.async_send_command( - hass, "44 FE 93", {"key": "value"}, entity_id="vacuum.mqtttest" - ) + with pytest.raises(HomeAssistantError): + await common.async_send_command( + hass, "44 FE 93", {"key": "value"}, entity_id="vacuum.mqtttest" + ) mqtt_mock.async_publish.assert_not_called() +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": { + "vacuum": { + "name": "test", + "schema": "state", + mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( + ALL_SERVICES, SERVICE_TO_STRING + ), + } + } + } + ], +) +async def test_command_without_command_topic( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test commands which are not supported by the vacuum.""" + mqtt_mock = await mqtt_mock_entry() + + await common.async_start(hass, "vacuum.test") + mqtt_mock.async_publish.assert_not_called() + mqtt_mock.async_publish.reset_mock() + + await common.async_set_fan_speed(hass, "low", "vacuum.test") + mqtt_mock.async_publish.assert_not_called() + mqtt_mock.async_publish.reset_mock() + + await common.async_send_command(hass, "some command", "vacuum.test") + mqtt_mock.async_publish.assert_not_called() + mqtt_mock.async_publish.reset_mock() + + @pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES]) async def test_status( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator @@ -768,7 +809,11 @@ async def test_encoding_subscribable_topics( ) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index b06cfa34442..4471cc7dc11 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -738,7 +738,11 @@ async def test_encoding_subscribable_topics( ) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index f8c7b55f7ce..55eac636edb 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -1,10 +1,10 @@ """The tests for MQTT tag scanner.""" +from collections.abc import Generator import copy import json -from unittest.mock import ANY, patch +from unittest.mock import ANY, AsyncMock, patch import pytest -from voluptuous import MultipleInvalid from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.mqtt.const import DOMAIN as MQTT_DOMAIN @@ -47,14 +47,14 @@ DEFAULT_TAG_SCAN_JSON = ( @pytest.fixture(autouse=True) -def binary_sensor_only(): +def binary_sensor_only() -> Generator[None, None, None]: """Only setup the binary_sensor platform to speed up test.""" with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]): yield @pytest.fixture -def tag_mock(): +def tag_mock() -> Generator[AsyncMock, None, None]: """Fixture to mock tag.""" with patch("homeassistant.components.tag.async_scan_tag") as mock_tag: yield mock_tag @@ -65,7 +65,7 @@ async def test_discover_bad_tag( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, - tag_mock, + tag_mock: AsyncMock, ) -> None: """Test bad discovery message.""" await mqtt_mock_entry() @@ -75,13 +75,13 @@ async def test_discover_bad_tag( data0 = '{ "device":{"identifiers":["0AFFD2"]}, "topics": "foobar/tag_scanned" }' async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data0) await hass.async_block_till_done() - assert device_registry.async_get_device({("mqtt", "0AFFD2")}) is None + assert device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) is None # Test sending correct data async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", json.dumps(config1)) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) # Fake tag scan. async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) await hass.async_block_till_done() @@ -92,7 +92,7 @@ async def test_if_fires_on_mqtt_message_with_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, - tag_mock, + tag_mock: AsyncMock, ) -> None: """Test tag scanning, with device.""" await mqtt_mock_entry() @@ -100,7 +100,7 @@ async def test_if_fires_on_mqtt_message_with_device( async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) # Fake tag scan. async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) @@ -110,9 +110,8 @@ async def test_if_fires_on_mqtt_message_with_device( async def test_if_fires_on_mqtt_message_without_device( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, - tag_mock, + tag_mock: AsyncMock, ) -> None: """Test tag scanning, without device.""" await mqtt_mock_entry() @@ -131,7 +130,7 @@ async def test_if_fires_on_mqtt_message_with_template( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, - tag_mock, + tag_mock: AsyncMock, ) -> None: """Test tag scanning, with device.""" await mqtt_mock_entry() @@ -139,7 +138,7 @@ async def test_if_fires_on_mqtt_message_with_template( async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) # Fake tag scan. async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN_JSON) @@ -150,7 +149,7 @@ async def test_if_fires_on_mqtt_message_with_template( async def test_strip_tag_id( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - tag_mock, + tag_mock: AsyncMock, ) -> None: """Test strip whitespace from tag_id.""" await mqtt_mock_entry() @@ -169,7 +168,7 @@ async def test_if_fires_on_mqtt_message_after_update_with_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, - tag_mock, + tag_mock: AsyncMock, ) -> None: """Test tag scanning after update.""" await mqtt_mock_entry() @@ -181,7 +180,7 @@ async def test_if_fires_on_mqtt_message_after_update_with_device( async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config1)) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) # Fake tag scan. async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) @@ -218,7 +217,7 @@ async def test_if_fires_on_mqtt_message_after_update_with_device( async def test_if_fires_on_mqtt_message_after_update_without_device( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - tag_mock, + tag_mock: AsyncMock, ) -> None: """Test tag scanning after update.""" await mqtt_mock_entry() @@ -265,7 +264,7 @@ async def test_if_fires_on_mqtt_message_after_update_with_template( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, - tag_mock, + tag_mock: AsyncMock, ) -> None: """Test tag scanning after update.""" await mqtt_mock_entry() @@ -276,7 +275,7 @@ async def test_if_fires_on_mqtt_message_after_update_with_template( async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config1)) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) # Fake tag scan. async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN_JSON) @@ -321,7 +320,7 @@ async def test_no_resubscribe_same_topic( async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) await hass.async_block_till_done() - assert device_registry.async_get_device({("mqtt", "0AFFD2")}) + assert device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) call_count = mqtt_mock.async_subscribe.call_count async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -333,7 +332,7 @@ async def test_not_fires_on_mqtt_message_after_remove_by_mqtt_with_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, - tag_mock, + tag_mock: AsyncMock, ) -> None: """Test tag scanning after removal.""" await mqtt_mock_entry() @@ -341,7 +340,7 @@ async def test_not_fires_on_mqtt_message_after_remove_by_mqtt_with_device( async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) # Fake tag scan. async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) @@ -369,7 +368,7 @@ async def test_not_fires_on_mqtt_message_after_remove_by_mqtt_with_device( async def test_not_fires_on_mqtt_message_after_remove_by_mqtt_without_device( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - tag_mock, + tag_mock: AsyncMock, ) -> None: """Test tag scanning not firing after removal.""" await mqtt_mock_entry() @@ -406,7 +405,7 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( hass_ws_client: WebSocketGenerator, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, - tag_mock, + tag_mock: AsyncMock, ) -> None: """Test tag scanning after removal.""" assert await async_setup_component(hass, "config", {}) @@ -418,7 +417,7 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) # Fake tag scan. async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) @@ -468,7 +467,7 @@ async def test_entity_device_info_with_connection( await hass.async_block_till_done() device = registry.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} + connections={(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} ) assert device is not None assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "02:5b:26:a8:dc:12")} @@ -502,7 +501,7 @@ async def test_entity_device_info_with_identifier( async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.identifiers == {("mqtt", "helloworld")} assert device.manufacturer == "Whatever" @@ -535,7 +534,7 @@ async def test_entity_device_info_update( async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.name == "Beer" @@ -544,7 +543,7 @@ async def test_entity_device_info_update( async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}) + device = registry.async_get_device(identifiers={("mqtt", "helloworld")}) assert device is not None assert device.name == "Milk" @@ -589,20 +588,24 @@ async def test_cleanup_tag( await hass.async_block_till_done() # Verify device registry entries are created - device_entry1 = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry1 = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry1 is not None assert device_entry1.config_entries == {config_entry.entry_id, mqtt_entry.entry_id} - device_entry2 = device_registry.async_get_device({("mqtt", "hejhopp")}) + device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")}) assert device_entry2 is not None # Remove other config entry from the device device_registry.async_update_device( device_entry1.id, remove_config_entry_id=config_entry.entry_id ) - device_entry1 = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry1 = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry1 is not None assert device_entry1.config_entries == {mqtt_entry.entry_id} - device_entry2 = device_registry.async_get_device({("mqtt", "hejhopp")}) + device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")}) assert device_entry2 is not None mqtt_mock.async_publish.assert_not_called() @@ -622,9 +625,11 @@ async def test_cleanup_tag( await hass.async_block_till_done() # Verify device registry entry is cleared - device_entry1 = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry1 = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry1 is None - device_entry2 = device_registry.async_get_device({("mqtt", "hejhopp")}) + device_entry2 = device_registry.async_get_device(identifiers={("mqtt", "hejhopp")}) assert device_entry2 is not None # Verify retained discovery topic has been cleared @@ -650,14 +655,18 @@ async def test_cleanup_device( await hass.async_block_till_done() # Verify device registry entry is created - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", "") await hass.async_block_till_done() # Verify device registry entry is cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is None @@ -685,14 +694,18 @@ async def test_cleanup_device_several_tags( await hass.async_block_till_done() # Verify device registry entry is created - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", "") await hass.async_block_till_done() # Verify device registry entry is not cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None # Fake tag scan. @@ -705,7 +718,9 @@ async def test_cleanup_device_several_tags( await hass.async_block_till_done() # Verify device registry entry is cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is None @@ -750,7 +765,9 @@ async def test_cleanup_device_with_entity_and_trigger_1( await hass.async_block_till_done() # Verify device registry entry is created - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None triggers = await async_get_device_automations( @@ -762,7 +779,9 @@ async def test_cleanup_device_with_entity_and_trigger_1( await hass.async_block_till_done() # Verify device registry entry is not cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", "") @@ -772,7 +791,9 @@ async def test_cleanup_device_with_entity_and_trigger_1( await hass.async_block_till_done() # Verify device registry entry is cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is None @@ -817,7 +838,9 @@ async def test_cleanup_device_with_entity2( await hass.async_block_till_done() # Verify device registry entry is created - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None triggers = await async_get_device_automations( @@ -832,22 +855,26 @@ async def test_cleanup_device_with_entity2( await hass.async_block_till_done() # Verify device registry entry is not cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is not None async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", "") await hass.async_block_till_done() # Verify device registry entry is cleared - device_entry = device_registry.async_get_device({("mqtt", "helloworld")}) + device_entry = device_registry.async_get_device( + identifiers={("mqtt", "helloworld")} + ) assert device_entry is None -@pytest.mark.xfail(raises=MultipleInvalid) async def test_update_with_bad_config_not_breaks_discovery( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, - tag_mock, + caplog: pytest.LogCaptureFixture, + tag_mock: AsyncMock, ) -> None: """Test a bad update does not break discovery.""" await mqtt_mock_entry() @@ -875,6 +902,7 @@ async def test_update_with_bad_config_not_breaks_discovery( # Update with bad identifier async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", data2) await hass.async_block_till_done() + assert "extra keys not allowed @ data['device']['bad_key']" in caplog.text # Topic update async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", data3) @@ -891,7 +919,7 @@ async def test_unload_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock: MqttMockHAClient, - tag_mock, + tag_mock: AsyncMock, ) -> None: """Test unloading the MQTT entry.""" @@ -899,7 +927,7 @@ async def test_unload_entry( async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) await hass.async_block_till_done() - device_entry = device_registry.async_get_device({("mqtt", "0AFFD2")}) + device_entry = device_registry.async_get_device(identifiers={("mqtt", "0AFFD2")}) # Fake tag scan, should be processed async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index b96b82277b0..9e068a07824 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -738,7 +738,11 @@ async def test_encoding_subscribable_topics( ) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_update.py b/tests/components/mqtt/test_update.py index 8e2cdaf8eaa..9c881352f8c 100644 --- a/tests/components/mqtt/test_update.py +++ b/tests/components/mqtt/test_update.py @@ -696,7 +696,11 @@ async def test_entity_id_update_discovery_update( ) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mqtt/test_water_heater.py b/tests/components/mqtt/test_water_heater.py index 942a2ec87d4..245af5c6918 100644 --- a/tests/components/mqtt/test_water_heater.py +++ b/tests/components/mqtt/test_water_heater.py @@ -257,6 +257,91 @@ async def test_set_operation_optimistic( assert state.state == "performance" +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ({"power_command_topic": "power-command"},), + ) + ], +) +async def test_set_operation_with_power_command( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting of new operation mode with power command enabled.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + await common.async_set_operation_mode(hass, "electric", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "electric" + mqtt_mock.async_publish.assert_has_calls([call("mode-topic", "electric", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + await common.async_set_operation_mode(hass, "off", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + mqtt_mock.async_publish.assert_has_calls([call("mode-topic", "off", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + await common.async_turn_on(hass, ENTITY_WATER_HEATER) + # the water heater is not updated optimistically as this is not supported + mqtt_mock.async_publish.assert_has_calls([call("power-command", "ON", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + await common.async_turn_off(hass, ENTITY_WATER_HEATER) + mqtt_mock.async_publish.assert_has_calls([call("power-command", "OFF", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + water_heater.DOMAIN, + DEFAULT_CONFIG, + ({"power_command_topic": "power-command", "optimistic": True},), + ) + ], +) +async def test_turn_on_and_off_optimistic_with_power_command( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test setting of turn on/off with power command enabled.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + await common.async_set_operation_mode(hass, "electric", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "electric" + mqtt_mock.async_publish.assert_has_calls([call("mode-topic", "electric", 0, False)]) + mqtt_mock.async_publish.reset_mock() + await common.async_set_operation_mode(hass, "off", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + + await common.async_turn_on(hass, ENTITY_WATER_HEATER) + # the water heater is not updated optimistically as this is not supported + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "off" + mqtt_mock.async_publish.assert_has_calls([call("power-command", "ON", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + await common.async_set_operation_mode(hass, "gas", ENTITY_WATER_HEATER) + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "gas" + await common.async_turn_off(hass, ENTITY_WATER_HEATER) + # the water heater is not updated optimistically as this is not supported + state = hass.states.get(ENTITY_WATER_HEATER) + assert state.state == "gas" + mqtt_mock.async_publish.assert_has_calls([call("power-command", "OFF", 0, False)]) + mqtt_mock.async_publish.reset_mock() + + @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_set_target_temperature( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator @@ -509,9 +594,11 @@ async def test_get_with_templates( "name": "test", "mode_command_topic": "mode-topic", "temperature_command_topic": "temperature-topic", + "power_command_topic": "power-topic", # Create simple templates "mode_command_template": "mode: {{ value }}", "temperature_command_template": "temp: {{ value }}", + "power_command_template": "pwr: {{ value }}", } } } @@ -544,6 +631,14 @@ async def test_set_and_templates( state = hass.states.get(ENTITY_WATER_HEATER) assert state.attributes.get("temperature") == 107 + # Power + await common.async_turn_on(hass, entity_id=ENTITY_WATER_HEATER) + mqtt_mock.async_publish.assert_called_once_with("power-topic", "pwr: ON", 0, False) + mqtt_mock.async_publish.reset_mock() + await common.async_turn_off(hass, entity_id=ENTITY_WATER_HEATER) + mqtt_mock.async_publish.assert_called_once_with("power-topic", "pwr: OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + @pytest.mark.parametrize( "hass_config", @@ -1047,6 +1142,20 @@ async def test_precision_whole( 20.1, "temperature_command_template", ), + ( + water_heater.SERVICE_TURN_ON, + "power_command_topic", + {}, + "ON", + "power_command_template", + ), + ( + water_heater.SERVICE_TURN_OFF, + "power_command_topic", + {}, + "OFF", + "power_command_template", + ), ], ) async def test_publishing_with_custom_encoding( @@ -1087,7 +1196,11 @@ async def test_reloadable( await help_test_reloadable(hass, mqtt_client_mock, domain, config) -@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +@pytest.mark.parametrize( + "hass_config", + [DEFAULT_CONFIG, {"mqtt": [DEFAULT_CONFIG["mqtt"]]}], + ids=["platform_key", "listed"], +) async def test_setup_manual_entity_from_yaml( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: diff --git a/tests/components/mystrom/__init__.py b/tests/components/mystrom/__init__.py index 21f6bd7a549..acd520cebaa 100644 --- a/tests/components/mystrom/__init__.py +++ b/tests/components/mystrom/__init__.py @@ -1,5 +1,5 @@ """Tests for the myStrom integration.""" -from typing import Any, Optional +from typing import Any def get_default_device_response(device_type: int | None) -> dict[str, Any]: @@ -79,49 +79,49 @@ class MyStromBulbMock(MyStromDeviceMock): self.mac = mac @property - def firmware(self) -> Optional[str]: + def firmware(self) -> str | None: """Return current firmware.""" if not self._requested_state: return None return self._state["fw_version"] @property - def consumption(self) -> Optional[float]: + def consumption(self) -> float | None: """Return current firmware.""" if not self._requested_state: return None return self._state["power"] @property - def color(self) -> Optional[str]: + def color(self) -> str | None: """Return current color settings.""" if not self._requested_state: return None return self._state["color"] @property - def mode(self) -> Optional[str]: + def mode(self) -> str | None: """Return current mode.""" if not self._requested_state: return None return self._state["mode"] @property - def transition_time(self) -> Optional[int]: + def transition_time(self) -> int | None: """Return current transition time (ramp).""" if not self._requested_state: return None return self._state["ramp"] @property - def bulb_type(self) -> Optional[str]: + def bulb_type(self) -> str | None: """Return the type of the bulb.""" if not self._requested_state: return None return self._state["type"] @property - def state(self) -> Optional[bool]: + def state(self) -> bool | None: """Return the current state of the bulb.""" if not self._requested_state: return None @@ -132,42 +132,42 @@ class MyStromSwitchMock(MyStromDeviceMock): """MyStrom Switch mock.""" @property - def relay(self) -> Optional[bool]: + def relay(self) -> bool | None: """Return the relay state.""" if not self._requested_state: return None return self._state["on"] @property - def consumption(self) -> Optional[float]: + def consumption(self) -> float | None: """Return the current power consumption in mWh.""" if not self._requested_state: return None return self._state["power"] @property - def consumedWs(self) -> Optional[float]: + def consumedWs(self) -> float | None: """The average of energy consumed per second since last report call.""" if not self._requested_state: return None return self._state["Ws"] @property - def firmware(self) -> Optional[str]: + def firmware(self) -> str | None: """Return the current firmware.""" if not self._requested_state: return None return self._state["version"] @property - def mac(self) -> Optional[str]: + def mac(self) -> str | None: """Return the MAC address.""" if not self._requested_state: return None return self._state["mac"] @property - def temperature(self) -> Optional[float]: + def temperature(self) -> float | None: """Return the current temperature in celsius.""" if not self._requested_state: return None diff --git a/tests/components/nest/test_camera_sdm.py b/tests/components/nest/test_camera.py similarity index 100% rename from tests/components/nest/test_camera_sdm.py rename to tests/components/nest/test_camera.py diff --git a/tests/components/nest/test_climate_sdm.py b/tests/components/nest/test_climate.py similarity index 100% rename from tests/components/nest/test_climate_sdm.py rename to tests/components/nest/test_climate.py diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow.py similarity index 100% rename from tests/components/nest/test_config_flow_sdm.py rename to tests/components/nest/test_config_flow.py diff --git a/tests/components/nest/test_config_flow_legacy.py b/tests/components/nest/test_config_flow_legacy.py deleted file mode 100644 index 897961d9f98..00000000000 --- a/tests/components/nest/test_config_flow_legacy.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Tests for the Nest config flow.""" -import asyncio -from unittest.mock import patch - -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.nest import DOMAIN, config_flow -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from .common import TEST_CONFIG_LEGACY - -from tests.common import MockConfigEntry - -CONFIG = TEST_CONFIG_LEGACY.config - - -async def test_abort_if_single_instance_allowed(hass: HomeAssistant) -> None: - """Test we abort if Nest is already setup.""" - existing_entry = MockConfigEntry(domain=DOMAIN, data={}) - existing_entry.add_to_hass(hass) - - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" - - -async def test_full_flow_implementation(hass: HomeAssistant) -> None: - """Test registering an implementation and finishing flow works.""" - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() - # Register an additional implementation to select from during the flow - config_flow.register_flow_implementation( - hass, "test-other", "Test Other", None, None - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"flow_impl": "nest"}, - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "link" - assert ( - result["description_placeholders"] - .get("url") - .startswith("https://home.nest.com/login/oauth2?client_id=some-client-id") - ) - - def mock_login(auth): - assert auth.pin == "123ABC" - auth.auth_callback({"access_token": "yoo"}) - - with patch( - "homeassistant.components.nest.legacy.local_auth.NestAuth.login", new=mock_login - ), patch( - "homeassistant.components.nest.async_setup_legacy_entry", return_value=True - ) as mock_setup: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"code": "123ABC"} - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"]["tokens"] == {"access_token": "yoo"} - assert result["data"]["impl_domain"] == "nest" - assert result["title"] == "Nest (via configuration.yaml)" - - -async def test_not_pick_implementation_if_only_one(hass: HomeAssistant) -> None: - """Test we pick the default implementation when registered.""" - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "link" - - -async def test_abort_if_timeout_generating_auth_url(hass: HomeAssistant) -> None: - """Test we abort if generating authorize url fails.""" - with patch( - "homeassistant.components.nest.legacy.local_auth.generate_auth_url", - side_effect=asyncio.TimeoutError, - ): - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "authorize_url_timeout" - - -async def test_abort_if_exception_generating_auth_url(hass: HomeAssistant) -> None: - """Test we abort if generating authorize url blows up.""" - with patch( - "homeassistant.components.nest.legacy.local_auth.generate_auth_url", - side_effect=ValueError, - ): - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "unknown_authorize_url_generation" - - -async def test_verify_code_timeout(hass: HomeAssistant) -> None: - """Test verify code timing out.""" - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "link" - - with patch( - "homeassistant.components.nest.legacy.local_auth.NestAuth.login", - side_effect=asyncio.TimeoutError, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"code": "123ABC"} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "link" - assert result["errors"] == {"code": "timeout"} - - -async def test_verify_code_invalid(hass: HomeAssistant) -> None: - """Test verify code invalid.""" - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "link" - - with patch( - "homeassistant.components.nest.legacy.local_auth.NestAuth.login", - side_effect=config_flow.CodeInvalid, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"code": "123ABC"} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "link" - assert result["errors"] == {"code": "invalid_pin"} - - -async def test_verify_code_unknown_error(hass: HomeAssistant) -> None: - """Test verify code unknown error.""" - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "link" - - with patch( - "homeassistant.components.nest.legacy.local_auth.NestAuth.login", - side_effect=config_flow.NestAuthError, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"code": "123ABC"} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "link" - assert result["errors"] == {"code": "unknown"} - - -async def test_verify_code_exception(hass: HomeAssistant) -> None: - """Test verify code blows up.""" - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "link" - - with patch( - "homeassistant.components.nest.legacy.local_auth.NestAuth.login", - side_effect=ValueError, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"code": "123ABC"} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "link" - assert result["errors"] == {"code": "internal_error"} - - -async def test_step_import(hass: HomeAssistant) -> None: - """Test that we trigger import when configuring with client.""" - with patch("os.path.isfile", return_value=False): - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() - - flow = hass.config_entries.flow.async_progress()[0] - result = await hass.config_entries.flow.async_configure(flow["flow_id"]) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "link" - - -async def test_step_import_with_token_cache(hass: HomeAssistant) -> None: - """Test that we import existing token cache.""" - with patch("os.path.isfile", return_value=True), patch( - "homeassistant.components.nest.config_flow.load_json_object", - return_value={"access_token": "yo"}, - ), patch( - "homeassistant.components.nest.async_setup_legacy_entry", return_value=True - ) as mock_setup: - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - - entry = hass.config_entries.async_entries(DOMAIN)[0] - - assert entry.data == {"impl_domain": "nest", "tokens": {"access_token": "yo"}} diff --git a/tests/components/nest/test_device_trigger.py b/tests/components/nest/test_device_trigger.py index f659568c674..a35b10afa9c 100644 --- a/tests/components/nest/test_device_trigger.py +++ b/tests/components/nest/test_device_trigger.py @@ -103,7 +103,7 @@ async def test_get_triggers( await setup_platform() device_registry = dr.async_get(hass) - device_entry = device_registry.async_get_device({("nest", DEVICE_ID)}) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) expected_triggers = [ { @@ -198,7 +198,7 @@ async def test_triggers_for_invalid_device_id( await setup_platform() device_registry = dr.async_get(hass) - device_entry = device_registry.async_get_device({("nest", DEVICE_ID)}) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) assert device_entry is not None # Create an additional device that does not exist. Fetching supported @@ -324,7 +324,7 @@ async def test_subscriber_automation( await setup_platform() device_registry = dr.async_get(hass) - device_entry = device_registry.async_get_device({("nest", DEVICE_ID)}) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) assert await setup_automation(hass, device_entry.id, "camera_motion") diff --git a/tests/components/nest/test_diagnostics.py b/tests/components/nest/test_diagnostics.py index 530e3695d11..191253a2a9a 100644 --- a/tests/components/nest/test_diagnostics.py +++ b/tests/components/nest/test_diagnostics.py @@ -9,8 +9,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .common import TEST_CONFIG_LEGACY - from tests.components.diagnostics import ( get_diagnostics_for_config_entry, get_diagnostics_for_device, @@ -119,7 +117,7 @@ async def test_device_diagnostics( assert config_entry.state is ConfigEntryState.LOADED device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, NEST_DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, NEST_DEVICE_ID)}) assert device is not None assert ( @@ -146,21 +144,6 @@ async def test_setup_susbcriber_failure( assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == {} -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_LEGACY]) -async def test_legacy_config_entry_diagnostics( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - config_entry, - setup_base_platform, -) -> None: - """Test config entry diagnostics for legacy integration doesn't fail.""" - - with patch("homeassistant.components.nest.legacy.Nest"): - await setup_base_platform() - - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == {} - - async def test_camera_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/nest/test_init_sdm.py b/tests/components/nest/test_init.py similarity index 90% rename from tests/components/nest/test_init_sdm.py rename to tests/components/nest/test_init.py index db560e44e83..ecfe412bdbf 100644 --- a/tests/components/nest/test_init_sdm.py +++ b/tests/components/nest/test_init.py @@ -22,15 +22,20 @@ import pytest from homeassistant.components.nest import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from .common import ( PROJECT_ID, SUBSCRIBER_ID, + TEST_CONFIG_ENTRY_LEGACY, + TEST_CONFIG_LEGACY, TEST_CONFIGFLOW_APP_CREDS, FakeSubscriber, YieldFixture, ) +from tests.common import MockConfigEntry + PLATFORM = "sensor" @@ -276,3 +281,26 @@ async def test_migrate_unique_id( assert config_entry.state is ConfigEntryState.LOADED assert config_entry.unique_id == PROJECT_ID + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_LEGACY]) +async def test_legacy_works_with_nest_yaml( + hass: HomeAssistant, + config: dict[str, Any], + config_entry: MockConfigEntry, +) -> None: + """Test integration won't start with legacy works with nest yaml config.""" + config_entry.add_to_hass(hass) + assert not await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_ENTRY_LEGACY]) +async def test_legacy_works_with_nest_cleanup( + hass: HomeAssistant, setup_platform +) -> None: + """Test legacy works with nest config entries are silently removed once yaml is removed.""" + await setup_platform() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 0 diff --git a/tests/components/nest/test_init_legacy.py b/tests/components/nest/test_init_legacy.py deleted file mode 100644 index f27382d0345..00000000000 --- a/tests/components/nest/test_init_legacy.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Test basic initialization for the Legacy Nest API using mocks for the Nest python library.""" -from unittest.mock import MagicMock, PropertyMock, patch - -import pytest - -from homeassistant.core import HomeAssistant - -from .common import TEST_CONFIG_ENTRY_LEGACY, TEST_CONFIG_LEGACY - -DOMAIN = "nest" - - -@pytest.fixture -def nest_test_config(): - """Fixture to specify the overall test fixture configuration.""" - return TEST_CONFIG_LEGACY - - -def make_thermostat(): - """Make a mock thermostat with dummy values.""" - device = MagicMock() - type(device).device_id = PropertyMock(return_value="a.b.c.d.e.f.g") - type(device).name = PropertyMock(return_value="My Thermostat") - type(device).name_long = PropertyMock(return_value="My Thermostat") - type(device).serial = PropertyMock(return_value="serial-number") - type(device).mode = "off" - type(device).hvac_state = "off" - type(device).target = PropertyMock(return_value=31.0) - type(device).temperature = PropertyMock(return_value=30.1) - type(device).min_temperature = PropertyMock(return_value=10.0) - type(device).max_temperature = PropertyMock(return_value=50.0) - type(device).humidity = PropertyMock(return_value=40.4) - type(device).software_version = PropertyMock(return_value="a.b.c") - return device - - -@pytest.mark.parametrize( - "nest_test_config", [TEST_CONFIG_LEGACY, TEST_CONFIG_ENTRY_LEGACY] -) -async def test_thermostat(hass: HomeAssistant, setup_base_platform) -> None: - """Test simple initialization for thermostat entities.""" - - thermostat = make_thermostat() - - structure = MagicMock() - type(structure).name = PropertyMock(return_value="My Room") - type(structure).thermostats = PropertyMock(return_value=[thermostat]) - type(structure).eta = PropertyMock(return_value="away") - - nest = MagicMock() - type(nest).structures = PropertyMock(return_value=[structure]) - - with patch("homeassistant.components.nest.legacy.Nest", return_value=nest), patch( - "homeassistant.components.nest.legacy.sensor._VALID_SENSOR_TYPES", - ["humidity", "temperature"], - ), patch( - "homeassistant.components.nest.legacy.binary_sensor._VALID_BINARY_SENSOR_TYPES", - {"fan": None}, - ): - await setup_base_platform() - - climate = hass.states.get("climate.my_thermostat") - assert climate is not None - assert climate.state == "off" - - temperature = hass.states.get("sensor.my_thermostat_temperature") - assert temperature is not None - assert temperature.state == "-1.1" - - humidity = hass.states.get("sensor.my_thermostat_humidity") - assert humidity is not None - assert humidity.state == "40.4" - - fan = hass.states.get("binary_sensor.my_thermostat_fan") - assert fan is not None - assert fan.state == "on" diff --git a/tests/components/nest/test_local_auth.py b/tests/components/nest/test_local_auth.py deleted file mode 100644 index 6ba704e6c3e..00000000000 --- a/tests/components/nest/test_local_auth.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Test Nest local auth.""" -from urllib.parse import parse_qsl - -import pytest -import requests_mock -from requests_mock import create_response - -from homeassistant.components.nest import config_flow, const -from homeassistant.components.nest.legacy import local_auth - - -@pytest.fixture -def registered_flow(hass): - """Mock a registered flow.""" - local_auth.initialize(hass, "TEST-CLIENT-ID", "TEST-CLIENT-SECRET") - return hass.data[config_flow.DATA_FLOW_IMPL][const.DOMAIN] - - -async def test_generate_auth_url(registered_flow) -> None: - """Test generating an auth url. - - Mainly testing that it doesn't blow up. - """ - url = await registered_flow["gen_authorize_url"]("TEST-FLOW-ID") - assert url is not None - - -async def test_convert_code( - requests_mock: requests_mock.Mocker, registered_flow -) -> None: - """Test converting a code.""" - from nest.nest import ACCESS_TOKEN_URL - - def token_matcher(request): - """Match a fetch token request.""" - if request.url != ACCESS_TOKEN_URL: - return None - - assert dict(parse_qsl(request.text)) == { - "client_id": "TEST-CLIENT-ID", - "client_secret": "TEST-CLIENT-SECRET", - "code": "TEST-CODE", - "grant_type": "authorization_code", - } - - return create_response(request, json={"access_token": "TEST-ACCESS-TOKEN"}) - - requests_mock.add_matcher(token_matcher) - - tokens = await registered_flow["convert_code"]("TEST-CODE") - assert tokens == {"access_token": "TEST-ACCESS-TOKEN"} diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 2b25694de6c..6c827e76163 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -256,7 +256,7 @@ async def test_supported_device(hass: HomeAssistant, setup_platform) -> None: assert camera is not None device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -318,7 +318,7 @@ async def test_camera_event( assert camera is not None device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -448,7 +448,7 @@ async def test_event_order( assert camera is not None device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -493,7 +493,7 @@ async def test_multiple_image_events_in_session( assert camera is not None device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -607,7 +607,7 @@ async def test_multiple_clip_preview_events_in_session( assert camera is not None device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -695,7 +695,7 @@ async def test_browse_invalid_device_id( await setup_platform() device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -716,7 +716,7 @@ async def test_browse_invalid_event_id( await setup_platform() device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -739,7 +739,7 @@ async def test_resolve_missing_event_id( await setup_platform() device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -771,7 +771,7 @@ async def test_resolve_invalid_event_id( await setup_platform() device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -819,7 +819,7 @@ async def test_camera_event_clip_preview( assert camera is not None device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -916,7 +916,7 @@ async def test_event_media_render_invalid_event_id( """Test event media API called with an invalid device id.""" await setup_platform() device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -958,7 +958,7 @@ async def test_event_media_failure( assert camera is not None device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -998,7 +998,7 @@ async def test_media_permission_unauthorized( assert camera is not None device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -1034,9 +1034,9 @@ async def test_multiple_devices( await setup_platform() device_registry = dr.async_get(hass) - device1 = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device1 = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device1 - device2 = device_registry.async_get_device({(DOMAIN, device_id2)}) + device2 = device_registry.async_get_device(identifiers={(DOMAIN, device_id2)}) assert device2 # Very no events have been received yet @@ -1121,7 +1121,7 @@ async def test_media_store_persistence( await setup_platform() device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -1174,7 +1174,7 @@ async def test_media_store_persistence( await hass.async_block_till_done() device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -1233,7 +1233,7 @@ async def test_media_store_save_filesystem_error( assert camera is not None device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -1272,7 +1272,7 @@ async def test_media_store_load_filesystem_error( assert camera is not None device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -1322,7 +1322,7 @@ async def test_camera_event_media_eviction( await setup_platform() device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME @@ -1399,7 +1399,7 @@ async def test_camera_image_resize( await setup_platform() device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)}) assert device assert device.name == DEVICE_NAME diff --git a/tests/components/netatmo/test_device_trigger.py b/tests/components/netatmo/test_device_trigger.py index c7cbaf4d131..ebafd313ff4 100644 --- a/tests/components/netatmo/test_device_trigger.py +++ b/tests/components/netatmo/test_device_trigger.py @@ -161,7 +161,7 @@ async def test_if_fires_on_event( }, ) - device = device_registry.async_get_device(set(), {connection}) + device = device_registry.async_get_device(connections={connection}) assert device is not None # Fake that the entity is turning on. @@ -244,7 +244,7 @@ async def test_if_fires_on_event_legacy( }, ) - device = device_registry.async_get_device(set(), {connection}) + device = device_registry.async_get_device(connections={connection}) assert device is not None # Fake that the entity is turning on. @@ -328,7 +328,7 @@ async def test_if_fires_on_event_with_subtype( }, ) - device = device_registry.async_get_device(set(), {connection}) + device = device_registry.async_get_device(connections={connection}) assert device is not None # Fake that the entity is turning on. diff --git a/tests/components/nina/fixtures/sample_warning_details.json b/tests/components/nina/fixtures/sample_warning_details.json index 612885e9aba..48a2e6964c7 100644 --- a/tests/components/nina/fixtures/sample_warning_details.json +++ b/tests/components/nina/fixtures/sample_warning_details.json @@ -30,7 +30,7 @@ ], "headline": "Corona-Verordnung des Landes: Warnstufe durch Landesgesundheitsamt ausgerufen", "description": "Die Zahl der mit dem Corona-Virus infizierten Menschen steigt gegenwärtig stark an. Es wächst daher die Gefahr einer weiteren Verbreitung der Infektion und - je nach Einzelfall - auch von schweren Erkrankungen.", - "instruction": "Waschen Sie sich regelmäßig und gründlich die Hände.
- Beachten Sie die AHA + A + L - Regeln:
Abstand halten - 1,5 m Mindestabstand beachten, Körperkontakt vermeiden!
Hygiene - regelmäßiges Händewaschen, Husten- und Nieshygiene beachten!
Alltagsmaske (Mund-Nase-Bedeckung) tragen!
App - installieren und nutzen Sie die Corona-Warn-App!
Lüften: Sorgen Sie für eine regelmäßige und gründliche Lüftung von Räumen - auch und gerade in der kommenden kalten Jahreszeit!
- Bitte folgen Sie den behördlichen Anordnungen.
- Husten und niesen Sie in ein Taschentuch oder in die Armbeuge.
- Bleiben Sie bei Erkältungssymptomen nach Möglichkeit zu Hause. Kontaktieren Sie Ihre Hausarztpraxis per Telefon oder wenden sich an die Telefonnummer 116117 des Ärztlichen Bereitschaftsdienstes und besprechen Sie das weitere Vorgehen. Gehen Sie nicht unaufgefordert in eine Arztpraxis oder ins Krankenhaus.
- Seien Sie kritisch: Informieren Sie sich nur aus gesicherten Quellen.", + "instruction": "Waschen sich regelmäßig und gründlich die Hände.", "contact": "Weitere Informationen und Empfehlungen finden Sie im Corona-Informations-Bereich der Warn-App NINA. Beachten Sie auch die Internetseiten der örtlichen Gesundheitsbehörde (Stadt- bzw. Kreisverwaltung) Ihres Aufenthaltsortes", "parameter": [ { @@ -125,7 +125,6 @@ "senderName": "Deutscher Wetterdienst", "headline": "Ausfall Notruf 112", "description": "Es treten Sturmböen mit Geschwindigkeiten zwischen 70 km/h (20m/s, 38kn, Bft 8) und 85 km/h (24m/s, 47kn, Bft 9) aus westlicher Richtung auf. In Schauernähe sowie in exponierten Lagen muss mit schweren Sturmböen bis 90 km/h (25m/s, 48kn, Bft 10) gerechnet werden.", - "instruction": "ACHTUNG! Hinweis auf mögliche Gefahren: Es können zum Beispiel einzelne Äste herabstürzen. Achten Sie besonders auf herabfallende Gegenstände.", "web": "https://www.wettergefahren.de", "contact": "Deutscher Wetterdienst", "parameter": [ diff --git a/tests/components/nina/test_binary_sensor.py b/tests/components/nina/test_binary_sensor.py index 6238496ed09..c6fd5bdd830 100644 --- a/tests/components/nina/test_binary_sensor.py +++ b/tests/components/nina/test_binary_sensor.py @@ -182,7 +182,7 @@ async def test_sensors_without_corona_filter(hass: HomeAssistant) -> None: assert state_w1.attributes.get(ATTR_SEVERITY) == "Minor" assert ( state_w1.attributes.get(ATTR_RECOMMENDED_ACTIONS) - == "Es besteht keine Gefahr." + == "Waschen sich regelmäßig und gründlich die Hände." ) assert state_w1.attributes.get(ATTR_ID) == "mow.DE-BW-S-SE018-20211102-18-001" assert state_w1.attributes.get(ATTR_SENT) == "2021-11-02T20:07:16+01:00" diff --git a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py index 85d4b16048a..673ac1a72d4 100644 --- a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py +++ b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py @@ -101,9 +101,7 @@ async def test_setup(hass: HomeAssistant) -> None: category="Category 1", location="Location 1", attribution="Attribution 1", - publication_date=datetime.datetime( - 2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc - ), + publication_date=datetime.datetime(2018, 9, 22, 8, 0, tzinfo=datetime.UTC), council_area="Council Area 1", status="Status 1", entry_type="Type 1", @@ -148,7 +146,7 @@ async def test_setup(hass: HomeAssistant) -> None: ATTR_LOCATION: "Location 1", ATTR_ATTRIBUTION: "Attribution 1", ATTR_PUBLICATION_DATE: datetime.datetime( - 2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc + 2018, 9, 22, 8, 0, tzinfo=datetime.UTC ), ATTR_FIRE: True, ATTR_COUNCIL_AREA: "Council Area 1", diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index d9cf27c12aa..37c0b175faa 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -22,6 +22,7 @@ from homeassistant.components.number.const import ( ) from homeassistant.components.sensor import ( DEVICE_CLASS_UNITS as SENSOR_DEVICE_CLASS_UNITS, + NON_NUMERIC_DEVICE_CLASSES, SensorDeviceClass, ) from homeassistant.config_entries import ConfigEntry, ConfigFlow @@ -769,22 +770,15 @@ async def test_custom_unit_change( def test_device_classes_aligned() -> None: """Make sure all sensor device classes are also available in NumberDeviceClass.""" - non_numeric_device_classes = { - SensorDeviceClass.DATE, - SensorDeviceClass.DURATION, - SensorDeviceClass.ENUM, - SensorDeviceClass.TIMESTAMP, - } - for device_class in SensorDeviceClass: - if device_class in non_numeric_device_classes: + if device_class in NON_NUMERIC_DEVICE_CLASSES: continue assert hasattr(NumberDeviceClass, device_class.name) assert getattr(NumberDeviceClass, device_class.name).value == device_class.value for device_class in SENSOR_DEVICE_CLASS_UNITS: - if device_class in non_numeric_device_classes: + if device_class in NON_NUMERIC_DEVICE_CLASSES: continue assert ( SENSOR_DEVICE_CLASS_UNITS[device_class] diff --git a/tests/components/nut/test_diagnostics.py b/tests/components/nut/test_diagnostics.py new file mode 100644 index 00000000000..f91269f5196 --- /dev/null +++ b/tests/components/nut/test_diagnostics.py @@ -0,0 +1,43 @@ +"""Tests for the diagnostics data provided by the Nut integration.""" + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.nut.diagnostics import TO_REDACT +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test diagnostics.""" + list_commands: set[str] = ["beeper.enable"] + list_commands_return_value = { + supported_command: supported_command for supported_command in list_commands + } + + mock_config_entry = await async_init_integration( + hass, + username="someuser", + password="somepassword", + list_vars={"ups.status": "OL"}, + list_ups={"ups1": "UPS 1"}, + list_commands_return_value=list_commands_return_value, + ) + + entry_dict = async_redact_data(mock_config_entry.as_dict(), TO_REDACT) + nut_data_dict = { + "ups_list": {"ups1": "UPS 1"}, + "status": {"ups.status": "OL"}, + "commands": list_commands, + } + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + assert result["entry"] == entry_dict + assert result["nut_data"] == nut_data_dict diff --git a/tests/components/nws/const.py b/tests/components/nws/const.py index 2048db2a2c3..106b80998ac 100644 --- a/tests/components/nws/const.py +++ b/tests/components/nws/const.py @@ -3,6 +3,8 @@ from homeassistant.components.nws.const import CONF_STATION from homeassistant.components.weather import ( ATTR_CONDITION_LIGHTNING_RAINY, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_DEW_POINT, + ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, @@ -59,6 +61,9 @@ DEFAULT_OBSERVATION = { "windGust": 20, } +CLEAR_NIGHT_OBSERVATION = DEFAULT_OBSERVATION.copy() +CLEAR_NIGHT_OBSERVATION["iconTime"] = "night" + SENSOR_EXPECTED_OBSERVATION_METRIC = { "dewpoint": "5", "temperature": "10", @@ -183,6 +188,9 @@ DEFAULT_FORECAST = [ "timestamp": "2019-08-12T23:53:00+00:00", "iconTime": "night", "iconWeather": (("lightning-rainy", 40), ("lightning-rainy", 90)), + "probabilityOfPrecipitation": 89, + "dewpoint": 4, + "relativeHumidity": 75, }, ] @@ -192,7 +200,9 @@ EXPECTED_FORECAST_IMPERIAL = { ATTR_FORECAST_TEMP: 10, ATTR_FORECAST_WIND_SPEED: 10, ATTR_FORECAST_WIND_BEARING: 180, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 90, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 89, + ATTR_FORECAST_DEW_POINT: 4, + ATTR_FORECAST_HUMIDITY: 75, } EXPECTED_FORECAST_METRIC = { @@ -211,7 +221,14 @@ EXPECTED_FORECAST_METRIC = { 2, ), ATTR_FORECAST_WIND_BEARING: 180, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: 90, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 89, + ATTR_FORECAST_DEW_POINT: round( + TemperatureConverter.convert( + 4, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS + ), + 1, + ), + ATTR_FORECAST_HUMIDITY: 75, } NONE_FORECAST = [{key: None for key in DEFAULT_FORECAST[0]}] diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index ce268796639..06d2c2006d8 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components import nws from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, ATTR_FORECAST, DOMAIN as WEATHER_DOMAIN, @@ -19,6 +20,7 @@ import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from .const import ( + CLEAR_NIGHT_OBSERVATION, EXPECTED_FORECAST_IMPERIAL, EXPECTED_FORECAST_METRIC, NONE_FORECAST, @@ -97,6 +99,23 @@ async def test_imperial_metric( assert forecast[0].get(key) == value +async def test_night_clear(hass: HomeAssistant, mock_simple_nws, no_sensor) -> None: + """Test with clear-night in observation.""" + instance = mock_simple_nws.return_value + instance.observation = CLEAR_NIGHT_OBSERVATION + + entry = MockConfigEntry( + domain=nws.DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("weather.abc_daynight") + assert state.state == ATTR_CONDITION_CLEAR_NIGHT + + async def test_none_values(hass: HomeAssistant, mock_simple_nws, no_sensor) -> None: """Test with none values in observation and forecast dicts.""" instance = mock_simple_nws.return_value diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 00fc77076e8..6133a382855 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -1,4 +1,5 @@ """Test ONVIF config flow.""" +import logging from unittest.mock import MagicMock, patch import pytest @@ -103,6 +104,7 @@ def setup_mock_discovery( async def test_flow_discovered_devices(hass: HomeAssistant) -> None: """Test that config flow works for discovered devices.""" + logging.getLogger("homeassistant.components.onvif").setLevel(logging.DEBUG) result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -172,6 +174,7 @@ async def test_flow_discovered_devices_ignore_configured_manual_input( hass: HomeAssistant, ) -> None: """Test that config flow discovery ignores configured devices.""" + logging.getLogger("homeassistant.components.onvif").setLevel(logging.DEBUG) await setup_onvif_integration(hass) result = await hass.config_entries.flow.async_init( @@ -241,6 +244,7 @@ async def test_flow_discovered_no_device(hass: HomeAssistant) -> None: async def test_flow_discovery_ignore_existing_and_abort(hass: HomeAssistant) -> None: """Test that config flow discovery ignores setup devices.""" + logging.getLogger("homeassistant.components.onvif").setLevel(logging.DEBUG) await setup_onvif_integration(hass) await setup_onvif_integration( hass, @@ -298,6 +302,7 @@ async def test_flow_discovery_ignore_existing_and_abort(hass: HomeAssistant) -> async def test_flow_manual_entry(hass: HomeAssistant) -> None: """Test that config flow works for discovered devices.""" + logging.getLogger("homeassistant.components.onvif").setLevel(logging.DEBUG) result = await hass.config_entries.flow.async_init( config_flow.DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index fe23bbac56c..1b9f81f60c0 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -2,10 +2,12 @@ from unittest.mock import patch from openai import error +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry as ar, device_registry as dr, intent from tests.common import MockConfigEntry @@ -158,3 +160,69 @@ async def test_conversation_agent( mock_config_entry.entry_id ) assert agent.supported_languages == "*" + + +@pytest.mark.parametrize( + ("service_data", "expected_args"), + [ + ( + {"prompt": "Picture of a dog"}, + {"prompt": "Picture of a dog", "size": "512x512"}, + ), + ( + {"prompt": "Picture of a dog", "size": "256"}, + {"prompt": "Picture of a dog", "size": "256x256"}, + ), + ( + {"prompt": "Picture of a dog", "size": "1024"}, + {"prompt": "Picture of a dog", "size": "1024x1024"}, + ), + ], +) +async def test_generate_image_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + service_data, + expected_args, +) -> None: + """Test generate image service.""" + service_data["config_entry"] = mock_config_entry.entry_id + expected_args["api_key"] = mock_config_entry.data["api_key"] + expected_args["n"] = 1 + + with patch( + "openai.Image.acreate", return_value={"data": [{"url": "A"}]} + ) as mock_create: + response = await hass.services.async_call( + "openai_conversation", + "generate_image", + service_data, + blocking=True, + return_response=True, + ) + + assert response == {"url": "A"} + assert len(mock_create.mock_calls) == 1 + assert mock_create.mock_calls[0][2] == expected_args + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_image_service_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test generate image service handles errors.""" + with patch( + "openai.Image.acreate", side_effect=error.ServiceUnavailableError("Reason") + ), pytest.raises(HomeAssistantError, match="Error generating image: Reason"): + await hass.services.async_call( + "openai_conversation", + "generate_image", + { + "config_entry": mock_config_entry.entry_id, + "prompt": "Image of an epic fail", + }, + blocking=True, + return_response=True, + ) diff --git a/tests/components/opensky/__init__.py b/tests/components/opensky/__init__.py new file mode 100644 index 00000000000..f985f068ab1 --- /dev/null +++ b/tests/components/opensky/__init__.py @@ -0,0 +1,9 @@ +"""Opensky tests.""" +from unittest.mock import patch + + +def patch_setup_entry() -> bool: + """Patch interface.""" + return patch( + "homeassistant.components.opensky.async_setup_entry", return_value=True + ) diff --git a/tests/components/opensky/conftest.py b/tests/components/opensky/conftest.py new file mode 100644 index 00000000000..63e514d0d8f --- /dev/null +++ b/tests/components/opensky/conftest.py @@ -0,0 +1,50 @@ +"""Configure tests for the OpenSky integration.""" +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +import pytest +from python_opensky import StatesResponse + +from homeassistant.components.opensky.const import CONF_ALTITUDE, DOMAIN +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +ComponentSetup = Callable[[MockConfigEntry], Awaitable[None]] + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Create OpenSky entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title="OpenSky", + data={ + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + }, + options={ + CONF_RADIUS: 10.0, + CONF_ALTITUDE: 0.0, + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, +) -> Callable[[MockConfigEntry], Awaitable[None]]: + """Fixture for setting up the component.""" + + async def func(mock_config_entry: MockConfigEntry) -> None: + mock_config_entry.add_to_hass(hass) + with patch( + "python_opensky.OpenSky.get_states", + return_value=StatesResponse(states=[], time=0), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return func diff --git a/tests/components/opensky/test_config_flow.py b/tests/components/opensky/test_config_flow.py new file mode 100644 index 00000000000..e785a5f3a8f --- /dev/null +++ b/tests/components/opensky/test_config_flow.py @@ -0,0 +1,155 @@ +"""Test OpenSky config flow.""" +from typing import Any + +import pytest + +from homeassistant.components.opensky.const import ( + CONF_ALTITUDE, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_RADIUS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import patch_setup_entry + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + with patch_setup_entry(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_RADIUS: 10, + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + CONF_ALTITUDE: 0, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "OpenSky" + assert result["data"] == { + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + } + assert result["options"] == { + CONF_ALTITUDE: 0.0, + CONF_RADIUS: 10.0, + } + + +@pytest.mark.parametrize( + ("config", "title", "data", "options"), + [ + ( + {CONF_RADIUS: 10.0}, + DEFAULT_NAME, + { + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + }, + { + CONF_RADIUS: 10000.0, + CONF_ALTITUDE: 0, + }, + ), + ( + { + CONF_RADIUS: 10.0, + CONF_NAME: "My home", + }, + "My home", + { + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + }, + { + CONF_RADIUS: 10000.0, + CONF_ALTITUDE: 0, + }, + ), + ( + { + CONF_RADIUS: 10.0, + CONF_LATITUDE: 10.0, + CONF_LONGITUDE: -100.0, + }, + DEFAULT_NAME, + { + CONF_LATITUDE: 10.0, + CONF_LONGITUDE: -100.0, + }, + { + CONF_RADIUS: 10000.0, + CONF_ALTITUDE: 0, + }, + ), + ( + {CONF_RADIUS: 10.0, CONF_ALTITUDE: 100.0}, + DEFAULT_NAME, + { + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + }, + { + CONF_RADIUS: 10000.0, + CONF_ALTITUDE: 100.0, + }, + ), + ], +) +async def test_import_flow( + hass: HomeAssistant, + config: dict[str, Any], + title: str, + data: dict[str, Any], + options: dict[str, Any], +) -> None: + """Test the import flow.""" + with patch_setup_entry(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == title + assert result["options"] == options + assert result["data"] == data + + +async def test_importing_already_exists_flow(hass: HomeAssistant) -> None: + """Test the import flow when same location already exists.""" + MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={}, + options={ + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + CONF_RADIUS: 10.0, + CONF_ALTITUDE: 100.0, + }, + ).add_to_hass(hass) + with patch_setup_entry(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + CONF_RADIUS: 10.0, + CONF_ALTITUDE: 100.0, + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/opensky/test_init.py b/tests/components/opensky/test_init.py new file mode 100644 index 00000000000..be1c21627f0 --- /dev/null +++ b/tests/components/opensky/test_init.py @@ -0,0 +1,28 @@ +"""Test OpenSky component setup process.""" +from __future__ import annotations + +from homeassistant.components.opensky.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import ComponentSetup + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + + state = hass.states.get("sensor.opensky") + assert state + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.opensky") + assert not state diff --git a/tests/components/opensky/test_sensor.py b/tests/components/opensky/test_sensor.py new file mode 100644 index 00000000000..1768efebc78 --- /dev/null +++ b/tests/components/opensky/test_sensor.py @@ -0,0 +1,20 @@ +"""OpenSky sensor tests.""" +from homeassistant.components.opensky.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PLATFORM, CONF_RADIUS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +LEGACY_CONFIG = {Platform.SENSOR: [{CONF_PLATFORM: DOMAIN, CONF_RADIUS: 10.0}]} + + +async def test_legacy_migration(hass: HomeAssistant) -> None: + """Test migration from yaml to config flow.""" + assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) + await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 diff --git a/tests/components/opower/__init__.py b/tests/components/opower/__init__.py new file mode 100644 index 00000000000..71aea27a698 --- /dev/null +++ b/tests/components/opower/__init__.py @@ -0,0 +1 @@ +"""Tests for the Opower integration.""" diff --git a/tests/components/opower/conftest.py b/tests/components/opower/conftest.py new file mode 100644 index 00000000000..0ee910f84f4 --- /dev/null +++ b/tests/components/opower/conftest.py @@ -0,0 +1,23 @@ +"""Fixtures for the Opower integration tests.""" +import pytest + +from homeassistant.components.opower.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + config_entry = MockConfigEntry( + title="Pacific Gas & Electric (test-username)", + domain=DOMAIN, + data={ + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username", + "password": "test-password", + }, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py new file mode 100644 index 00000000000..6a45a0dcc56 --- /dev/null +++ b/tests/components/opower/test_config_flow.py @@ -0,0 +1,206 @@ +"""Test the Opower config flow.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from opower import CannotConnect, InvalidAuth +import pytest + +from homeassistant import config_entries +from homeassistant.components.opower.const import DOMAIN +from homeassistant.components.recorder import Recorder +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True, name="mock_setup_entry") +def override_async_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.opower.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_unload_entry() -> Generator[AsyncMock, None, None]: + """Mock unloading a config entry.""" + with patch( + "homeassistant.components.opower.async_unload_entry", + return_value=True, + ) as mock_unload_entry: + yield mock_unload_entry + + +async def test_form( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + ) as mock_login: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Pacific Gas and Electric Company (PG&E) (test-username)" + assert result2["data"] == { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + assert mock_login.call_count == 1 + + +@pytest.mark.parametrize( + ("api_exception", "expected_error"), + [ + (InvalidAuth(), "invalid_auth"), + (CannotConnect(), "cannot_connect"), + ], +) +async def test_form_exceptions( + recorder_mock: Recorder, hass: HomeAssistant, api_exception, expected_error +) -> None: + """Test we handle exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=api_exception, + ) as mock_login: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": expected_error} + assert mock_login.call_count == 1 + + +async def test_form_already_configured( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test user input for config_entry that already exists.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + ) as mock_login: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" + assert mock_login.call_count == 0 + + +async def test_form_not_already_configured( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test user input for config_entry different than the existing one.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + ) as mock_login: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username2", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert ( + result2["title"] == "Pacific Gas and Electric Company (PG&E) (test-username2)" + ) + assert result2["data"] == { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username2", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 2 + assert mock_login.call_count == 1 + + +async def test_form_valid_reauth( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that we can handle a valid reauth.""" + mock_config_entry.state = ConfigEntryState.LOADED + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + assert result["context"]["source"] == "reauth" + assert result["context"]["title_placeholders"] == {"name": mock_config_entry.title} + + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + ) as mock_login: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-username", "password": "test-password2"}, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + await hass.async_block_till_done() + assert hass.config_entries.async_entries(DOMAIN)[0].data == { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username", + "password": "test-password2", + } + assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert mock_login.call_count == 1 diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index 1f103884db2..9f2fd4a4355 100644 --- a/tests/components/otbr/__init__.py +++ b/tests/components/otbr/__init__.py @@ -1,6 +1,7 @@ """Tests for the Open Thread Border Router integration.""" BASE_URL = "http://core-silabs-multiprotocol:8081" -CONFIG_ENTRY_DATA = {"url": "http://core-silabs-multiprotocol:8081"} +CONFIG_ENTRY_DATA_MULTIPAN = {"url": "http://core-silabs-multiprotocol:8081"} +CONFIG_ENTRY_DATA_THREAD = {"url": "/dev/ttyAMA1"} DATASET_CH15 = bytes.fromhex( "0E080000000000010000000300000F35060004001FFFE00208F642646DA209B1D00708FDF57B5A" diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index bb3b474519e..e7d5ac8980e 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -6,16 +6,34 @@ import pytest from homeassistant.components import otbr from homeassistant.core import HomeAssistant -from . import CONFIG_ENTRY_DATA, DATASET_CH16 +from . import CONFIG_ENTRY_DATA_MULTIPAN, CONFIG_ENTRY_DATA_THREAD, DATASET_CH16 from tests.common import MockConfigEntry -@pytest.fixture(name="otbr_config_entry") -async def otbr_config_entry_fixture(hass): +@pytest.fixture(name="otbr_config_entry_multipan") +async def otbr_config_entry_multipan_fixture(hass): """Mock Open Thread Border Router config entry.""" config_entry = MockConfigEntry( - data=CONFIG_ENTRY_DATA, + data=CONFIG_ENTRY_DATA_MULTIPAN, + domain=otbr.DOMAIN, + options={}, + title="Open Thread Border Router", + ) + config_entry.add_to_hass(hass) + with patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 + ), patch( + "homeassistant.components.otbr.util.compute_pskc" + ): # Patch to speed up tests + assert await hass.config_entries.async_setup(config_entry.entry_id) + + +@pytest.fixture(name="otbr_config_entry_thread") +async def otbr_config_entry_thread_fixture(hass): + """Mock Open Thread Border Router config entry.""" + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA_THREAD, domain=otbr.DOMAIN, options={}, title="Open Thread Border Router", diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index 2659f8d151d..deb8672b961 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -307,7 +307,7 @@ async def test_hassio_discovery_flow_sky_connect( } assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Home Assistant SkyConnect" + assert result["title"] == "Home Assistant SkyConnect (Silicon Labs Multiprotocol)" assert result["data"] == expected_data assert result["options"] == {} assert len(mock_setup_entry.mock_calls) == 1 @@ -315,7 +315,9 @@ async def test_hassio_discovery_flow_sky_connect( config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] assert config_entry.data == expected_data assert config_entry.options == {} - assert config_entry.title == "Home Assistant SkyConnect" + assert ( + config_entry.title == "Home Assistant SkyConnect (Silicon Labs Multiprotocol)" + ) assert config_entry.unique_id == HASSIO_DATA.uuid @@ -378,7 +380,9 @@ async def test_hassio_discovery_flow_2x_addons( } assert results[0]["type"] == FlowResultType.CREATE_ENTRY - assert results[0]["title"] == "Home Assistant SkyConnect" + assert ( + results[0]["title"] == "Home Assistant SkyConnect (Silicon Labs Multiprotocol)" + ) assert results[0]["data"] == expected_data assert results[0]["options"] == {} assert results[1]["type"] == FlowResultType.ABORT @@ -389,7 +393,9 @@ async def test_hassio_discovery_flow_2x_addons( config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] assert config_entry.data == expected_data assert config_entry.options == {} - assert config_entry.title == "Home Assistant SkyConnect" + assert ( + config_entry.title == "Home Assistant SkyConnect (Silicon Labs Multiprotocol)" + ) assert config_entry.unique_id == HASSIO_DATA.uuid diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 990c015244f..49694cf5585 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -11,10 +11,12 @@ from homeassistant.components import otbr from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component from . import ( BASE_URL, - CONFIG_ENTRY_DATA, + CONFIG_ENTRY_DATA_MULTIPAN, + CONFIG_ENTRY_DATA_THREAD, DATASET_CH15, DATASET_CH16, DATASET_INSECURE_NW_KEY, @@ -36,7 +38,7 @@ async def test_import_dataset(hass: HomeAssistant) -> None: issue_registry = ir.async_get(hass) config_entry = MockConfigEntry( - data=CONFIG_ENTRY_DATA, + data=CONFIG_ENTRY_DATA_MULTIPAN, domain=otbr.DOMAIN, options={}, title="My OTBR", @@ -72,7 +74,7 @@ async def test_import_share_radio_channel_collision( multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 config_entry = MockConfigEntry( - data=CONFIG_ENTRY_DATA, + data=CONFIG_ENTRY_DATA_MULTIPAN, domain=otbr.DOMAIN, options={}, title="My OTBR", @@ -105,7 +107,7 @@ async def test_import_share_radio_no_channel_collision( multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 config_entry = MockConfigEntry( - data=CONFIG_ENTRY_DATA, + data=CONFIG_ENTRY_DATA_MULTIPAN, domain=otbr.DOMAIN, options={}, title="My OTBR", @@ -136,7 +138,7 @@ async def test_import_insecure_dataset(hass: HomeAssistant, dataset: bytes) -> N issue_registry = ir.async_get(hass) config_entry = MockConfigEntry( - data=CONFIG_ENTRY_DATA, + data=CONFIG_ENTRY_DATA_MULTIPAN, domain=otbr.DOMAIN, options={}, title="My OTBR", @@ -167,7 +169,7 @@ async def test_config_entry_not_ready(hass: HomeAssistant, error) -> None: """Test raising ConfigEntryNotReady .""" config_entry = MockConfigEntry( - data=CONFIG_ENTRY_DATA, + data=CONFIG_ENTRY_DATA_MULTIPAN, domain=otbr.DOMAIN, options={}, title="My OTBR", @@ -180,7 +182,7 @@ async def test_config_entry_not_ready(hass: HomeAssistant, error) -> None: async def test_config_entry_update(hass: HomeAssistant) -> None: """Test update config entry settings.""" config_entry = MockConfigEntry( - data=CONFIG_ENTRY_DATA, + data=CONFIG_ENTRY_DATA_MULTIPAN, domain=otbr.DOMAIN, options={}, title="My OTBR", @@ -191,10 +193,10 @@ async def test_config_entry_update(hass: HomeAssistant) -> None: with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: assert await hass.config_entries.async_setup(config_entry.entry_id) - mock_otrb_api.assert_called_once_with(CONFIG_ENTRY_DATA["url"], ANY, ANY) + mock_otrb_api.assert_called_once_with(CONFIG_ENTRY_DATA_MULTIPAN["url"], ANY, ANY) new_config_entry_data = {"url": "http://core-silabs-multiprotocol:8082"} - assert CONFIG_ENTRY_DATA["url"] != new_config_entry_data["url"] + assert CONFIG_ENTRY_DATA_MULTIPAN["url"] != new_config_entry_data["url"] with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: hass.config_entries.async_update_entry(config_entry, data=new_config_entry_data) await hass.async_block_till_done() @@ -203,7 +205,7 @@ async def test_config_entry_update(hass: HomeAssistant) -> None: async def test_remove_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry_multipan ) -> None: """Test async_get_active_dataset_tlvs after removing the config entry.""" @@ -219,7 +221,7 @@ async def test_remove_entry( async def test_get_active_dataset_tlvs( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry_multipan ) -> None: """Test async_get_active_dataset_tlvs.""" @@ -237,7 +239,7 @@ async def test_get_active_dataset_tlvs( async def test_get_active_dataset_tlvs_empty( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry_multipan ) -> None: """Test async_get_active_dataset_tlvs.""" @@ -253,7 +255,7 @@ async def test_get_active_dataset_tlvs_addon_not_installed(hass: HomeAssistant) async def test_get_active_dataset_tlvs_404( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry_multipan ) -> None: """Test async_get_active_dataset_tlvs with error.""" @@ -263,7 +265,7 @@ async def test_get_active_dataset_tlvs_404( async def test_get_active_dataset_tlvs_201( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry_multipan ) -> None: """Test async_get_active_dataset_tlvs with error.""" @@ -273,10 +275,39 @@ async def test_get_active_dataset_tlvs_201( async def test_get_active_dataset_tlvs_invalid( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry_multipan ) -> None: """Test async_get_active_dataset_tlvs with error.""" aioclient_mock.get(f"{BASE_URL}/node/dataset/active", text="unexpected") with pytest.raises(HomeAssistantError): assert await otbr.async_get_active_dataset_tlvs(hass) + + +async def test_remove_extra_entries( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we remove additional config entries.""" + + config_entry1 = MockConfigEntry( + data=CONFIG_ENTRY_DATA_MULTIPAN, + domain=otbr.DOMAIN, + options={}, + title="Open Thread Border Router", + ) + config_entry2 = MockConfigEntry( + data=CONFIG_ENTRY_DATA_THREAD, + domain=otbr.DOMAIN, + options={}, + title="Open Thread Border Router", + ) + config_entry1.add_to_hass(hass) + config_entry2.add_to_hass(hass) + assert len(hass.config_entries.async_entries(otbr.DOMAIN)) == 2 + with patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 + ), patch( + "homeassistant.components.otbr.util.compute_pskc" + ): # Patch to speed up tests + assert await async_setup_component(hass, otbr.DOMAIN, {}) + assert len(hass.config_entries.async_entries(otbr.DOMAIN)) == 1 diff --git a/tests/components/otbr/test_silabs_multiprotocol.py b/tests/components/otbr/test_silabs_multiprotocol.py index 8dd07db6f22..83416ae297d 100644 --- a/tests/components/otbr/test_silabs_multiprotocol.py +++ b/tests/components/otbr/test_silabs_multiprotocol.py @@ -31,7 +31,9 @@ DATASET_CH16_PENDING = ( ) -async def test_async_change_channel(hass: HomeAssistant, otbr_config_entry) -> None: +async def test_async_change_channel( + hass: HomeAssistant, otbr_config_entry_multipan +) -> None: """Test test_async_change_channel.""" store = await dataset_store.async_get_store(hass) @@ -55,7 +57,7 @@ async def test_async_change_channel(hass: HomeAssistant, otbr_config_entry) -> N async def test_async_change_channel_no_pending( - hass: HomeAssistant, otbr_config_entry + hass: HomeAssistant, otbr_config_entry_multipan ) -> None: """Test test_async_change_channel when the pending dataset already expired.""" @@ -83,7 +85,7 @@ async def test_async_change_channel_no_pending( async def test_async_change_channel_no_update( - hass: HomeAssistant, otbr_config_entry + hass: HomeAssistant, otbr_config_entry_multipan ) -> None: """Test test_async_change_channel when we didn't get a dataset from the OTBR.""" @@ -112,7 +114,9 @@ async def test_async_change_channel_no_otbr(hass: HomeAssistant) -> None: mock_set_channel.assert_not_awaited() -async def test_async_get_channel(hass: HomeAssistant, otbr_config_entry) -> None: +async def test_async_get_channel( + hass: HomeAssistant, otbr_config_entry_multipan +) -> None: """Test test_async_get_channel.""" with patch( @@ -124,7 +128,7 @@ async def test_async_get_channel(hass: HomeAssistant, otbr_config_entry) -> None async def test_async_get_channel_no_dataset( - hass: HomeAssistant, otbr_config_entry + hass: HomeAssistant, otbr_config_entry_multipan ) -> None: """Test test_async_get_channel.""" @@ -136,7 +140,9 @@ async def test_async_get_channel_no_dataset( mock_get_active_dataset.assert_awaited_once_with() -async def test_async_get_channel_error(hass: HomeAssistant, otbr_config_entry) -> None: +async def test_async_get_channel_error( + hass: HomeAssistant, otbr_config_entry_multipan +) -> None: """Test test_async_get_channel.""" with patch( @@ -160,7 +166,7 @@ async def test_async_get_channel_no_otbr(hass: HomeAssistant) -> None: [(OTBR_MULTIPAN_URL, True), (OTBR_NON_MULTIPAN_URL, False)], ) async def test_async_using_multipan( - hass: HomeAssistant, otbr_config_entry, url: str, expected: bool + hass: HomeAssistant, otbr_config_entry_multipan, url: str, expected: bool ) -> None: """Test async_change_channel when otbr is not configured.""" data: otbr.OTBRData = hass.data[otbr.DOMAIN] diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index 65bec9e8408..b5dd7aa62c4 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -23,20 +23,23 @@ async def websocket_client(hass, hass_ws_client): async def test_get_info( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test async_get_info.""" - aioclient_mock.get(f"{BASE_URL}/node/dataset/active", text=DATASET_CH16.hex()) + with patch( + "python_otbr_api.OTBR.get_active_dataset", + return_value=python_otbr_api.ActiveDataSet(channel=16), + ), patch("python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16): + await websocket_client.send_json_auto_id({"type": "otbr/info"}) + msg = await websocket_client.receive_json() - await websocket_client.send_json_auto_id({"type": "otbr/info"}) - - msg = await websocket_client.receive_json() assert msg["success"] assert msg["result"] == { "url": BASE_URL, "active_dataset_tlvs": DATASET_CH16.hex().lower(), + "channel": 16, } @@ -58,12 +61,12 @@ async def test_get_info_no_entry( async def test_get_info_fetch_fails( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test async_get_info.""" with patch( - "python_otbr_api.OTBR.get_active_dataset_tlvs", + "python_otbr_api.OTBR.get_active_dataset", side_effect=python_otbr_api.OTBRError, ): await websocket_client.send_json_auto_id({"type": "otbr/info"}) @@ -76,7 +79,7 @@ async def test_get_info_fetch_fails( async def test_create_network( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test create network.""" @@ -127,7 +130,7 @@ async def test_create_network_no_entry( async def test_create_network_fails_1( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test create network.""" @@ -145,7 +148,7 @@ async def test_create_network_fails_1( async def test_create_network_fails_2( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test create network.""" @@ -165,7 +168,7 @@ async def test_create_network_fails_2( async def test_create_network_fails_3( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test create network.""" @@ -187,7 +190,7 @@ async def test_create_network_fails_3( async def test_create_network_fails_4( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test create network.""" @@ -209,7 +212,7 @@ async def test_create_network_fails_4( async def test_create_network_fails_5( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test create network.""" @@ -228,7 +231,7 @@ async def test_create_network_fails_5( async def test_create_network_fails_6( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test create network.""" @@ -248,7 +251,7 @@ async def test_create_network_fails_6( async def test_set_network( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test set network.""" @@ -303,7 +306,7 @@ async def test_set_network_channel_conflict( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, multiprotocol_addon_manager_mock, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test set network.""" @@ -329,7 +332,7 @@ async def test_set_network_channel_conflict( async def test_set_network_unknown_dataset( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test set network.""" @@ -350,7 +353,7 @@ async def test_set_network_unknown_dataset( async def test_set_network_fails_1( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test set network.""" @@ -377,7 +380,7 @@ async def test_set_network_fails_1( async def test_set_network_fails_2( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test set network.""" @@ -406,7 +409,7 @@ async def test_set_network_fails_2( async def test_set_network_fails_3( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test set network.""" @@ -435,7 +438,7 @@ async def test_set_network_fails_3( async def test_get_extended_address( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test get extended address.""" @@ -469,7 +472,7 @@ async def test_get_extended_address_no_entry( async def test_get_extended_address_fetch_fails( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, - otbr_config_entry, + otbr_config_entry_multipan, websocket_client, ) -> None: """Test get extended address.""" @@ -482,3 +485,76 @@ async def test_get_extended_address_fetch_fails( assert not msg["success"] assert msg["error"]["code"] == "get_extended_address_failed" + + +async def test_set_channel( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_thread, + websocket_client, +) -> None: + """Test set channel.""" + + with patch("python_otbr_api.OTBR.set_channel"): + await websocket_client.send_json_auto_id( + {"type": "otbr/set_channel", "channel": 12} + ) + msg = await websocket_client.receive_json() + + assert msg["success"] + assert msg["result"] == {"delay": 300.0} + + +async def test_set_channel_multiprotocol( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_multipan, + websocket_client, +) -> None: + """Test set channel.""" + + with patch("python_otbr_api.OTBR.set_channel"): + await websocket_client.send_json_auto_id( + {"type": "otbr/set_channel", "channel": 12} + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "multiprotocol_enabled" + + +async def test_set_channel_no_entry( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test set channel.""" + await async_setup_component(hass, "otbr", {}) + websocket_client = await hass_ws_client(hass) + await websocket_client.send_json_auto_id( + {"type": "otbr/set_channel", "channel": 12} + ) + + msg = await websocket_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_loaded" + + +async def test_set_channel_fails( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + otbr_config_entry_thread, + websocket_client, +) -> None: + """Test set channel.""" + with patch( + "python_otbr_api.OTBR.set_channel", + side_effect=python_otbr_api.OTBRError, + ): + await websocket_client.send_json_auto_id( + {"type": "otbr/set_channel", "channel": 12} + ) + msg = await websocket_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "set_channel_failed" diff --git a/tests/components/p1_monitor/test_sensor.py b/tests/components/p1_monitor/test_sensor.py index 14ff3b1e519..f84df458d4b 100644 --- a/tests/components/p1_monitor/test_sensor.py +++ b/tests/components/p1_monitor/test_sensor.py @@ -43,20 +43,23 @@ async def test_smartmeter( assert state assert entry.unique_id == f"{entry_id}_smartmeter_power_consumption" assert state.state == "877" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Power Consumption" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "SmartMeter Power consumption" assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert ATTR_ICON not in state.attributes - state = hass.states.get("sensor.smartmeter_energy_consumption_high") - entry = entity_registry.async_get("sensor.smartmeter_energy_consumption_high") + state = hass.states.get("sensor.smartmeter_energy_consumption_high_tariff") + entry = entity_registry.async_get( + "sensor.smartmeter_energy_consumption_high_tariff" + ) assert entry assert state assert entry.unique_id == f"{entry_id}_smartmeter_energy_consumption_high" assert state.state == "2770.133" assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption - High Tariff" + state.attributes.get(ATTR_FRIENDLY_NAME) + == "SmartMeter Energy consumption - High tariff" ) assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR @@ -69,7 +72,7 @@ async def test_smartmeter( assert state assert entry.unique_id == f"{entry_id}_smartmeter_energy_tariff_period" assert state.state == "high" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Tariff Period" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "SmartMeter Energy tariff period" assert state.attributes.get(ATTR_ICON) == "mdi:calendar-clock" assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes assert ATTR_DEVICE_CLASS not in state.attributes @@ -100,7 +103,7 @@ async def test_phases( assert state assert entry.unique_id == f"{entry_id}_phases_voltage_phase_l1" assert state.state == "233.6" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Voltage Phase L1" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Phases Voltage phase L1" assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT @@ -114,7 +117,7 @@ async def test_phases( assert state assert entry.unique_id == f"{entry_id}_phases_current_phase_l1" assert state.state == "1.6" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Current Phase L1" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Phases Current phase L1" assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricCurrent.AMPERE @@ -128,7 +131,7 @@ async def test_phases( assert state assert entry.unique_id == f"{entry_id}_phases_power_consumed_phase_l1" assert state.state == "315" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Power Consumed Phase L1" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Phases Power consumed phase L1" assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER @@ -160,7 +163,10 @@ async def test_settings( assert state assert entry.unique_id == f"{entry_id}_settings_energy_consumption_price_low" assert state.state == "0.20522" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption Price - Low" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Settings Energy consumption price - Low" + ) assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -173,7 +179,10 @@ async def test_settings( assert state assert entry.unique_id == f"{entry_id}_settings_energy_production_price_low" assert state.state == "0.20522" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Production Price - Low" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == "Settings Energy production price - Low" + ) assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -205,7 +214,7 @@ async def test_watermeter( assert state assert entry.unique_id == f"{entry_id}_watermeter_consumption_day" assert state.state == "112.0" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Consumption Day" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "WaterMeter Consumption day" assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.LITERS diff --git a/tests/components/panel_custom/test_init.py b/tests/components/panel_custom/test_init.py index 81365273986..d84b4c812c7 100644 --- a/tests/components/panel_custom/test_init.py +++ b/tests/components/panel_custom/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, patch from homeassistant import setup -from homeassistant.components import frontend +from homeassistant.components import frontend, panel_custom from homeassistant.core import HomeAssistant @@ -155,3 +155,37 @@ async def test_url_path_conflict(hass: HomeAssistant) -> None: ] }, ) + + +async def test_register_config_panel(hass: HomeAssistant) -> None: + """Test setting up a custom config panel for an integration.""" + result = await setup.async_setup_component(hass, "panel_custom", {}) + assert result + + # Register a custom panel + await panel_custom.async_register_panel( + hass=hass, + frontend_url_path="config_panel", + webcomponent_name="custom-frontend", + module_url="custom-frontend", + embed_iframe=True, + require_admin=True, + config_panel_domain="test", + ) + + panels = hass.data.get(frontend.DATA_PANELS, []) + assert panels + assert "config_panel" in panels + + panel = panels["config_panel"] + + assert panel.config == { + "_panel_custom": { + "module_url": "custom-frontend", + "name": "custom-frontend", + "embed_iframe": True, + "trust_external": False, + }, + } + assert panel.frontend_url_path == "config_panel" + assert panel.config_panel_domain == "test" diff --git a/tests/components/panel_iframe/test_init.py b/tests/components/panel_iframe/test_init.py index 79bc7e37ee3..bd8950163a9 100644 --- a/tests/components/panel_iframe/test_init.py +++ b/tests/components/panel_iframe/test_init.py @@ -54,6 +54,7 @@ async def test_correct_config(hass: HomeAssistant) -> None: assert panels.get("router").to_response() == { "component_name": "iframe", "config": {"url": "http://192.168.1.1"}, + "config_panel_domain": None, "icon": "mdi:network-wireless", "title": "Router", "url_path": "router", @@ -63,6 +64,7 @@ async def test_correct_config(hass: HomeAssistant) -> None: assert panels.get("weather").to_response() == { "component_name": "iframe", "config": {"url": "https://www.wunderground.com/us/ca/san-diego"}, + "config_panel_domain": None, "icon": "mdi:weather", "title": "Weather", "url_path": "weather", @@ -72,6 +74,7 @@ async def test_correct_config(hass: HomeAssistant) -> None: assert panels.get("api").to_response() == { "component_name": "iframe", "config": {"url": "/api"}, + "config_panel_domain": None, "icon": "mdi:weather", "title": "Api", "url_path": "api", @@ -81,6 +84,7 @@ async def test_correct_config(hass: HomeAssistant) -> None: assert panels.get("ftp").to_response() == { "component_name": "iframe", "config": {"url": "ftp://some/ftp"}, + "config_panel_domain": None, "icon": "mdi:weather", "title": "FTP", "url_path": "ftp", diff --git a/tests/components/pegel_online/__init__.py b/tests/components/pegel_online/__init__.py new file mode 100644 index 00000000000..ac3f9bda7dd --- /dev/null +++ b/tests/components/pegel_online/__init__.py @@ -0,0 +1,40 @@ +"""Tests for Pegel Online component.""" + + +class PegelOnlineMock: + """Class mock of PegelOnline.""" + + def __init__( + self, + nearby_stations=None, + station_details=None, + station_measurement=None, + side_effect=None, + ) -> None: + """Init the mock.""" + self.nearby_stations = nearby_stations + self.station_details = station_details + self.station_measurement = station_measurement + self.side_effect = side_effect + + async def async_get_nearby_stations(self, *args): + """Mock async_get_nearby_stations.""" + if self.side_effect: + raise self.side_effect + return self.nearby_stations + + async def async_get_station_details(self, *args): + """Mock async_get_station_details.""" + if self.side_effect: + raise self.side_effect + return self.station_details + + async def async_get_station_measurement(self, *args): + """Mock async_get_station_measurement.""" + if self.side_effect: + raise self.side_effect + return self.station_measurement + + def override_side_effect(self, side_effect): + """Override the side_effect.""" + self.side_effect = side_effect diff --git a/tests/components/pegel_online/test_config_flow.py b/tests/components/pegel_online/test_config_flow.py new file mode 100644 index 00000000000..ffc2f88d5a8 --- /dev/null +++ b/tests/components/pegel_online/test_config_flow.py @@ -0,0 +1,209 @@ +"""Tests for Pegel Online config flow.""" +from unittest.mock import patch + +from aiohttp.client_exceptions import ClientError +from aiopegelonline import Station + +from homeassistant.components.pegel_online.const import ( + CONF_STATION, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_RADIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import PegelOnlineMock + +from tests.common import MockConfigEntry + +MOCK_USER_DATA_STEP1 = { + CONF_LOCATION: {CONF_LATITUDE: 51.0, CONF_LONGITUDE: 13.0}, + CONF_RADIUS: 25, +} + +MOCK_USER_DATA_STEP2 = {CONF_STATION: "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8"} + +MOCK_CONFIG_ENTRY_DATA = {CONF_STATION: "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8"} + +MOCK_NEARBY_STATIONS = { + "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8": Station( + { + "uuid": "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + "number": "501060", + "shortname": "DRESDEN", + "longname": "DRESDEN", + "km": 55.63, + "agency": "STANDORT DRESDEN", + "longitude": 13.738831783620384, + "latitude": 51.054459765598125, + "water": {"shortname": "ELBE", "longname": "ELBE"}, + } + ), + "85d686f1-xxxx-xxxx-xxxx-3207b50901a7": Station( + { + "uuid": "85d686f1-xxxx-xxxx-xxxx-3207b50901a7", + "number": "501060", + "shortname": "MEISSEN", + "longname": "MEISSEN", + "km": 82.2, + "agency": "STANDORT DRESDEN", + "longitude": 13.475467710324812, + "latitude": 51.16440557554545, + "water": {"shortname": "ELBE", "longname": "ELBE"}, + } + ), +} + + +async def test_user(hass: HomeAssistant) -> None: + """Test starting a flow by user.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.pegel_online.async_setup_entry", return_value=True + ) as mock_setup_entry, patch( + "homeassistant.components.pegel_online.config_flow.PegelOnline", + ) as pegelonline: + pegelonline.return_value = PegelOnlineMock(nearby_stations=MOCK_NEARBY_STATIONS) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA_STEP1 + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_station" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA_STEP2 + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_STATION] == "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8" + assert result["title"] == "DRESDEN ELBE" + + await hass.async_block_till_done() + + assert mock_setup_entry.called + + +async def test_user_already_configured(hass: HomeAssistant) -> None: + """Test starting a flow by user with an already configured statioon.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_ENTRY_DATA, + unique_id=MOCK_CONFIG_ENTRY_DATA[CONF_STATION], + ) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.pegel_online.config_flow.PegelOnline", + ) as pegelonline: + pegelonline.return_value = PegelOnlineMock(nearby_stations=MOCK_NEARBY_STATIONS) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA_STEP1 + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_station" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA_STEP2 + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test connection error during user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.pegel_online.async_setup_entry", return_value=True + ) as mock_setup_entry, patch( + "homeassistant.components.pegel_online.config_flow.PegelOnline", + ) as pegelonline: + # connection issue during setup + pegelonline.return_value = PegelOnlineMock(side_effect=ClientError) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA_STEP1 + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "cannot_connect" + + # connection issue solved + pegelonline.return_value = PegelOnlineMock(nearby_stations=MOCK_NEARBY_STATIONS) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA_STEP1 + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_station" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA_STEP2 + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_STATION] == "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8" + assert result["title"] == "DRESDEN ELBE" + + await hass.async_block_till_done() + + assert mock_setup_entry.called + + +async def test_user_no_stations(hass: HomeAssistant) -> None: + """Test starting a flow by user which does not find any station.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.pegel_online.async_setup_entry", return_value=True + ) as mock_setup_entry, patch( + "homeassistant.components.pegel_online.config_flow.PegelOnline", + ) as pegelonline: + # no stations found + pegelonline.return_value = PegelOnlineMock(nearby_stations={}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA_STEP1 + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"][CONF_RADIUS] == "no_stations" + + # stations found, go ahead + pegelonline.return_value = PegelOnlineMock(nearby_stations=MOCK_NEARBY_STATIONS) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA_STEP1 + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "select_station" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA_STEP2 + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"][CONF_STATION] == "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8" + assert result["title"] == "DRESDEN ELBE" + + await hass.async_block_till_done() + + assert mock_setup_entry.called diff --git a/tests/components/pegel_online/test_init.py b/tests/components/pegel_online/test_init.py new file mode 100644 index 00000000000..93ade373315 --- /dev/null +++ b/tests/components/pegel_online/test_init.py @@ -0,0 +1,63 @@ +"""Test pegel_online component.""" +from unittest.mock import patch + +from aiohttp.client_exceptions import ClientError +from aiopegelonline import CurrentMeasurement, Station + +from homeassistant.components.pegel_online.const import ( + CONF_STATION, + DOMAIN, + MIN_TIME_BETWEEN_UPDATES, +) +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util import utcnow + +from . import PegelOnlineMock + +from tests.common import MockConfigEntry, async_fire_time_changed + +MOCK_CONFIG_ENTRY_DATA = {CONF_STATION: "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8"} + +MOCK_STATION_DETAILS = Station( + { + "uuid": "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + "number": "501060", + "shortname": "DRESDEN", + "longname": "DRESDEN", + "km": 55.63, + "agency": "STANDORT DRESDEN", + "longitude": 13.738831783620384, + "latitude": 51.054459765598125, + "water": {"shortname": "ELBE", "longname": "ELBE"}, + } +) +MOCK_STATION_MEASUREMENT = CurrentMeasurement("cm", 56) + + +async def test_update_error(hass: HomeAssistant) -> None: + """Tests error during update entity.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_ENTRY_DATA, + unique_id=MOCK_CONFIG_ENTRY_DATA[CONF_STATION], + ) + entry.add_to_hass(hass) + with patch("homeassistant.components.pegel_online.PegelOnline") as pegelonline: + pegelonline.return_value = PegelOnlineMock( + station_details=MOCK_STATION_DETAILS, + station_measurement=MOCK_STATION_MEASUREMENT, + ) + assert await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + state = hass.states.get("sensor.dresden_elbe_water_level") + assert state + + pegelonline().override_side_effect(ClientError) + async_fire_time_changed(hass, utcnow() + MIN_TIME_BETWEEN_UPDATES) + await hass.async_block_till_done() + + state = hass.states.get("sensor.dresden_elbe_water_level") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/pegel_online/test_sensor.py b/tests/components/pegel_online/test_sensor.py new file mode 100644 index 00000000000..216ca3427c5 --- /dev/null +++ b/tests/components/pegel_online/test_sensor.py @@ -0,0 +1,53 @@ +"""Test pegel_online component.""" +from unittest.mock import patch + +from aiopegelonline import CurrentMeasurement, Station + +from homeassistant.components.pegel_online.const import CONF_STATION, DOMAIN +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import HomeAssistant + +from . import PegelOnlineMock + +from tests.common import MockConfigEntry + +MOCK_CONFIG_ENTRY_DATA = {CONF_STATION: "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8"} + +MOCK_STATION_DETAILS = Station( + { + "uuid": "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + "number": "501060", + "shortname": "DRESDEN", + "longname": "DRESDEN", + "km": 55.63, + "agency": "STANDORT DRESDEN", + "longitude": 13.738831783620384, + "latitude": 51.054459765598125, + "water": {"shortname": "ELBE", "longname": "ELBE"}, + } +) +MOCK_STATION_MEASUREMENT = CurrentMeasurement("cm", 56) + + +async def test_sensor(hass: HomeAssistant) -> None: + """Tests sensor entity.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_ENTRY_DATA, + unique_id=MOCK_CONFIG_ENTRY_DATA[CONF_STATION], + ) + entry.add_to_hass(hass) + with patch("homeassistant.components.pegel_online.PegelOnline") as pegelonline: + pegelonline.return_value = PegelOnlineMock( + station_details=MOCK_STATION_DETAILS, + station_measurement=MOCK_STATION_MEASUREMENT, + ) + assert await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + state = hass.states.get("sensor.dresden_elbe_water_level") + assert state.name == "DRESDEN ELBE Water level" + assert state.state == "56" + assert state.attributes[ATTR_LATITUDE] == 51.054459765598125 + assert state.attributes[ATTR_LONGITUDE] == 13.738831783620384 diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index d9231732941..8b0acb9c5b0 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -44,7 +44,6 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONTENT_TYPE_TEXT_PLAIN, DEGREE, - EVENT_STATE_CHANGED, PERCENTAGE, STATE_CLOSED, STATE_CLOSING, @@ -59,7 +58,7 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -1568,23 +1567,13 @@ def mock_client_fixture(): yield counter_client -@pytest.fixture -def mock_bus(hass): - """Mock the event bus listener.""" - hass.bus.listen = mock.MagicMock() - - -@pytest.mark.usefixtures("mock_bus") async def test_minimal_config(hass: HomeAssistant, mock_client) -> None: """Test the minimal config and defaults of component.""" config = {prometheus.DOMAIN: {}} assert await async_setup_component(hass, prometheus.DOMAIN, config) await hass.async_block_till_done() - assert hass.bus.listen.called - assert hass.bus.listen.call_args_list[0][0][0] == EVENT_STATE_CHANGED -@pytest.mark.usefixtures("mock_bus") async def test_full_config(hass: HomeAssistant, mock_client) -> None: """Test the full config of component.""" config = { @@ -1607,21 +1596,6 @@ async def test_full_config(hass: HomeAssistant, mock_client) -> None: } assert await async_setup_component(hass, prometheus.DOMAIN, config) await hass.async_block_till_done() - assert hass.bus.listen.called - assert hass.bus.listen.call_args_list[0][0][0] == EVENT_STATE_CHANGED - - -def make_event(entity_id): - """Make a mock event for test.""" - domain = split_entity_id(entity_id)[0] - state = mock.MagicMock( - state="not blank", - domain=domain, - entity_id=entity_id, - object_id="entity", - attributes={}, - ) - return mock.MagicMock(data={"new_state": state}, time_fired=12345) async def _setup(hass, filter_config): @@ -1629,13 +1603,11 @@ async def _setup(hass, filter_config): config = {prometheus.DOMAIN: {"filter": filter_config}} assert await async_setup_component(hass, prometheus.DOMAIN, config) await hass.async_block_till_done() - return hass.bus.listen.call_args_list[0][0][1] -@pytest.mark.usefixtures("mock_bus") async def test_allowlist(hass: HomeAssistant, mock_client) -> None: """Test an allowlist only config.""" - handler_method = await _setup( + await _setup( hass, { "include_domains": ["fake"], @@ -1654,18 +1626,17 @@ async def test_allowlist(hass: HomeAssistant, mock_client) -> None: ] for test in tests: - event = make_event(test.id) - handler_method(event) + hass.states.async_set(test.id, "not blank") + await hass.async_block_till_done() was_called = mock_client.labels.call_count == 1 assert test.should_pass == was_called mock_client.labels.reset_mock() -@pytest.mark.usefixtures("mock_bus") async def test_denylist(hass: HomeAssistant, mock_client) -> None: """Test a denylist only config.""" - handler_method = await _setup( + await _setup( hass, { "exclude_domains": ["fake"], @@ -1684,18 +1655,17 @@ async def test_denylist(hass: HomeAssistant, mock_client) -> None: ] for test in tests: - event = make_event(test.id) - handler_method(event) + hass.states.async_set(test.id, "not blank") + await hass.async_block_till_done() was_called = mock_client.labels.call_count == 1 assert test.should_pass == was_called mock_client.labels.reset_mock() -@pytest.mark.usefixtures("mock_bus") async def test_filtered_denylist(hass: HomeAssistant, mock_client) -> None: """Test a denylist config with a filtering allowlist.""" - handler_method = await _setup( + await _setup( hass, { "include_entities": ["fake.included", "test.excluded_test"], @@ -1715,8 +1685,8 @@ async def test_filtered_denylist(hass: HomeAssistant, mock_client) -> None: ] for test in tests: - event = make_event(test.id) - handler_method(event) + hass.states.async_set(test.id, "not blank") + await hass.async_block_till_done() was_called = mock_client.labels.call_count == 1 assert test.should_pass == was_called diff --git a/tests/components/pure_energie/test_sensor.py b/tests/components/pure_energie/test_sensor.py index 2881bf28d8f..eb0b9634e83 100644 --- a/tests/components/pure_energie/test_sensor.py +++ b/tests/components/pure_energie/test_sensor.py @@ -34,7 +34,7 @@ async def test_sensors( assert state assert entry.unique_id == "aabbccddeeff_energy_consumption_total" assert state.state == "17762.1" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Consumption" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home Energy consumption" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY @@ -46,7 +46,7 @@ async def test_sensors( assert state assert entry.unique_id == "aabbccddeeff_energy_production_total" assert state.state == "21214.6" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Energy Production" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home Energy production" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY @@ -58,7 +58,7 @@ async def test_sensors( assert state assert entry.unique_id == "aabbccddeeff_power_flow" assert state.state == "338" - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Power Flow" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "home Power flow" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER diff --git a/tests/components/purpleair/conftest.py b/tests/components/purpleair/conftest.py index ef48a5988a3..4883f79b349 100644 --- a/tests/components/purpleair/conftest.py +++ b/tests/components/purpleair/conftest.py @@ -19,6 +19,7 @@ def api_fixture(get_sensors_response): """Define a fixture to return a mocked aiopurple API object.""" return Mock( async_check_api_key=AsyncMock(), + get_map_url=Mock(return_value="http://example.com"), sensors=Mock( async_get_nearby_sensors=AsyncMock( return_value=[ diff --git a/tests/components/purpleair/test_config_flow.py b/tests/components/purpleair/test_config_flow.py index ce911183dfd..b72ac7e3a79 100644 --- a/tests/components/purpleair/test_config_flow.py +++ b/tests/components/purpleair/test_config_flow.py @@ -288,7 +288,9 @@ async def test_options_remove_sensor( assert result["step_id"] == "remove_sensor" device_registry = dr.async_get(hass) - device_entry = device_registry.async_get_device({(DOMAIN, str(TEST_SENSOR_INDEX1))}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, str(TEST_SENSOR_INDEX1))} + ) result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"sensor_device_id": device_entry.id}, @@ -302,3 +304,29 @@ async def test_options_remove_sensor( # Unload to make sure the update does not run after the # mock is removed. await hass.config_entries.async_unload(config_entry.entry_id) + + +async def test_options_settings( + hass: HomeAssistant, config_entry, setup_config_entry +) -> None: + """Test setting settings via the options flow.""" + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.MENU + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"next_step_id": "settings"} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"show_on_map": True} + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor_indices": [TEST_SENSOR_INDEX1], + "show_on_map": True, + } + + assert config_entry.options["show_on_map"] is True diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py index 767b0bca742..9326869b272 100644 --- a/tests/components/python_script/test_init.py +++ b/tests/components/python_script/test_init.py @@ -357,9 +357,7 @@ async def test_service_descriptions(hass: HomeAssistant) -> None: " example: 'This is a test of python_script.hello'" ) services_yaml1 = { - "{}/{}/services.yaml".format( - hass.config.config_dir, FOLDER - ): service_descriptions1 + f"{hass.config.config_dir}/{FOLDER}/services.yaml": service_descriptions1 } with patch( @@ -408,9 +406,7 @@ async def test_service_descriptions(hass: HomeAssistant) -> None: " example: 'This is a test of python_script.hello2'" ) services_yaml2 = { - "{}/{}/services.yaml".format( - hass.config.config_dir, FOLDER - ): service_descriptions2 + f"{hass.config.config_dir}/{FOLDER}/services.yaml": service_descriptions2 } with patch( diff --git a/tests/components/qld_bushfire/test_geo_location.py b/tests/components/qld_bushfire/test_geo_location.py index a164cfbeb78..18b33a6ef0c 100644 --- a/tests/components/qld_bushfire/test_geo_location.py +++ b/tests/components/qld_bushfire/test_geo_location.py @@ -80,8 +80,8 @@ async def test_setup(hass: HomeAssistant) -> None: (38.0, -3.0), category="Category 1", attribution="Attribution 1", - published=datetime.datetime(2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc), - updated=datetime.datetime(2018, 9, 22, 8, 10, tzinfo=datetime.timezone.utc), + published=datetime.datetime(2018, 9, 22, 8, 0, tzinfo=datetime.UTC), + updated=datetime.datetime(2018, 9, 22, 8, 10, tzinfo=datetime.UTC), status="Status 1", ) mock_entry_2 = _generate_mock_feed_entry("2345", "Title 2", 20.5, (38.1, -3.1)) @@ -119,10 +119,10 @@ async def test_setup(hass: HomeAssistant) -> None: ATTR_CATEGORY: "Category 1", ATTR_ATTRIBUTION: "Attribution 1", ATTR_PUBLICATION_DATE: datetime.datetime( - 2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc + 2018, 9, 22, 8, 0, tzinfo=datetime.UTC ), ATTR_UPDATED_DATE: datetime.datetime( - 2018, 9, 22, 8, 10, tzinfo=datetime.timezone.utc + 2018, 9, 22, 8, 10, tzinfo=datetime.UTC ), ATTR_STATUS: "Status 1", ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, diff --git a/tests/components/radarr/__init__.py b/tests/components/radarr/__init__.py index 7e574b1e3e0..069eeabe8d8 100644 --- a/tests/components/radarr/__init__.py +++ b/tests/components/radarr/__init__.py @@ -41,6 +41,7 @@ def mock_connection( error: bool = False, invalid_auth: bool = False, windows: bool = False, + single_return: bool = False, ) -> None: """Mock radarr connection.""" if error: @@ -75,22 +76,27 @@ def mock_connection( headers={"Content-Type": CONTENT_TYPE_JSON}, ) + root_folder_fixture = "rootfolder-linux" + if windows: - aioclient_mock.get( - f"{url}/api/v3/rootfolder", - text=load_fixture("radarr/rootfolder-windows.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) - else: - aioclient_mock.get( - f"{url}/api/v3/rootfolder", - text=load_fixture("radarr/rootfolder-linux.json"), - headers={"Content-Type": CONTENT_TYPE_JSON}, - ) + root_folder_fixture = "rootfolder-windows" + + if single_return: + root_folder_fixture = f"single-{root_folder_fixture}" + + aioclient_mock.get( + f"{url}/api/v3/rootfolder", + text=load_fixture(f"radarr/{root_folder_fixture}.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + movie_fixture = "movie" + if single_return: + movie_fixture = f"single-{movie_fixture}" aioclient_mock.get( f"{url}/api/v3/movie", - text=load_fixture("radarr/movie.json"), + text=load_fixture(f"radarr/{movie_fixture}.json"), headers={"Content-Type": CONTENT_TYPE_JSON}, ) @@ -139,6 +145,7 @@ async def setup_integration( connection_error: bool = False, invalid_auth: bool = False, windows: bool = False, + single_return: bool = False, ) -> MockConfigEntry: """Set up the radarr integration in Home Assistant.""" entry = MockConfigEntry( @@ -159,6 +166,7 @@ async def setup_integration( error=connection_error, invalid_auth=invalid_auth, windows=windows, + single_return=single_return, ) if not skip_entry_setup: @@ -183,7 +191,7 @@ def patch_radarr(): def create_entry(hass: HomeAssistant) -> MockConfigEntry: - """Create Efergy entry in Home Assistant.""" + """Create Radarr entry in Home Assistant.""" entry = MockConfigEntry( domain=DOMAIN, data={ diff --git a/tests/components/radarr/fixtures/single-movie.json b/tests/components/radarr/fixtures/single-movie.json new file mode 100644 index 00000000000..db9e720d285 --- /dev/null +++ b/tests/components/radarr/fixtures/single-movie.json @@ -0,0 +1,116 @@ +{ + "id": 0, + "title": "string", + "originalTitle": "string", + "alternateTitles": [ + { + "sourceType": "tmdb", + "movieId": 1, + "title": "string", + "sourceId": 0, + "votes": 0, + "voteCount": 0, + "language": { + "id": 1, + "name": "English" + }, + "id": 1 + } + ], + "sortTitle": "string", + "sizeOnDisk": 0, + "overview": "string", + "inCinemas": "2020-11-06T00:00:00Z", + "physicalRelease": "2019-03-19T00:00:00Z", + "images": [ + { + "coverType": "poster", + "url": "string", + "remoteUrl": "string" + } + ], + "website": "string", + "year": 0, + "hasFile": true, + "youTubeTrailerId": "string", + "studio": "string", + "path": "string", + "rootFolderPath": "string", + "qualityProfileId": 0, + "monitored": true, + "minimumAvailability": "announced", + "isAvailable": true, + "folderName": "string", + "runtime": 0, + "cleanTitle": "string", + "imdbId": "string", + "tmdbId": 0, + "titleSlug": "string", + "certification": "string", + "genres": ["string"], + "tags": [0], + "added": "2018-12-28T05:56:49Z", + "ratings": { + "votes": 0, + "value": 0 + }, + "movieFile": { + "movieId": 0, + "relativePath": "string", + "path": "string", + "size": 916662234, + "dateAdded": "2020-11-26T02:00:35Z", + "indexerFlags": 1, + "quality": { + "quality": { + "id": 14, + "name": "WEBRip-720p", + "source": "webrip", + "resolution": 720, + "modifier": "none" + }, + "revision": { + "version": 1, + "real": 0, + "isRepack": false + } + }, + "mediaInfo": { + "audioBitrate": 0, + "audioChannels": 2, + "audioCodec": "AAC", + "audioLanguages": "", + "audioStreamCount": 1, + "videoBitDepth": 8, + "videoBitrate": 1000000, + "videoCodec": "x264", + "videoFps": 25.0, + "resolution": "1280x534", + "runTime": "1:49:06", + "scanType": "Progressive", + "subtitles": "" + }, + "originalFilePath": "string", + "qualityCutoffNotMet": true, + "languages": [ + { + "id": 26, + "name": "Hindi" + } + ], + "edition": "", + "id": 35361 + }, + "collection": { + "name": "string", + "tmdbId": 0, + "images": [ + { + "coverType": "poster", + "url": "string", + "remoteUrl": "string" + } + ] + }, + "status": "deleted" +} diff --git a/tests/components/radarr/fixtures/single-rootfolder-linux.json b/tests/components/radarr/fixtures/single-rootfolder-linux.json new file mode 100644 index 00000000000..085467fda6a --- /dev/null +++ b/tests/components/radarr/fixtures/single-rootfolder-linux.json @@ -0,0 +1,6 @@ +{ + "path": "/downloads", + "freeSpace": 282500064232, + "unmappedFolders": [], + "id": 1 +} diff --git a/tests/components/radarr/fixtures/single-rootfolder-windows.json b/tests/components/radarr/fixtures/single-rootfolder-windows.json new file mode 100644 index 00000000000..25a93baa10d --- /dev/null +++ b/tests/components/radarr/fixtures/single-rootfolder-windows.json @@ -0,0 +1,6 @@ +{ + "path": "D:\\Downloads\\TV", + "freeSpace": 282500064232, + "unmappedFolders": [], + "id": 1 +} diff --git a/tests/components/radarr/test_init.py b/tests/components/radarr/test_init.py index 0bd4c538cf6..6b602c8c4d1 100644 --- a/tests/components/radarr/test_init.py +++ b/tests/components/radarr/test_init.py @@ -51,7 +51,7 @@ async def test_device_info( entry = await setup_integration(hass, aioclient_mock) device_registry = dr.async_get(hass) await hass.async_block_till_done() - device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) assert device.configuration_url == "http://192.168.1.189:7887/test" assert device.identifiers == {(DOMAIN, entry.entry_id)} diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index d3dde74dcbf..f4f863d9bb6 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -1,4 +1,5 @@ """The tests for Radarr sensor platform.""" +import pytest from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT @@ -9,15 +10,43 @@ from . import setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.parametrize( + ("windows", "single", "root_folder"), + [ + ( + False, + False, + "downloads", + ), + ( + False, + True, + "downloads", + ), + ( + True, + False, + "tv", + ), + ( + True, + True, + "tv", + ), + ], +) async def test_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, entity_registry_enabled_by_default: None, + windows: bool, + single: bool, + root_folder: str, ) -> None: """Test for successfully setting up the Radarr platform.""" - await setup_integration(hass, aioclient_mock) + await setup_integration(hass, aioclient_mock, windows=windows, single_return=single) - state = hass.states.get("sensor.mock_title_disk_space_downloads") + state = hass.states.get(f"sensor.mock_title_disk_space_{root_folder}") assert state.state == "263.10" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "GB" state = hass.states.get("sensor.mock_title_movies") @@ -26,13 +55,3 @@ async def test_sensors( state = hass.states.get("sensor.mock_title_start_time") assert state.state == "2020-09-01T23:50:20+00:00" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP - - -async def test_windows( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test for successfully setting up the Radarr platform on Windows.""" - await setup_integration(hass, aioclient_mock, windows=True) - - state = hass.states.get("sensor.mock_title_disk_space_tv") - assert state.state == "263.10" diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py index 816f2a3b969..cfa2c4d2684 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -31,10 +31,10 @@ async def test_rainsensor( assert await setup_integration() - rainsensor = hass.states.get("binary_sensor.rainsensor") + rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") assert rainsensor is not None assert rainsensor.state == expected_state assert rainsensor.attributes == { - "friendly_name": "Rainsensor", + "friendly_name": "Rain Bird Controller Rainsensor", "icon": "mdi:water", } diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 2ecdfcc537f..1335a1595d3 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -67,7 +67,7 @@ async def test_set_value( assert await setup_integration() device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, SERIAL_NUMBER)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL_NUMBER)}) assert device assert device.name == "Rain Bird Controller" assert device.model == "ST8x-WiFi" diff --git a/tests/components/rainbird/test_sensor.py b/tests/components/rainbird/test_sensor.py index e9923b1a052..049a5f15c45 100644 --- a/tests/components/rainbird/test_sensor.py +++ b/tests/components/rainbird/test_sensor.py @@ -28,10 +28,10 @@ async def test_sensors( assert await setup_integration() - raindelay = hass.states.get("sensor.raindelay") + raindelay = hass.states.get("sensor.rain_bird_controller_raindelay") assert raindelay is not None assert raindelay.state == expected_state assert raindelay.attributes == { - "friendly_name": "Raindelay", + "friendly_name": "Rain Bird Controller Raindelay", "icon": "mdi:water-off", } diff --git a/tests/components/rainforest_eagle/test_sensor.py b/tests/components/rainforest_eagle/test_sensor.py index 96b9e0a85dc..5e76a81932a 100644 --- a/tests/components/rainforest_eagle/test_sensor.py +++ b/tests/components/rainforest_eagle/test_sensor.py @@ -32,7 +32,7 @@ async def test_sensors_200(hass: HomeAssistant, setup_rainforest_200) -> None: assert len(hass.states.async_all()) == 4 - price = hass.states.get("sensor.meter_price") + price = hass.states.get("sensor.eagle_200_meter_price") assert price is not None assert price.state == "0.053990" assert price.attributes["unit_of_measurement"] == "USD/kWh" @@ -42,17 +42,17 @@ async def test_sensors_100(hass: HomeAssistant, setup_rainforest_100) -> None: """Test the sensors.""" assert len(hass.states.async_all()) == 3 - demand = hass.states.get("sensor.eagle_200_meter_power_demand") + demand = hass.states.get("sensor.eagle_100_meter_power_demand") assert demand is not None assert demand.state == "1.152000" assert demand.attributes["unit_of_measurement"] == "kW" - delivered = hass.states.get("sensor.eagle_200_total_meter_energy_delivered") + delivered = hass.states.get("sensor.eagle_100_total_meter_energy_delivered") assert delivered is not None assert delivered.state == "45251.285000" assert delivered.attributes["unit_of_measurement"] == "kWh" - received = hass.states.get("sensor.eagle_200_total_meter_energy_received") + received = hass.states.get("sensor.eagle_100_total_meter_energy_received") assert received is not None assert received.state == "232.232000" assert received.attributes["unit_of_measurement"] == "kWh" diff --git a/tests/components/recorder/db_schema_30.py b/tests/components/recorder/db_schema_30.py index 40417752719..55bee20df56 100644 --- a/tests/components/recorder/db_schema_30.py +++ b/tests/components/recorder/db_schema_30.py @@ -9,7 +9,7 @@ from collections.abc import Callable from datetime import datetime, timedelta import logging import time -from typing import Any, TypedDict, cast, overload +from typing import Any, Self, TypedDict, cast, overload import ciso8601 from fnv_hash_fast import fnv1a_32 @@ -34,7 +34,6 @@ from sqlalchemy import ( from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite from sqlalchemy.orm import aliased, declarative_base, relationship from sqlalchemy.orm.session import Session -from typing_extensions import Self from homeassistant.components.recorder.const import SupportedDialect from homeassistant.const import ( diff --git a/tests/components/recorder/db_schema_32.py b/tests/components/recorder/db_schema_32.py index 03a71697227..660a2a54d4b 100644 --- a/tests/components/recorder/db_schema_32.py +++ b/tests/components/recorder/db_schema_32.py @@ -9,7 +9,7 @@ from collections.abc import Callable from datetime import datetime, timedelta import logging import time -from typing import Any, TypedDict, cast, overload +from typing import Any, Self, TypedDict, cast, overload import ciso8601 from fnv_hash_fast import fnv1a_32 @@ -34,7 +34,6 @@ from sqlalchemy import ( from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite from sqlalchemy.orm import aliased, declarative_base, relationship from sqlalchemy.orm.session import Session -from typing_extensions import Self from homeassistant.components.recorder.const import SupportedDialect from homeassistant.const import ( diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 0bb315365b5..4e9a0261ec2 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -56,6 +56,10 @@ from homeassistant.components.recorder.services import ( SERVICE_PURGE, SERVICE_PURGE_ENTITIES, ) +from homeassistant.components.recorder.table_managers import ( + state_attributes as state_attributes_table_manager, + states_meta as states_meta_table_manager, +) from homeassistant.components.recorder.util import session_scope from homeassistant.const import ( EVENT_COMPONENT_LOADED, @@ -93,6 +97,15 @@ from tests.common import ( from tests.typing import RecorderInstanceGenerator +@pytest.fixture +def small_cache_size() -> None: + """Patch the default cache size to 8.""" + with patch.object(state_attributes_table_manager, "CACHE_SIZE", 8), patch.object( + states_meta_table_manager, "CACHE_SIZE", 8 + ): + yield + + def _default_recorder(hass): """Return a recorder with reasonable defaults.""" return Recorder( @@ -2022,13 +2035,10 @@ def test_deduplication_event_data_inside_commit_interval( assert all(event.data_id == first_data_id for event in events) -# Patch CACHE_SIZE since otherwise -# the CI can fail because the test takes too long to run -@patch( - "homeassistant.components.recorder.table_managers.state_attributes.CACHE_SIZE", 5 -) def test_deduplication_state_attributes_inside_commit_interval( - hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture + small_cache_size: None, + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, ) -> None: """Test deduplication of state attributes inside the commit interval.""" hass = hass_recorder() @@ -2306,16 +2316,15 @@ async def test_excluding_attributes_by_integration( async def test_lru_increases_with_many_entities( - recorder_mock: Recorder, hass: HomeAssistant + small_cache_size: None, recorder_mock: Recorder, hass: HomeAssistant ) -> None: """Test that the recorder's internal LRU cache increases with many entities.""" - # We do not actually want to record 4096 entities so we mock the entity count - mock_entity_count = 4096 - with patch.object( - hass.states, "async_entity_ids_count", return_value=mock_entity_count - ): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await async_wait_recording_done(hass) + mock_entity_count = 16 + for idx in range(mock_entity_count): + hass.states.async_set(f"test.entity{idx}", "on") + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await async_wait_recording_done(hass) assert ( recorder_mock.state_attributes_manager._id_map.get_size() diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 2c76c947350..32d4fabb02b 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -215,9 +215,7 @@ async def test_statistics_during_period( } -@pytest.mark.freeze_time( - datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc) -) +@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) @pytest.mark.parametrize("offset", (0, 1, 2)) async def test_statistic_during_period( recorder_mock: Recorder, @@ -632,9 +630,7 @@ async def test_statistic_during_period( } -@pytest.mark.freeze_time( - datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc) -) +@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) async def test_statistic_during_period_hole( recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -797,9 +793,7 @@ async def test_statistic_during_period_hole( } -@pytest.mark.freeze_time( - datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.timezone.utc) -) +@pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) @pytest.mark.parametrize( ("calendar_period", "start_time", "end_time"), ( diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py index 8dd0a1bf154..8c47410ce40 100644 --- a/tests/components/renault/__init__.py +++ b/tests/components/renault/__init__.py @@ -37,7 +37,9 @@ def check_device_registry( ) -> None: """Ensure that the expected_device is correctly registered.""" assert len(device_registry.devices) == 1 - registry_entry = device_registry.async_get_device(expected_device[ATTR_IDENTIFIERS]) + registry_entry = device_registry.async_get_device( + identifiers=expected_device[ATTR_IDENTIFIERS] + ) assert registry_entry is not None assert registry_entry.identifiers == expected_device[ATTR_IDENTIFIERS] assert registry_entry.manufacturer == expected_device[ATTR_MANUFACTURER] diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py index 2aceb5e7489..4b2a7dfc72b 100644 --- a/tests/components/renault/const.py +++ b/tests/components/renault/const.py @@ -151,7 +151,7 @@ MOCK_VEHICLES = { }, { ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY, - ATTR_ENTITY_ID: "sensor.reg_number_battery_level", + ATTR_ENTITY_ID: "sensor.reg_number_battery", ATTR_STATE: "60", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_level", @@ -386,7 +386,7 @@ MOCK_VEHICLES = { }, { ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY, - ATTR_ENTITY_ID: "sensor.reg_number_battery_level", + ATTR_ENTITY_ID: "sensor.reg_number_battery", ATTR_STATE: "50", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777999_battery_level", @@ -621,7 +621,7 @@ MOCK_VEHICLES = { }, { ATTR_DEVICE_CLASS: SensorDeviceClass.BATTERY, - ATTR_ENTITY_ID: "sensor.reg_number_battery_level", + ATTR_ENTITY_ID: "sensor.reg_number_battery", ATTR_STATE: "60", ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, ATTR_UNIQUE_ID: "vf1aaaaa555777123_battery_level", diff --git a/tests/components/renault/snapshots/test_binary_sensor.ambr b/tests/components/renault/snapshots/test_binary_sensor.ambr index dc10dd839f0..9625810bedb 100644 --- a/tests/components/renault/snapshots/test_binary_sensor.ambr +++ b/tests/components/renault/snapshots/test_binary_sensor.ambr @@ -54,7 +54,7 @@ 'original_name': 'Lock', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'lock_status', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_lock_status', 'unit_of_measurement': None, }), @@ -325,7 +325,7 @@ 'original_name': 'Plug', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'plugged_in', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_plugged_in', 'unit_of_measurement': None, }), @@ -353,7 +353,7 @@ 'original_name': 'Charging', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'charging', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_charging', 'unit_of_measurement': None, }), @@ -381,7 +381,7 @@ 'original_name': 'Lock', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'lock_status', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_lock_status', 'unit_of_measurement': None, }), @@ -674,7 +674,7 @@ 'original_name': 'Plug', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'plugged_in', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_plugged_in', 'unit_of_measurement': None, }), @@ -702,7 +702,7 @@ 'original_name': 'Charging', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'charging', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_charging', 'unit_of_measurement': None, }), @@ -828,7 +828,7 @@ 'original_name': 'Plug', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'plugged_in', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_plugged_in', 'unit_of_measurement': None, }), @@ -856,7 +856,7 @@ 'original_name': 'Charging', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'charging', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_charging', 'unit_of_measurement': None, }), @@ -912,7 +912,7 @@ 'original_name': 'Lock', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'lock_status', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_lock_status', 'unit_of_measurement': None, }), @@ -1216,7 +1216,7 @@ 'original_name': 'Lock', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'lock_status', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_lock_status', 'unit_of_measurement': None, }), @@ -1487,7 +1487,7 @@ 'original_name': 'Plug', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'plugged_in', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_plugged_in', 'unit_of_measurement': None, }), @@ -1515,7 +1515,7 @@ 'original_name': 'Charging', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'charging', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_charging', 'unit_of_measurement': None, }), @@ -1543,7 +1543,7 @@ 'original_name': 'Lock', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'lock_status', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_lock_status', 'unit_of_measurement': None, }), @@ -1836,7 +1836,7 @@ 'original_name': 'Plug', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'plugged_in', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_plugged_in', 'unit_of_measurement': None, }), @@ -1864,7 +1864,7 @@ 'original_name': 'Charging', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'charging', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_charging', 'unit_of_measurement': None, }), @@ -1990,7 +1990,7 @@ 'original_name': 'Plug', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'plugged_in', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_plugged_in', 'unit_of_measurement': None, }), @@ -2018,7 +2018,7 @@ 'original_name': 'Charging', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'charging', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_charging', 'unit_of_measurement': None, }), @@ -2074,7 +2074,7 @@ 'original_name': 'Lock', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'lock_status', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_lock_status', 'unit_of_measurement': None, }), diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index 72f9201b7a4..b4e2f105b3b 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -327,7 +327,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_level', + 'entity_id': 'sensor.reg_number_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -337,10 +337,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery level', + 'original_name': 'Battery', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'battery_level', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_battery_level', 'unit_of_measurement': '%', }), @@ -777,12 +777,12 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery level', + 'friendly_name': 'REG-NUMBER Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.reg_number_battery_level', + 'entity_id': 'sensor.reg_number_battery', 'last_changed': , 'last_updated': , 'state': 'unknown', @@ -1023,7 +1023,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_level', + 'entity_id': 'sensor.reg_number_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1033,10 +1033,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery level', + 'original_name': 'Battery', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'battery_level', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_battery_level', 'unit_of_measurement': '%', }), @@ -1471,12 +1471,12 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery level', + 'friendly_name': 'REG-NUMBER Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.reg_number_battery_level', + 'entity_id': 'sensor.reg_number_battery', 'last_changed': , 'last_updated': , 'state': 'unknown', @@ -1713,7 +1713,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_level', + 'entity_id': 'sensor.reg_number_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1723,10 +1723,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery level', + 'original_name': 'Battery', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'battery_level', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_battery_level', 'unit_of_measurement': '%', }), @@ -2189,12 +2189,12 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery level', + 'friendly_name': 'REG-NUMBER Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.reg_number_battery_level', + 'entity_id': 'sensor.reg_number_battery', 'last_changed': , 'last_updated': , 'state': 'unknown', @@ -2726,7 +2726,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_level', + 'entity_id': 'sensor.reg_number_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2736,10 +2736,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery level', + 'original_name': 'Battery', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'battery_level', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_battery_level', 'unit_of_measurement': '%', }), @@ -3176,12 +3176,12 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery level', + 'friendly_name': 'REG-NUMBER Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.reg_number_battery_level', + 'entity_id': 'sensor.reg_number_battery', 'last_changed': , 'last_updated': , 'state': '60', @@ -3422,7 +3422,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_level', + 'entity_id': 'sensor.reg_number_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3432,10 +3432,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery level', + 'original_name': 'Battery', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'battery_level', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_battery_level', 'unit_of_measurement': '%', }), @@ -3870,12 +3870,12 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery level', + 'friendly_name': 'REG-NUMBER Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.reg_number_battery_level', + 'entity_id': 'sensor.reg_number_battery', 'last_changed': , 'last_updated': , 'state': '60', @@ -4112,7 +4112,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.reg_number_battery_level', + 'entity_id': 'sensor.reg_number_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -4122,10 +4122,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Battery level', + 'original_name': 'Battery', 'platform': 'renault', 'supported_features': 0, - 'translation_key': 'battery_level', + 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_battery_level', 'unit_of_measurement': '%', }), @@ -4588,12 +4588,12 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'REG-NUMBER Battery level', + 'friendly_name': 'REG-NUMBER Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.reg_number_battery_level', + 'entity_id': 'sensor.reg_number_battery', 'last_changed': , 'last_updated': , 'state': '50', diff --git a/tests/components/renault/test_diagnostics.py b/tests/components/renault/test_diagnostics.py index 31148d4551a..76ea88b4b45 100644 --- a/tests/components/renault/test_diagnostics.py +++ b/tests/components/renault/test_diagnostics.py @@ -197,7 +197,9 @@ async def test_device_diagnostics( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - device = device_registry.async_get_device({(DOMAIN, "VF1AAAAA555777999")}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, "VF1AAAAA555777999")} + ) assert device is not None assert await get_diagnostics_for_device( diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py index 7f2aee9d7bd..415b07dc7e6 100644 --- a/tests/components/renault/test_init.py +++ b/tests/components/renault/test_init.py @@ -1,7 +1,7 @@ """Tests for Renault setup process.""" from collections.abc import Generator from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch import aiohttp import pytest @@ -76,3 +76,22 @@ async def test_setup_entry_exception( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.SETUP_RETRY assert not hass.data.get(DOMAIN) + + +@pytest.mark.usefixtures("patch_renault_account") +async def test_setup_entry_kamereon_exception( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Test ConfigEntryNotReady when API raises an exception during entry setup.""" + # In this case we are testing the condition where renault_hub fails to retrieve + # list of vehicles (see Gateway Time-out on #97324). + with patch( + "renault_api.renault_client.RenaultClient.get_api_account", + side_effect=aiohttp.ClientResponseError(Mock(), (), status=504), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index d0ba320c1c6..58d51eca537 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -55,7 +55,7 @@ def get_device_id(hass: HomeAssistant) -> str: """Get device_id.""" device_registry = dr.async_get(hass) identifiers = {(DOMAIN, "VF1AAAAA555777999")} - device = device_registry.async_get_device(identifiers) + device = device_registry.async_get_device(identifiers=identifiers) return device.id @@ -272,7 +272,9 @@ async def test_service_invalid_device_id2( model=extra_vehicle[ATTR_MODEL], sw_version=extra_vehicle[ATTR_SW_VERSION], ) - device_id = device_registry.async_get_device(extra_vehicle[ATTR_IDENTIFIERS]).id + device_id = device_registry.async_get_device( + identifiers=extra_vehicle[ATTR_IDENTIFIERS] + ).id data = {ATTR_VEHICLE: device_id} diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 7d25fd62811..b6e48cab7b2 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -9,6 +9,7 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp from homeassistant.components.reolink import const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL +from homeassistant.components.reolink.exceptions import ReolinkWebhookException from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac @@ -109,6 +110,20 @@ async def test_config_flow_errors( assert result["step_id"] == "user" assert result["errors"] == {CONF_HOST: "cannot_connect"} + reolink_connect.get_host_data.side_effect = ReolinkWebhookException("Test error") + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) + + assert result["type"] is data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "webhook_exception"} + reolink_connect.get_host_data.side_effect = json.JSONDecodeError( "test_error", "test", 1 ) diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 1e588d5e3a1..f5f581760c1 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -116,7 +116,14 @@ async def test_https_repair_issue( hass, {"country": "GB", "internal_url": "https://test_homeassistant_address"} ) - assert await hass.config_entries.async_setup(config_entry.entry_id) + with patch( + "homeassistant.components.reolink.host.FIRST_ONVIF_TIMEOUT", new=0 + ), patch( + "homeassistant.components.reolink.host.FIRST_ONVIF_LONG_POLL_TIMEOUT", new=0 + ), patch( + "homeassistant.components.reolink.host.ReolinkHost._async_long_polling", + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() issue_registry = ir.async_get(hass) @@ -150,6 +157,8 @@ async def test_webhook_repair_issue( """Test repairs issue is raised when the webhook url is unreachable.""" with patch( "homeassistant.components.reolink.host.FIRST_ONVIF_TIMEOUT", new=0 + ), patch( + "homeassistant.components.reolink.host.FIRST_ONVIF_LONG_POLL_TIMEOUT", new=0 ), patch( "homeassistant.components.reolink.host.ReolinkHost._async_long_polling", ): diff --git a/tests/components/rflink/test_light.py b/tests/components/rflink/test_light.py index 27dca72fd96..34b918cd3ed 100644 --- a/tests/components/rflink/test_light.py +++ b/tests/components/rflink/test_light.py @@ -285,11 +285,13 @@ async def test_signal_repetitions_cancelling(hass: HomeAssistant, monkeypatch) - await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: f"{DOMAIN}.test"} ) + # Get background service time to start running await asyncio.sleep(0) await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, blocking=True ) + await hass.async_block_till_done() assert [call[0][1] for call in protocol.send_command_ack.call_args_list] == [ "off", diff --git a/tests/components/rfxtrx/test_device_action.py b/tests/components/rfxtrx/test_device_action.py index d53ef6a7a02..087a6840c59 100644 --- a/tests/components/rfxtrx/test_device_action.py +++ b/tests/components/rfxtrx/test_device_action.py @@ -86,7 +86,9 @@ async def test_get_actions( """Test we get the expected actions from a rfxtrx.""" await setup_entry(hass, {device.code: {}}) - device_entry = device_registry.async_get_device(device.device_identifiers, set()) + device_entry = device_registry.async_get_device( + identifiers=device.device_identifiers + ) assert device_entry # Add alternate identifiers, to make sure we can handle future formats @@ -94,7 +96,9 @@ async def test_get_actions( device_registry.async_update_device( device_entry.id, merge_identifiers={(identifiers[0], "_".join(identifiers[1:]))} ) - device_entry = device_registry.async_get_device(device.device_identifiers, set()) + device_entry = device_registry.async_get_device( + identifiers=device.device_identifiers + ) assert device_entry actions = await async_get_device_automations( @@ -142,7 +146,9 @@ async def test_action( await setup_entry(hass, {device.code: {}}) - device_entry = device_registry.async_get_device(device.device_identifiers, set()) + device_entry = device_registry.async_get_device( + identifiers=device.device_identifiers + ) assert device_entry assert await async_setup_component( @@ -181,8 +187,8 @@ async def test_invalid_action( await setup_entry(hass, {device.code: {}}) - device_identifers: Any = device.device_identifiers - device_entry = device_registry.async_get_device(device_identifers, set()) + device_identifiers: Any = device.device_identifiers + device_entry = device_registry.async_get_device(identifiers=device_identifiers) assert device_entry assert await async_setup_component( diff --git a/tests/components/rfxtrx/test_device_trigger.py b/tests/components/rfxtrx/test_device_trigger.py index 02e9ec87630..a253810c4c8 100644 --- a/tests/components/rfxtrx/test_device_trigger.py +++ b/tests/components/rfxtrx/test_device_trigger.py @@ -87,7 +87,9 @@ async def test_get_triggers( """Test we get the expected triggers from a rfxtrx.""" await setup_entry(hass, {event.code: {}}) - device_entry = device_registry.async_get_device(event.device_identifiers, set()) + device_entry = device_registry.async_get_device( + identifiers=event.device_identifiers + ) assert device_entry # Add alternate identifiers, to make sure we can handle future formats @@ -95,7 +97,9 @@ async def test_get_triggers( device_registry.async_update_device( device_entry.id, merge_identifiers={(identifiers[0], "_".join(identifiers[1:]))} ) - device_entry = device_registry.async_get_device(event.device_identifiers, set()) + device_entry = device_registry.async_get_device( + identifiers=event.device_identifiers + ) assert device_entry expected_triggers = [ @@ -131,7 +135,9 @@ async def test_firing_event( await setup_entry(hass, {event.code: {"fire_event": True}}) - device_entry = device_registry.async_get_device(event.device_identifiers, set()) + device_entry = device_registry.async_get_device( + identifiers=event.device_identifiers + ) assert device_entry calls = async_mock_service(hass, "test", "automation") @@ -175,8 +181,8 @@ async def test_invalid_trigger( await setup_entry(hass, {event.code: {"fire_event": True}}) - device_identifers: Any = event.device_identifiers - device_entry = device_registry.async_get_device(device_identifers, set()) + device_identifiers: Any = event.device_identifiers + device_entry = device_registry.async_get_device(identifiers=device_identifiers) assert device_entry assert await async_setup_component( diff --git a/tests/components/ring/test_light.py b/tests/components/ring/test_light.py index a2eb72c2711..7607f9fa5db 100644 --- a/tests/components/ring/test_light.py +++ b/tests/components/ring/test_light.py @@ -32,7 +32,7 @@ async def test_light_off_reports_correctly( state = hass.states.get("light.front_light") assert state.state == "off" - assert state.attributes.get("friendly_name") == "Front light" + assert state.attributes.get("friendly_name") == "Front Light" async def test_light_on_reports_correctly( @@ -43,7 +43,7 @@ async def test_light_on_reports_correctly( state = hass.states.get("light.internal_light") assert state.state == "on" - assert state.attributes.get("friendly_name") == "Internal light" + assert state.attributes.get("friendly_name") == "Internal Light" async def test_light_can_be_turned_on( diff --git a/tests/components/ring/test_siren.py b/tests/components/ring/test_siren.py index fbbd14aaf4e..916da5d24fb 100644 --- a/tests/components/ring/test_siren.py +++ b/tests/components/ring/test_siren.py @@ -59,10 +59,10 @@ async def test_default_ding_chime_can_be_played( assert state.state == "unknown" -async def test_toggle_plays_default_chime( +async def test_turn_on_plays_default_chime( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: - """Tests the play chime request is sent correctly when toggled.""" + """Tests the play chime request is sent correctly when turned on.""" await setup_platform(hass, Platform.SIREN) # Mocks the response for playing a test sound @@ -72,7 +72,7 @@ async def test_toggle_plays_default_chime( ) await hass.services.async_call( "siren", - "toggle", + "turn_on", {"entity_id": "siren.downstairs_siren"}, blocking=True, ) diff --git a/tests/components/ring/test_switch.py b/tests/components/ring/test_switch.py index a33b9a0d732..468b4f0d0ec 100644 --- a/tests/components/ring/test_switch.py +++ b/tests/components/ring/test_switch.py @@ -32,7 +32,7 @@ async def test_siren_off_reports_correctly( state = hass.states.get("switch.front_siren") assert state.state == "off" - assert state.attributes.get("friendly_name") == "Front siren" + assert state.attributes.get("friendly_name") == "Front Siren" async def test_siren_on_reports_correctly( @@ -43,7 +43,7 @@ async def test_siren_on_reports_correctly( state = hass.states.get("switch.internal_siren") assert state.state == "on" - assert state.attributes.get("friendly_name") == "Internal siren" + assert state.attributes.get("friendly_name") == "Internal Siren" assert state.attributes.get("icon") == "mdi:alarm-bell" diff --git a/tests/components/risco/test_alarm_control_panel.py b/tests/components/risco/test_alarm_control_panel.py index 56756aa87fb..e49817469b4 100644 --- a/tests/components/risco/test_alarm_control_panel.py +++ b/tests/components/risco/test_alarm_control_panel.py @@ -149,11 +149,11 @@ async def test_cloud_setup( assert registry.async_is_registered(SECOND_CLOUD_ENTITY_ID) registry = dr.async_get(hass) - device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_0")}) + device = registry.async_get_device(identifiers={(DOMAIN, TEST_SITE_UUID + "_0")}) assert device is not None assert device.manufacturer == "Risco" - device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_1")}) + device = registry.async_get_device(identifiers={(DOMAIN, TEST_SITE_UUID + "_1")}) assert device is not None assert device.manufacturer == "Risco" @@ -485,11 +485,15 @@ async def test_local_setup( assert registry.async_is_registered(SECOND_LOCAL_ENTITY_ID) registry = dr.async_get(hass) - device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_0_local")}) + device = registry.async_get_device( + identifiers={(DOMAIN, TEST_SITE_UUID + "_0_local")} + ) assert device is not None assert device.manufacturer == "Risco" - device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_1_local")}) + device = registry.async_get_device( + identifiers={(DOMAIN, TEST_SITE_UUID + "_1_local")} + ) assert device is not None assert device.manufacturer == "Risco" with patch("homeassistant.components.risco.RiscoLocal.disconnect") as mock_close: diff --git a/tests/components/risco/test_binary_sensor.py b/tests/components/risco/test_binary_sensor.py index a223bcd8f74..ee74dbbedc8 100644 --- a/tests/components/risco/test_binary_sensor.py +++ b/tests/components/risco/test_binary_sensor.py @@ -41,11 +41,15 @@ async def test_cloud_setup( assert registry.async_is_registered(SECOND_ENTITY_ID) registry = dr.async_get(hass) - device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_zone_0")}) + device = registry.async_get_device( + identifiers={(DOMAIN, TEST_SITE_UUID + "_zone_0")} + ) assert device is not None assert device.manufacturer == "Risco" - device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_zone_1")}) + device = registry.async_get_device( + identifiers={(DOMAIN, TEST_SITE_UUID + "_zone_1")} + ) assert device is not None assert device.manufacturer == "Risco" @@ -99,11 +103,15 @@ async def test_local_setup( assert registry.async_is_registered(SECOND_ALARMED_ENTITY_ID) registry = dr.async_get(hass) - device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_zone_0_local")}) + device = registry.async_get_device( + identifiers={(DOMAIN, TEST_SITE_UUID + "_zone_0_local")} + ) assert device is not None assert device.manufacturer == "Risco" - device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_zone_1_local")}) + device = registry.async_get_device( + identifiers={(DOMAIN, TEST_SITE_UUID + "_zone_1_local")} + ) assert device is not None assert device.manufacturer == "Risco" diff --git a/tests/components/roborock/test_number.py b/tests/components/roborock/test_number.py new file mode 100644 index 00000000000..b660bfc2969 --- /dev/null +++ b/tests/components/roborock/test_number.py @@ -0,0 +1,38 @@ +"""Test Roborock Number platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("number.roborock_s7_maxv_volume", 3.0), + ], +) +async def test_update_success( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, + value: float, +) -> None: + """Test allowed changing values for number entities.""" + # Ensure that the entity exist, as these test can pass even if there is no entity. + assert hass.states.get(entity_id) is not None + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ) as mock_send_message: + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + service_data={ATTR_VALUE: value}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert mock_send_message.assert_called_once diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index daa904d482a..f9f3d327d29 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 9 + assert len(hass.states.async_all("sensor")) == 10 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -36,3 +36,4 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non hass.states.get("sensor.roborock_s7_maxv_total_cleaning_area").state == "1159.2" ) assert hass.states.get("sensor.roborock_s7_maxv_cleaning_area").state == "21.0" + assert hass.states.get("sensor.roborock_s7_maxv_vacuum_error").state == "none" diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py new file mode 100644 index 00000000000..6ba996ca23f --- /dev/null +++ b/tests/components/roborock/test_time.py @@ -0,0 +1,39 @@ +"""Test Roborock Time platform.""" +from datetime import time +from unittest.mock import patch + +import pytest + +from homeassistant.components.time import SERVICE_SET_VALUE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("time.roborock_s7_maxv_do_not_disturb_begin"), + ("time.roborock_s7_maxv_do_not_disturb_end"), + ], +) +async def test_update_success( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test turning switch entities on and off.""" + # Ensure that the entity exist, as these test can pass even if there is no entity. + assert hass.states.get(entity_id) is not None + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ) as mock_send_message: + await hass.services.async_call( + "time", + SERVICE_SET_VALUE, + service_data={"time": time(hour=1, minute=1)}, + blocking=True, + target={"entity_id": entity_id}, + ) + assert mock_send_message.assert_called_once diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index 80fbd4092c0..080893f1d95 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -20,7 +20,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from tests.common import MockConfigEntry @@ -88,3 +88,37 @@ async def test_commands( assert mock_send_command.call_count == 1 assert mock_send_command.call_args[0][0] == command assert mock_send_command.call_args[0][1] == called_params + + +@pytest.mark.parametrize( + ("service", "issue_id"), + [ + (SERVICE_START_PAUSE, "service_deprecation_start_pause"), + ], +) +async def test_issues( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + service: str, + issue_id: str, +) -> None: + """Test issues raised by calling deprecated services.""" + vacuum = hass.states.get(ENTITY_ID) + assert vacuum + + data = {ATTR_ENTITY_ID: ENTITY_ID} + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_command" + ): + await hass.services.async_call( + Platform.VACUUM, + service, + data, + blocking=True, + ) + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue("roborock", issue_id) + assert issue.is_fixable is True + assert issue.is_persistent is True diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 3d3077a1c6a..674dea752a0 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -694,7 +694,7 @@ async def test_device_class(hass: HomeAssistant) -> None: """Test for device_class property.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) state = hass.states.get(ENTITY_ID) - assert state.attributes[ATTR_DEVICE_CLASS] is MediaPlayerDeviceClass.TV.value + assert state.attributes[ATTR_DEVICE_CLASS] == MediaPlayerDeviceClass.TV @pytest.mark.usefixtures("rest_api") diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index 44c264520d6..60cde48e5bf 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -1,12 +1,16 @@ """The tests for the Scrape sensor platform.""" from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from unittest.mock import patch import pytest -from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL +from homeassistant.components.scrape.const import ( + CONF_INDEX, + CONF_SELECT, + DEFAULT_SCAN_INTERVAL, +) from homeassistant.components.sensor import ( CONF_STATE_CLASS, SensorDeviceClass, @@ -14,6 +18,9 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( CONF_DEVICE_CLASS, + CONF_ICON, + CONF_NAME, + CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -21,7 +28,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.template_entity import CONF_AVAILABILITY, CONF_PICTURE from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from . import MockRestData, return_integration_config @@ -469,3 +478,83 @@ async def test_setup_config_entry( entity = entity_reg.async_get("sensor.current_version") assert entity.unique_id == "3699ef88-69e6-11ed-a1eb-0242ac120002" + + +async def test_templates_with_yaml(hass: HomeAssistant) -> None: + """Test the Scrape sensor from yaml config with templates.""" + + hass.states.async_set("sensor.input1", "on") + hass.states.async_set("sensor.input2", "on") + await hass.async_block_till_done() + + config = { + DOMAIN: [ + return_integration_config( + sensors=[ + { + CONF_NAME: "Get values with template", + CONF_SELECT: ".current-version h1", + CONF_INDEX: 0, + CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002", + CONF_ICON: '{% if states("sensor.input1")=="on" %} mdi:on {% else %} mdi:off {% endif %}', + CONF_PICTURE: '{% if states("sensor.input1")=="on" %} /local/picture1.jpg {% else %} /local/picture2.jpg {% endif %}', + CONF_AVAILABILITY: '{{ states("sensor.input2")=="on" }}', + } + ] + ) + ] + } + + mocker = MockRestData("test_scrape_sensor") + with patch( + "homeassistant.components.rest.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.get_values_with_template") + assert state.state == "Current Version: 2021.12.10" + assert state.attributes[CONF_ICON] == "mdi:on" + assert state.attributes["entity_picture"] == "/local/picture1.jpg" + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=10), + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.get_values_with_template") + assert state.state == "Current Version: 2021.12.10" + assert state.attributes[CONF_ICON] == "mdi:off" + assert state.attributes["entity_picture"] == "/local/picture2.jpg" + + hass.states.async_set("sensor.input2", "off") + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=20), + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.get_values_with_template") + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set("sensor.input1", "on") + hass.states.async_set("sensor.input2", "on") + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=30), + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.get_values_with_template") + assert state.state == "Current Version: 2021.12.10" + assert state.attributes[CONF_ICON] == "mdi:on" + assert state.attributes["entity_picture"] == "/local/picture1.jpg" diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index cc41b6c404c..cddefc8d3dc 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -196,6 +196,15 @@ async def test_setup_with_invalid_configs( "has invalid object id", "invalid slug Bad Script", ), + ( + "turn_on", + {}, + "has invalid object id", + ( + "A script's object_id must not be one of " + "reload, toggle, turn_off, turn_on. Got 'turn_on'" + ), + ), ), ) async def test_bad_config_validation_critical( diff --git a/tests/components/sensibo/snapshots/test_diagnostics.ambr b/tests/components/sensibo/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..b1cda16fb4d --- /dev/null +++ b/tests/components/sensibo/snapshots/test_diagnostics.ambr @@ -0,0 +1,242 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'modes': dict({ + 'auto': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'cool': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'dry': dict({ + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 64, + 66, + 68, + ]), + }), + }), + }), + 'fan': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + }), + }), + 'heat': dict({ + 'fanLevels': list([ + 'quiet', + 'low', + 'medium', + ]), + 'horizontalSwing': list([ + 'stopped', + 'fixedLeft', + 'fixedCenterLeft', + ]), + 'light': list([ + 'on', + 'off', + ]), + 'swing': list([ + 'stopped', + 'fixedTop', + 'fixedMiddleTop', + ]), + 'temperatures': dict({ + 'C': dict({ + 'isNative': True, + 'values': list([ + 10, + 16, + 17, + 18, + 19, + 20, + ]), + }), + 'F': dict({ + 'isNative': False, + 'values': list([ + 63, + 64, + 66, + ]), + }), + }), + }), + }), + }) +# --- +# name: test_diagnostics.1 + dict({ + 'low': 'low', + 'medium': 'medium', + 'quiet': 'quiet', + }) +# --- +# name: test_diagnostics.2 + dict({ + 'fixedmiddletop': 'fixedMiddleTop', + 'fixedtop': 'fixedTop', + 'stopped': 'stopped', + }) +# --- +# name: test_diagnostics.3 + dict({ + 'fixedcenterleft': 'fixedCenterLeft', + 'fixedleft': 'fixedLeft', + 'stopped': 'stopped', + }) +# --- +# name: test_diagnostics.4 + dict({ + 'fanlevel': 'low', + 'horizontalswing': 'stopped', + 'light': 'on', + 'mode': 'heat', + 'on': True, + 'swing': 'stopped', + 'targettemperature': 21, + 'temperatureunit': 'c', + }) +# --- +# name: test_diagnostics.5 + dict({ + 'fanlevel': 'high', + 'horizontalswing': 'stopped', + 'light': 'on', + 'mode': 'cool', + 'on': True, + 'swing': 'stopped', + 'targettemperature': 21, + 'temperatureunit': 'c', + }) +# --- +# name: test_diagnostics.6 + dict({ + }) +# --- diff --git a/tests/components/sensibo/test_binary_sensor.py b/tests/components/sensibo/test_binary_sensor.py index bb190908847..99bcfac8c9b 100644 --- a/tests/components/sensibo/test_binary_sensor.py +++ b/tests/components/sensibo/test_binary_sensor.py @@ -23,7 +23,7 @@ async def test_binary_sensor( ) -> None: """Test the Sensibo binary sensor.""" - state1 = hass.states.get("binary_sensor.hallway_motion_sensor_alive") + state1 = hass.states.get("binary_sensor.hallway_motion_sensor_connectivity") state2 = hass.states.get("binary_sensor.hallway_motion_sensor_main_sensor") state3 = hass.states.get("binary_sensor.hallway_motion_sensor_motion") state4 = hass.states.get("binary_sensor.hallway_room_occupied") @@ -57,7 +57,7 @@ async def test_binary_sensor( ) await hass.async_block_till_done() - state1 = hass.states.get("binary_sensor.hallway_motion_sensor_alive") + state1 = hass.states.get("binary_sensor.hallway_motion_sensor_connectivity") state3 = hass.states.get("binary_sensor.hallway_motion_sensor_motion") assert state1.state == "off" assert state3.state == "off" diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index b2108d3e6f4..4e856d396c1 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -86,21 +86,21 @@ async def test_climate( assert state1.state == "heat" assert state1.attributes == { "hvac_modes": [ - "cool", - "heat", - "dry", "heat_cool", + "cool", + "dry", "fan_only", + "heat", "off", ], "min_temp": 10, "max_temp": 20, "target_temp_step": 1, - "fan_modes": ["quiet", "low", "medium"], + "fan_modes": ["low", "medium", "quiet"], "swing_modes": [ - "stopped", - "fixedtop", "fixedmiddletop", + "fixedtop", + "stopped", ], "current_temperature": 21.2, "temperature": 25, diff --git a/tests/components/sensibo/test_diagnostics.py b/tests/components/sensibo/test_diagnostics.py index 2cbd20a7437..bc35b7fdd57 100644 --- a/tests/components/sensibo/test_diagnostics.py +++ b/tests/components/sensibo/test_diagnostics.py @@ -1,6 +1,8 @@ """Test Sensibo diagnostics.""" from __future__ import annotations +from syrupy.assertion import SnapshotAssertion + from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -9,17 +11,20 @@ from tests.typing import ClientSessionGenerator async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator, load_int: ConfigEntry + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + load_int: ConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" entry = load_int diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert diag["status"] == "success" - for device in diag["result"]: - assert device["id"] == "**REDACTED**" - assert device["qrId"] == "**REDACTED**" - assert device["macAddress"] == "**REDACTED**" - assert device["location"] == "**REDACTED**" - assert device["productModel"] in ["skyv2", "pure"] + assert diag["ABC999111"]["full_capabilities"] == snapshot + assert diag["ABC999111"]["fan_modes_translated"] == snapshot + assert diag["ABC999111"]["swing_modes_translated"] == snapshot + assert diag["ABC999111"]["horizontal_swing_modes_translated"] == snapshot + assert diag["ABC999111"]["smart_low_state"] == snapshot + assert diag["ABC999111"]["smart_high_state"] == snapshot + assert diag["ABC999111"]["pure_conf"] == snapshot diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index 003c2f27903..24dbdef1fe3 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -44,12 +44,12 @@ async def test_sensor( "state_class": "measurement", "unit_of_measurement": "°C", "on": True, - "targetTemperature": 21, - "temperatureUnit": "C", + "targettemperature": 21, + "temperatureunit": "c", "mode": "heat", - "fanLevel": "low", + "fanlevel": "low", "swing": "stopped", - "horizontalSwing": "stopped", + "horizontalswing": "stopped", "light": "on", } diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index d1da0a8166f..c5406a85fc0 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -17,6 +17,7 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, SensorStateClass, + async_rounded_state, async_update_suggested_units, ) from homeassistant.config_entries import ConfigEntry, ConfigFlow @@ -557,6 +558,22 @@ async def test_restore_sensor_restore_state( 100, "38", ), + ( + SensorDeviceClass.ATMOSPHERIC_PRESSURE, + UnitOfPressure.INHG, + UnitOfPressure.HPA, + UnitOfPressure.HPA, + -0.00, + "0.0", + ), + ( + SensorDeviceClass.ATMOSPHERIC_PRESSURE, + UnitOfPressure.INHG, + UnitOfPressure.HPA, + UnitOfPressure.HPA, + -0.00001, + "0", + ), ], ) async def test_custom_unit( @@ -592,10 +609,15 @@ async def test_custom_unit( assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) await hass.async_block_till_done() - state = hass.states.get(entity0.entity_id) + entity_id = entity0.entity_id + state = hass.states.get(entity_id) assert state.state == custom_state assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == state_unit + assert ( + async_rounded_state(hass, entity_id, hass.states.get(entity_id)) == custom_state + ) + @pytest.mark.parametrize( ( @@ -902,7 +924,7 @@ async def test_custom_unit_change( "1000000", "1093613", SensorDeviceClass.DISTANCE, - ), + ) ], ) async def test_unit_conversion_priority( @@ -1130,6 +1152,9 @@ async def test_unit_conversion_priority_precision( "sensor": {"suggested_display_precision": 2}, "sensor.private": {"suggested_unit_of_measurement": automatic_unit}, } + assert float(async_rounded_state(hass, entity0.entity_id, state)) == pytest.approx( + round(automatic_state, 2) + ) # Unregistered entity -> Follow native unit state = hass.states.get(entity1.entity_id) @@ -1172,6 +1197,20 @@ async def test_unit_conversion_priority_precision( assert float(state.state) == pytest.approx(custom_state) assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == custom_unit + # Set a display_precision, this should have priority over suggested_display_precision + entity_registry.async_update_entity_options( + entity0.entity_id, + "sensor", + {"suggested_display_precision": 2, "display_precision": 4}, + ) + entry0 = entity_registry.async_get(entity0.entity_id) + assert entry0.options["sensor"]["suggested_display_precision"] == 2 + assert entry0.options["sensor"]["display_precision"] == 4 + await hass.async_block_till_done() + assert float(async_rounded_state(hass, entity0.entity_id, state)) == pytest.approx( + round(custom_state, 4) + ) + @pytest.mark.parametrize( ( @@ -1760,6 +1799,7 @@ async def test_non_numeric_device_class_with_unit_of_measurement( SensorDeviceClass.NITROGEN_MONOXIDE, SensorDeviceClass.NITROUS_OXIDE, SensorDeviceClass.OZONE, + SensorDeviceClass.PH, SensorDeviceClass.PM1, SensorDeviceClass.PM10, SensorDeviceClass.PM25, @@ -2362,3 +2402,39 @@ async def test_name(hass: HomeAssistant) -> None: state = hass.states.get(entity4.entity_id) assert state.attributes == {"device_class": "battery", "friendly_name": "Battery"} + + +def test_async_rounded_state_unregistered_entity_is_passthrough( + hass: HomeAssistant, +) -> None: + """Test async_rounded_state on unregistered entity is passthrough.""" + hass.states.async_set("sensor.test", "1.004") + state = hass.states.get("sensor.test") + assert async_rounded_state(hass, "sensor.test", state) == "1.004" + hass.states.async_set("sensor.test", "-0.0") + state = hass.states.get("sensor.test") + assert async_rounded_state(hass, "sensor.test", state) == "-0.0" + + +def test_async_rounded_state_registered_entity_with_display_precision( + hass: HomeAssistant, +) -> None: + """Test async_rounded_state on registered with display precision. + + The -0 should be dropped. + """ + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get_or_create("sensor", "test", "very_unique") + entity_registry.async_update_entity_options( + entry.entity_id, + "sensor", + {"suggested_display_precision": 2, "display_precision": 4}, + ) + entity_id = entry.entity_id + hass.states.async_set(entity_id, "1.004") + state = hass.states.get(entity_id) + assert async_rounded_state(hass, entity_id, state) == "1.0040" + hass.states.async_set(entity_id, "-0.0") + state = hass.states.get(entity_id) + assert async_rounded_state(hass, entity_id, state) == "0.0000" diff --git a/tests/components/sentry/test_init.py b/tests/components/sentry/test_init.py index f4486ca5a19..25b77922878 100644 --- a/tests/components/sentry/test_init.py +++ b/tests/components/sentry/test_init.py @@ -47,8 +47,8 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert entry.options[CONF_ENVIRONMENT] == "production" assert sentry_logging_mock.call_count == 1 - assert sentry_logging_mock.called_once_with( - level=logging.WARNING, event_level=logging.WARNING + sentry_logging_mock.assert_called_once_with( + level=logging.WARNING, event_level=logging.ERROR ) assert sentry_aiohttp_mock.call_count == 1 diff --git a/tests/components/sfr_box/snapshots/test_button.ambr b/tests/components/sfr_box/snapshots/test_button.ambr index dc6ccc1f25d..f362cfc146f 100644 --- a/tests/components/sfr_box/snapshots/test_button.ambr +++ b/tests/components/sfr_box/snapshots/test_button.ambr @@ -54,7 +54,7 @@ 'original_name': 'Restart', 'platform': 'sfr_box', 'supported_features': 0, - 'translation_key': 'reboot', + 'translation_key': None, 'unique_id': 'e4:5d:51:00:11:22_system_reboot', 'unit_of_measurement': None, }), diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index 2390ba625eb..171a5803ada 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -89,7 +89,7 @@ 'original_name': 'Voltage', 'platform': 'sfr_box', 'supported_features': 0, - 'translation_key': 'voltage', + 'translation_key': None, 'unique_id': 'e4:5d:51:00:11:22_system_alimvoltage', 'unit_of_measurement': , }), @@ -117,7 +117,7 @@ 'original_name': 'Temperature', 'platform': 'sfr_box', 'supported_features': 0, - 'translation_key': 'temperature', + 'translation_key': None, 'unique_id': 'e4:5d:51:00:11:22_system_temperature', 'unit_of_measurement': , }), diff --git a/tests/components/sharkiq/test_vacuum.py b/tests/components/sharkiq/test_vacuum.py index cfd62c9deaf..34b49f5d581 100644 --- a/tests/components/sharkiq/test_vacuum.py +++ b/tests/components/sharkiq/test_vacuum.py @@ -64,7 +64,6 @@ EXPECTED_FEATURES = ( | VacuumEntityFeature.RETURN_HOME | VacuumEntityFeature.START | VacuumEntityFeature.STATE - | VacuumEntityFeature.STATUS | VacuumEntityFeature.STOP | VacuumEntityFeature.LOCATE ) @@ -219,7 +218,7 @@ async def test_device_properties( ) -> None: """Test device properties.""" registry = dr.async_get(hass) - device = registry.async_get_device({(DOMAIN, "AC000Wxxxxxxxxx")}) + device = registry.async_get_device(identifiers={(DOMAIN, "AC000Wxxxxxxxxx")}) assert getattr(device, device_property) == target_value diff --git a/tests/components/shell_command/test_init.py b/tests/components/shell_command/test_init.py index 96ccaa47d84..ac594c811ed 100644 --- a/tests/components/shell_command/test_init.py +++ b/tests/components/shell_command/test_init.py @@ -1,14 +1,16 @@ """The tests for the Shell command component.""" from __future__ import annotations +import asyncio import os import tempfile -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from homeassistant.components import shell_command from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError from homeassistant.setup import async_setup_component @@ -82,6 +84,28 @@ async def test_template_render_no_template(mock_call, hass: HomeAssistant) -> No assert cmd == "ls /bin" +@patch("homeassistant.components.shell_command.asyncio.create_subprocess_shell") +async def test_incorrect_template(mock_call, hass: HomeAssistant) -> None: + """Ensure shell_commands with invalid templates are handled properly.""" + mock_call.return_value = mock_process_creator(error=False) + assert await async_setup_component( + hass, + shell_command.DOMAIN, + { + shell_command.DOMAIN: { + "test_service": ("ls /bin {{ states['invalid/domain'] }}") + } + }, + ) + + with pytest.raises(TemplateError): + await hass.services.async_call( + "shell_command", "test_service", blocking=True, return_response=True + ) + + await hass.async_block_till_done() + + @patch("homeassistant.components.shell_command.asyncio.create_subprocess_exec") async def test_template_render(mock_call, hass: HomeAssistant) -> None: """Ensure shell_commands with templates get rendered properly.""" @@ -119,11 +143,14 @@ async def test_subprocess_error(mock_error, mock_call, hass: HomeAssistant) -> N {shell_command.DOMAIN: {"test_service": f"touch {path}"}}, ) - await hass.services.async_call("shell_command", "test_service", blocking=True) + response = await hass.services.async_call( + "shell_command", "test_service", blocking=True, return_response=True + ) await hass.async_block_till_done() assert mock_call.call_count == 1 assert mock_error.call_count == 1 assert not os.path.isfile(path) + assert response["returncode"] == 1 @patch("homeassistant.components.shell_command._LOGGER.debug") @@ -136,11 +163,15 @@ async def test_stdout_captured(mock_output, hass: HomeAssistant) -> None: {shell_command.DOMAIN: {"test_service": f"echo {test_phrase}"}}, ) - await hass.services.async_call("shell_command", "test_service", blocking=True) + response = await hass.services.async_call( + "shell_command", "test_service", blocking=True, return_response=True + ) await hass.async_block_till_done() assert mock_output.call_count == 1 assert test_phrase.encode() + b"\n" == mock_output.call_args_list[0][0][-1] + assert response["stdout"] == test_phrase + assert response["returncode"] == 0 @patch("homeassistant.components.shell_command._LOGGER.debug") @@ -153,31 +184,51 @@ async def test_stderr_captured(mock_output, hass: HomeAssistant) -> None: {shell_command.DOMAIN: {"test_service": f">&2 echo {test_phrase}"}}, ) - await hass.services.async_call("shell_command", "test_service", blocking=True) + response = await hass.services.async_call( + "shell_command", "test_service", blocking=True, return_response=True + ) await hass.async_block_till_done() assert mock_output.call_count == 1 assert test_phrase.encode() + b"\n" == mock_output.call_args_list[0][0][-1] + assert response["stderr"] == test_phrase -@pytest.mark.skip(reason="disabled to check if it fixes flaky CI") -async def test_do_no_run_forever( +async def test_do_not_run_forever( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test subprocesses terminate after the timeout.""" - with patch.object(shell_command, "COMMAND_TIMEOUT", 0.001): - assert await async_setup_component( - hass, - shell_command.DOMAIN, - {shell_command.DOMAIN: {"test_service": "sleep 10000"}}, - ) - await hass.async_block_till_done() - - await hass.services.async_call( - shell_command.DOMAIN, "test_service", blocking=True - ) + async def block(): + event = asyncio.Event() + await event.wait() + return (None, None) + + mock_process = Mock() + mock_process.communicate = block + mock_process.kill = Mock() + mock_create_subprocess_shell = AsyncMock(return_value=mock_process) + + assert await async_setup_component( + hass, + shell_command.DOMAIN, + {shell_command.DOMAIN: {"test_service": "mock_sleep 10000"}}, + ) + await hass.async_block_till_done() + + with patch.object(shell_command, "COMMAND_TIMEOUT", 0.001), patch( + "homeassistant.components.shell_command.asyncio.create_subprocess_shell", + side_effect=mock_create_subprocess_shell, + ): + with pytest.raises(asyncio.TimeoutError): + await hass.services.async_call( + shell_command.DOMAIN, + "test_service", + blocking=True, + return_response=True, + ) await hass.async_block_till_done() + mock_process.kill.assert_called_once() assert "Timed out" in caplog.text - assert "sleep 10000" in caplog.text + assert "mock_sleep 10000" in caplog.text diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 2a80233aeb9..96e888d7509 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -189,6 +189,7 @@ MOCK_STATUS_RPC = { "current_pos": 50, "apower": 85.3, }, + "devicepower:0": {"external": {"present": True}}, "temperature:0": {"tC": 22.9}, "illuminance:0": {"lux": 345}, "sys": { diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 207b73bf44b..c067f5dffc9 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -218,6 +218,11 @@ async def test_rpc_sleeping_binary_sensor( assert hass.states.get(entity_id).state == STATE_ON + # test external power sensor + state = hass.states.get("binary_sensor.test_name_external_power") + assert state + assert state.state == STATE_ON + async def test_rpc_restored_sleeping_binary_sensor( hass: HomeAssistant, mock_rpc_device, device_reg, monkeypatch diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 6c0ac74296a..c806cb5e742 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -21,9 +21,11 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.issue_registry as ir from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM -from . import init_integration, register_device, register_entity +from . import MOCK_MAC, init_integration, register_device, register_entity +from .conftest import MOCK_STATUS_COAP from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data @@ -427,6 +429,7 @@ async def test_block_set_mode_auth_error( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) + await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED @@ -485,3 +488,43 @@ async def test_block_restored_climate_auth_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == entry.entry_id + + +async def test_device_not_calibrated( + hass: HomeAssistant, mock_block_device, monkeypatch +) -> None: + """Test to create an issue when the device is not calibrated.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + + await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01") + + # Make device online + mock_block_device.mock_update() + await hass.async_block_till_done() + + mock_status = MOCK_STATUS_COAP.copy() + mock_status["calibrated"] = False + monkeypatch.setattr( + mock_block_device, + "status", + mock_status, + ) + mock_block_device.mock_update() + await hass.async_block_till_done() + + assert issue_registry.async_get_issue( + domain=DOMAIN, issue_id=f"not_calibrated_{MOCK_MAC}" + ) + + # The device has been calibrated + monkeypatch.setattr( + mock_block_device, + "status", + MOCK_STATUS_COAP, + ) + mock_block_device.mock_update() + await hass.async_block_till_done() + + assert not issue_registry.async_get_issue( + domain=DOMAIN, issue_id=f"not_calibrated_{MOCK_MAC}" + ) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 9039893999d..8536c3d72e6 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -13,6 +13,7 @@ from homeassistant.components.shelly.const import ( ATTR_GENERATION, DOMAIN, ENTRY_RELOAD_COOLDOWN, + MAX_PUSH_UPDATE_FAILURES, RPC_RECONNECT_INTERVAL, SLEEP_PERIOD_MULTIPLIER, UPDATE_PERIOD_MULTIPLIER, @@ -24,15 +25,18 @@ from homeassistant.helpers.device_registry import ( async_entries_for_config_entry, async_get as async_get_dev_reg, ) +import homeassistant.helpers.issue_registry as ir from homeassistant.util import dt as dt_util from . import ( + MOCK_MAC, init_integration, inject_rpc_device_event, mock_polling_rpc_update, mock_rest_update, register_entity, ) +from .conftest import MOCK_BLOCKS from tests.common import async_fire_time_changed @@ -249,6 +253,31 @@ async def test_block_sleeping_device_no_periodic_updates( assert hass.states.get(entity_id).state == STATE_UNAVAILABLE +async def test_block_device_push_updates_failure( + hass: HomeAssistant, mock_block_device, monkeypatch +) -> None: + """Test block device with push updates failure.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + + monkeypatch.setattr( + mock_block_device, + "update", + AsyncMock(return_value=MOCK_BLOCKS), + ) + await init_integration(hass, 1) + + # Move time to force polling + for _ in range(MAX_PUSH_UPDATE_FAILURES + 1): + async_fire_time_changed( + hass, dt_util.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) + ) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue( + domain=DOMAIN, issue_id=f"push_update_{MOCK_MAC}" + ) + + async def test_block_button_click_event( hass: HomeAssistant, mock_block_device, events, monkeypatch ) -> None: diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 403d2f2993d..a072c7638a1 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -186,6 +186,7 @@ async def test_block_set_value_auth_error( {ATTR_ENTITY_ID: "number.test_name_valve_position", ATTR_VALUE: 30}, blocking=True, ) + await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 7892d98c45a..7a709e0cc2e 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -82,6 +82,7 @@ async def test_block_set_state_auth_error( {ATTR_ENTITY_ID: "switch.test_name_channel_1"}, blocking=True, ) + await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED @@ -211,6 +212,7 @@ async def test_rpc_auth_error( {ATTR_ENTITY_ID: "switch.test_switch_0"}, blocking=True, ) + await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 89d78dd8fa1..ed5dd81339e 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -203,6 +203,7 @@ async def test_block_update_auth_error( {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, blocking=True, ) + await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED @@ -541,6 +542,7 @@ async def test_rpc_update_auth_error( blocking=True, ) + await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED flows = hass.config_entries.flow.async_progress() diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py index e5f1e30efdb..a28b1ee0cfb 100644 --- a/tests/components/shopping_list/test_init.py +++ b/tests/components/shopping_list/test_init.py @@ -34,7 +34,8 @@ async def test_add_item(hass: HomeAssistant, sl_setup) -> None: hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} ) - assert response.speech["plain"]["speech"] == "I've added beer to your shopping list" + # Response text is now handled by default conversation agent + assert response.response_type == intent.IntentResponseType.ACTION_DONE async def test_remove_item(hass: HomeAssistant, sl_setup) -> None: diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py index 0b697786b18..d6fe0bd40fc 100644 --- a/tests/components/smartthings/test_binary_sensor.py +++ b/tests/components/smartthings/test_binary_sensor.py @@ -51,7 +51,15 @@ async def test_entity_and_device_attributes( """Test the attributes of the entity are correct.""" # Arrange device = device_factory( - "Motion Sensor 1", [Capability.motion_sensor], {Attribute.motion: "inactive"} + "Motion Sensor 1", + [Capability.motion_sensor], + { + Attribute.motion: "inactive", + Attribute.mnmo: "123", + Attribute.mnmn: "Generic manufacturer", + Attribute.mnhw: "v4.56", + Attribute.mnfv: "v7.89", + }, ) entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -61,13 +69,15 @@ async def test_entity_and_device_attributes( entry = entity_registry.async_get("binary_sensor.motion_sensor_1_motion") assert entry assert entry.unique_id == f"{device.device_id}.{Attribute.motion}" - entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) assert entry assert entry.configuration_url == "https://account.smartthings.com" assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label - assert entry.model == device.device_type_name - assert entry.manufacturer == "Unavailable" + assert entry.model == "123" + assert entry.manufacturer == "Generic manufacturer" + assert entry.hw_version == "v4.56" + assert entry.sw_version == "v7.89" async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 02f6af46655..ce875190efb 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -112,6 +112,10 @@ def thermostat_fixture(device_factory): ], Attribute.thermostat_operating_state: "idle", Attribute.humidity: 34, + Attribute.mnmo: "123", + Attribute.mnmn: "Generic manufacturer", + Attribute.mnhw: "v4.56", + Attribute.mnfv: "v7.89", }, ) device.status.attributes[Attribute.temperature] = Status(70, "F", None) @@ -576,10 +580,14 @@ async def test_entity_and_device_attributes(hass: HomeAssistant, thermostat) -> assert entry assert entry.unique_id == thermostat.device_id - entry = device_registry.async_get_device({(DOMAIN, thermostat.device_id)}) + entry = device_registry.async_get_device( + identifiers={(DOMAIN, thermostat.device_id)} + ) assert entry assert entry.configuration_url == "https://account.smartthings.com" assert entry.identifiers == {(DOMAIN, thermostat.device_id)} assert entry.name == thermostat.label - assert entry.model == thermostat.device_type_name - assert entry.manufacturer == "Unavailable" + assert entry.model == "123" + assert entry.manufacturer == "Generic manufacturer" + assert entry.hw_version == "v4.56" + assert entry.sw_version == "v7.89" diff --git a/tests/components/smartthings/test_cover.py b/tests/components/smartthings/test_cover.py index 4e637450fec..bf781c71c4e 100644 --- a/tests/components/smartthings/test_cover.py +++ b/tests/components/smartthings/test_cover.py @@ -33,7 +33,15 @@ async def test_entity_and_device_attributes( """Test the attributes of the entity are correct.""" # Arrange device = device_factory( - "Garage", [Capability.garage_door_control], {Attribute.door: "open"} + "Garage", + [Capability.garage_door_control], + { + Attribute.door: "open", + Attribute.mnmo: "123", + Attribute.mnmn: "Generic manufacturer", + Attribute.mnhw: "v4.56", + Attribute.mnfv: "v7.89", + }, ) entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -44,13 +52,15 @@ async def test_entity_and_device_attributes( assert entry assert entry.unique_id == device.device_id - entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) assert entry assert entry.configuration_url == "https://account.smartthings.com" assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label - assert entry.model == device.device_type_name - assert entry.manufacturer == "Unavailable" + assert entry.model == "123" + assert entry.manufacturer == "Generic manufacturer" + assert entry.hw_version == "v4.56" + assert entry.sw_version == "v7.89" async def test_open(hass: HomeAssistant, device_factory) -> None: diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index 120a90fb2f4..ccf4b50fa1b 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -48,7 +48,14 @@ async def test_entity_and_device_attributes( device = device_factory( "Fan 1", capabilities=[Capability.switch, Capability.fan_speed], - status={Attribute.switch: "on", Attribute.fan_speed: 2}, + status={ + Attribute.switch: "on", + Attribute.fan_speed: 2, + Attribute.mnmo: "123", + Attribute.mnmn: "Generic manufacturer", + Attribute.mnhw: "v4.56", + Attribute.mnfv: "v7.89", + }, ) # Act await setup_platform(hass, FAN_DOMAIN, devices=[device]) @@ -59,13 +66,15 @@ async def test_entity_and_device_attributes( assert entry assert entry.unique_id == device.device_id - entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) assert entry assert entry.configuration_url == "https://account.smartthings.com" assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label - assert entry.model == device.device_type_name - assert entry.manufacturer == "Unavailable" + assert entry.model == "123" + assert entry.manufacturer == "Generic manufacturer" + assert entry.hw_version == "v4.56" + assert entry.sw_version == "v7.89" async def test_turn_off(hass: HomeAssistant, device_factory) -> None: diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index 713b156fc4f..d2d0a133859 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -109,7 +109,16 @@ async def test_entity_and_device_attributes( ) -> None: """Test the attributes of the entity are correct.""" # Arrange - device = device_factory("Light 1", [Capability.switch, Capability.switch_level]) + device = device_factory( + "Light 1", + [Capability.switch, Capability.switch_level], + { + Attribute.mnmo: "123", + Attribute.mnmn: "Generic manufacturer", + Attribute.mnhw: "v4.56", + Attribute.mnfv: "v7.89", + }, + ) entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) # Act @@ -119,13 +128,15 @@ async def test_entity_and_device_attributes( assert entry assert entry.unique_id == device.device_id - entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) assert entry assert entry.configuration_url == "https://account.smartthings.com" assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label - assert entry.model == device.device_type_name - assert entry.manufacturer == "Unavailable" + assert entry.model == "123" + assert entry.manufacturer == "Generic manufacturer" + assert entry.hw_version == "v4.56" + assert entry.sw_version == "v7.89" async def test_turn_off(hass: HomeAssistant, light_devices) -> None: diff --git a/tests/components/smartthings/test_lock.py b/tests/components/smartthings/test_lock.py index 6c01bc2b6c4..58111087848 100644 --- a/tests/components/smartthings/test_lock.py +++ b/tests/components/smartthings/test_lock.py @@ -22,7 +22,17 @@ async def test_entity_and_device_attributes( ) -> None: """Test the attributes of the entity are correct.""" # Arrange - device = device_factory("Lock_1", [Capability.lock], {Attribute.lock: "unlocked"}) + device = device_factory( + "Lock_1", + [Capability.lock], + { + Attribute.lock: "unlocked", + Attribute.mnmo: "123", + Attribute.mnmn: "Generic manufacturer", + Attribute.mnhw: "v4.56", + Attribute.mnfv: "v7.89", + }, + ) entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) # Act @@ -32,13 +42,15 @@ async def test_entity_and_device_attributes( assert entry assert entry.unique_id == device.device_id - entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) assert entry assert entry.configuration_url == "https://account.smartthings.com" assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label - assert entry.model == device.device_type_name - assert entry.manufacturer == "Unavailable" + assert entry.model == "123" + assert entry.manufacturer == "Generic manufacturer" + assert entry.hw_version == "v4.56" + assert entry.sw_version == "v7.89" async def test_lock(hass: HomeAssistant, device_factory) -> None: diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 01745878bf0..ab163360778 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -90,7 +90,17 @@ async def test_entity_and_device_attributes( ) -> None: """Test the attributes of the entity are correct.""" # Arrange - device = device_factory("Sensor 1", [Capability.battery], {Attribute.battery: 100}) + device = device_factory( + "Sensor 1", + [Capability.battery], + { + Attribute.battery: 100, + Attribute.mnmo: "123", + Attribute.mnmn: "Generic manufacturer", + Attribute.mnhw: "v4.56", + Attribute.mnfv: "v7.89", + }, + ) entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) # Act @@ -100,13 +110,15 @@ async def test_entity_and_device_attributes( assert entry assert entry.unique_id == f"{device.device_id}.{Attribute.battery}" assert entry.entity_category is EntityCategory.DIAGNOSTIC - entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) assert entry assert entry.configuration_url == "https://account.smartthings.com" assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label - assert entry.model == device.device_type_name - assert entry.manufacturer == "Unavailable" + assert entry.model == "123" + assert entry.manufacturer == "Generic manufacturer" + assert entry.hw_version == "v4.56" + assert entry.sw_version == "v7.89" async def test_energy_sensors_for_switch_device( @@ -117,7 +129,15 @@ async def test_energy_sensors_for_switch_device( device = device_factory( "Switch_1", [Capability.switch, Capability.power_meter, Capability.energy_meter], - {Attribute.switch: "off", Attribute.power: 355, Attribute.energy: 11.422}, + { + Attribute.switch: "off", + Attribute.power: 355, + Attribute.energy: 11.422, + Attribute.mnmo: "123", + Attribute.mnmn: "Generic manufacturer", + Attribute.mnhw: "v4.56", + Attribute.mnfv: "v7.89", + }, ) entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -131,13 +151,15 @@ async def test_energy_sensors_for_switch_device( assert entry assert entry.unique_id == f"{device.device_id}.{Attribute.energy}" assert entry.entity_category is None - entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) assert entry assert entry.configuration_url == "https://account.smartthings.com" assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label - assert entry.model == device.device_type_name - assert entry.manufacturer == "Unavailable" + assert entry.model == "123" + assert entry.manufacturer == "Generic manufacturer" + assert entry.hw_version == "v4.56" + assert entry.sw_version == "v7.89" state = hass.states.get("sensor.switch_1_power_meter") assert state @@ -146,13 +168,15 @@ async def test_energy_sensors_for_switch_device( assert entry assert entry.unique_id == f"{device.device_id}.{Attribute.power}" assert entry.entity_category is None - entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) assert entry assert entry.configuration_url == "https://account.smartthings.com" assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label - assert entry.model == device.device_type_name - assert entry.manufacturer == "Unavailable" + assert entry.model == "123" + assert entry.manufacturer == "Generic manufacturer" + assert entry.hw_version == "v4.56" + assert entry.sw_version == "v7.89" async def test_power_consumption_sensor(hass: HomeAssistant, device_factory) -> None: @@ -171,7 +195,11 @@ async def test_power_consumption_sensor(hass: HomeAssistant, device_factory) -> "energySaved": 0, "start": "2021-07-30T16:45:25Z", "end": "2021-07-30T16:58:33Z", - } + }, + Attribute.mnmo: "123", + Attribute.mnmn: "Generic manufacturer", + Attribute.mnhw: "v4.56", + Attribute.mnfv: "v7.89", }, ) entity_registry = er.async_get(hass) @@ -185,13 +213,15 @@ async def test_power_consumption_sensor(hass: HomeAssistant, device_factory) -> entry = entity_registry.async_get("sensor.refrigerator_energy") assert entry assert entry.unique_id == f"{device.device_id}.energy_meter" - entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) assert entry assert entry.configuration_url == "https://account.smartthings.com" assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label - assert entry.model == device.device_type_name - assert entry.manufacturer == "Unavailable" + assert entry.model == "123" + assert entry.manufacturer == "Generic manufacturer" + assert entry.hw_version == "v4.56" + assert entry.sw_version == "v7.89" state = hass.states.get("sensor.refrigerator_power") assert state @@ -201,18 +231,26 @@ async def test_power_consumption_sensor(hass: HomeAssistant, device_factory) -> entry = entity_registry.async_get("sensor.refrigerator_power") assert entry assert entry.unique_id == f"{device.device_id}.power_meter" - entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) assert entry assert entry.configuration_url == "https://account.smartthings.com" assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label - assert entry.model == device.device_type_name - assert entry.manufacturer == "Unavailable" + assert entry.model == "123" + assert entry.manufacturer == "Generic manufacturer" + assert entry.hw_version == "v4.56" + assert entry.sw_version == "v7.89" device = device_factory( "vacuum", [Capability.power_consumption_report], - {Attribute.power_consumption: {}}, + { + Attribute.power_consumption: {}, + Attribute.mnmo: "123", + Attribute.mnmn: "Generic manufacturer", + Attribute.mnhw: "v4.56", + Attribute.mnfv: "v7.89", + }, ) entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -225,13 +263,15 @@ async def test_power_consumption_sensor(hass: HomeAssistant, device_factory) -> entry = entity_registry.async_get("sensor.vacuum_energy") assert entry assert entry.unique_id == f"{device.device_id}.energy_meter" - entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) assert entry assert entry.configuration_url == "https://account.smartthings.com" assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label - assert entry.model == device.device_type_name - assert entry.manufacturer == "Unavailable" + assert entry.model == "123" + assert entry.manufacturer == "Generic manufacturer" + assert entry.hw_version == "v4.56" + assert entry.sw_version == "v7.89" async def test_update_from_signal(hass: HomeAssistant, device_factory) -> None: diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index 81bb8579cfd..437acb04f56 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -21,7 +21,17 @@ async def test_entity_and_device_attributes( ) -> None: """Test the attributes of the entity are correct.""" # Arrange - device = device_factory("Switch_1", [Capability.switch], {Attribute.switch: "on"}) + device = device_factory( + "Switch_1", + [Capability.switch], + { + Attribute.switch: "on", + Attribute.mnmo: "123", + Attribute.mnmn: "Generic manufacturer", + Attribute.mnhw: "v4.56", + Attribute.mnfv: "v7.89", + }, + ) entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) # Act @@ -31,13 +41,15 @@ async def test_entity_and_device_attributes( assert entry assert entry.unique_id == device.device_id - entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + entry = device_registry.async_get_device(identifiers={(DOMAIN, device.device_id)}) assert entry assert entry.configuration_url == "https://account.smartthings.com" assert entry.identifiers == {(DOMAIN, device.device_id)} assert entry.name == device.label - assert entry.model == device.device_type_name - assert entry.manufacturer == "Unavailable" + assert entry.model == "123" + assert entry.manufacturer == "Generic manufacturer" + assert entry.hw_version == "v4.56" + assert entry.sw_version == "v7.89" async def test_turn_off(hass: HomeAssistant, device_factory) -> None: diff --git a/tests/components/songpal/test_media_player.py b/tests/components/songpal/test_media_player.py index d5e89e887d1..534e2e6e9e6 100644 --- a/tests/components/songpal/test_media_player.py +++ b/tests/components/songpal/test_media_player.py @@ -103,8 +103,8 @@ async def test_setup_failed( await hass.async_block_till_done() all_states = hass.states.async_all() assert len(all_states) == 0 - warning_records = [x for x in caplog.records if x.levelno == logging.WARNING] - assert len(warning_records) == 2 + assert "[name(http://0.0.0.0:10000/sony)] Unable to connect" in caplog.text + assert "Platform songpal not ready yet: Unable to do POST request" in caplog.text assert not any(x.levelno == logging.ERROR for x in caplog.records) caplog.clear() diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 730f0f5e8f3..bab2b89009f 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -107,6 +107,9 @@ def config_entry_fixture(): class MockSoCo(MagicMock): """Mock the Soco Object.""" + audio_delay = 2 + sub_gain = 5 + @property def visible_zones(self): """Return visible zones and allow property to be overridden by device classes.""" diff --git a/tests/components/sonos/test_number.py b/tests/components/sonos/test_number.py index d5da2af629e..38456058d8a 100644 --- a/tests/components/sonos/test_number.py +++ b/tests/components/sonos/test_number.py @@ -1,5 +1,5 @@ """Tests for the Sonos number platform.""" -from unittest.mock import patch +from unittest.mock import PropertyMock, patch from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE from homeassistant.const import ATTR_ENTITY_ID @@ -37,24 +37,28 @@ async def test_number_entities( music_surround_level_state = hass.states.get(music_surround_level_number.entity_id) assert music_surround_level_state.state == "4" - with patch("soco.SoCo.audio_delay") as mock_audio_delay: + with patch.object( + type(soco), "audio_delay", new_callable=PropertyMock + ) as mock_audio_delay: await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: audio_delay_number.entity_id, "value": 3}, blocking=True, ) - assert mock_audio_delay.called_with(3) + mock_audio_delay.assert_called_once_with(3) sub_gain_number = entity_registry.entities["number.zone_a_sub_gain"] sub_gain_state = hass.states.get(sub_gain_number.entity_id) assert sub_gain_state.state == "5" - with patch("soco.SoCo.sub_gain") as mock_sub_gain: + with patch.object( + type(soco), "sub_gain", new_callable=PropertyMock + ) as mock_sub_gain: await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: sub_gain_number.entity_id, "value": -8}, blocking=True, ) - assert mock_sub_gain.called_with(-8) + mock_sub_gain.assert_called_once_with(-8) diff --git a/tests/components/sonos/test_sensor.py b/tests/components/sonos/test_sensor.py index 2d7a9322aeb..40b0c2d21c6 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -28,7 +28,7 @@ async def test_entity_registry_unsupported( assert "media_player.zone_a" in entity_registry.entities assert "sensor.zone_a_battery" not in entity_registry.entities - assert "binary_sensor.zone_a_power" not in entity_registry.entities + assert "binary_sensor.zone_a_charging" not in entity_registry.entities async def test_entity_registry_supported( @@ -37,7 +37,7 @@ async def test_entity_registry_supported( """Test sonos device with battery registered in the device registry.""" assert "media_player.zone_a" in entity_registry.entities assert "sensor.zone_a_battery" in entity_registry.entities - assert "binary_sensor.zone_a_power" in entity_registry.entities + assert "binary_sensor.zone_a_charging" in entity_registry.entities async def test_battery_attributes( @@ -49,7 +49,7 @@ async def test_battery_attributes( assert battery_state.state == "100" assert battery_state.attributes.get("unit_of_measurement") == "%" - power = entity_registry.entities["binary_sensor.zone_a_power"] + power = entity_registry.entities["binary_sensor.zone_a_charging"] power_state = hass.states.get(power.entity_id) assert power_state.state == STATE_ON assert ( @@ -73,7 +73,7 @@ async def test_battery_on_s1( sub_callback = subscription.callback assert "sensor.zone_a_battery" not in entity_registry.entities - assert "binary_sensor.zone_a_power" not in entity_registry.entities + assert "binary_sensor.zone_a_charging" not in entity_registry.entities # Update the speaker with a callback event sub_callback(device_properties_event) @@ -83,7 +83,7 @@ async def test_battery_on_s1( battery_state = hass.states.get(battery.entity_id) assert battery_state.state == "100" - power = entity_registry.entities["binary_sensor.zone_a_power"] + power = entity_registry.entities["binary_sensor.zone_a_charging"] power_state = hass.states.get(power.entity_id) assert power_state.state == STATE_OFF assert power_state.attributes.get(ATTR_BATTERY_POWER_SOURCE) == "BATTERY" diff --git a/tests/components/speedtestdotnet/test_sensor.py b/tests/components/speedtestdotnet/test_sensor.py index 68f14c64a69..887f0ba0491 100644 --- a/tests/components/speedtestdotnet/test_sensor.py +++ b/tests/components/speedtestdotnet/test_sensor.py @@ -3,8 +3,6 @@ from unittest.mock import MagicMock from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.speedtestdotnet import DOMAIN -from homeassistant.components.speedtestdotnet.const import DEFAULT_NAME -from homeassistant.components.speedtestdotnet.sensor import SENSOR_TYPES from homeassistant.core import HomeAssistant, State from . import MOCK_RESULTS, MOCK_SERVERS, MOCK_STATES @@ -27,10 +25,17 @@ async def test_speedtestdotnet_sensors( assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 - for description in SENSOR_TYPES: - sensor = hass.states.get(f"sensor.{DEFAULT_NAME}_{description.name}") - assert sensor - assert sensor.state == MOCK_STATES[description.key] + sensor = hass.states.get("sensor.speedtest_ping") + assert sensor + assert sensor.state == MOCK_STATES["ping"] + + sensor = hass.states.get("sensor.speedtest_download") + assert sensor + assert sensor.state == MOCK_STATES["download"] + + sensor = hass.states.get("sensor.speedtest_ping") + assert sensor + assert sensor.state == MOCK_STATES["ping"] async def test_restore_last_state(hass: HomeAssistant, mock_api: MagicMock) -> None: @@ -50,7 +55,14 @@ async def test_restore_last_state(hass: HomeAssistant, mock_api: MagicMock) -> N assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 - for description in SENSOR_TYPES: - sensor = hass.states.get(f"sensor.speedtest_{description.name}") - assert sensor - assert sensor.state == MOCK_STATES[description.key] + sensor = hass.states.get("sensor.speedtest_ping") + assert sensor + assert sensor.state == MOCK_STATES["ping"] + + sensor = hass.states.get("sensor.speedtest_download") + assert sensor + assert sensor.state == MOCK_STATES["download"] + + sensor = hass.states.get("sensor.speedtest_ping") + assert sensor + assert sensor.state == MOCK_STATES["ping"] diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 9927a9734cd..53356a85c4e 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -13,12 +13,14 @@ from homeassistant.components.sql.const import CONF_COLUMN_NAME, CONF_QUERY, DOM from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_DEVICE_CLASS, + CONF_ICON, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.template_entity import CONF_AVAILABILITY, CONF_PICTURE from tests.common import MockConfigEntry @@ -27,6 +29,8 @@ ENTRY_CONFIG = { CONF_QUERY: "SELECT 5 as value", CONF_COLUMN_NAME: "value", CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_SIZE, + CONF_STATE_CLASS: SensorStateClass.TOTAL, } ENTRY_CONFIG_WITH_VALUE_TEMPLATE = { @@ -146,6 +150,23 @@ YAML_CONFIG_NO_DB = { } } +YAML_CONFIG_ALL_TEMPLATES = { + "sql": { + CONF_DB_URL: "sqlite://", + CONF_NAME: "Get values with template", + CONF_QUERY: "SELECT 5 as output", + CONF_COLUMN_NAME: "output", + CONF_UNIT_OF_MEASUREMENT: "MiB/s", + CONF_UNIQUE_ID: "unique_id_123456", + CONF_VALUE_TEMPLATE: "{{ value }}", + CONF_ICON: '{% if states("sensor.input1")=="on" %} mdi:on {% else %} mdi:off {% endif %}', + CONF_PICTURE: '{% if states("sensor.input1")=="on" %} /local/picture1.jpg {% else %} /local/picture2.jpg {% endif %}', + CONF_AVAILABILITY: '{{ states("sensor.input2")=="on" }}', + CONF_DEVICE_CLASS: SensorDeviceClass.DATA_RATE, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, + } +} + async def init_integration( hass: HomeAssistant, diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index 8958454ac62..915394863ea 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -7,6 +7,8 @@ from sqlalchemy.exc import SQLAlchemyError from homeassistant import config_entries from homeassistant.components.recorder import Recorder +from homeassistant.components.sensor.const import SensorDeviceClass, SensorStateClass +from homeassistant.components.sql.config_flow import NONE_SENTINEL from homeassistant.components.sql.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -50,6 +52,8 @@ async def test_form(recorder_mock: Recorder, hass: HomeAssistant) -> None: "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", + "device_class": SensorDeviceClass.DATA_SIZE, + "state_class": SensorStateClass.TOTAL, } assert len(mock_setup_entry.mock_calls) == 1 @@ -151,6 +155,8 @@ async def test_flow_fails_invalid_query( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", + "device_class": SensorDeviceClass.DATA_SIZE, + "state_class": SensorStateClass.TOTAL, } @@ -187,6 +193,8 @@ async def test_flow_fails_invalid_column_name( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", + "device_class": SensorDeviceClass.DATA_SIZE, + "state_class": SensorStateClass.TOTAL, } @@ -201,6 +209,8 @@ async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> Non "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", + "device_class": SensorDeviceClass.DATA_SIZE, + "state_class": SensorStateClass.TOTAL, }, ) entry.add_to_hass(hass) @@ -225,6 +235,8 @@ async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> Non "column": "size", "unit_of_measurement": "MiB", "value_template": "{{ value }}", + "device_class": SensorDeviceClass.DATA_SIZE, + "state_class": SensorStateClass.TOTAL, }, ) @@ -235,6 +247,8 @@ async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> Non "column": "size", "unit_of_measurement": "MiB", "value_template": "{{ value }}", + "device_class": SensorDeviceClass.DATA_SIZE, + "state_class": SensorStateClass.TOTAL, } @@ -594,3 +608,79 @@ async def test_full_flow_not_recorder_db( "column": "value", "unit_of_measurement": "MB", } + + +async def test_device_state_class(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test we get the form.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + "name": "Get Value", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + with patch( + "homeassistant.components.sql.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + "device_class": SensorDeviceClass.DATA_SIZE, + "state_class": SensorStateClass.TOTAL, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { + "name": "Get Value", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + "device_class": SensorDeviceClass.DATA_SIZE, + "state_class": SensorStateClass.TOTAL, + } + + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + with patch( + "homeassistant.components.sql.async_setup_entry", + return_value=True, + ): + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + "device_class": NONE_SENTINEL, + "state_class": NONE_SENTINEL, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert "device_class" not in result3["data"] + assert "state_class" not in result3["data"] + assert result3["data"] == { + "name": "Get Value", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + } diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index a6aa18c9294..3d0e2768ade 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -13,7 +13,12 @@ from homeassistant.components.recorder import Recorder from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sql.const import CONF_QUERY, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_UNIQUE_ID, STATE_UNKNOWN +from homeassistant.const import ( + CONF_ICON, + CONF_UNIQUE_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component @@ -21,6 +26,7 @@ from homeassistant.util import dt as dt_util from . import ( YAML_CONFIG, + YAML_CONFIG_ALL_TEMPLATES, YAML_CONFIG_BINARY, YAML_CONFIG_FULL_TABLE_SCAN, YAML_CONFIG_FULL_TABLE_SCAN_NO_UNIQUE_ID, @@ -32,13 +38,14 @@ from . import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_query(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def test_query_basic(recorder_mock: Recorder, hass: HomeAssistant) -> None: """Test the SQL sensor.""" config = { "db_url": "sqlite://", "query": "SELECT 5 as value", "column": "value", "name": "Select value SQL query", + "unique_id": "very_unique_id", } await init_integration(hass, config) @@ -235,6 +242,65 @@ async def test_query_from_yaml(recorder_mock: Recorder, hass: HomeAssistant) -> assert state.state == "5" +async def test_templates_with_yaml( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test the SQL sensor from yaml config with templates.""" + + hass.states.async_set("sensor.input1", "on") + hass.states.async_set("sensor.input2", "on") + await hass.async_block_till_done() + + assert await async_setup_component(hass, DOMAIN, YAML_CONFIG_ALL_TEMPLATES) + await hass.async_block_till_done() + + state = hass.states.get("sensor.get_values_with_template") + assert state.state == "5" + assert state.attributes[CONF_ICON] == "mdi:on" + assert state.attributes["entity_picture"] == "/local/picture1.jpg" + + hass.states.async_set("sensor.input1", "off") + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=1), + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.get_values_with_template") + assert state.state == "5" + assert state.attributes[CONF_ICON] == "mdi:off" + assert state.attributes["entity_picture"] == "/local/picture2.jpg" + + hass.states.async_set("sensor.input2", "off") + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=2), + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.get_values_with_template") + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set("sensor.input1", "on") + hass.states.async_set("sensor.input2", "on") + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=3), + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.get_values_with_template") + assert state.state == "5" + assert state.attributes[CONF_ICON] == "mdi:on" + assert state.attributes["entity_picture"] == "/local/picture1.jpg" + + async def test_config_from_old_yaml( recorder_mock: Recorder, hass: HomeAssistant ) -> None: @@ -457,3 +523,47 @@ async def test_engine_is_disposed_at_stop( await hass.async_stop() assert mock_engine_dispose.call_count == 2 + + +async def test_attributes_from_entry_config( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test attributes from entry config.""" + + await init_integration( + hass, + config={ + "name": "Get Value - With", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + "device_class": SensorDeviceClass.DATA_SIZE, + "state_class": SensorStateClass.TOTAL, + }, + entry_id="8693d4782ced4fb1ecca4743f29ab8f1", + ) + + state = hass.states.get("sensor.get_value_with") + assert state.state == "5" + assert state.attributes["value"] == 5 + assert state.attributes["unit_of_measurement"] == "MiB" + assert state.attributes["device_class"] == SensorDeviceClass.DATA_SIZE + assert state.attributes["state_class"] == SensorStateClass.TOTAL + + await init_integration( + hass, + config={ + "name": "Get Value - Without", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + }, + entry_id="7aec7cd8045fba4778bb0621469e3cd9", + ) + + state = hass.states.get("sensor.get_value_without") + assert state.state == "5" + assert state.attributes["value"] == 5 + assert state.attributes["unit_of_measurement"] == "MiB" + assert "device_class" not in state.attributes + assert "state_class" not in state.attributes diff --git a/tests/components/starline/test_config_flow.py b/tests/components/starline/test_config_flow.py index c659ca5c585..4277f01037f 100644 --- a/tests/components/starline/test_config_flow.py +++ b/tests/components/starline/test_config_flow.py @@ -37,9 +37,7 @@ async def test_flow_works(hass: HomeAssistant) -> None: cookies={"slnet": TEST_APP_SLNET}, ) mock.get( - "https://developer.starline.ru/json/v2/user/{}/user_info".format( - TEST_APP_UID - ), + f"https://developer.starline.ru/json/v2/user/{TEST_APP_UID}/user_info", text='{"code": 200, "devices": [{"device_id": "123", "imei": "123", "alias": "123", "battery": "123", "ctemp": "123", "etemp": "123", "fw_version": "123", "gsm_lvl": "123", "phone": "123", "status": "1", "ts_activity": "123", "typename": "123", "balance": {}, "car_state": {}, "car_alr_state": {}, "functions": [], "position": {}}], "shared_devices": []}', ) diff --git a/tests/components/statsd/test_init.py b/tests/components/statsd/test_init.py index 2b94d8c0790..1b48b6195e5 100644 --- a/tests/components/statsd/test_init.py +++ b/tests/components/statsd/test_init.py @@ -1,13 +1,12 @@ """The tests for the StatsD feeder.""" from unittest import mock -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest import voluptuous as vol import homeassistant.components.statsd as statsd -from homeassistant.const import EVENT_STATE_CHANGED, STATE_OFF, STATE_ON -import homeassistant.core as ha +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -32,15 +31,15 @@ def test_invalid_config() -> None: async def test_statsd_setup_full(hass: HomeAssistant) -> None: """Test setup with all data.""" config = {"statsd": {"host": "host", "port": 123, "rate": 1, "prefix": "foo"}} - hass.bus.listen = MagicMock() with patch("statsd.StatsClient") as mock_init: assert await async_setup_component(hass, statsd.DOMAIN, config) assert mock_init.call_count == 1 assert mock_init.call_args == mock.call(host="host", port=123, prefix="foo") - assert hass.bus.listen.called - assert hass.bus.listen.call_args_list[0][0][0] == EVENT_STATE_CHANGED + hass.states.async_set("domain.test", "on") + await hass.async_block_till_done() + assert len(mock_init.mock_calls) == 3 async def test_statsd_setup_defaults(hass: HomeAssistant) -> None: @@ -50,13 +49,14 @@ async def test_statsd_setup_defaults(hass: HomeAssistant) -> None: config["statsd"][statsd.CONF_PORT] = statsd.DEFAULT_PORT config["statsd"][statsd.CONF_PREFIX] = statsd.DEFAULT_PREFIX - hass.bus.listen = MagicMock() with patch("statsd.StatsClient") as mock_init: assert await async_setup_component(hass, statsd.DOMAIN, config) assert mock_init.call_count == 1 assert mock_init.call_args == mock.call(host="host", port=8125, prefix="hass") - assert hass.bus.listen.called + hass.states.async_set("domain.test", "on") + await hass.async_block_till_done() + assert len(mock_init.mock_calls) == 3 async def test_event_listener_defaults(hass: HomeAssistant, mock_client) -> None: @@ -65,31 +65,27 @@ async def test_event_listener_defaults(hass: HomeAssistant, mock_client) -> None config["statsd"][statsd.CONF_RATE] = statsd.DEFAULT_RATE - hass.bus.listen = MagicMock() await async_setup_component(hass, statsd.DOMAIN, config) - assert hass.bus.listen.called - handler_method = hass.bus.listen.call_args_list[0][0][1] valid = {"1": 1, "1.0": 1.0, "custom": 3, STATE_ON: 1, STATE_OFF: 0} for in_, out in valid.items(): - state = MagicMock(state=in_, attributes={"attribute key": 3.2}) - handler_method(MagicMock(data={"new_state": state})) + hass.states.async_set("domain.test", in_, {"attribute key": 3.2}) + await hass.async_block_till_done() mock_client.gauge.assert_has_calls( - [mock.call(state.entity_id, out, statsd.DEFAULT_RATE)] + [mock.call("domain.test", out, statsd.DEFAULT_RATE)] ) mock_client.gauge.reset_mock() assert mock_client.incr.call_count == 1 assert mock_client.incr.call_args == mock.call( - state.entity_id, rate=statsd.DEFAULT_RATE + "domain.test", rate=statsd.DEFAULT_RATE ) mock_client.incr.reset_mock() for invalid in ("foo", "", object): - handler_method( - MagicMock(data={"new_state": ha.State("domain.test", invalid, {})}) - ) + hass.states.async_set("domain.test", invalid, {}) + await hass.async_block_till_done() assert not mock_client.gauge.called assert mock_client.incr.called @@ -100,19 +96,16 @@ async def test_event_listener_attr_details(hass: HomeAssistant, mock_client) -> config["statsd"][statsd.CONF_RATE] = statsd.DEFAULT_RATE - hass.bus.listen = MagicMock() await async_setup_component(hass, statsd.DOMAIN, config) - assert hass.bus.listen.called - handler_method = hass.bus.listen.call_args_list[0][0][1] valid = {"1": 1, "1.0": 1.0, STATE_ON: 1, STATE_OFF: 0} for in_, out in valid.items(): - state = MagicMock(state=in_, attributes={"attribute key": 3.2}) - handler_method(MagicMock(data={"new_state": state})) + hass.states.async_set("domain.test", in_, {"attribute key": 3.2}) + await hass.async_block_till_done() mock_client.gauge.assert_has_calls( [ - mock.call(f"{state.entity_id}.state", out, statsd.DEFAULT_RATE), - mock.call(f"{state.entity_id}.attribute_key", 3.2, statsd.DEFAULT_RATE), + mock.call("domain.test.state", out, statsd.DEFAULT_RATE), + mock.call("domain.test.attribute_key", 3.2, statsd.DEFAULT_RATE), ] ) @@ -120,13 +113,12 @@ async def test_event_listener_attr_details(hass: HomeAssistant, mock_client) -> assert mock_client.incr.call_count == 1 assert mock_client.incr.call_args == mock.call( - state.entity_id, rate=statsd.DEFAULT_RATE + "domain.test", rate=statsd.DEFAULT_RATE ) mock_client.incr.reset_mock() for invalid in ("foo", "", object): - handler_method( - MagicMock(data={"new_state": ha.State("domain.test", invalid, {})}) - ) + hass.states.async_set("domain.test", invalid, {}) + await hass.async_block_till_done() assert not mock_client.gauge.called assert mock_client.incr.called diff --git a/tests/components/steam_online/test_init.py b/tests/components/steam_online/test_init.py index 435a5ac6f5a..e3f473e01c6 100644 --- a/tests/components/steam_online/test_init.py +++ b/tests/components/steam_online/test_init.py @@ -43,7 +43,7 @@ async def test_device_info(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) device_registry = dr.async_get(hass) await hass.async_block_till_done() - device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) assert device.configuration_url == "https://store.steampowered.com" assert device.entry_type == dr.DeviceEntryType.SERVICE diff --git a/tests/components/steamist/test_init.py b/tests/components/steamist/test_init.py index a40917cfc3c..0a98f746c4c 100644 --- a/tests/components/steamist/test_init.py +++ b/tests/components/steamist/test_init.py @@ -105,7 +105,7 @@ async def test_config_entry_fills_unique_id_with_directed_discovery( device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, FORMATTED_MAC_ADDRESS)}, identifiers={} + connections={(dr.CONNECTION_NETWORK_MAC, FORMATTED_MAC_ADDRESS)} ) assert isinstance(device_entry, dr.DeviceEntry) assert device_entry.name == DEVICE_NAME diff --git a/tests/components/subaru/test_sensor.py b/tests/components/subaru/test_sensor.py index aa351f7ccbd..fd03ed3044b 100644 --- a/tests/components/subaru/test_sensor.py +++ b/tests/components/subaru/test_sensor.py @@ -1,4 +1,5 @@ """Test Subaru sensors.""" +from typing import Any from unittest.mock import patch import pytest @@ -12,7 +13,6 @@ from homeassistant.components.subaru.sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import slugify from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .api_responses import ( @@ -25,7 +25,6 @@ from .api_responses import ( from .conftest import ( MOCK_API_FETCH, MOCK_API_GET_DATA, - TEST_DEVICE_NAME, advance_time_to_next_fetch, setup_subaru_config_entry, ) @@ -65,9 +64,9 @@ async def test_sensors_missing_vin_data(hass: HomeAssistant, ev_entry) -> None: { "domain": SENSOR_DOMAIN, "platform": SUBARU_DOMAIN, - "unique_id": f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + "unique_id": f"{TEST_VIN_2_EV}_Avg fuel consumption", }, - f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + f"{TEST_VIN_2_EV}_Avg fuel consumption", f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].key}", ), ], @@ -97,9 +96,9 @@ async def test_sensor_migrate_unique_ids( { "domain": SENSOR_DOMAIN, "platform": SUBARU_DOMAIN, - "unique_id": f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + "unique_id": f"{TEST_VIN_2_EV}_Avg fuel consumption", }, - f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].name}", + f"{TEST_VIN_2_EV}_Avg fuel consumption", f"{TEST_VIN_2_EV}_{API_GEN_2_SENSORS[0].key}", ) ], @@ -136,15 +135,17 @@ async def test_sensor_migrate_unique_ids_duplicate( assert entity_migrated != entity_not_changed -def _assert_data(hass, expected_state): +def _assert_data(hass: HomeAssistant, expected_state: dict[str, Any]) -> None: sensor_list = EV_SENSORS sensor_list.extend(API_GEN_2_SENSORS) sensor_list.extend(SAFETY_SENSORS) expected_states = {} + entity_registry = er.async_get(hass) for item in sensor_list: - expected_states[ - f"sensor.{slugify(f'{TEST_DEVICE_NAME} {item.name}')}" - ] = expected_state[item.key] + entity = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, SUBARU_DOMAIN, f"{TEST_VIN_2_EV}_{item.key}" + ) + expected_states[entity] = expected_state[item.key] for sensor, value in expected_states.items(): actual = hass.states.get(sensor) diff --git a/tests/components/tado/test_binary_sensor.py b/tests/components/tado/test_binary_sensor.py index 9226543abef..1e2f53efeb5 100644 --- a/tests/components/tado/test_binary_sensor.py +++ b/tests/components/tado/test_binary_sensor.py @@ -13,13 +13,13 @@ async def test_air_con_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.air_conditioning_power") assert state.state == STATE_ON - state = hass.states.get("binary_sensor.air_conditioning_link") + state = hass.states.get("binary_sensor.air_conditioning_connectivity") assert state.state == STATE_ON state = hass.states.get("binary_sensor.air_conditioning_overlay") assert state.state == STATE_ON - state = hass.states.get("binary_sensor.air_conditioning_open_window") + state = hass.states.get("binary_sensor.air_conditioning_window") assert state.state == STATE_OFF @@ -31,7 +31,7 @@ async def test_heater_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.baseboard_heater_power") assert state.state == STATE_ON - state = hass.states.get("binary_sensor.baseboard_heater_link") + state = hass.states.get("binary_sensor.baseboard_heater_connectivity") assert state.state == STATE_ON state = hass.states.get("binary_sensor.baseboard_heater_early_start") @@ -40,7 +40,7 @@ async def test_heater_create_binary_sensors(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.baseboard_heater_overlay") assert state.state == STATE_ON - state = hass.states.get("binary_sensor.baseboard_heater_open_window") + state = hass.states.get("binary_sensor.baseboard_heater_window") assert state.state == STATE_OFF @@ -49,7 +49,7 @@ async def test_water_heater_create_binary_sensors(hass: HomeAssistant) -> None: await async_init_integration(hass) - state = hass.states.get("binary_sensor.water_heater_link") + state = hass.states.get("binary_sensor.water_heater_connectivity") assert state.state == STATE_ON state = hass.states.get("binary_sensor.water_heater_overlay") diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index 4744d6c2ccf..703dd2a1893 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -413,7 +413,7 @@ async def help_test_discovery_removal( # Verify device and entity registry entries are created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, config1[CONF_MAC])} + connections={(dr.CONNECTION_NETWORK_MAC, config1[CONF_MAC])} ) assert device_entry is not None entity_entry = entity_reg.async_get(f"{domain}.{entity_id}") @@ -436,7 +436,7 @@ async def help_test_discovery_removal( # Verify entity registry entries are cleared device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, config2[CONF_MAC])} + connections={(dr.CONNECTION_NETWORK_MAC, config2[CONF_MAC])} ) assert device_entry is not None entity_entry = entity_reg.async_get(f"{domain}.{entity_id}") @@ -522,7 +522,7 @@ async def help_test_discovery_device_remove( await hass.async_block_till_done() device = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, config[CONF_MAC])} + connections={(dr.CONNECTION_NETWORK_MAC, config[CONF_MAC])} ) assert device is not None assert entity_reg.async_get_entity_id(domain, "tasmota", unique_id) @@ -531,7 +531,7 @@ async def help_test_discovery_device_remove( await hass.async_block_till_done() device = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, config[CONF_MAC])} + connections={(dr.CONNECTION_NETWORK_MAC, config[CONF_MAC])} ) assert device is None assert not entity_reg.async_get_entity_id(domain, "tasmota", unique_id) diff --git a/tests/components/tasmota/test_device_trigger.py b/tests/components/tasmota/test_device_trigger.py index 880f4ed0e75..ffff4b1b8b0 100644 --- a/tests/components/tasmota/test_device_trigger.py +++ b/tests/components/tasmota/test_device_trigger.py @@ -49,7 +49,7 @@ async def test_get_triggers_btn( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) expected_triggers = [ { @@ -93,7 +93,7 @@ async def test_get_triggers_swc( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) expected_triggers = [ { @@ -129,7 +129,7 @@ async def test_get_unknown_triggers( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert await async_setup_component( @@ -178,7 +178,7 @@ async def test_get_non_existing_triggers( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -210,7 +210,7 @@ async def test_discover_bad_triggers( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -246,7 +246,7 @@ async def test_discover_bad_triggers( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id @@ -299,7 +299,7 @@ async def test_update_remove_triggers( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) expected_triggers1 = [ @@ -365,7 +365,7 @@ async def test_if_fires_on_mqtt_message_btn( async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{mac}/config", json.dumps(config)) await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert await async_setup_component( @@ -437,7 +437,7 @@ async def test_if_fires_on_mqtt_message_swc( async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{mac}/config", json.dumps(config)) await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert await async_setup_component( @@ -535,7 +535,7 @@ async def test_if_fires_on_mqtt_message_late_discover( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert await async_setup_component( @@ -611,7 +611,7 @@ async def test_if_fires_on_mqtt_message_after_update( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert await async_setup_component( @@ -692,7 +692,7 @@ async def test_no_resubscribe_same_topic( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert await async_setup_component( @@ -740,7 +740,7 @@ async def test_not_fires_on_mqtt_message_after_remove_by_mqtt( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert await async_setup_component( @@ -817,7 +817,7 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert await async_setup_component( @@ -876,7 +876,7 @@ async def test_attach_remove( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) calls = [] @@ -939,7 +939,7 @@ async def test_attach_remove_late( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) calls = [] @@ -1012,7 +1012,7 @@ async def test_attach_remove_late2( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) calls = [] @@ -1066,7 +1066,7 @@ async def test_attach_remove_unknown1( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) remove = await async_initialize_triggers( @@ -1119,7 +1119,7 @@ async def test_attach_unknown_remove_device_from_registry( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) await async_initialize_triggers( @@ -1160,7 +1160,7 @@ async def test_attach_remove_config_entry( await hass.async_block_till_done() device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) calls = [] diff --git a/tests/components/tasmota/test_discovery.py b/tests/components/tasmota/test_discovery.py index 74014c91102..9a3f4f91ec7 100644 --- a/tests/components/tasmota/test_discovery.py +++ b/tests/components/tasmota/test_discovery.py @@ -140,7 +140,7 @@ async def test_correct_config_discovery( # Verify device and registry entries are created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None entity_entry = entity_reg.async_get("switch.test") @@ -174,7 +174,7 @@ async def test_device_discover( # Verify device and registry entries are created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None assert device_entry.configuration_url == f"http://{config['ip']}/" @@ -205,7 +205,7 @@ async def test_device_discover_deprecated( # Verify device and registry entries are created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None assert device_entry.manufacturer == "Tasmota" @@ -238,7 +238,7 @@ async def test_device_update( # Verify device entry is created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None @@ -256,7 +256,7 @@ async def test_device_update( # Verify device entry is updated device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None assert device_entry.model == "Another model" @@ -285,7 +285,7 @@ async def test_device_remove( # Verify device entry is created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None @@ -298,7 +298,7 @@ async def test_device_remove( # Verify device entry is removed device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is None @@ -334,7 +334,7 @@ async def test_device_remove_multiple_config_entries_1( # Verify device entry is created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None assert device_entry.config_entries == {tasmota_entry.entry_id, mock_entry.entry_id} @@ -348,7 +348,7 @@ async def test_device_remove_multiple_config_entries_1( # Verify device entry is not removed device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None assert device_entry.config_entries == {mock_entry.entry_id} @@ -390,7 +390,7 @@ async def test_device_remove_multiple_config_entries_2( # Verify device entry is created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None assert device_entry.config_entries == {tasmota_entry.entry_id, mock_entry.entry_id} @@ -404,7 +404,7 @@ async def test_device_remove_multiple_config_entries_2( # Verify device entry is not removed device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None assert device_entry.config_entries == {tasmota_entry.entry_id} @@ -440,7 +440,7 @@ async def test_device_remove_stale( # Verify device entry was created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None @@ -449,7 +449,7 @@ async def test_device_remove_stale( # Verify device entry is removed device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is None @@ -475,7 +475,7 @@ async def test_device_rediscover( # Verify device entry is created device_entry1 = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry1 is not None @@ -488,7 +488,7 @@ async def test_device_rediscover( # Verify device entry is removed device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is None @@ -501,7 +501,7 @@ async def test_device_rediscover( # Verify device entry is created, and id is reused device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None assert device_entry1.id == device_entry.id @@ -602,7 +602,7 @@ async def test_same_topic( # Verify device registry entries are created for both devices for config in configs[0:2]: device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, config["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, config["mac"])} ) assert device_entry is not None assert device_entry.configuration_url == f"http://{config['ip']}/" @@ -613,11 +613,11 @@ async def test_same_topic( # Verify entities are created only for the first device device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, configs[0]["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, configs[0]["mac"])} ) assert len(er.async_entries_for_device(entity_reg, device_entry.id, True)) == 1 device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, configs[1]["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, configs[1]["mac"])} ) assert len(er.async_entries_for_device(entity_reg, device_entry.id, True)) == 0 @@ -637,7 +637,7 @@ async def test_same_topic( # Verify device registry entries was created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, configs[2]["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, configs[2]["mac"])} ) assert device_entry is not None assert device_entry.configuration_url == f"http://{configs[2]['ip']}/" @@ -648,7 +648,7 @@ async def test_same_topic( # Verify no entities were created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, configs[2]["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, configs[2]["mac"])} ) assert len(er.async_entries_for_device(entity_reg, device_entry.id, True)) == 0 @@ -667,7 +667,7 @@ async def test_same_topic( # Verify entities are created also for the third device device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, configs[2]["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, configs[2]["mac"])} ) assert len(er.async_entries_for_device(entity_reg, device_entry.id, True)) == 1 @@ -686,7 +686,7 @@ async def test_same_topic( # Verify entities are created also for the second device device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, configs[1]["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, configs[1]["mac"])} ) assert len(er.async_entries_for_device(entity_reg, device_entry.id, True)) == 1 @@ -716,7 +716,7 @@ async def test_topic_no_prefix( # Verify device registry entry is created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, config["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, config["mac"])} ) assert device_entry is not None assert device_entry.configuration_url == f"http://{config['ip']}/" @@ -727,7 +727,7 @@ async def test_topic_no_prefix( # Verify entities are not created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, config["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, config["mac"])} ) assert len(er.async_entries_for_device(entity_reg, device_entry.id, True)) == 0 @@ -747,7 +747,7 @@ async def test_topic_no_prefix( # Verify entities are created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, config["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, config["mac"])} ) assert len(er.async_entries_for_device(entity_reg, device_entry.id, True)) == 1 diff --git a/tests/components/tasmota/test_init.py b/tests/components/tasmota/test_init.py index b19e8e51103..09467b893e0 100644 --- a/tests/components/tasmota/test_init.py +++ b/tests/components/tasmota/test_init.py @@ -44,7 +44,7 @@ async def test_device_remove( # Verify device entry is created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None @@ -53,7 +53,7 @@ async def test_device_remove( # Verify device entry is removed device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is None @@ -104,7 +104,7 @@ async def test_device_remove_non_tasmota_device( # Verify device entry is removed device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is None @@ -135,7 +135,7 @@ async def test_device_remove_stale_tasmota_device( # Verify device entry is removed device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is None @@ -161,7 +161,7 @@ async def test_tasmota_ws_remove_discovered_device( # Verify device entry is created device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is not None @@ -180,6 +180,6 @@ async def test_tasmota_ws_remove_discovered_device( # Verify device entry is cleared device_entry = device_reg.async_get_device( - set(), {(dr.CONNECTION_NETWORK_MAC, mac)} + connections={(dr.CONNECTION_NETWORK_MAC, mac)} ) assert device_entry is None diff --git a/tests/components/tellduslive/test_config_flow.py b/tests/components/tellduslive/test_config_flow.py index 0eaadae4931..de284bb8c16 100644 --- a/tests/components/tellduslive/test_config_flow.py +++ b/tests/components/tellduslive/test_config_flow.py @@ -261,4 +261,4 @@ async def test_discovery_already_configured( flow.context = {"source": SOURCE_DISCOVERY} with pytest.raises(data_entry_flow.AbortFlow): - result = await flow.async_step_discovery(["some-host", ""]) + await flow.async_step_discovery(["some-host", ""]) diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 3a911a68416..e6850728450 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -12,6 +12,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_component import async_update_entity from tests.common import assert_setup_component @@ -317,31 +318,37 @@ async def test_unique_id(hass: HomeAssistant, start_ha) -> None: async def test_unused_services(hass: HomeAssistant) -> None: - """Test calling unused services should not crash.""" + """Test calling unused services raises.""" await _register_basic_vacuum(hass) # Pause vacuum - await common.async_pause(hass, _TEST_VACUUM) + with pytest.raises(HomeAssistantError): + await common.async_pause(hass, _TEST_VACUUM) await hass.async_block_till_done() # Stop vacuum - await common.async_stop(hass, _TEST_VACUUM) + with pytest.raises(HomeAssistantError): + await common.async_stop(hass, _TEST_VACUUM) await hass.async_block_till_done() # Return vacuum to base - await common.async_return_to_base(hass, _TEST_VACUUM) + with pytest.raises(HomeAssistantError): + await common.async_return_to_base(hass, _TEST_VACUUM) await hass.async_block_till_done() # Spot cleaning - await common.async_clean_spot(hass, _TEST_VACUUM) + with pytest.raises(HomeAssistantError): + await common.async_clean_spot(hass, _TEST_VACUUM) await hass.async_block_till_done() # Locate vacuum - await common.async_locate(hass, _TEST_VACUUM) + with pytest.raises(HomeAssistantError): + await common.async_locate(hass, _TEST_VACUUM) await hass.async_block_till_done() # Set fan's speed - await common.async_set_fan_speed(hass, "medium", _TEST_VACUUM) + with pytest.raises(HomeAssistantError): + await common.async_set_fan_speed(hass, "medium", _TEST_VACUUM) await hass.async_block_till_done() _verify(hass, STATE_UNKNOWN, None) diff --git a/tests/components/tomorrowio/fixtures/v4.json b/tests/components/tomorrowio/fixtures/v4.json index ed5fb0982a0..0ca4f348956 100644 --- a/tests/components/tomorrowio/fixtures/v4.json +++ b/tests/components/tomorrowio/fixtures/v4.json @@ -31,7 +31,9 @@ "pressureSurfaceLevel": 29.47, "solarGHI": 0, "cloudBase": 0.74, - "cloudCeiling": 0.74 + "cloudCeiling": 0.74, + "uvIndex": 3, + "uvHealthConcern": 1 }, "forecasts": { "nowcast": [ diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index 487b3a4adb8..77335769383 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -60,6 +60,9 @@ CLOUD_COVER = "cloud_cover" CLOUD_CEILING = "cloud_ceiling" WIND_GUST = "wind_gust" PRECIPITATION_TYPE = "precipitation_type" +UV_INDEX = "uv_index" +UV_HEALTH_CONCERN = "uv_radiation_health_concern" + V3_FIELDS = [ O3, @@ -91,6 +94,8 @@ V4_FIELDS = [ CLOUD_CEILING, WIND_GUST, PRECIPITATION_TYPE, + UV_INDEX, + UV_HEALTH_CONCERN, ] @@ -171,6 +176,8 @@ async def test_v4_sensor(hass: HomeAssistant) -> None: check_sensor_state(hass, CLOUD_CEILING, "0.74") check_sensor_state(hass, WIND_GUST, "12.64") check_sensor_state(hass, PRECIPITATION_TYPE, "rain") + check_sensor_state(hass, UV_INDEX, "3") + check_sensor_state(hass, UV_HEALTH_CONCERN, "moderate") async def test_v4_sensor_imperial(hass: HomeAssistant) -> None: @@ -202,6 +209,8 @@ async def test_v4_sensor_imperial(hass: HomeAssistant) -> None: check_sensor_state(hass, CLOUD_CEILING, "0.46") check_sensor_state(hass, WIND_GUST, "28.27") check_sensor_state(hass, PRECIPITATION_TYPE, "rain") + check_sensor_state(hass, UV_INDEX, "3") + check_sensor_state(hass, UV_HEALTH_CONCERN, "moderate") async def test_entity_description() -> None: diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index 6161b793610..be1a05947cc 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -129,7 +129,7 @@ async def test_arm_home_failure(hass: HomeAssistant) -> None: await hass.services.async_call( ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert f"{err.value}" == "TotalConnect failed to arm home test." assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 @@ -139,7 +139,7 @@ async def test_arm_home_failure(hass: HomeAssistant) -> None: await hass.services.async_call( ALARM_DOMAIN, SERVICE_ALARM_ARM_HOME, DATA, blocking=True ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert f"{err.value}" == "TotalConnect usercode is invalid. Did not arm home" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED # should have started a re-auth flow @@ -183,7 +183,7 @@ async def test_arm_home_instant_failure(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert f"{err.value}" == "TotalConnect failed to arm home instant test." assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 @@ -193,7 +193,7 @@ async def test_arm_home_instant_failure(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_ALARM_ARM_HOME_INSTANT, DATA, blocking=True ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert ( f"{err.value}" == "TotalConnect usercode is invalid. Did not arm home instant" @@ -240,7 +240,7 @@ async def test_arm_away_instant_failure(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_ALARM_ARM_AWAY_INSTANT, DATA, blocking=True ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert f"{err.value}" == "TotalConnect failed to arm away instant test." assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 @@ -250,7 +250,7 @@ async def test_arm_away_instant_failure(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_ALARM_ARM_AWAY_INSTANT, DATA, blocking=True ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert ( f"{err.value}" == "TotalConnect usercode is invalid. Did not arm away instant" @@ -296,7 +296,7 @@ async def test_arm_away_failure(hass: HomeAssistant) -> None: await hass.services.async_call( ALARM_DOMAIN, SERVICE_ALARM_ARM_AWAY, DATA, blocking=True ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert f"{err.value}" == "TotalConnect failed to arm away test." assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 @@ -306,7 +306,7 @@ async def test_arm_away_failure(hass: HomeAssistant) -> None: await hass.services.async_call( ALARM_DOMAIN, SERVICE_ALARM_ARM_AWAY, DATA, blocking=True ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert f"{err.value}" == "TotalConnect usercode is invalid. Did not arm away" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED # should have started a re-auth flow @@ -353,7 +353,7 @@ async def test_disarm_failure(hass: HomeAssistant) -> None: await hass.services.async_call( ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert f"{err.value}" == "TotalConnect failed to disarm test." assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY assert mock_request.call_count == 2 @@ -363,7 +363,7 @@ async def test_disarm_failure(hass: HomeAssistant) -> None: await hass.services.async_call( ALARM_DOMAIN, SERVICE_ALARM_DISARM, DATA, blocking=True ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert f"{err.value}" == "TotalConnect usercode is invalid. Did not disarm" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY # should have started a re-auth flow @@ -406,7 +406,7 @@ async def test_arm_night_failure(hass: HomeAssistant) -> None: await hass.services.async_call( ALARM_DOMAIN, SERVICE_ALARM_ARM_NIGHT, DATA, blocking=True ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert f"{err.value}" == "TotalConnect failed to arm night test." assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 2 @@ -416,7 +416,7 @@ async def test_arm_night_failure(hass: HomeAssistant) -> None: await hass.services.async_call( ALARM_DOMAIN, SERVICE_ALARM_ARM_NIGHT, DATA, blocking=True ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert f"{err.value}" == "TotalConnect usercode is invalid. Did not arm night" assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED # should have started a re-auth flow diff --git a/tests/components/tplink/test_switch.py b/tests/components/tplink/test_switch.py index ba4419500f4..1e5e03c0f37 100644 --- a/tests/components/tplink/test_switch.py +++ b/tests/components/tplink/test_switch.py @@ -147,7 +147,7 @@ async def test_strip(hass: HomeAssistant) -> None: assert hass.states.get("switch.my_strip") is None for plug_id in range(2): - entity_id = f"switch.plug{plug_id}" + entity_id = f"switch.my_strip_plug{plug_id}" state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -176,7 +176,7 @@ async def test_strip_unique_ids(hass: HomeAssistant) -> None: await hass.async_block_till_done() for plug_id in range(2): - entity_id = f"switch.plug{plug_id}" + entity_id = f"switch.my_strip_plug{plug_id}" entity_registry = er.async_get(hass) assert ( entity_registry.async_get(entity_id).unique_id == f"PLUG{plug_id}DEVICEID" diff --git a/tests/components/tradfri/test_sensor.py b/tests/components/tradfri/test_sensor.py index 23391c8e875..d301638ec5d 100644 --- a/tests/components/tradfri/test_sensor.py +++ b/tests/components/tradfri/test_sensor.py @@ -61,7 +61,7 @@ async def test_battery_sensor( remote_control: Device, ) -> None: """Test that a battery sensor is correctly added.""" - entity_id = "sensor.test" + entity_id = "sensor.test_battery" device = remote_control mock_gateway.mock_devices.append(device) await setup_integration(hass) @@ -92,7 +92,7 @@ async def test_cover_battery_sensor( blind: Blind, ) -> None: """Test that a battery sensor is correctly added for a cover (blind).""" - entity_id = "sensor.test" + entity_id = "sensor.test_battery" device = blind.device mock_gateway.mock_devices.append(device) await setup_integration(hass) diff --git a/tests/components/transport_nsw/test_sensor.py b/tests/components/transport_nsw/test_sensor.py index 181c5fdd1e4..46aee182b53 100644 --- a/tests/components/transport_nsw/test_sensor.py +++ b/tests/components/transport_nsw/test_sensor.py @@ -1,6 +1,10 @@ """The tests for the Transport NSW (AU) sensor platform.""" from unittest.mock import patch +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorStateClass, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -42,3 +46,38 @@ async def test_transportnsw_config(mocked_get_departures, hass: HomeAssistant) - assert state.attributes["real_time"] == "y" assert state.attributes["destination"] == "Palm Beach" assert state.attributes["mode"] == "Bus" + assert state.attributes["device_class"] == SensorDeviceClass.DURATION + assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT + + +def get_departuresMock_notFound(_stop_id, route, destination, api_key): + """Mock TransportNSW departures loading.""" + data = { + "stop_id": "n/a", + "route": "n/a", + "due": "n/a", + "delay": "n/a", + "real_time": "n/a", + "destination": "n/a", + "mode": "n/a", + } + return data + + +@patch( + "TransportNSW.TransportNSW.get_departures", side_effect=get_departuresMock_notFound +) +async def test_transportnsw_config_not_found( + mocked_get_departures_not_found, hass: HomeAssistant +) -> None: + """Test minimal TransportNSW configuration.""" + assert await async_setup_component(hass, "sensor", VALID_CONFIG) + await hass.async_block_till_done() + state = hass.states.get("sensor.next_bus") + assert state.state == "unknown" + assert state.attributes["stop_id"] == "209516" + assert state.attributes["route"] is None + assert state.attributes["delay"] is None + assert state.attributes["real_time"] is None + assert state.attributes["destination"] is None + assert state.attributes["mode"] is None diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr index 46b21ebab32..367da49c7f6 100644 --- a/tests/components/twentemilieu/snapshots/test_sensor.ambr +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -38,7 +38,7 @@ 'original_name': 'Christmas tree pickup', 'platform': 'twentemilieu', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'christmas_tree_pickup', 'unique_id': 'twentemilieu_12345_tree', 'unit_of_measurement': None, }) @@ -109,7 +109,7 @@ 'original_name': 'Non-recyclable waste pickup', 'platform': 'twentemilieu', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'non_recyclable_waste_pickup', 'unique_id': 'twentemilieu_12345_Non-recyclable', 'unit_of_measurement': None, }) @@ -180,7 +180,7 @@ 'original_name': 'Organic waste pickup', 'platform': 'twentemilieu', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'organic_waste_pickup', 'unique_id': 'twentemilieu_12345_Organic', 'unit_of_measurement': None, }) @@ -251,7 +251,7 @@ 'original_name': 'Packages waste pickup', 'platform': 'twentemilieu', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'packages_waste_pickup', 'unique_id': 'twentemilieu_12345_Plastic', 'unit_of_measurement': None, }) @@ -322,7 +322,7 @@ 'original_name': 'Paper waste pickup', 'platform': 'twentemilieu', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'paper_waste_pickup', 'unique_id': 'twentemilieu_12345_Paper', 'unit_of_measurement': None, }) diff --git a/tests/components/twinkly/test_light.py b/tests/components/twinkly/test_light.py index 278c2549b45..f66c82dc2ed 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -342,7 +342,7 @@ async def _create_entries( entity_id = entity_registry.async_get_entity_id("light", TWINKLY_DOMAIN, client.id) entity_entry = entity_registry.async_get(entity_id) - device = device_registry.async_get_device({(TWINKLY_DOMAIN, client.id)}) + device = device_registry.async_get_device(identifiers={(TWINKLY_DOMAIN, client.id)}) assert entity_entry is not None assert device is not None diff --git a/tests/components/twitch/__init__.py b/tests/components/twitch/__init__.py index 5c371a0e2ee..bf35484f53e 100644 --- a/tests/components/twitch/__init__.py +++ b/tests/components/twitch/__init__.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import AsyncGenerator from dataclasses import dataclass -from typing import Any, Optional +from typing import Any from twitchAPI.object import TwitchUser from twitchAPI.twitch import ( @@ -88,7 +88,7 @@ class TwitchMock: pass async def get_users( - self, user_ids: Optional[list[str]] = None, logins: Optional[list[str]] = None + self, user_ids: list[str] | None = None, logins: list[str] | None = None ) -> AsyncGenerator[TwitchUser, None]: """Get list of mock users.""" for user in [USER_OBJECT]: @@ -101,7 +101,7 @@ class TwitchMock: return True async def get_users_follows( - self, to_id: Optional[str] = None, from_id: Optional[str] = None + self, to_id: str | None = None, from_id: str | None = None ) -> TwitchUserFollowResultMock: """Return the followers of the user.""" if self._is_following: @@ -169,7 +169,7 @@ class TwitchInvalidUserMock(TwitchMock): """Twitch mock to test invalid user.""" async def get_users( - self, user_ids: Optional[list[str]] = None, logins: Optional[list[str]] = None + self, user_ids: list[str] | None = None, logins: list[str] | None = None ) -> AsyncGenerator[TwitchUser, None]: """Get list of mock users.""" if user_ids is not None or logins is not None: diff --git a/tests/components/unifi/snapshots/test_image.ambr b/tests/components/unifi/snapshots/test_image.ambr new file mode 100644 index 00000000000..77b171118a1 --- /dev/null +++ b/tests/components/unifi/snapshots/test_image.ambr @@ -0,0 +1,7 @@ +# serializer version: 1 +# name: test_wlan_qr_code + b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x84\x00\x00\x00\x84\x01\x00\x00\x00\x00y?\xbe\n\x00\x00\x00\xcaIDATx\xda\xedV[\n\xc30\x0c\x13\xbb\x80\xef\x7fK\xdd\xc0\x93\x94\xfd\xac\x1fcL\xfbl(\xc4\x04*\xacG\xdcb/\x8b\xb8O\xdeO\x00\xccP\x95\x8b\xe5\x03\xd7\xf5\xcd\x89pF\xcf\x8c \\48\x08\nS\x948\x03p\xfe\x80C\xa8\x9d\x16\xc7P\xabvJ}\xe2\xd7\x84[\xe5W\xfc7\xbbS\xfd\xde\xcfB\xf115\xa2\xe3%\x99\xad\x93\xa0:\xbf6\xbeS\xec\x1a^\xb4\xed\xfb\xb2\xab\xd1\x99\xc9\xcdAjx\x89\x0e\xc5\xea\xf4T\xf9\xee\xe40m58\xb6<\x1b\xab~\xf4\xban\xd7:\xceu\x9e\x05\xc4I\xa6\xbb\xfb%q<7:\xbf\xa2\x90wo\xf5 None: diff --git a/tests/components/unifi/test_image.py b/tests/components/unifi/test_image.py new file mode 100644 index 00000000000..564fd7598d8 --- /dev/null +++ b/tests/components/unifi/test_image.py @@ -0,0 +1,122 @@ +"""UniFi Network image platform tests.""" + +from copy import deepcopy +from datetime import timedelta +from http import HTTPStatus + +from aiounifi.models.message import MessageKey +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY +from homeassistant.const import ( + EntityCategory, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import RegistryEntryDisabler +from homeassistant.util import dt as dt_util + +from .test_controller import ( + setup_unifi_integration, +) + +from tests.common import async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +WLAN = { + "_id": "012345678910111213141516", + "bc_filter_enabled": False, + "bc_filter_list": [], + "dtim_mode": "default", + "dtim_na": 1, + "dtim_ng": 1, + "enabled": True, + "group_rekey": 3600, + "mac_filter_enabled": False, + "mac_filter_list": [], + "mac_filter_policy": "allow", + "minrate_na_advertising_rates": False, + "minrate_na_beacon_rate_kbps": 6000, + "minrate_na_data_rate_kbps": 6000, + "minrate_na_enabled": False, + "minrate_na_mgmt_rate_kbps": 6000, + "minrate_ng_advertising_rates": False, + "minrate_ng_beacon_rate_kbps": 1000, + "minrate_ng_data_rate_kbps": 1000, + "minrate_ng_enabled": False, + "minrate_ng_mgmt_rate_kbps": 1000, + "name": "SSID 1", + "no2ghz_oui": False, + "schedule": [], + "security": "wpapsk", + "site_id": "5a32aa4ee4b0412345678910", + "usergroup_id": "012345678910111213141518", + "wep_idx": 1, + "wlangroup_id": "012345678910111213141519", + "wpa_enc": "ccmp", + "wpa_mode": "wpa2", + "x_iapp_key": "01234567891011121314151617181920", + "x_passphrase": "password", +} + + +async def test_wlan_qr_code( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_unifi_websocket, +) -> None: + """Test the update_clients function when no clients are found.""" + await setup_unifi_integration(hass, aioclient_mock, wlans_response=[WLAN]) + assert len(hass.states.async_entity_ids(IMAGE_DOMAIN)) == 0 + + ent_reg = er.async_get(hass) + ent_reg_entry = ent_reg.async_get("image.ssid_1_qr_code") + assert ent_reg_entry.unique_id == "qr_code-012345678910111213141516" + assert ent_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + assert ent_reg_entry.entity_category is EntityCategory.DIAGNOSTIC + + # Enable entity + ent_reg.async_update_entity(entity_id="image.ssid_1_qr_code", disabled_by=None) + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + # Validate state object + image_state_1 = hass.states.get("image.ssid_1_qr_code") + assert image_state_1.name == "SSID 1 QR Code" + + # Validate image + client = await hass_client() + resp = await client.get("/api/image_proxy/image.ssid_1_qr_code") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == snapshot + + # Update state object - same password - no change to state + mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=WLAN) + await hass.async_block_till_done() + image_state_2 = hass.states.get("image.ssid_1_qr_code") + assert image_state_1.state == image_state_2.state + + # Update state object - changeed password - new state + data = deepcopy(WLAN) + data["x_passphrase"] = "new password" + mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=data) + await hass.async_block_till_done() + image_state_3 = hass.states.get("image.ssid_1_qr_code") + assert image_state_1.state != image_state_3.state + + # Validate image + client = await hass_client() + resp = await client.get("/api/image_proxy/image.ssid_1_qr_code") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == snapshot diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index bf7ba4d53c0..d619cd4c3c9 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -19,6 +19,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL from homeassistant.helpers.entity_registry import RegistryEntryDisabler import homeassistant.util.dt as dt_util @@ -95,6 +96,42 @@ DEVICE_1 = { "version": "4.0.42.10433", } +WLAN = { + "_id": "012345678910111213141516", + "bc_filter_enabled": False, + "bc_filter_list": [], + "dtim_mode": "default", + "dtim_na": 1, + "dtim_ng": 1, + "enabled": True, + "group_rekey": 3600, + "mac_filter_enabled": False, + "mac_filter_list": [], + "mac_filter_policy": "allow", + "minrate_na_advertising_rates": False, + "minrate_na_beacon_rate_kbps": 6000, + "minrate_na_data_rate_kbps": 6000, + "minrate_na_enabled": False, + "minrate_na_mgmt_rate_kbps": 6000, + "minrate_ng_advertising_rates": False, + "minrate_ng_beacon_rate_kbps": 1000, + "minrate_ng_data_rate_kbps": 1000, + "minrate_ng_enabled": False, + "minrate_ng_mgmt_rate_kbps": 1000, + "name": "SSID 1", + "no2ghz_oui": False, + "schedule": [], + "security": "wpapsk", + "site_id": "5a32aa4ee4b0412345678910", + "usergroup_id": "012345678910111213141518", + "wep_idx": 1, + "wlangroup_id": "012345678910111213141519", + "wpa_enc": "ccmp", + "wpa_mode": "wpa2", + "x_iapp_key": "01234567891011121314151617181920", + "x_passphrase": "password", +} + async def test_no_clients( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker @@ -424,3 +461,90 @@ async def test_poe_port_switches( mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() assert hass.states.get("sensor.mock_name_port_1_poe_power") + + +async def test_wlan_client_sensors( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket +) -> None: + """Verify that WLAN client sensors are working as expected.""" + wireless_client_1 = { + "essid": "SSID 1", + "is_wired": False, + "mac": "00:00:00:00:00:01", + "name": "Wireless client", + "oui": "Producer", + "rx_bytes-r": 2345000000, + "tx_bytes-r": 6789000000, + } + wireless_client_2 = { + "essid": "SSID 2", + "is_wired": False, + "mac": "00:00:00:00:00:02", + "name": "Wireless client2", + "oui": "Producer2", + "rx_bytes-r": 2345000000, + "tx_bytes-r": 6789000000, + } + + await setup_unifi_integration( + hass, + aioclient_mock, + clients_response=[wireless_client_1, wireless_client_2], + wlans_response=[WLAN], + ) + + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + + ent_reg = er.async_get(hass) + ent_reg_entry = ent_reg.async_get("sensor.ssid_1") + assert ent_reg_entry.unique_id == "wlan_clients-012345678910111213141516" + assert ent_reg_entry.entity_category is EntityCategory.DIAGNOSTIC + + # Validate state object + ssid_1 = hass.states.get("sensor.ssid_1") + assert ssid_1 is not None + assert ssid_1.state == "1" + + # Verify state update - increasing number + + wireless_client_1["essid"] = "SSID 1" + wireless_client_2["essid"] = "SSID 1" + + mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client_1) + mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client_2) + await hass.async_block_till_done() + + ssid_1 = hass.states.get("sensor.ssid_1") + assert ssid_1.state == "1" + + async_fire_time_changed(hass, datetime.utcnow() + DEFAULT_SCAN_INTERVAL) + await hass.async_block_till_done() + + ssid_1 = hass.states.get("sensor.ssid_1") + assert ssid_1.state == "2" + + # Verify state update - decreasing number + + wireless_client_1["essid"] = "SSID" + wireless_client_2["essid"] = "SSID" + + mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client_1) + mock_unifi_websocket(message=MessageKey.CLIENT, data=wireless_client_2) + + async_fire_time_changed(hass, datetime.utcnow() + DEFAULT_SCAN_INTERVAL) + await hass.async_block_till_done() + + ssid_1 = hass.states.get("sensor.ssid_1") + assert ssid_1.state == "0" + + # Availability signalling + + # Controller disconnects + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert hass.states.get("sensor.ssid_1").state == STATE_UNAVAILABLE + + # Controller reconnects + mock_unifi_websocket(state=WebsocketState.RUNNING) + await hass.async_block_till_done() + assert hass.states.get("sensor.ssid_1").state == "0" diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index f93abc291b8..ad5131614af 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -580,6 +580,43 @@ OUTLET_UP1 = { } +WLAN = { + "_id": "012345678910111213141516", + "bc_filter_enabled": False, + "bc_filter_list": [], + "dtim_mode": "default", + "dtim_na": 1, + "dtim_ng": 1, + "enabled": True, + "group_rekey": 3600, + "mac_filter_enabled": False, + "mac_filter_list": [], + "mac_filter_policy": "allow", + "minrate_na_advertising_rates": False, + "minrate_na_beacon_rate_kbps": 6000, + "minrate_na_data_rate_kbps": 6000, + "minrate_na_enabled": False, + "minrate_na_mgmt_rate_kbps": 6000, + "minrate_ng_advertising_rates": False, + "minrate_ng_beacon_rate_kbps": 1000, + "minrate_ng_data_rate_kbps": 1000, + "minrate_ng_enabled": False, + "minrate_ng_mgmt_rate_kbps": 1000, + "name": "SSID 1", + "no2ghz_oui": False, + "schedule": [], + "security": "wpapsk", + "site_id": "5a32aa4ee4b0412345678910", + "usergroup_id": "012345678910111213141518", + "wep_idx": 1, + "wlangroup_id": "012345678910111213141519", + "wpa_enc": "ccmp", + "wpa_mode": "wpa2", + "x_iapp_key": "01234567891011121314151617181920", + "x_passphrase": "password", +} + + async def test_no_clients( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: @@ -1230,3 +1267,71 @@ async def test_remove_poe_client_switches( for entry in ent_reg.entities.values() if entry.config_entry_id == config_entry.entry_id ] + + +async def test_wlan_switches( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket +) -> None: + """Test control of UniFi WLAN availability.""" + config_entry = await setup_unifi_integration( + hass, aioclient_mock, wlans_response=[WLAN] + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + ent_reg = er.async_get(hass) + ent_reg_entry = ent_reg.async_get("switch.ssid_1") + assert ent_reg_entry.unique_id == "wlan-012345678910111213141516" + assert ent_reg_entry.entity_category is EntityCategory.CONFIG + + # Validate state object + switch_1 = hass.states.get("switch.ssid_1") + assert switch_1 is not None + assert switch_1.state == STATE_ON + assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.SWITCH + + # Update state object + wlan = deepcopy(WLAN) + wlan["enabled"] = False + mock_unifi_websocket(message=MessageKey.WLAN_CONF_UPDATED, data=wlan) + await hass.async_block_till_done() + assert hass.states.get("switch.ssid_1").state == STATE_OFF + + # Disable WLAN + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{controller.host}:1234/api/s/{controller.site}" + + f"/rest/wlanconf/{WLAN['_id']}", + ) + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.ssid_1"}, + blocking=True, + ) + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == {"enabled": False} + + # Enable WLAN + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.ssid_1"}, + blocking=True, + ) + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[1][2] == {"enabled": True} + + # Availability signalling + + # Controller disconnects + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert hass.states.get("switch.ssid_1").state == STATE_UNAVAILABLE + + # Controller reconnects + mock_unifi_websocket(state=WebsocketState.RUNNING) + await hass.async_block_till_done() + assert hass.states.get("switch.ssid_1").state == STATE_OFF diff --git a/tests/components/unifiprotect/test_repairs.py b/tests/components/unifiprotect/test_repairs.py index b9fa9bc57b8..f68ebd9c8c6 100644 --- a/tests/components/unifiprotect/test_repairs.py +++ b/tests/components/unifiprotect/test_repairs.py @@ -5,7 +5,7 @@ from copy import copy from http import HTTPStatus from unittest.mock import Mock -from pyunifiprotect.data import Camera, Version +from pyunifiprotect.data import Version from homeassistant.components.repairs.issue_handler import ( async_process_repairs_platforms, @@ -15,9 +15,7 @@ from homeassistant.components.repairs.websocket_api import ( RepairsFlowResourceView, ) from homeassistant.components.unifiprotect.const import DOMAIN -from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from .utils import MockUFPFixture, init_entry @@ -127,59 +125,3 @@ async def test_ea_warning_fix( data = await resp.json() assert data["type"] == "create_entry" - - -async def test_deprecate_smart_default( - hass: HomeAssistant, - ufp: MockUFPFixture, - hass_ws_client: WebSocketGenerator, - doorbell: Camera, -) -> None: - """Test Deprecate Sensor repair does not exist by default (new installs).""" - - await init_entry(hass, ufp, [doorbell]) - - await async_process_repairs_platforms(hass) - ws_client = await hass_ws_client(hass) - - await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - - assert msg["success"] - issue = None - for i in msg["result"]["issues"]: - if i["issue_id"] == "deprecate_smart_sensor": - issue = i - assert issue is None - - -async def test_deprecate_smart_no_automations( - hass: HomeAssistant, - ufp: MockUFPFixture, - hass_ws_client: WebSocketGenerator, - doorbell: Camera, -) -> None: - """Test Deprecate Sensor repair exists for existing installs.""" - - registry = er.async_get(hass) - registry.async_get_or_create( - Platform.SENSOR, - DOMAIN, - f"{doorbell.mac}_detected_object", - config_entry=ufp.entry, - ) - - await init_entry(hass, ufp, [doorbell]) - - await async_process_repairs_platforms(hass) - ws_client = await hass_ws_client(hass) - - await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - - assert msg["success"] - issue = None - for i in msg["result"]["issues"]: - if i["issue_id"] == "deprecate_smart_sensor": - issue = i - assert issue is None diff --git a/tests/components/upnp/test_sensor.py b/tests/components/upnp/test_sensor.py index f29d7ac9276..7dfbb144b01 100644 --- a/tests/components/upnp/test_sensor.py +++ b/tests/components/upnp/test_sensor.py @@ -16,16 +16,16 @@ async def test_upnp_sensors( ) -> None: """Test sensors.""" # First poll. - assert hass.states.get("sensor.mock_name_b_received").state == "0" - assert hass.states.get("sensor.mock_name_b_sent").state == "0" + assert hass.states.get("sensor.mock_name_data_received").state == "0" + assert hass.states.get("sensor.mock_name_data_sent").state == "0" assert hass.states.get("sensor.mock_name_packets_received").state == "0" assert hass.states.get("sensor.mock_name_packets_sent").state == "0" assert hass.states.get("sensor.mock_name_external_ip").state == "8.9.10.11" assert hass.states.get("sensor.mock_name_wan_status").state == "Connected" - assert hass.states.get("sensor.mock_name_kib_s_received").state == "unknown" - assert hass.states.get("sensor.mock_name_kib_s_sent").state == "unknown" - assert hass.states.get("sensor.mock_name_packets_s_received").state == "unknown" - assert hass.states.get("sensor.mock_name_packets_s_sent").state == "unknown" + assert hass.states.get("sensor.mock_name_download_speed").state == "unknown" + assert hass.states.get("sensor.mock_name_upload_speed").state == "unknown" + assert hass.states.get("sensor.mock_name_packet_download_speed").state == "unknown" + assert hass.states.get("sensor.mock_name_packet_upload_speed").state == "unknown" # Second poll. mock_igd_device: IgdDevice = mock_config_entry.igd_device @@ -51,13 +51,13 @@ async def test_upnp_sensors( async_fire_time_changed(hass, now + timedelta(seconds=DEFAULT_SCAN_INTERVAL)) await hass.async_block_till_done() - assert hass.states.get("sensor.mock_name_b_received").state == "10240" - assert hass.states.get("sensor.mock_name_b_sent").state == "20480" + assert hass.states.get("sensor.mock_name_data_received").state == "10240" + assert hass.states.get("sensor.mock_name_data_sent").state == "20480" assert hass.states.get("sensor.mock_name_packets_received").state == "30" assert hass.states.get("sensor.mock_name_packets_sent").state == "40" assert hass.states.get("sensor.mock_name_external_ip").state == "" assert hass.states.get("sensor.mock_name_wan_status").state == "Disconnected" - assert hass.states.get("sensor.mock_name_kib_s_received").state == "10.0" - assert hass.states.get("sensor.mock_name_kib_s_sent").state == "20.0" - assert hass.states.get("sensor.mock_name_packets_s_received").state == "30.0" - assert hass.states.get("sensor.mock_name_packets_s_sent").state == "40.0" + assert hass.states.get("sensor.mock_name_download_speed").state == "10.0" + assert hass.states.get("sensor.mock_name_upload_speed").state == "20.0" + assert hass.states.get("sensor.mock_name_packet_download_speed").state == "30.0" + assert hass.states.get("sensor.mock_name_packet_upload_speed").state == "40.0" diff --git a/tests/components/uptimerobot/common.py b/tests/components/uptimerobot/common.py index 6a82d75a9f8..15f6e153b19 100644 --- a/tests/components/uptimerobot/common.py +++ b/tests/components/uptimerobot/common.py @@ -66,7 +66,7 @@ STATE_UP = "up" UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY = "binary_sensor.test_monitor" UPTIMEROBOT_SENSOR_TEST_ENTITY = "sensor.test_monitor" -UPTIMEROBOT_SWITCH_TEST_ENTITY = "switch.test_monitor_active" +UPTIMEROBOT_SWITCH_TEST_ENTITY = "switch.test_monitor" class MockApiResponseKey(str, Enum): diff --git a/tests/components/usgs_earthquakes_feed/test_geo_location.py b/tests/components/usgs_earthquakes_feed/test_geo_location.py index 1288c0ae177..6307125930c 100644 --- a/tests/components/usgs_earthquakes_feed/test_geo_location.py +++ b/tests/components/usgs_earthquakes_feed/test_geo_location.py @@ -102,8 +102,8 @@ async def test_setup(hass: HomeAssistant) -> None: (-31.0, 150.0), place="Location 1", attribution="Attribution 1", - time=datetime.datetime(2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc), - updated=datetime.datetime(2018, 9, 22, 9, 0, tzinfo=datetime.timezone.utc), + time=datetime.datetime(2018, 9, 22, 8, 0, tzinfo=datetime.UTC), + updated=datetime.datetime(2018, 9, 22, 9, 0, tzinfo=datetime.UTC), magnitude=5.7, status="Status 1", entry_type="Type 1", @@ -143,12 +143,8 @@ async def test_setup(hass: HomeAssistant) -> None: ATTR_FRIENDLY_NAME: "Title 1", ATTR_PLACE: "Location 1", ATTR_ATTRIBUTION: "Attribution 1", - ATTR_TIME: datetime.datetime( - 2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc - ), - ATTR_UPDATED: datetime.datetime( - 2018, 9, 22, 9, 0, tzinfo=datetime.timezone.utc - ), + ATTR_TIME: datetime.datetime(2018, 9, 22, 8, 0, tzinfo=datetime.UTC), + ATTR_UPDATED: datetime.datetime(2018, 9, 22, 9, 0, tzinfo=datetime.UTC), ATTR_STATUS: "Status 1", ATTR_TYPE: "Type 1", ATTR_ALERT: "Alert 1", diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 5cb9e594cb2..3d2d95fd26f 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -489,7 +489,7 @@ async def test_device_class( state = hass.states.get("sensor.energy_meter") assert state is not None assert state.state == "0" - assert state.attributes.get(ATTR_DEVICE_CLASS) is SensorDeviceClass.ENERGY.value + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py new file mode 100644 index 00000000000..eaa39bceaec --- /dev/null +++ b/tests/components/vacuum/test_init.py @@ -0,0 +1,93 @@ +"""The tests for the Vacuum entity integration.""" +from __future__ import annotations + +from collections.abc import Generator + +import pytest + +from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN, VacuumEntity +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +async def test_deprecated_base_class( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test warnings when adding VacuumEntity to the state machine.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, VACUUM_DOMAIN) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + entity1 = VacuumEntity() + entity1.entity_id = "vacuum.test1" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test stt platform via config entry.""" + async_add_entities([entity1]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{VACUUM_DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity1.entity_id) + + assert ( + "test::VacuumEntity is extending the deprecated base class VacuumEntity" + in caplog.text + ) + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + VACUUM_DOMAIN, f"deprecated_vacuum_base_class_{TEST_DOMAIN}" + ) + assert issue.issue_domain == TEST_DOMAIN diff --git a/tests/components/velbus/test_init.py b/tests/components/velbus/test_init.py index ce0a08f18ff..0a1a727abcf 100644 --- a/tests/components/velbus/test_init.py +++ b/tests/components/velbus/test_init.py @@ -45,17 +45,17 @@ async def test_device_identifier_migration( sw_version="module_sw_version", ) assert device_registry.async_get_device( - original_identifiers # type: ignore[arg-type] + identifiers=original_identifiers # type: ignore[arg-type] ) - assert not device_registry.async_get_device(target_identifiers) + assert not device_registry.async_get_device(identifiers=target_identifiers) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert not device_registry.async_get_device( - original_identifiers # type: ignore[arg-type] + identifiers=original_identifiers # type: ignore[arg-type] ) - device_entry = device_registry.async_get_device(target_identifiers) + device_entry = device_registry.async_get_device(identifiers=target_identifiers) assert device_entry assert device_entry.name == "channel_name" assert device_entry.manufacturer == "Velleman" diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 2c12f9bc5f6..c463db179eb 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -192,7 +192,7 @@ 'name': None, 'original_device_class': None, 'original_icon': None, - 'original_name': 'Fan', + 'original_name': None, 'state': dict({ 'attributes': dict({ 'friendly_name': 'Fan', @@ -220,10 +220,10 @@ 'name': None, 'original_device_class': None, 'original_icon': None, - 'original_name': 'Fan Air Quality', + 'original_name': 'Air quality', 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Fan Air Quality', + 'friendly_name': 'Fan Air quality', }), 'entity_id': 'sensor.fan_air_quality', 'last_changed': str, @@ -238,19 +238,19 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': 'diagnostic', - 'entity_id': 'sensor.fan_filter_life', + 'entity_id': 'sensor.fan_filter_lifetime', 'icon': None, 'name': None, 'original_device_class': None, 'original_icon': None, - 'original_name': 'Fan Filter Life', + 'original_name': 'Filter lifetime', 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Fan Filter Life', + 'friendly_name': 'Fan Filter lifetime', 'state_class': 'measurement', 'unit_of_measurement': '%', }), - 'entity_id': 'sensor.fan_filter_life', + 'entity_id': 'sensor.fan_filter_lifetime', 'last_changed': str, 'last_updated': str, 'state': 'unavailable', diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 82a31b5fc14..428f066e6cc 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -47,7 +47,7 @@ 'domain': 'fan', 'entity_category': None, 'entity_id': 'fan.air_purifier_131s', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -56,7 +56,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Air Purifier 131s', + 'original_name': None, 'platform': 'vesync', 'supported_features': , 'translation_key': None, @@ -129,7 +129,7 @@ 'domain': 'fan', 'entity_category': None, 'entity_id': 'fan.air_purifier_200s', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -138,7 +138,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Air Purifier 200s', + 'original_name': None, 'platform': 'vesync', 'supported_features': , 'translation_key': None, @@ -218,7 +218,7 @@ 'domain': 'fan', 'entity_category': None, 'entity_id': 'fan.air_purifier_400s', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -227,7 +227,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Air Purifier 400s', + 'original_name': None, 'platform': 'vesync', 'supported_features': , 'translation_key': None, @@ -308,7 +308,7 @@ 'domain': 'fan', 'entity_category': None, 'entity_id': 'fan.air_purifier_600s', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -317,7 +317,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Air Purifier 600s', + 'original_name': None, 'platform': 'vesync', 'supported_features': , 'translation_key': None, diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 1f7b0aa9baf..67940603d41 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -178,7 +178,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.dimmable_light', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -187,7 +187,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Dimmable Light', + 'original_name': None, 'platform': 'vesync', 'supported_features': 0, 'translation_key': None, @@ -259,7 +259,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.dimmer_switch', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -268,7 +268,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Dimmer Switch', + 'original_name': None, 'platform': 'vesync', 'supported_features': 0, 'translation_key': None, @@ -395,7 +395,7 @@ 'domain': 'light', 'entity_category': None, 'entity_id': 'light.temperature_light', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -404,7 +404,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Temperature Light', + 'original_name': None, 'platform': 'vesync', 'supported_features': 0, 'translation_key': None, diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 040e41747a2..06198bca145 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -43,8 +43,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.air_purifier_131s_filter_life', - 'has_entity_name': False, + 'entity_id': 'sensor.air_purifier_131s_filter_lifetime', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -53,10 +53,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Air Purifier 131s Filter Life', + 'original_name': 'Filter lifetime', 'platform': 'vesync', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'filter_life', 'unique_id': 'air-purifier-filter-life', 'unit_of_measurement': '%', }), @@ -72,7 +72,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.air_purifier_131s_air_quality', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -81,10 +81,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Air Purifier 131s Air Quality', + 'original_name': 'Air quality', 'platform': 'vesync', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'air_quality', 'unique_id': 'air-purifier-air-quality', 'unit_of_measurement': None, }), @@ -93,7 +93,7 @@ # name: test_sensor_state[Air Purifier 131s][sensor.air_purifier_131s_air_quality] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Air Purifier 131s Air Quality', + 'friendly_name': 'Air Purifier 131s Air quality', }), 'context': , 'entity_id': 'sensor.air_purifier_131s_air_quality', @@ -102,15 +102,15 @@ 'state': 'unavailable', }) # --- -# name: test_sensor_state[Air Purifier 131s][sensor.air_purifier_131s_filter_life] +# name: test_sensor_state[Air Purifier 131s][sensor.air_purifier_131s_filter_lifetime] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Air Purifier 131s Filter Life', + 'friendly_name': 'Air Purifier 131s Filter lifetime', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.air_purifier_131s_filter_life', + 'entity_id': 'sensor.air_purifier_131s_filter_lifetime', 'last_changed': , 'last_updated': , 'state': 'unavailable', @@ -160,8 +160,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.air_purifier_200s_filter_life', - 'has_entity_name': False, + 'entity_id': 'sensor.air_purifier_200s_filter_lifetime', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -170,24 +170,24 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Air Purifier 200s Filter Life', + 'original_name': 'Filter lifetime', 'platform': 'vesync', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'filter_life', 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55-filter-life', 'unit_of_measurement': '%', }), ]) # --- -# name: test_sensor_state[Air Purifier 200s][sensor.air_purifier_200s_filter_life] +# name: test_sensor_state[Air Purifier 200s][sensor.air_purifier_200s_filter_lifetime] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Air Purifier 200s Filter Life', + 'friendly_name': 'Air Purifier 200s Filter lifetime', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.air_purifier_200s_filter_life', + 'entity_id': 'sensor.air_purifier_200s_filter_lifetime', 'last_changed': , 'last_updated': , 'state': '99', @@ -237,8 +237,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.air_purifier_400s_filter_life', - 'has_entity_name': False, + 'entity_id': 'sensor.air_purifier_400s_filter_lifetime', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -247,10 +247,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Air Purifier 400s Filter Life', + 'original_name': 'Filter lifetime', 'platform': 'vesync', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'filter_life', 'unique_id': '400s-purifier-filter-life', 'unit_of_measurement': '%', }), @@ -266,7 +266,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.air_purifier_400s_air_quality', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -275,10 +275,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Air Purifier 400s Air Quality', + 'original_name': 'Air quality', 'platform': 'vesync', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'air_quality', 'unique_id': '400s-purifier-air-quality', 'unit_of_measurement': None, }), @@ -296,7 +296,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.air_purifier_400s_pm2_5', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -305,7 +305,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Air Purifier 400s PM2.5', + 'original_name': 'PM2.5', 'platform': 'vesync', 'supported_features': 0, 'translation_key': None, @@ -317,7 +317,7 @@ # name: test_sensor_state[Air Purifier 400s][sensor.air_purifier_400s_air_quality] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Air Purifier 400s Air Quality', + 'friendly_name': 'Air Purifier 400s Air quality', }), 'context': , 'entity_id': 'sensor.air_purifier_400s_air_quality', @@ -326,15 +326,15 @@ 'state': '5', }) # --- -# name: test_sensor_state[Air Purifier 400s][sensor.air_purifier_400s_filter_life] +# name: test_sensor_state[Air Purifier 400s][sensor.air_purifier_400s_filter_lifetime] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Air Purifier 400s Filter Life', + 'friendly_name': 'Air Purifier 400s Filter lifetime', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.air_purifier_400s_filter_life', + 'entity_id': 'sensor.air_purifier_400s_filter_lifetime', 'last_changed': , 'last_updated': , 'state': '99', @@ -399,8 +399,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.air_purifier_600s_filter_life', - 'has_entity_name': False, + 'entity_id': 'sensor.air_purifier_600s_filter_lifetime', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -409,10 +409,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Air Purifier 600s Filter Life', + 'original_name': 'Filter lifetime', 'platform': 'vesync', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'filter_life', 'unique_id': '600s-purifier-filter-life', 'unit_of_measurement': '%', }), @@ -428,7 +428,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.air_purifier_600s_air_quality', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -437,10 +437,10 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Air Purifier 600s Air Quality', + 'original_name': 'Air quality', 'platform': 'vesync', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'air_quality', 'unique_id': '600s-purifier-air-quality', 'unit_of_measurement': None, }), @@ -458,7 +458,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.air_purifier_600s_pm2_5', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -467,7 +467,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Air Purifier 600s PM2.5', + 'original_name': 'PM2.5', 'platform': 'vesync', 'supported_features': 0, 'translation_key': None, @@ -479,7 +479,7 @@ # name: test_sensor_state[Air Purifier 600s][sensor.air_purifier_600s_air_quality] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Air Purifier 600s Air Quality', + 'friendly_name': 'Air Purifier 600s Air quality', }), 'context': , 'entity_id': 'sensor.air_purifier_600s_air_quality', @@ -488,15 +488,15 @@ 'state': '5', }) # --- -# name: test_sensor_state[Air Purifier 600s][sensor.air_purifier_600s_filter_life] +# name: test_sensor_state[Air Purifier 600s][sensor.air_purifier_600s_filter_lifetime] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Air Purifier 600s Filter Life', + 'friendly_name': 'Air Purifier 600s Filter lifetime', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.air_purifier_600s_filter_life', + 'entity_id': 'sensor.air_purifier_600s_filter_lifetime', 'last_changed': , 'last_updated': , 'state': '99', @@ -644,7 +644,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.outlet_current_power', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -653,10 +653,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Outlet current power', + 'original_name': 'Current power', 'platform': 'vesync', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'current_power', 'unique_id': 'outlet-power', 'unit_of_measurement': , }), @@ -674,7 +674,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.outlet_energy_use_today', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -683,10 +683,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Outlet energy use today', + 'original_name': 'Energy use today', 'platform': 'vesync', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'energy_today', 'unique_id': 'outlet-energy', 'unit_of_measurement': , }), @@ -704,7 +704,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.outlet_energy_use_weekly', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -713,10 +713,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Outlet energy use weekly', + 'original_name': 'Energy use weekly', 'platform': 'vesync', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'energy_week', 'unique_id': 'outlet-energy-weekly', 'unit_of_measurement': , }), @@ -734,7 +734,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.outlet_energy_use_monthly', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -743,10 +743,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Outlet energy use monthly', + 'original_name': 'Energy use monthly', 'platform': 'vesync', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'energy_month', 'unique_id': 'outlet-energy-monthly', 'unit_of_measurement': , }), @@ -764,7 +764,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.outlet_energy_use_yearly', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -773,10 +773,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Outlet energy use yearly', + 'original_name': 'Energy use yearly', 'platform': 'vesync', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'energy_year', 'unique_id': 'outlet-energy-yearly', 'unit_of_measurement': , }), @@ -794,7 +794,7 @@ 'domain': 'sensor', 'entity_category': None, 'entity_id': 'sensor.outlet_current_voltage', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -803,10 +803,10 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Outlet current voltage', + 'original_name': 'Current voltage', 'platform': 'vesync', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'current_voltage', 'unique_id': 'outlet-voltage', 'unit_of_measurement': , }), @@ -816,7 +816,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'power', - 'friendly_name': 'Outlet current power', + 'friendly_name': 'Outlet Current power', 'state_class': , 'unit_of_measurement': , }), @@ -831,7 +831,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', - 'friendly_name': 'Outlet current voltage', + 'friendly_name': 'Outlet Current voltage', 'state_class': , 'unit_of_measurement': , }), @@ -846,7 +846,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Outlet energy use monthly', + 'friendly_name': 'Outlet Energy use monthly', 'state_class': , 'unit_of_measurement': , }), @@ -861,7 +861,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Outlet energy use today', + 'friendly_name': 'Outlet Energy use today', 'state_class': , 'unit_of_measurement': , }), @@ -876,7 +876,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Outlet energy use weekly', + 'friendly_name': 'Outlet Energy use weekly', 'state_class': , 'unit_of_measurement': , }), @@ -891,7 +891,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Outlet energy use yearly', + 'friendly_name': 'Outlet Energy use yearly', 'state_class': , 'unit_of_measurement': , }), diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 77f4011a532..cfe9d66a2ed 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -256,7 +256,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.outlet', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -265,7 +265,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Outlet', + 'original_name': None, 'platform': 'vesync', 'supported_features': 0, 'translation_key': None, @@ -362,7 +362,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.wall_switch', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -371,7 +371,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Wall Switch', + 'original_name': None, 'platform': 'vesync', 'supported_features': 0, 'translation_key': None, diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 0f77c9cbf35..c643e2bda19 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -33,15 +33,12 @@ async def test_async_setup_entry__not_login( hass.config_entries, "async_forward_entry_setup" ) as setup_mock, patch( "homeassistant.components.vesync.async_process_devices" - ) as process_mock, patch.object( - hass.services, "async_register" - ) as register_mock: + ) as process_mock: assert not await async_setup_entry(hass, config_entry) await hass.async_block_till_done() assert setups_mock.call_count == 0 assert setup_mock.call_count == 0 assert process_mock.call_count == 0 - assert register_mock.call_count == 0 assert manager.login.call_count == 1 assert DOMAIN not in hass.data diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 86733d83f15..660de3ff6b6 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -456,6 +456,7 @@ async def test_options_update( options=new_options, ) assert config_entry.options == updated_options + await hass.async_block_till_done() await _test_service( hass, MP_DOMAIN, "vol_up", SERVICE_VOLUME_UP, None, num=VOLUME_STEP ) diff --git a/tests/components/voip/test_devices.py b/tests/components/voip/test_devices.py index c421a08ccf8..189dff49839 100644 --- a/tests/components/voip/test_devices.py +++ b/tests/components/voip/test_devices.py @@ -19,7 +19,9 @@ async def test_device_registry_info( voip_device = voip_devices.async_get_or_create(call_info) assert not voip_device.async_allow_call(hass) - device = device_registry.async_get_device({(DOMAIN, call_info.caller_ip)}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, call_info.caller_ip)} + ) assert device is not None assert device.name == call_info.caller_ip assert device.manufacturer == "Grandstream" @@ -32,7 +34,9 @@ async def test_device_registry_info( assert not voip_device.async_allow_call(hass) - device = device_registry.async_get_device({(DOMAIN, call_info.caller_ip)}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, call_info.caller_ip)} + ) assert device.sw_version == "2.0.0.0" @@ -47,7 +51,9 @@ async def test_device_registry_info_from_unknown_phone( voip_device = voip_devices.async_get_or_create(call_info) assert not voip_device.async_allow_call(hass) - device = device_registry.async_get_device({(DOMAIN, call_info.caller_ip)}) + device = device_registry.async_get_device( + identifiers={(DOMAIN, call_info.caller_ip)} + ) assert device.manufacturer is None assert device.model == "Unknown" assert device.sw_version is None diff --git a/tests/components/wake_on_lan/conftest.py b/tests/components/wake_on_lan/conftest.py index 582698e39d5..5fa44f10c2c 100644 --- a/tests/components/wake_on_lan/conftest.py +++ b/tests/components/wake_on_lan/conftest.py @@ -1,7 +1,8 @@ """Test fixtures for Wake on Lan.""" from __future__ import annotations -from unittest.mock import AsyncMock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -11,3 +12,19 @@ def mock_send_magic_packet() -> AsyncMock: """Mock magic packet.""" with patch("wakeonlan.send_magic_packet") as mock_send: yield mock_send + + +@pytest.fixture +def subprocess_call_return_value() -> int | None: + """Return value for subprocess.""" + return 1 + + +@pytest.fixture(autouse=True) +def mock_subprocess_call( + subprocess_call_return_value: int, +) -> Generator[None, None, MagicMock]: + """Mock magic packet.""" + with patch("homeassistant.components.wake_on_lan.switch.sp.call") as mock_sp: + mock_sp.return_value = subprocess_call_return_value + yield mock_sp diff --git a/tests/components/wake_on_lan/test_switch.py b/tests/components/wake_on_lan/test_switch.py index 8a7fe185662..b2702ed1815 100644 --- a/tests/components/wake_on_lan/test_switch.py +++ b/tests/components/wake_on_lan/test_switch.py @@ -1,7 +1,6 @@ """The tests for the wake on lan switch platform.""" from __future__ import annotations -import subprocess from unittest.mock import AsyncMock, patch from homeassistant.components import switch @@ -38,7 +37,7 @@ async def test_valid_hostname( state = hass.states.get("switch.wake_on_lan") assert state.state == STATE_OFF - with patch.object(subprocess, "call", return_value=0): + with patch("homeassistant.components.wake_on_lan.switch.sp.call", return_value=0): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, @@ -85,17 +84,16 @@ async def test_broadcast_config_ip_and_port( state = hass.states.get("switch.wake_on_lan") assert state.state == STATE_OFF - with patch.object(subprocess, "call", return_value=0): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wake_on_lan"}, - blocking=True, - ) + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.wake_on_lan"}, + blocking=True, + ) - mock_send_magic_packet.assert_called_with( - mac, ip_address=broadcast_address, port=port - ) + mock_send_magic_packet.assert_called_with( + mac, ip_address=broadcast_address, port=port + ) async def test_broadcast_config_ip( @@ -122,15 +120,14 @@ async def test_broadcast_config_ip( state = hass.states.get("switch.wake_on_lan") assert state.state == STATE_OFF - with patch.object(subprocess, "call", return_value=0): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wake_on_lan"}, - blocking=True, - ) + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.wake_on_lan"}, + blocking=True, + ) - mock_send_magic_packet.assert_called_with(mac, ip_address=broadcast_address) + mock_send_magic_packet.assert_called_with(mac, ip_address=broadcast_address) async def test_broadcast_config_port( @@ -151,15 +148,14 @@ async def test_broadcast_config_port( state = hass.states.get("switch.wake_on_lan") assert state.state == STATE_OFF - with patch.object(subprocess, "call", return_value=0): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wake_on_lan"}, - blocking=True, - ) + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.wake_on_lan"}, + blocking=True, + ) - mock_send_magic_packet.assert_called_with(mac, port=port) + mock_send_magic_packet.assert_called_with(mac, port=port) async def test_off_script( @@ -185,7 +181,7 @@ async def test_off_script( state = hass.states.get("switch.wake_on_lan") assert state.state == STATE_OFF - with patch.object(subprocess, "call", return_value=0): + with patch("homeassistant.components.wake_on_lan.switch.sp.call", return_value=0): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_ON, @@ -197,7 +193,7 @@ async def test_off_script( assert state.state == STATE_ON assert len(calls) == 0 - with patch.object(subprocess, "call", return_value=2): + with patch("homeassistant.components.wake_on_lan.switch.sp.call", return_value=1): await hass.services.async_call( switch.DOMAIN, SERVICE_TURN_OFF, @@ -230,23 +226,22 @@ async def test_no_hostname_state( state = hass.states.get("switch.wake_on_lan") assert state.state == STATE_OFF - with patch.object(subprocess, "call", return_value=0): - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.wake_on_lan"}, - blocking=True, - ) + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.wake_on_lan"}, + blocking=True, + ) - state = hass.states.get("switch.wake_on_lan") - assert state.state == STATE_ON + state = hass.states.get("switch.wake_on_lan") + assert state.state == STATE_ON - await hass.services.async_call( - switch.DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.wake_on_lan"}, - blocking=True, - ) + await hass.services.async_call( + switch.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.wake_on_lan"}, + blocking=True, + ) - state = hass.states.get("switch.wake_on_lan") - assert state.state == STATE_OFF + state = hass.states.get("switch.wake_on_lan") + assert state.state == STATE_OFF diff --git a/tests/components/water_heater/common.py b/tests/components/water_heater/common.py index ece283f4bab..0d2d73d17fd 100644 --- a/tests/components/water_heater/common.py +++ b/tests/components/water_heater/common.py @@ -11,8 +11,11 @@ from homeassistant.components.water_heater import ( SERVICE_SET_AWAY_MODE, SERVICE_SET_OPERATION_MODE, SERVICE_SET_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, ENTITY_MATCH_ALL +from homeassistant.core import HomeAssistant async def async_set_away_mode(hass, away_mode, entity_id=ENTITY_MATCH_ALL): @@ -54,3 +57,25 @@ async def async_set_operation_mode(hass, operation_mode, entity_id=ENTITY_MATCH_ await hass.services.async_call( DOMAIN, SERVICE_SET_OPERATION_MODE, data, blocking=True ) + + +async def async_turn_on(hass: HomeAssistant, entity_id: str = ENTITY_MATCH_ALL) -> None: + """Turn all or specified water_heater devices on.""" + data = {} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data, blocking=True) + + +async def async_turn_off( + hass: HomeAssistant, entity_id: str = ENTITY_MATCH_ALL +) -> None: + """Turn all or specified water_heater devices off.""" + data = {} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data, blocking=True) diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py new file mode 100644 index 00000000000..66276f0bc88 --- /dev/null +++ b/tests/components/water_heater/test_init.py @@ -0,0 +1,102 @@ +"""The tests for the water heater component.""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +import voluptuous as vol + +from homeassistant.components.water_heater import ( + SET_TEMPERATURE_SCHEMA, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.core import HomeAssistant + +from tests.common import async_mock_service + + +async def test_set_temp_schema_no_req( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the set temperature schema with missing required data.""" + domain = "climate" + service = "test_set_temperature" + schema = SET_TEMPERATURE_SCHEMA + calls = async_mock_service(hass, domain, service, schema) + + data = {"hvac_mode": "off", "entity_id": ["climate.test_id"]} + with pytest.raises(vol.Invalid): + await hass.services.async_call(domain, service, data) + await hass.async_block_till_done() + + assert len(calls) == 0 + + +async def test_set_temp_schema( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the set temperature schema with ok required data.""" + domain = "water_heater" + service = "test_set_temperature" + schema = SET_TEMPERATURE_SCHEMA + calls = async_mock_service(hass, domain, service, schema) + + data = { + "temperature": 20.0, + "operation_mode": "gas", + "entity_id": ["water_heater.test_id"], + } + await hass.services.async_call(domain, service, data) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[-1].data == data + + +class MockWaterHeaterEntity(WaterHeaterEntity): + """Mock water heater device to use in tests.""" + + _attr_operation_list: list[str] = ["off", "heat_pump", "gas"] + _attr_operation = "heat_pump" + _attr_supported_features = WaterHeaterEntityFeature.ON_OFF + + +async def test_sync_turn_on(hass: HomeAssistant) -> None: + """Test if async turn_on calls sync turn_on.""" + water_heater = MockWaterHeaterEntity() + water_heater.hass = hass + + # Test with turn_on method defined + setattr(water_heater, "turn_on", MagicMock()) + await water_heater.async_turn_on() + + # pylint: disable-next=no-member + assert water_heater.turn_on.call_count == 1 + + # Test with async_turn_on method defined + setattr(water_heater, "async_turn_on", AsyncMock()) + await water_heater.async_turn_on() + + # pylint: disable-next=no-member + assert water_heater.async_turn_on.call_count == 1 + + +async def test_sync_turn_off(hass: HomeAssistant) -> None: + """Test if async turn_off calls sync turn_off.""" + water_heater = MockWaterHeaterEntity() + water_heater.hass = hass + + # Test with turn_off method defined + setattr(water_heater, "turn_off", MagicMock()) + await water_heater.async_turn_off() + + # pylint: disable-next=no-member + assert water_heater.turn_off.call_count == 1 + + # Test with async_turn_off method defined + setattr(water_heater, "async_turn_off", AsyncMock()) + await water_heater.async_turn_off() + + # pylint: disable-next=no-member + assert water_heater.async_turn_off.call_count == 1 diff --git a/tests/components/weather/__init__.py b/tests/components/weather/__init__.py index 24df7abb1f3..91097dfae14 100644 --- a/tests/components/weather/__init__.py +++ b/tests/components/weather/__init__.py @@ -1 +1,32 @@ """The tests for Weather platforms.""" + + +from homeassistant.components.weather import ATTR_CONDITION_SUNNY +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.testing_config.custom_components.test import weather as WeatherPlatform + + +async def create_entity(hass: HomeAssistant, **kwargs): + """Create the weather entity to run tests on.""" + kwargs = { + "native_temperature": None, + "native_temperature_unit": None, + "is_daytime": True, + **kwargs, + } + platform: WeatherPlatform = getattr(hass.components, "test.weather") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockForecast( + name="Test", condition=ATTR_CONDITION_SUNNY, **kwargs + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test"}} + ) + await hass.async_block_till_done() + return entity0 diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 5ed6a02f24b..92643b616c9 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -13,6 +13,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_UV_INDEX, ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_GUST_SPEED, ATTR_FORECAST_WIND_SPEED, @@ -23,6 +24,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_PRESSURE_UNIT, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_UV_INDEX, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_VISIBILITY_UNIT, ATTR_WEATHER_WIND_BEARING, @@ -32,6 +34,7 @@ from homeassistant.components.weather import ( ROUNDING_PRECISION, Forecast, WeatherEntity, + WeatherEntityFeature, round_temperature, ) from homeassistant.components.weather.const import ( @@ -52,6 +55,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( DistanceConverter, PressureConverter, @@ -60,7 +64,10 @@ from homeassistant.util.unit_conversion import ( ) from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM +from . import create_entity + from tests.testing_config.custom_components.test import weather as WeatherPlatform +from tests.typing import WebSocketGenerator class MockWeatherEntity(WeatherEntity): @@ -84,12 +91,19 @@ class MockWeatherEntity(WeatherEntity): self._attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND self._attr_forecast = [ Forecast( - datetime=datetime(2022, 6, 20, 20, 00, 00), + datetime=datetime(2022, 6, 20, 00, 00, 00, tzinfo=dt_util.UTC), native_precipitation=1, native_temperature=20, native_dew_point=2, ) ] + self._attr_forecast_twice_daily = [ + Forecast( + datetime=datetime(2022, 6, 20, 8, 00, 00, tzinfo=dt_util.UTC), + native_precipitation=10, + native_temperature=25, + ) + ] class MockWeatherEntityPrecision(WeatherEntity): @@ -124,32 +138,13 @@ class MockWeatherEntityCompat(WeatherEntity): self._attr_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND self._attr_forecast = [ Forecast( - datetime=datetime(2022, 6, 20, 20, 00, 00), + datetime=datetime(2022, 6, 20, 0, 00, 00, tzinfo=dt_util.UTC), precipitation=1, temperature=20, ) ] -async def create_entity(hass: HomeAssistant, **kwargs): - """Create the weather entity to run tests on.""" - kwargs = {"native_temperature": None, "native_temperature_unit": None, **kwargs} - platform: WeatherPlatform = getattr(hass.components, "test.weather") - platform.init(empty=True) - platform.ENTITIES.append( - platform.MockWeatherMockForecast( - name="Test", condition=ATTR_CONDITION_SUNNY, **kwargs - ) - ) - - entity0 = platform.ENTITIES[0] - assert await async_setup_component( - hass, "weather", {"weather": {"platform": "test"}} - ) - await hass.async_block_till_done() - return entity0 - - @pytest.mark.parametrize( "native_unit", (UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS) ) @@ -190,7 +185,7 @@ async def test_temperature( ) state = hass.states.get(entity0.entity_id) - forecast = state.attributes[ATTR_FORECAST][0] + forecast_daily = state.attributes[ATTR_FORECAST][0] expected = state_value apparent_expected = apparent_state_value @@ -205,14 +200,20 @@ async def test_temperature( dew_point_expected, rel=0.1 ) assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == state_unit - assert float(forecast[ATTR_FORECAST_TEMP]) == pytest.approx(expected, rel=0.1) - assert float(forecast[ATTR_FORECAST_APPARENT_TEMP]) == pytest.approx( + assert float(forecast_daily[ATTR_FORECAST_TEMP]) == pytest.approx(expected, rel=0.1) + assert float(forecast_daily[ATTR_FORECAST_APPARENT_TEMP]) == pytest.approx( apparent_expected, rel=0.1 ) - assert float(forecast[ATTR_FORECAST_DEW_POINT]) == pytest.approx( + assert float(forecast_daily[ATTR_FORECAST_DEW_POINT]) == pytest.approx( dew_point_expected, rel=0.1 ) - assert float(forecast[ATTR_FORECAST_TEMP_LOW]) == pytest.approx(expected, rel=0.1) + assert float(forecast_daily[ATTR_FORECAST_TEMP_LOW]) == pytest.approx( + expected, rel=0.1 + ) + assert float(forecast_daily[ATTR_FORECAST_TEMP]) == pytest.approx(expected, rel=0.1) + assert float(forecast_daily[ATTR_FORECAST_TEMP_LOW]) == pytest.approx( + expected, rel=0.1 + ) @pytest.mark.parametrize("native_unit", (None,)) @@ -583,7 +584,7 @@ async def test_precipitation_no_unit( ) -async def test_wind_bearing_ozone_and_cloud_coverage( +async def test_wind_bearing_ozone_and_cloud_coverage_and_uv_index( hass: HomeAssistant, enable_custom_integrations: None, ) -> None: @@ -591,18 +592,23 @@ async def test_wind_bearing_ozone_and_cloud_coverage( wind_bearing_value = 180 ozone_value = 10 cloud_coverage = 75 + uv_index = 1.2 entity0 = await create_entity( hass, wind_bearing=wind_bearing_value, ozone=ozone_value, cloud_coverage=cloud_coverage, + uv_index=uv_index, ) state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] assert float(state.attributes[ATTR_WEATHER_WIND_BEARING]) == 180 assert float(state.attributes[ATTR_WEATHER_OZONE]) == 10 assert float(state.attributes[ATTR_WEATHER_CLOUD_COVERAGE]) == 75 + assert float(state.attributes[ATTR_WEATHER_UV_INDEX]) == 1.2 + assert float(forecast[ATTR_FORECAST_UV_INDEX]) == 1.2 async def test_humidity( @@ -688,6 +694,7 @@ async def test_custom_units( native_visibility_unit=visibility_unit, native_precipitation=precipitation_value, native_precipitation_unit=precipitation_unit, + is_daytime=True, unique_id="very_unique", ) ) @@ -1024,7 +1031,7 @@ async def test_attr_compatibility(hass: HomeAssistant) -> None: forecast_entry = [ Forecast( - datetime=datetime(2022, 6, 20, 20, 00, 00), + datetime=datetime(2022, 6, 20, 0, 00, 00, tzinfo=dt_util.UTC), precipitation=1, temperature=20, ) @@ -1060,3 +1067,39 @@ async def test_precision_for_temperature(hass: HomeAssistant) -> None: assert weather.state_attributes[ATTR_WEATHER_TEMPERATURE] == 20.5 assert weather.state_attributes[ATTR_WEATHER_DEW_POINT] == 2.5 + + +async def test_forecast_twice_daily_missing_is_daytime( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + enable_custom_integrations: None, +) -> None: + """Test forecast_twice_daily missing mandatory attribute is_daytime.""" + + entity0 = await create_entity( + hass, + native_temperature=38, + native_temperature_unit=UnitOfTemperature.CELSIUS, + is_daytime=None, + supported_features=WeatherEntityFeature.FORECAST_TWICE_DAILY, + ) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "twice_daily", + "entity_id": entity0.entity_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["error"] == {"code": "unknown_error", "message": "Unknown error"} + assert not msg["success"] + assert msg["type"] == "result" diff --git a/tests/components/weather/test_recorder.py b/tests/components/weather/test_recorder.py index 5d7928124dd..2864abf58bb 100644 --- a/tests/components/weather/test_recorder.py +++ b/tests/components/weather/test_recorder.py @@ -5,7 +5,11 @@ from datetime import timedelta from homeassistant.components.recorder import Recorder from homeassistant.components.recorder.history import get_significant_states -from homeassistant.components.weather import ATTR_FORECAST, DOMAIN +from homeassistant.components.weather import ( + ATTR_CONDITION_SUNNY, + ATTR_FORECAST, +) +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -13,17 +17,47 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from tests.common import async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done +from tests.testing_config.custom_components.test import weather as WeatherPlatform -async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: +async def create_entity(hass: HomeAssistant, **kwargs): + """Create the weather entity to run tests on.""" + kwargs = { + "native_temperature": None, + "native_temperature_unit": None, + "is_daytime": True, + **kwargs, + } + platform: WeatherPlatform = getattr(hass.components, "test.weather") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockForecast( + name="Test", condition=ATTR_CONDITION_SUNNY, **kwargs + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test"}} + ) + await hass.async_block_till_done() + return entity0 + + +async def test_exclude_attributes( + recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None +) -> None: """Test weather attributes to be excluded.""" now = dt_util.utcnow() - await async_setup_component(hass, "homeassistant", {}) - await async_setup_component(hass, DOMAIN, {DOMAIN: {"platform": "demo"}}) + entity0 = await create_entity( + hass, + native_temperature=38, + native_temperature_unit=UnitOfTemperature.CELSIUS, + ) hass.config.units = METRIC_SYSTEM await hass.async_block_till_done() - state = hass.states.get("weather.demo_weather_south") + state = hass.states.get(entity0.entity_id) assert state.attributes[ATTR_FORECAST] await hass.async_block_till_done() diff --git a/tests/components/weather/test_websocket_api.py b/tests/components/weather/test_websocket_api.py index 760acbb2bb0..4f5223c6f79 100644 --- a/tests/components/weather/test_websocket_api.py +++ b/tests/components/weather/test_websocket_api.py @@ -1,8 +1,12 @@ """Test the weather websocket API.""" +from homeassistant.components.weather import WeatherEntityFeature from homeassistant.components.weather.const import DOMAIN +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from . import create_entity + from tests.typing import WebSocketGenerator @@ -31,3 +35,118 @@ async def test_device_class_units( "wind_speed_unit": ["ft/s", "km/h", "kn", "m/s", "mph"], } } + + +async def test_subscribe_forecast( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + enable_custom_integrations: None, +) -> None: + """Test multiple forecast.""" + + entity0 = await create_entity( + hass, + native_temperature=38, + native_temperature_unit=UnitOfTemperature.CELSIUS, + supported_features=WeatherEntityFeature.FORECAST_DAILY, + ) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "daily", + "entity_id": entity0.entity_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast = msg["event"] + assert forecast == { + "type": "daily", + "forecast": [ + { + "cloud_coverage": None, + "temperature": 38.0, + "templow": 38.0, + "uv_index": None, + "wind_bearing": None, + } + ], + } + + await entity0.async_update_listeners(None) + msg = await client.receive_json() + assert msg["event"] == forecast + + await entity0.async_update_listeners(["daily"]) + msg = await client.receive_json() + assert msg["event"] == forecast + + entity0.forecast_list = None + await entity0.async_update_listeners(None) + msg = await client.receive_json() + assert msg["event"] == {"type": "daily", "forecast": None} + + +async def test_subscribe_forecast_unknown_entity( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + enable_custom_integrations: None, +) -> None: + """Test multiple forecast.""" + + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "daily", + "entity_id": "weather.unknown", + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "invalid_entity_id", + "message": "Weather entity not found: weather.unknown", + } + + +async def test_subscribe_forecast_unsupported( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + enable_custom_integrations: None, +) -> None: + """Test multiple forecast.""" + + entity0 = await create_entity( + hass, + native_temperature=38, + native_temperature_unit=UnitOfTemperature.CELSIUS, + ) + + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": "daily", + "entity_id": entity0.entity_id, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "forecast_not_supported", + "message": "The weather entity does not support forecast type: daily", + } diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index fec1bf7a04a..c027b57acf8 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -279,7 +279,7 @@ async def test_device_info_startup_off( assert hass.states.get(ENTITY_ID).state == STATE_OFF - device = device_registry.async_get_device({(DOMAIN, entry.unique_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, entry.unique_id)}) assert device assert device.identifiers == {(DOMAIN, entry.unique_id)} @@ -326,7 +326,7 @@ async def test_entity_attributes( assert attrs[ATTR_MEDIA_TITLE] == "Channel Name 2" # Device Info - device = device_registry.async_get_device({(DOMAIN, entry.unique_id)}) + device = device_registry.async_get_device(identifiers={(DOMAIN, entry.unique_id)}) assert device assert device.identifiers == {(DOMAIN, entry.unique_id)} diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index ca04fef4f77..232362ce96f 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -1767,6 +1767,7 @@ async def test_execute_script_complex_response( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: """Test testing a condition.""" + await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) await hass.async_block_till_done() ws_client = await hass_ws_client(hass) diff --git a/tests/components/websocket_api/test_messages.py b/tests/components/websocket_api/test_messages.py index d2102b651b7..6aafb9f2685 100644 --- a/tests/components/websocket_api/test_messages.py +++ b/tests/components/websocket_api/test_messages.py @@ -222,6 +222,21 @@ async def test_state_diff_event(hass: HomeAssistant) -> None: } } + hass.states.async_set("light.window", "green", {}, context=new_context) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + + assert message == { + "c": { + "light.window": { + "+": {"lc": new_state.last_changed.timestamp(), "s": "green"}, + "-": {"a": ["new"]}, + } + } + } + async def test_message_to_json(caplog: pytest.LogCaptureFixture) -> None: """Test we can serialize websocket messages.""" diff --git a/tests/components/wemo/conftest.py b/tests/components/wemo/conftest.py index 5fe798004da..6c4d28ecae7 100644 --- a/tests/components/wemo/conftest.py +++ b/tests/components/wemo/conftest.py @@ -1,5 +1,4 @@ """Fixtures for pywemo.""" -import asyncio import contextlib from unittest.mock import create_autospec, patch @@ -33,11 +32,9 @@ async def async_pywemo_registry_fixture(): registry = create_autospec(pywemo.SubscriptionRegistry, instance=True) registry.callbacks = {} - registry.semaphore = asyncio.Semaphore(value=0) def on_func(device, type_filter, callback): registry.callbacks[device.name] = callback - registry.semaphore.release() registry.on.side_effect = on_func registry.is_subscribed.return_value = False diff --git a/tests/components/wemo/test_init.py b/tests/components/wemo/test_init.py index 0e9ba19af42..1d4271063f2 100644 --- a/tests/components/wemo/test_init.py +++ b/tests/components/wemo/test_init.py @@ -1,16 +1,24 @@ """Tests for the wemo component.""" +import asyncio from datetime import timedelta from unittest.mock import create_autospec, patch import pywemo -from homeassistant.components.wemo import CONF_DISCOVERY, CONF_STATIC, WemoDiscovery +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.wemo import ( + CONF_DISCOVERY, + CONF_STATIC, + WemoDiscovery, + async_wemo_dispatcher_connect, +) from homeassistant.components.wemo.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from . import entity_test_helpers from .conftest import ( MOCK_FIRMWARE_VERSION, MOCK_HOST, @@ -92,6 +100,54 @@ async def test_static_config_without_port(hass: HomeAssistant, pywemo_device) -> assert len(entity_entries) == 1 +async def test_reload_config_entry( + hass: HomeAssistant, + pywemo_device: pywemo.WeMoDevice, + pywemo_registry: pywemo.SubscriptionRegistry, +) -> None: + """Config entry can be reloaded without errors.""" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_DISCOVERY: False, + CONF_STATIC: [MOCK_HOST], + }, + }, + ) + + async def _async_test_entry_and_entity() -> tuple[str, str]: + await hass.async_block_till_done() + + pywemo_device.get_state.assert_called() + pywemo_device.get_state.reset_mock() + + pywemo_registry.register.assert_called_once_with(pywemo_device) + pywemo_registry.register.reset_mock() + + entity_registry = er.async_get(hass) + entity_entries = list(entity_registry.entities.values()) + assert len(entity_entries) == 1 + await entity_test_helpers.test_turn_off_state( + hass, entity_entries[0], SWITCH_DOMAIN + ) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + return entries[0].entry_id, entity_entries[0].entity_id + + entry_id, entity_id = await _async_test_entry_and_entity() + pywemo_registry.unregister.assert_not_called() + + assert await hass.config_entries.async_reload(entry_id) + + ids = await _async_test_entry_and_entity() + pywemo_registry.unregister.assert_called_once_with(pywemo_device) + assert ids == (entry_id, entity_id) + + async def test_static_config_with_invalid_host(hass: HomeAssistant) -> None: """Component setup fails if a static host is invalid.""" setup_success = await async_setup_component( @@ -146,17 +202,26 @@ async def test_discovery(hass: HomeAssistant, pywemo_registry) -> None: device.supports_long_press.return_value = False return device + semaphore = asyncio.Semaphore(value=0) + + async def async_connect(*args): + await async_wemo_dispatcher_connect(*args) + semaphore.release() + pywemo_devices = [create_device(0), create_device(1)] # Setup the component and start discovery. with patch( "pywemo.discover_devices", return_value=pywemo_devices ) as mock_discovery, patch( "homeassistant.components.wemo.WemoDiscovery.discover_statics" - ) as mock_discover_statics: + ) as mock_discover_statics, patch( + "homeassistant.components.wemo.binary_sensor.async_wemo_dispatcher_connect", + side_effect=async_connect, + ): assert await async_setup_component( hass, DOMAIN, {DOMAIN: {CONF_DISCOVERY: True}} ) - await pywemo_registry.semaphore.acquire() # Returns after platform setup. + await semaphore.acquire() # Returns after platform setup. mock_discovery.assert_called() mock_discover_statics.assert_called() pywemo_devices.append(create_device(2)) diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index be78b0e2df8..4e451f46e9b 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -4,13 +4,14 @@ from unittest.mock import MagicMock from whirlpool.washerdryer import MachineState +from homeassistant.components.whirlpool.sensor import SCAN_INTERVAL from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import as_timestamp, utc_from_timestamp +from homeassistant.util.dt import as_timestamp, utc_from_timestamp, utcnow from . import init_integration -from tests.common import mock_restore_cache_with_extra_data +from tests.common import async_fire_time_changed, mock_restore_cache_with_extra_data async def update_sensor_state( @@ -132,6 +133,12 @@ async def test_washer_sensor_values( await init_integration(hass) + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + entity_id = "sensor.washer_state" mock_instance = mock_sensor1_api entry = entity_registry.async_get(entity_id) diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr index d0bcff20b0e..464af13c7c8 100644 --- a/tests/components/whois/snapshots/test_sensor.ambr +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -37,7 +37,7 @@ 'original_name': 'Admin', 'platform': 'whois', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'admin', 'unique_id': 'home-assistant.io_admin', 'unit_of_measurement': None, }) @@ -107,7 +107,7 @@ 'original_name': 'Created', 'platform': 'whois', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'creation_date', 'unique_id': 'home-assistant.io_creation_date', 'unit_of_measurement': None, }) @@ -182,7 +182,7 @@ 'original_name': 'Days until expiration', 'platform': 'whois', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'days_until_expiration', 'unique_id': 'home-assistant.io_days_until_expiration', 'unit_of_measurement': , }) @@ -252,7 +252,7 @@ 'original_name': 'Expires', 'platform': 'whois', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'expiration_date', 'unique_id': 'home-assistant.io_expiration_date', 'unit_of_measurement': None, }) @@ -322,7 +322,7 @@ 'original_name': 'Last updated', 'platform': 'whois', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'last_updated', 'unique_id': 'home-assistant.io_last_updated', 'unit_of_measurement': None, }) @@ -392,7 +392,7 @@ 'original_name': 'Owner', 'platform': 'whois', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'owner', 'unique_id': 'home-assistant.io_owner', 'unit_of_measurement': None, }) @@ -462,7 +462,7 @@ 'original_name': 'Registrant', 'platform': 'whois', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'registrant', 'unique_id': 'home-assistant.io_registrant', 'unit_of_measurement': None, }) @@ -532,7 +532,7 @@ 'original_name': 'Registrar', 'platform': 'whois', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'registrar', 'unique_id': 'home-assistant.io_registrar', 'unit_of_measurement': None, }) @@ -602,7 +602,7 @@ 'original_name': 'Reseller', 'platform': 'whois', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'reseller', 'unique_id': 'home-assistant.io_reseller', 'unit_of_measurement': None, }) @@ -672,7 +672,7 @@ 'original_name': 'Last updated', 'platform': 'whois', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'last_updated', 'unique_id': 'home-assistant.io_last_updated', 'unit_of_measurement': None, }) diff --git a/tests/components/wiz/test_sensor.py b/tests/components/wiz/test_sensor.py index a1eb6ded51d..522eb5c7cba 100644 --- a/tests/components/wiz/test_sensor.py +++ b/tests/components/wiz/test_sensor.py @@ -49,7 +49,7 @@ async def test_power_monitoring(hass: HomeAssistant) -> None: _, entry = await async_setup_integration( hass, wizlight=socket, bulb_type=FAKE_SOCKET_WITH_POWER_MONITORING ) - entity_id = "sensor.mock_title_current_power" + entity_id = "sensor.mock_title_power" entity_registry = er.async_get(hass) reg_entry = entity_registry.async_get(entity_id) assert reg_entry.unique_id == f"{FAKE_MAC}_power" diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index d48b908f26b..3d12d41ce5e 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -1,16 +1,26 @@ """Tests for the Wyoming integration.""" -from wyoming.info import AsrModel, AsrProgram, Attribution, Info, TtsProgram, TtsVoice +from wyoming.info import ( + AsrModel, + AsrProgram, + Attribution, + Info, + TtsProgram, + TtsVoice, + TtsVoiceSpeaker, +) TEST_ATTR = Attribution(name="Test", url="http://www.test.com") STT_INFO = Info( asr=[ AsrProgram( name="Test ASR", + description="Test ASR", installed=True, attribution=TEST_ATTR, models=[ AsrModel( name="Test Model", + description="Test Model", installed=True, attribution=TEST_ATTR, languages=["en-US"], @@ -23,14 +33,17 @@ TTS_INFO = Info( tts=[ TtsProgram( name="Test TTS", + description="Test TTS", installed=True, attribution=TEST_ATTR, voices=[ TtsVoice( name="Test Voice", + description="Test Voice", installed=True, attribution=TEST_ATTR, languages=["en-US"], + speakers=[TtsVoiceSpeaker(name="Test Speaker")], ) ], ) diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 0dd9041a0d5..6b4e705914f 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest +from homeassistant.components import stt from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -69,3 +70,16 @@ async def init_wyoming_tts(hass: HomeAssistant, tts_config_entry: ConfigEntry): return_value=TTS_INFO, ): await hass.config_entries.async_setup(tts_config_entry.entry_id) + + +@pytest.fixture +def metadata(hass: HomeAssistant) -> stt.SpeechMetadata: + """Get default STT metadata.""" + return stt.SpeechMetadata( + language=hass.config.language, + format=stt.AudioFormats.WAV, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) diff --git a/tests/components/wyoming/snapshots/test_stt.ambr b/tests/components/wyoming/snapshots/test_stt.ambr index 08fe6a1ef8e..784f89b2ab8 100644 --- a/tests/components/wyoming/snapshots/test_stt.ambr +++ b/tests/components/wyoming/snapshots/test_stt.ambr @@ -1,6 +1,13 @@ # serializer version: 1 # name: test_streaming_audio list([ + dict({ + 'data': dict({ + 'language': 'en', + }), + 'payload': None, + 'type': 'transcibe', + }), dict({ 'data': dict({ 'channels': 1, diff --git a/tests/components/wyoming/snapshots/test_tts.ambr b/tests/components/wyoming/snapshots/test_tts.ambr index eb0b33c3276..1cb5a6cb874 100644 --- a/tests/components/wyoming/snapshots/test_tts.ambr +++ b/tests/components/wyoming/snapshots/test_tts.ambr @@ -21,3 +21,18 @@ }), ]) # --- +# name: test_voice_speaker + list([ + dict({ + 'data': dict({ + 'text': 'Hello world', + 'voice': dict({ + 'name': 'voice1', + 'speaker': 'speaker1', + }), + }), + 'payload': None, + 'type': 'synthesize', + }), + ]) +# --- diff --git a/tests/components/wyoming/test_stt.py b/tests/components/wyoming/test_stt.py index 021419f3a5e..1938d44d310 100644 --- a/tests/components/wyoming/test_stt.py +++ b/tests/components/wyoming/test_stt.py @@ -27,7 +27,9 @@ async def test_support(hass: HomeAssistant, init_wyoming_stt) -> None: assert entity.supported_channels == [stt.AudioChannels.CHANNEL_MONO] -async def test_streaming_audio(hass: HomeAssistant, init_wyoming_stt, snapshot) -> None: +async def test_streaming_audio( + hass: HomeAssistant, init_wyoming_stt, metadata, snapshot +) -> None: """Test streaming audio.""" entity = stt.async_get_speech_to_text_entity(hass, "stt.test_asr") assert entity is not None @@ -40,7 +42,7 @@ async def test_streaming_audio(hass: HomeAssistant, init_wyoming_stt, snapshot) "homeassistant.components.wyoming.stt.AsyncTcpClient", MockAsyncTcpClient([Transcript(text="Hello world").event()]), ) as mock_client: - result = await entity.async_process_audio_stream(None, audio_stream()) + result = await entity.async_process_audio_stream(metadata, audio_stream()) assert result.result == stt.SpeechResultState.SUCCESS assert result.text == "Hello world" @@ -48,7 +50,7 @@ async def test_streaming_audio(hass: HomeAssistant, init_wyoming_stt, snapshot) async def test_streaming_audio_connection_lost( - hass: HomeAssistant, init_wyoming_stt + hass: HomeAssistant, init_wyoming_stt, metadata ) -> None: """Test streaming audio and losing connection.""" entity = stt.async_get_speech_to_text_entity(hass, "stt.test_asr") @@ -61,13 +63,15 @@ async def test_streaming_audio_connection_lost( "homeassistant.components.wyoming.stt.AsyncTcpClient", MockAsyncTcpClient([None]), ): - result = await entity.async_process_audio_stream(None, audio_stream()) + result = await entity.async_process_audio_stream(metadata, audio_stream()) assert result.result == stt.SpeechResultState.ERROR assert result.text is None -async def test_streaming_audio_oserror(hass: HomeAssistant, init_wyoming_stt) -> None: +async def test_streaming_audio_oserror( + hass: HomeAssistant, init_wyoming_stt, metadata +) -> None: """Test streaming audio and error raising.""" entity = stt.async_get_speech_to_text_entity(hass, "stt.test_asr") assert entity is not None @@ -81,7 +85,7 @@ async def test_streaming_audio_oserror(hass: HomeAssistant, init_wyoming_stt) -> "homeassistant.components.wyoming.stt.AsyncTcpClient", mock_client, ), patch.object(mock_client, "read_event", side_effect=OSError("Boom!")): - result = await entity.async_process_audio_stream(None, audio_stream()) + result = await entity.async_process_audio_stream(metadata, audio_stream()) assert result.result == stt.SpeechResultState.ERROR assert result.text is None diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 8767660ca08..51a684bc4fd 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -8,7 +8,7 @@ import wave import pytest from wyoming.audio import AudioChunk, AudioStop -from homeassistant.components import tts +from homeassistant.components import tts, wyoming from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_component import DATA_INSTANCES @@ -31,7 +31,11 @@ async def test_support(hass: HomeAssistant, init_wyoming_tts) -> None: assert entity is not None assert entity.supported_languages == ["en-US"] - assert entity.supported_options == [tts.ATTR_AUDIO_OUTPUT, tts.ATTR_VOICE] + assert entity.supported_options == [ + tts.ATTR_AUDIO_OUTPUT, + tts.ATTR_VOICE, + wyoming.ATTR_SPEAKER, + ] voices = entity.async_get_supported_voices("en-US") assert len(voices) == 1 assert voices[0].name == "Test Voice" @@ -137,3 +141,28 @@ async def test_get_tts_audio_audio_oserror( hass, "Hello world", "tts.test_tts", hass.config.language ), ) + + +async def test_voice_speaker(hass: HomeAssistant, init_wyoming_tts, snapshot) -> None: + """Test using a different voice and speaker.""" + audio = bytes(100) + audio_events = [ + AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), + AudioStop().event(), + ] + + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient(audio_events), + ) as mock_client: + await tts.async_get_media_source_audio( + hass, + tts.generate_media_source_id( + hass, + "Hello world", + "tts.test_tts", + "en-US", + options={tts.ATTR_VOICE: "voice1", wyoming.ATTR_SPEAKER: "speaker1"}, + ), + ) + assert mock_client.written == snapshot diff --git a/tests/components/xiaomi_ble/__init__.py b/tests/components/xiaomi_ble/__init__.py index ea11feab9c2..197745b70f1 100644 --- a/tests/components/xiaomi_ble/__init__.py +++ b/tests/components/xiaomi_ble/__init__.py @@ -105,6 +105,38 @@ HHCCJCY10_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=False, ) +MISCALE_V1_SERVICE_INFO = BluetoothServiceInfoBleak( + name="MISCA", + address="50:FB:19:1B:B5:DC", + device=generate_ble_device("00:00:00:00:00:00", None), + rssi=-60, + manufacturer_data={}, + service_data={ + "0000181d-0000-1000-8000-00805f9b34fb": b"\x22\x9e\x43\xe5\x07\x04\x0b\x10\x13\x01" + }, + service_uuids=["0000181d-0000-1000-8000-00805f9b34fb"], + source="local", + advertisement=generate_advertisement_data(local_name="Not it"), + time=0, + connectable=False, +) + +MISCALE_V2_SERVICE_INFO = BluetoothServiceInfoBleak( + name="MIBFS", + address="50:FB:19:1B:B5:DC", + device=generate_ble_device("00:00:00:00:00:00", None), + rssi=-60, + manufacturer_data={}, + service_data={ + "0000181b-0000-1000-8000-00805f9b34fb": b"\x02&\xb2\x07\x05\x04\x0f\x02\x01\xac\x01\x86B" + }, + service_uuids=["0000181b-0000-1000-8000-00805f9b34fb"], + source="local", + advertisement=generate_advertisement_data(local_name="Not it"), + time=0, + connectable=False, +) + MISSING_PAYLOAD_ENCRYPTED = BluetoothServiceInfoBleak( name="LYWSD02MMC", address="A4:C1:38:56:53:84", diff --git a/tests/components/xiaomi_ble/test_device_trigger.py b/tests/components/xiaomi_ble/test_device_trigger.py index 85454959cf4..eba850e61e9 100644 --- a/tests/components/xiaomi_ble/test_device_trigger.py +++ b/tests/components/xiaomi_ble/test_device_trigger.py @@ -99,7 +99,7 @@ async def test_get_triggers( await hass.async_block_till_done() assert len(events) == 1 - device = device_registry.async_get_device({get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) assert device expected_trigger = { CONF_PLATFORM: "device", @@ -196,7 +196,7 @@ async def test_if_fires_on_motion_detected( # wait for the event await hass.async_block_till_done() - device = device_registry.async_get_device({get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -256,7 +256,7 @@ async def test_automation_with_invalid_trigger_type( # wait for the event await hass.async_block_till_done() - device = device_registry.async_get_device({get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( @@ -305,7 +305,7 @@ async def test_automation_with_invalid_trigger_event_property( # wait for the event await hass.async_block_till_done() - device = device_registry.async_get_device({get_device_id(mac)}) + device = device_registry.async_get_device(identifiers={get_device_id(mac)}) device_id = device.id assert await async_setup_component( diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index 1d6344063b5..7f39228a012 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -1,13 +1,34 @@ """Test Xiaomi BLE sensors.""" +from datetime import timedelta +import time +from unittest.mock import patch + +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.xiaomi_ble.const import DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util -from . import HHCCJCY10_SERVICE_INFO, MMC_T201_1_SERVICE_INFO, make_advertisement +from . import ( + HHCCJCY10_SERVICE_INFO, + MISCALE_V1_SERVICE_INFO, + MISCALE_V2_SERVICE_INFO, + MMC_T201_1_SERVICE_INFO, + make_advertisement, +) -from tests.common import MockConfigEntry -from tests.components.bluetooth import inject_bluetooth_service_info_bleak +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + inject_bluetooth_service_info_bleak, + patch_all_discovered_devices, +) async def test_sensors(hass: HomeAssistant) -> None: @@ -506,3 +527,201 @@ async def test_hhccjcy10_uuid(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_miscale_v1_uuid(hass: HomeAssistant) -> None: + """Test MiScale V1 UUID. + + This device uses a different UUID compared to the other Xiaomi sensors. + """ + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="50:FB:19:1B:B5:DC", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + inject_bluetooth_service_info_bleak(hass, MISCALE_V1_SERVICE_INFO) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 2 + + mass_non_stabilized_sensor = hass.states.get( + "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + ) + mass_non_stabilized_sensor_attr = mass_non_stabilized_sensor.attributes + assert mass_non_stabilized_sensor.state == "86.55" + assert ( + mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] + == "Mi Smart Scale (B5DC) Mass Non Stabilized" + ) + assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" + assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" + + mass_sensor = hass.states.get("sensor.mi_smart_scale_b5dc_mass") + mass_sensor_attr = mass_sensor.attributes + assert mass_sensor.state == "86.55" + assert mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Smart Scale (B5DC) Mass" + assert mass_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" + assert mass_sensor_attr[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_miscale_v2_uuid(hass: HomeAssistant) -> None: + """Test MiScale V2 UUID. + + This device uses a different UUID compared to the other Xiaomi sensors. + """ + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="50:FB:19:1B:B5:DC", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + inject_bluetooth_service_info_bleak(hass, MISCALE_V2_SERVICE_INFO) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 3 + + mass_non_stabilized_sensor = hass.states.get( + "sensor.mi_body_composition_scale_b5dc_mass_non_stabilized" + ) + mass_non_stabilized_sensor_attr = mass_non_stabilized_sensor.attributes + assert mass_non_stabilized_sensor.state == "85.15" + assert ( + mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] + == "Mi Body Composition Scale (B5DC) Mass Non Stabilized" + ) + assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" + assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" + + mass_sensor = hass.states.get("sensor.mi_body_composition_scale_b5dc_mass") + mass_sensor_attr = mass_sensor.attributes + assert mass_sensor.state == "85.15" + assert ( + mass_sensor_attr[ATTR_FRIENDLY_NAME] == "Mi Body Composition Scale (B5DC) Mass" + ) + assert mass_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" + assert mass_sensor_attr[ATTR_STATE_CLASS] == "measurement" + + impedance_sensor = hass.states.get( + "sensor.mi_body_composition_scale_b5dc_impedance" + ) + impedance_sensor_attr = impedance_sensor.attributes + assert impedance_sensor.state == "428" + assert ( + impedance_sensor_attr[ATTR_FRIENDLY_NAME] + == "Mi Body Composition Scale (B5DC) Impedance" + ) + assert impedance_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "ohm" + assert impedance_sensor_attr[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_unavailable(hass: HomeAssistant) -> None: + """Test normal device goes to unavailable after 60 minutes.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="58:2D:34:12:20:89", + data={"bindkey": "a3bfe9853dd85a620debe3620caaa351"}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + inject_bluetooth_service_info_bleak( + hass, + make_advertisement( + "58:2D:34:12:20:89", + b"XXo\x06\x07\x89 \x124-X_\x17m\xd5O\x02\x00\x00/\xa4S\xfa", + ), + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + temp_sensor = hass.states.get("sensor.temperature_humidity_sensor_2089_temperature") + assert temp_sensor.state == "22.6" + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + temp_sensor = hass.states.get("sensor.temperature_humidity_sensor_2089_temperature") + + # Sleepy devices should keep their state over time + assert temp_sensor.state == STATE_UNAVAILABLE + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sleepy_device(hass: HomeAssistant) -> None: + """Test normal device goes to unavailable after 60 minutes.""" + start_monotonic = time.monotonic() + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="50:FB:19:1B:B5:DC", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + inject_bluetooth_service_info_bleak(hass, MISCALE_V1_SERVICE_INFO) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 2 + + mass_non_stabilized_sensor = hass.states.get( + "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + ) + assert mass_non_stabilized_sensor.state == "86.55" + + # Fastforward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), patch_all_discovered_devices([]): + async_fire_time_changed( + hass, + dt_util.utcnow() + + timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1), + ) + await hass.async_block_till_done() + + mass_non_stabilized_sensor = hass.states.get( + "sensor.mi_smart_scale_b5dc_mass_non_stabilized" + ) + + # Sleepy devices should keep their state over time + assert mass_non_stabilized_sensor.state == "86.55" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/yolink/test_device_trigger.py b/tests/components/yolink/test_device_trigger.py index e0ef37c1b75..0e258d0e1c7 100644 --- a/tests/components/yolink/test_device_trigger.py +++ b/tests/components/yolink/test_device_trigger.py @@ -154,7 +154,7 @@ async def test_if_fires_on_event( }, ) - device = device_registry.async_get_device(set(), {connection}) + device = device_registry.async_get_device(connections={connection}) assert device is not None # Fake remote button long press. hass.bus.async_fire( diff --git a/tests/components/youtube/__init__.py b/tests/components/youtube/__init__.py index 15a43d7a62f..665f5f3a762 100644 --- a/tests/components/youtube/__init__.py +++ b/tests/components/youtube/__init__.py @@ -1,78 +1,18 @@ """Tests for the YouTube integration.""" -from dataclasses import dataclass +from collections.abc import AsyncGenerator import json -from typing import Any + +from youtubeaio.models import YouTubeChannel, YouTubePlaylistItem, YouTubeSubscription +from youtubeaio.types import AuthScope from tests.common import load_fixture -@dataclass -class MockRequest: - """Mock object for a request.""" - - fixture: str - - def execute(self) -> dict[str, Any]: - """Return a fixture.""" - return json.loads(load_fixture(self.fixture)) - - -class MockChannels: - """Mock object for channels.""" - - def __init__(self, fixture: str): - """Initialize mock channels.""" - self._fixture = fixture - - def list( - self, - part: str, - id: str | None = None, - mine: bool | None = None, - maxResults: int | None = None, - ) -> MockRequest: - """Return a fixture.""" - return MockRequest(fixture=self._fixture) - - -class MockPlaylistItems: - """Mock object for playlist items.""" - - def __init__(self, fixture: str): - """Initialize mock playlist items.""" - self._fixture = fixture - - def list( - self, - part: str, - playlistId: str, - maxResults: int | None = None, - ) -> MockRequest: - """Return a fixture.""" - return MockRequest(fixture=self._fixture) - - -class MockSubscriptions: - """Mock object for subscriptions.""" - - def __init__(self, fixture: str): - """Initialize mock subscriptions.""" - self._fixture = fixture - - def list( - self, - part: str, - mine: bool, - maxResults: int | None = None, - pageToken: str | None = None, - ) -> MockRequest: - """Return a fixture.""" - return MockRequest(fixture=self._fixture) - - -class MockService: +class MockYouTube: """Service which returns mock objects.""" + _thrown_error: Exception | None = None + def __init__( self, channel_fixture: str = "youtube/get_channel.json", @@ -84,14 +24,41 @@ class MockService: self._playlist_items_fixture = playlist_items_fixture self._subscriptions_fixture = subscriptions_fixture - def channels(self) -> MockChannels: - """Return a mock object.""" - return MockChannels(self._channel_fixture) + async def set_user_authentication( + self, token: str, scopes: list[AuthScope] + ) -> None: + """Authenticate the user.""" - def playlistItems(self) -> MockPlaylistItems: - """Return a mock object.""" - return MockPlaylistItems(self._playlist_items_fixture) + async def get_user_channels(self) -> AsyncGenerator[YouTubeChannel, None]: + """Get channels for authenticated user.""" + channels = json.loads(load_fixture(self._channel_fixture)) + for item in channels["items"]: + yield YouTubeChannel(**item) - def subscriptions(self) -> MockSubscriptions: - """Return a mock object.""" - return MockSubscriptions(self._subscriptions_fixture) + async def get_channels( + self, channel_ids: list[str] + ) -> AsyncGenerator[YouTubeChannel, None]: + """Get channels.""" + if self._thrown_error is not None: + raise self._thrown_error + channels = json.loads(load_fixture(self._channel_fixture)) + for item in channels["items"]: + yield YouTubeChannel(**item) + + async def get_playlist_items( + self, playlist_id: str, amount: int + ) -> AsyncGenerator[YouTubePlaylistItem, None]: + """Get channels.""" + channels = json.loads(load_fixture(self._playlist_items_fixture)) + for item in channels["items"]: + yield YouTubePlaylistItem(**item) + + async def get_user_subscriptions(self) -> AsyncGenerator[YouTubeSubscription, None]: + """Get channels for authenticated user.""" + channels = json.loads(load_fixture(self._subscriptions_fixture)) + for item in channels["items"]: + yield YouTubeSubscription(**item) + + def set_thrown_exception(self, exception: Exception) -> None: + """Set thrown exception for testing purposes.""" + self._thrown_error = exception diff --git a/tests/components/youtube/conftest.py b/tests/components/youtube/conftest.py index 6513c359a7c..8b6ce5d00a2 100644 --- a/tests/components/youtube/conftest.py +++ b/tests/components/youtube/conftest.py @@ -1,4 +1,4 @@ -"""Configure tests for the Google Mail integration.""" +"""Configure tests for the YouTube integration.""" from collections.abc import Awaitable, Callable, Coroutine import time from typing import Any @@ -15,12 +15,11 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.components.youtube import MockService +from tests.components.youtube import MockYouTube from tests.test_util.aiohttp import AiohttpClientMocker -ComponentSetup = Callable[[], Awaitable[None]] +ComponentSetup = Callable[[], Awaitable[MockYouTube]] -BUILD = "homeassistant.components.google_mail.api.build" CLIENT_ID = "1234" CLIENT_SECRET = "5678" GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth" @@ -28,7 +27,6 @@ GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token" SCOPES = [ "https://www.googleapis.com/auth/youtube.readonly", ] -SENSOR = "sensor.example_gmail_com_vacation_end_date" TITLE = "Google for Developers" TOKEN = "homeassistant.components.youtube.api.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid" @@ -59,7 +57,7 @@ def mock_expires_at() -> int: @pytest.fixture(name="config_entry") def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: - """Create Google Mail entry in Home Assistant.""" + """Create YouTube entry in Home Assistant.""" return MockConfigEntry( domain=DOMAIN, title=TITLE, @@ -79,7 +77,7 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: @pytest.fixture(autouse=True) def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: - """Mock Google Mail connection.""" + """Mock YouTube connection.""" aioclient_mock.post( GOOGLE_TOKEN_URI, json={ @@ -94,7 +92,7 @@ def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: @pytest.fixture(name="setup_integration") async def mock_setup_integration( hass: HomeAssistant, config_entry: MockConfigEntry -) -> Callable[[], Coroutine[Any, Any, None]]: +) -> Callable[[], Coroutine[Any, Any, MockYouTube]]: """Fixture for setting up the component.""" config_entry.add_to_hass(hass) @@ -106,11 +104,11 @@ async def mock_setup_integration( DOMAIN, ) - async def func() -> None: - with patch( - "homeassistant.components.youtube.api.build", return_value=MockService() - ): + async def func() -> MockYouTube: + mock = MockYouTube() + with patch("homeassistant.components.youtube.api.YouTube", return_value=mock): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() + return mock return func diff --git a/tests/components/youtube/fixtures/get_channel_2.json b/tests/components/youtube/fixtures/get_channel_2.json index 24e71ad91ab..f2757b169bb 100644 --- a/tests/components/youtube/fixtures/get_channel_2.json +++ b/tests/components/youtube/fixtures/get_channel_2.json @@ -1,47 +1,54 @@ { - "kind": "youtube#SubscriptionListResponse", - "etag": "6C9iFE7CzKQqPrEoJlE0H2U27xI", - "nextPageToken": "CAEQAA", + "kind": "youtube#channelListResponse", + "etag": "en7FWhCsHOdM398MU6qRntH03cQ", "pageInfo": { - "totalResults": 525, - "resultsPerPage": 1 + "totalResults": 1, + "resultsPerPage": 5 }, "items": [ { - "kind": "youtube#subscription", - "etag": "4Hr8w5f03mLak3fZID0aXypQRDg", - "id": "l6YW-siEBx2rtBlTJ_ip10UA2t_d09UYkgtJsqbYblE", + "kind": "youtube#channel", + "etag": "PyFk-jpc2-v4mvG_6imAHx3y6TM", + "id": "UCXuqSBlHAE6Xw-yeJA0Tunw", "snippet": { - "publishedAt": "2015-08-09T21:37:44Z", "title": "Linus Tech Tips", - "description": "Linus Tech Tips is a passionate team of \"professionally curious\" experts in consumer technology and video production who aim to educate and entertain.", - "resourceId": { - "kind": "youtube#channel", - "channelId": "UCXuqSBlHAE6Xw-yeJA0Tunw" - }, - "channelId": "UCXuqSBlHAE6Xw-yeJA0Tunw", + "description": "Linus Tech Tips is a passionate team of \"professionally curious\" experts in consumer technology and video production who aim to educate and entertain.\n", + "customUrl": "@linustechtips", + "publishedAt": "2008-11-25T00:46:52Z", "thumbnails": { "default": { - "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s88-c-k-c0x00ffffff-no-rj" + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s88-c-k-c0x00ffffff-no-rj", + "width": 88, + "height": 88 }, "medium": { - "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s240-c-k-c0x00ffffff-no-rj" + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s240-c-k-c0x00ffffff-no-rj", + "width": 240, + "height": 240 }, "high": { - "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s800-c-k-c0x00ffffff-no-rj" + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s800-c-k-c0x00ffffff-no-rj", + "width": 800, + "height": 800 } - } + }, + "localized": { + "title": "Linus Tech Tips", + "description": "Linus Tech Tips is a passionate team of \"professionally curious\" experts in consumer technology and video production who aim to educate and entertain.\n" + }, + "country": "CA" }, "contentDetails": { - "totalItemCount": 6178, - "newItemCount": 0, - "activityType": "all" + "relatedPlaylists": { + "likes": "", + "uploads": "UUXuqSBlHAE6Xw-yeJA0Tunw" + } }, "statistics": { - "viewCount": "214141263", - "subscriberCount": "2290000", + "viewCount": "7190986011", + "subscriberCount": "15600000", "hiddenSubscriberCount": false, - "videoCount": "5798" + "videoCount": "6541" } } ] diff --git a/tests/components/youtube/fixtures/get_no_playlist_items.json b/tests/components/youtube/fixtures/get_no_playlist_items.json new file mode 100644 index 00000000000..98b9a11737e --- /dev/null +++ b/tests/components/youtube/fixtures/get_no_playlist_items.json @@ -0,0 +1,9 @@ +{ + "kind": "youtube#playlistItemListResponse", + "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", + "items": [], + "pageInfo": { + "totalResults": 0, + "resultsPerPage": 0 + } +} diff --git a/tests/components/youtube/fixtures/thumbnail/default.json b/tests/components/youtube/fixtures/thumbnail/default.json deleted file mode 100644 index 6b5d66d6501..00000000000 --- a/tests/components/youtube/fixtures/thumbnail/default.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "kind": "youtube#playlistItemListResponse", - "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", - "nextPageToken": "EAAaBlBUOkNBVQ", - "items": [ - { - "kind": "youtube#playlistItem", - "etag": "qgpoAJRNskzLhD99njC8e2kPB0M", - "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV", - "snippet": { - "publishedAt": "2023-05-11T00:20:46Z", - "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", - "title": "What's new in Google Home in less than 1 minute", - "description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome", - "thumbnails": { - "default": { - "url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg", - "width": 120, - "height": 90 - } - }, - "channelTitle": "Google for Developers", - "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", - "position": 0, - "resourceId": { - "kind": "youtube#video", - "videoId": "wysukDrMdqU" - }, - "videoOwnerChannelTitle": "Google for Developers", - "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" - }, - "contentDetails": { - "videoId": "wysukDrMdqU", - "videoPublishedAt": "2023-05-11T00:20:46Z" - } - } - ], - "pageInfo": { - "totalResults": 5798, - "resultsPerPage": 1 - } -} diff --git a/tests/components/youtube/fixtures/thumbnail/high.json b/tests/components/youtube/fixtures/thumbnail/high.json deleted file mode 100644 index 430ad3715cc..00000000000 --- a/tests/components/youtube/fixtures/thumbnail/high.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "kind": "youtube#playlistItemListResponse", - "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", - "nextPageToken": "EAAaBlBUOkNBVQ", - "items": [ - { - "kind": "youtube#playlistItem", - "etag": "qgpoAJRNskzLhD99njC8e2kPB0M", - "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV", - "snippet": { - "publishedAt": "2023-05-11T00:20:46Z", - "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", - "title": "What's new in Google Home in less than 1 minute", - "description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome", - "thumbnails": { - "default": { - "url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg", - "width": 120, - "height": 90 - }, - "medium": { - "url": "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg", - "width": 320, - "height": 180 - }, - "high": { - "url": "https://i.ytimg.com/vi/wysukDrMdqU/hqdefault.jpg", - "width": 480, - "height": 360 - } - }, - "channelTitle": "Google for Developers", - "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", - "position": 0, - "resourceId": { - "kind": "youtube#video", - "videoId": "wysukDrMdqU" - }, - "videoOwnerChannelTitle": "Google for Developers", - "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" - }, - "contentDetails": { - "videoId": "wysukDrMdqU", - "videoPublishedAt": "2023-05-11T00:20:46Z" - } - } - ], - "pageInfo": { - "totalResults": 5798, - "resultsPerPage": 1 - } -} diff --git a/tests/components/youtube/fixtures/thumbnail/medium.json b/tests/components/youtube/fixtures/thumbnail/medium.json deleted file mode 100644 index 21cb09bd886..00000000000 --- a/tests/components/youtube/fixtures/thumbnail/medium.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "kind": "youtube#playlistItemListResponse", - "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", - "nextPageToken": "EAAaBlBUOkNBVQ", - "items": [ - { - "kind": "youtube#playlistItem", - "etag": "qgpoAJRNskzLhD99njC8e2kPB0M", - "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV", - "snippet": { - "publishedAt": "2023-05-11T00:20:46Z", - "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", - "title": "What's new in Google Home in less than 1 minute", - "description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome", - "thumbnails": { - "default": { - "url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg", - "width": 120, - "height": 90 - }, - "medium": { - "url": "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg", - "width": 320, - "height": 180 - } - }, - "channelTitle": "Google for Developers", - "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", - "position": 0, - "resourceId": { - "kind": "youtube#video", - "videoId": "wysukDrMdqU" - }, - "videoOwnerChannelTitle": "Google for Developers", - "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" - }, - "contentDetails": { - "videoId": "wysukDrMdqU", - "videoPublishedAt": "2023-05-11T00:20:46Z" - } - } - ], - "pageInfo": { - "totalResults": 5798, - "resultsPerPage": 1 - } -} diff --git a/tests/components/youtube/fixtures/thumbnail/none.json b/tests/components/youtube/fixtures/thumbnail/none.json deleted file mode 100644 index d4c28730cab..00000000000 --- a/tests/components/youtube/fixtures/thumbnail/none.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "kind": "youtube#playlistItemListResponse", - "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", - "nextPageToken": "EAAaBlBUOkNBVQ", - "items": [ - { - "kind": "youtube#playlistItem", - "etag": "qgpoAJRNskzLhD99njC8e2kPB0M", - "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV", - "snippet": { - "publishedAt": "2023-05-11T00:20:46Z", - "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", - "title": "What's new in Google Home in less than 1 minute", - "description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome", - "thumbnails": {}, - "channelTitle": "Google for Developers", - "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", - "position": 0, - "resourceId": { - "kind": "youtube#video", - "videoId": "wysukDrMdqU" - }, - "videoOwnerChannelTitle": "Google for Developers", - "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" - }, - "contentDetails": { - "videoId": "wysukDrMdqU", - "videoPublishedAt": "2023-05-11T00:20:46Z" - } - } - ], - "pageInfo": { - "totalResults": 5798, - "resultsPerPage": 1 - } -} diff --git a/tests/components/youtube/fixtures/thumbnail/standard.json b/tests/components/youtube/fixtures/thumbnail/standard.json deleted file mode 100644 index bdbedfcf4c9..00000000000 --- a/tests/components/youtube/fixtures/thumbnail/standard.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "kind": "youtube#playlistItemListResponse", - "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", - "nextPageToken": "EAAaBlBUOkNBVQ", - "items": [ - { - "kind": "youtube#playlistItem", - "etag": "qgpoAJRNskzLhD99njC8e2kPB0M", - "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV", - "snippet": { - "publishedAt": "2023-05-11T00:20:46Z", - "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", - "title": "What's new in Google Home in less than 1 minute", - "description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome", - "thumbnails": { - "default": { - "url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg", - "width": 120, - "height": 90 - }, - "medium": { - "url": "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg", - "width": 320, - "height": 180 - }, - "high": { - "url": "https://i.ytimg.com/vi/wysukDrMdqU/hqdefault.jpg", - "width": 480, - "height": 360 - }, - "standard": { - "url": "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg", - "width": 640, - "height": 480 - } - }, - "channelTitle": "Google for Developers", - "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", - "position": 0, - "resourceId": { - "kind": "youtube#video", - "videoId": "wysukDrMdqU" - }, - "videoOwnerChannelTitle": "Google for Developers", - "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" - }, - "contentDetails": { - "videoId": "wysukDrMdqU", - "videoPublishedAt": "2023-05-11T00:20:46Z" - } - } - ], - "pageInfo": { - "totalResults": 5798, - "resultsPerPage": 1 - } -} diff --git a/tests/components/youtube/snapshots/test_diagnostics.ambr b/tests/components/youtube/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..a938cb8daad --- /dev/null +++ b/tests/components/youtube/snapshots/test_diagnostics.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'UC_x5XG1OV2P6uZZ5FSM9Ttw': dict({ + 'icon': 'https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj', + 'id': 'UC_x5XG1OV2P6uZZ5FSM9Ttw', + 'latest_video': dict({ + 'published_at': '2023-05-11T00:20:46+00:00', + 'thumbnail': 'https://i.ytimg.com/vi/wysukDrMdqU/maxresdefault.jpg', + 'title': "What's new in Google Home in less than 1 minute", + 'video_id': 'wysukDrMdqU', + }), + 'subscriber_count': 2290000, + 'title': 'Google for Developers', + }), + }) +# --- diff --git a/tests/components/youtube/snapshots/test_sensor.ambr b/tests/components/youtube/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..e3bfa4ec4bd --- /dev/null +++ b/tests/components/youtube/snapshots/test_sensor.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://i.ytimg.com/vi/wysukDrMdqU/maxresdefault.jpg', + 'friendly_name': 'Google for Developers Latest upload', + 'icon': 'mdi:youtube', + 'published_at': datetime.datetime(2023, 5, 11, 0, 20, 46, tzinfo=datetime.timezone.utc), + 'video_id': 'wysukDrMdqU', + }), + 'context': , + 'entity_id': 'sensor.google_for_developers_latest_upload', + 'last_changed': , + 'last_updated': , + 'state': "What's new in Google Home in less than 1 minute", + }) +# --- +# name: test_sensor.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj', + 'friendly_name': 'Google for Developers Subscribers', + 'icon': 'mdi:youtube-subscription', + 'unit_of_measurement': 'subscribers', + }), + 'context': , + 'entity_id': 'sensor.google_for_developers_subscribers', + 'last_changed': , + 'last_updated': , + 'state': '2290000', + }) +# --- +# name: test_sensor_without_uploaded_video + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Google for Developers Latest upload', + 'icon': 'mdi:youtube', + }), + 'context': , + 'entity_id': 'sensor.google_for_developers_latest_upload', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_sensor_without_uploaded_video.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj', + 'friendly_name': 'Google for Developers Subscribers', + 'icon': 'mdi:youtube-subscription', + 'unit_of_measurement': 'subscribers', + }), + 'context': , + 'entity_id': 'sensor.google_for_developers_subscribers', + 'last_changed': , + 'last_updated': , + 'state': '2290000', + }) +# --- diff --git a/tests/components/youtube/test_config_flow.py b/tests/components/youtube/test_config_flow.py index 5b91ff958f8..97875004d11 100644 --- a/tests/components/youtube/test_config_flow.py +++ b/tests/components/youtube/test_config_flow.py @@ -1,9 +1,8 @@ """Test the YouTube config flow.""" from unittest.mock import patch -from googleapiclient.errors import HttpError -from httplib2 import Response import pytest +from youtubeaio.types import ForbiddenError from homeassistant import config_entries from homeassistant.components.youtube.const import CONF_CHANNELS, DOMAIN @@ -11,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from . import MockService +from . import MockYouTube from .conftest import ( CLIENT_ID, GOOGLE_AUTH_URI, @@ -21,7 +20,7 @@ from .conftest import ( ComponentSetup, ) -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -58,9 +57,8 @@ async def test_full_flow( with patch( "homeassistant.components.youtube.async_setup_entry", return_value=True ) as mock_setup, patch( - "homeassistant.components.youtube.api.build", return_value=MockService() - ), patch( - "homeassistant.components.youtube.config_flow.build", return_value=MockService() + "homeassistant.components.youtube.config_flow.YouTube", + return_value=MockYouTube(), ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.FORM @@ -112,11 +110,11 @@ async def test_flow_abort_without_channel( assert resp.status == 200 assert resp.headers["content-type"] == "text/html; charset=utf-8" - service = MockService(channel_fixture="youtube/get_no_channel.json") + service = MockYouTube(channel_fixture="youtube/get_no_channel.json") with patch( "homeassistant.components.youtube.async_setup_entry", return_value=True - ), patch("homeassistant.components.youtube.api.build", return_value=service), patch( - "homeassistant.components.youtube.config_flow.build", return_value=service + ), patch( + "homeassistant.components.youtube.config_flow.YouTube", return_value=service ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.ABORT @@ -153,41 +151,29 @@ async def test_flow_http_error( assert resp.headers["content-type"] == "text/html; charset=utf-8" with patch( - "homeassistant.components.youtube.config_flow.build", - side_effect=HttpError( - Response( - { - "vary": "Origin, X-Origin, Referer", - "content-type": "application/json; charset=UTF-8", - "date": "Mon, 15 May 2023 21:25:42 GMT", - "server": "scaffolding on HTTPServer2", - "cache-control": "private", - "x-xss-protection": "0", - "x-frame-options": "SAMEORIGIN", - "x-content-type-options": "nosniff", - "alt-svc": 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000', - "transfer-encoding": "chunked", - "status": "403", - "content-length": "947", - "-content-encoding": "gzip", - } - ), - b'{"error": {"code": 403,"message": "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.","errors": [ { "message": "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", "domain": "usageLimits", "reason": "accessNotConfigured", "extendedHelp": "https://console.developers.google.com" }],"status": "PERMISSION_DENIED"\n }\n}\n', + "homeassistant.components.youtube.config_flow.YouTube.get_user_channels", + side_effect=ForbiddenError( + "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." ), ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "access_not_configured" - assert ( - result["description_placeholders"]["message"] - == "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." + assert result["description_placeholders"]["message"] == ( + "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." ) @pytest.mark.parametrize( ("fixture", "abort_reason", "placeholders", "calls", "access_token"), [ - ("get_channel", "reauth_successful", None, 1, "updated-access-token"), + ( + "get_channel", + "reauth_successful", + None, + 1, + "updated-access-token", + ), ( "get_channel_2", "wrong_account", @@ -254,14 +240,12 @@ async def test_reauth( }, ) + youtube = MockYouTube(channel_fixture=f"youtube/{fixture}.json") with patch( "homeassistant.components.youtube.async_setup_entry", return_value=True ) as mock_setup, patch( - "httplib2.Http.request", - return_value=( - Response({}), - bytes(load_fixture(f"youtube/{fixture}.json"), encoding="UTF-8"), - ), + "homeassistant.components.youtube.config_flow.YouTube", + return_value=youtube, ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -309,7 +293,7 @@ async def test_flow_exception( assert resp.headers["content-type"] == "text/html; charset=utf-8" with patch( - "homeassistant.components.youtube.config_flow.build", side_effect=Exception + "homeassistant.components.youtube.config_flow.YouTube", side_effect=Exception ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.ABORT @@ -322,7 +306,8 @@ async def test_options_flow( """Test the full options flow.""" await setup_integration() with patch( - "homeassistant.components.youtube.config_flow.build", return_value=MockService() + "homeassistant.components.youtube.config_flow.YouTube", + return_value=MockYouTube(), ): entry = hass.config_entries.async_entries(DOMAIN)[0] result = await hass.config_entries.options.async_init(entry.entry_id) diff --git a/tests/components/youtube/test_diagnostics.py b/tests/components/youtube/test_diagnostics.py new file mode 100644 index 00000000000..4fe16c3a8b6 --- /dev/null +++ b/tests/components/youtube/test_diagnostics.py @@ -0,0 +1,23 @@ +"""Tests for the diagnostics data provided by the YouTube integration.""" +from syrupy import SnapshotAssertion + +from homeassistant.components.youtube.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import ComponentSetup + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_integration: ComponentSetup, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot diff --git a/tests/components/youtube/test_init.py b/tests/components/youtube/test_init.py index 02df1b0e32e..bd3babdc383 100644 --- a/tests/components/youtube/test_init.py +++ b/tests/components/youtube/test_init.py @@ -126,7 +126,7 @@ async def test_device_info( entry = hass.config_entries.async_entries(DOMAIN)[0] channel_id = entry.options[CONF_CHANNELS][0] device = device_registry.async_get_device( - {(DOMAIN, f"{entry.entry_id}_{channel_id}")} + identifiers={(DOMAIN, f"{entry.entry_id}_{channel_id}")} ) assert device.entry_type is dr.DeviceEntryType.SERVICE diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py index 6bd99399952..9f0b63bc062 100644 --- a/tests/components/youtube/test_sensor.py +++ b/tests/components/youtube/test_sensor.py @@ -2,42 +2,54 @@ from datetime import timedelta from unittest.mock import patch -from google.auth.exceptions import RefreshError -import pytest +from syrupy import SnapshotAssertion +from youtubeaio.types import UnauthorizedError, YouTubeBackendError from homeassistant import config_entries -from homeassistant.components.youtube import DOMAIN +from homeassistant.components.youtube.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import MockService -from .conftest import TOKEN, ComponentSetup +from . import MockYouTube +from .conftest import ComponentSetup from tests.common import async_fire_time_changed -async def test_sensor(hass: HomeAssistant, setup_integration: ComponentSetup) -> None: +async def test_sensor( + hass: HomeAssistant, snapshot: SnapshotAssertion, setup_integration: ComponentSetup +) -> None: """Test sensor.""" await setup_integration() state = hass.states.get("sensor.google_for_developers_latest_upload") - assert state - assert state.name == "Google for Developers Latest upload" - assert state.state == "What's new in Google Home in less than 1 minute" - assert ( - state.attributes["entity_picture"] - == "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg" - ) - assert state.attributes["video_id"] == "wysukDrMdqU" + assert state == snapshot state = hass.states.get("sensor.google_for_developers_subscribers") - assert state - assert state.name == "Google for Developers Subscribers" - assert state.state == "2290000" - assert ( - state.attributes["entity_picture"] - == "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj" - ) + assert state == snapshot + + +async def test_sensor_without_uploaded_video( + hass: HomeAssistant, snapshot: SnapshotAssertion, setup_integration: ComponentSetup +) -> None: + """Test sensor when there is no video on the channel.""" + await setup_integration() + + with patch( + "homeassistant.components.youtube.api.AsyncConfigEntryAuth.get_resource", + return_value=MockYouTube( + playlist_items_fixture="youtube/get_no_playlist_items.json" + ), + ): + future = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.google_for_developers_latest_upload") + assert state == snapshot + + state = hass.states.get("sensor.google_for_developers_subscribers") + assert state == snapshot async def test_sensor_updating( @@ -51,8 +63,8 @@ async def test_sensor_updating( assert state.attributes["video_id"] == "wysukDrMdqU" with patch( - "homeassistant.components.youtube.api.build", - return_value=MockService( + "homeassistant.components.youtube.api.AsyncConfigEntryAuth.get_resource", + return_value=MockYouTube( playlist_items_fixture="youtube/get_playlist_items_2.json" ), ): @@ -65,7 +77,7 @@ async def test_sensor_updating( assert state.state == "Google I/O 2023 Developer Keynote in 5 minutes" assert ( state.attributes["entity_picture"] - == "https://i.ytimg.com/vi/hleLlcHwQLM/sddefault.jpg" + == "https://i.ytimg.com/vi/hleLlcHwQLM/maxresdefault.jpg" ) assert state.attributes["video_id"] == "hleLlcHwQLM" @@ -74,12 +86,18 @@ async def test_sensor_reauth_trigger( hass: HomeAssistant, setup_integration: ComponentSetup ) -> None: """Test reauth is triggered after a refresh error.""" - await setup_integration() + mock = await setup_integration() - with patch(TOKEN, side_effect=RefreshError): - future = dt_util.utcnow() + timedelta(minutes=15) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + state = hass.states.get("sensor.google_for_developers_latest_upload") + assert state.state == "What's new in Google Home in less than 1 minute" + + state = hass.states.get("sensor.google_for_developers_subscribers") + assert state.state == "2290000" + + mock.set_thrown_exception(UnauthorizedError()) + future = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() @@ -90,36 +108,25 @@ async def test_sensor_reauth_trigger( assert flow["context"]["source"] == config_entries.SOURCE_REAUTH -@pytest.mark.parametrize( - ("fixture", "url", "has_entity_picture"), - [ - ("standard", "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg", True), - ("high", "https://i.ytimg.com/vi/wysukDrMdqU/hqdefault.jpg", True), - ("medium", "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg", True), - ("default", "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg", True), - ("none", None, False), - ], -) -async def test_thumbnail( - hass: HomeAssistant, - setup_integration: ComponentSetup, - fixture: str, - url: str | None, - has_entity_picture: bool, +async def test_sensor_unavailable( + hass: HomeAssistant, setup_integration: ComponentSetup ) -> None: - """Test if right thumbnail is selected.""" - await setup_integration() + """Test update failed.""" + mock = await setup_integration() - with patch( - "homeassistant.components.youtube.api.build", - return_value=MockService( - playlist_items_fixture=f"youtube/thumbnail/{fixture}.json" - ), - ): - future = dt_util.utcnow() + timedelta(minutes=15) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() state = hass.states.get("sensor.google_for_developers_latest_upload") - assert state - assert ("entity_picture" in state.attributes) is has_entity_picture - assert state.attributes.get("entity_picture") == url + assert state.state == "What's new in Google Home in less than 1 minute" + + state = hass.states.get("sensor.google_for_developers_subscribers") + assert state.state == "2290000" + + mock.set_thrown_exception(YouTubeBackendError()) + future = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.google_for_developers_latest_upload") + assert state.state == "unavailable" + + state = hass.states.get("sensor.google_for_developers_subscribers") + assert state.state == "unavailable" diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 5740abef789..b07e2d5880a 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -12,7 +12,6 @@ from zeroconf import ( from zeroconf.asyncio import AsyncServiceInfo from homeassistant.components import zeroconf -from homeassistant.components.zeroconf import CONF_DEFAULT_INTERFACE, CONF_IPV6 from homeassistant.const import ( EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, @@ -231,82 +230,10 @@ async def test_setup_with_overly_long_url_and_name( assert "German Umlaut" in caplog.text -async def test_setup_with_default_interface( - hass: HomeAssistant, mock_async_zeroconf: None +async def test_setup_with_defaults( + hass: HomeAssistant, mock_zeroconf: None, mock_async_zeroconf: None ) -> None: """Test default interface config.""" - with patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock - ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", - side_effect=get_service_info_mock, - ): - assert await async_setup_component( - hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {CONF_DEFAULT_INTERFACE: True}} - ) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - assert mock_async_zeroconf.called_with(interface_choice=InterfaceChoice.Default) - - -async def test_setup_without_default_interface( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: - """Test without default interface config.""" - with patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock - ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", - side_effect=get_service_info_mock, - ): - assert await async_setup_component( - hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {CONF_DEFAULT_INTERFACE: False}} - ) - - assert mock_async_zeroconf.called_with() - - -async def test_setup_without_ipv6( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: - """Test without ipv6.""" - with patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock - ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", - side_effect=get_service_info_mock, - ): - assert await async_setup_component( - hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {CONF_IPV6: False}} - ) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - assert mock_async_zeroconf.called_with(ip_version=IPVersion.V4Only) - - -async def test_setup_with_ipv6(hass: HomeAssistant, mock_async_zeroconf: None) -> None: - """Test without ipv6.""" - with patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock - ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo", - side_effect=get_service_info_mock, - ): - assert await async_setup_component( - hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {CONF_IPV6: True}} - ) - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - assert mock_async_zeroconf.called_with() - - -async def test_setup_with_ipv6_default( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: - """Test without ipv6 as default.""" with patch.object(hass.config_entries.flow, "async_init"), patch.object( zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock ), patch( @@ -317,7 +244,9 @@ async def test_setup_with_ipv6_default( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert mock_async_zeroconf.called_with() + mock_zeroconf.assert_called_with( + interfaces=InterfaceChoice.Default, ip_version=IPVersion.V4Only + ) async def test_zeroconf_match_macaddress( diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 271108496b2..e3a12703640 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -332,8 +332,8 @@ def zha_device_mock( @pytest.fixture def hass_disable_services(hass): - """Mock service register.""" - with patch.object(hass.services, "async_register"), patch.object( - hass.services, "has_service", return_value=True + """Mock services.""" + with patch.object( + hass, "services", MagicMock(has_service=MagicMock(return_value=True)) ): yield hass diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index 1897383b6c4..7e0e8eaab85 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -22,6 +22,7 @@ from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.endpoint import Endpoint import homeassistant.components.zha.core.registries as registries from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .common import get_zha_gateway, make_zcl_header from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE @@ -831,3 +832,37 @@ async def test_invalid_cluster_handler(hass: HomeAssistant, caplog) -> None: zha_endpoint.add_all_cluster_handlers() assert "missing_attr" in caplog.text + + +# parametrize side effects: +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (zigpy.exceptions.ZigbeeException(), "Failed to send request"), + ( + zigpy.exceptions.ZigbeeException("Zigbee exception"), + "Failed to send request: Zigbee exception", + ), + (asyncio.TimeoutError(), "Failed to send request: device did not respond"), + ], +) +async def test_retry_request( + side_effect: Exception | None, expected_error: str | None +) -> None: + """Test the `retry_request` decorator's handling of zigpy-internal exceptions.""" + + async def func(arg1: int, arg2: int) -> int: + assert arg1 == 1 + assert arg2 == 2 + + raise side_effect + + func = mock.AsyncMock(wraps=func) + decorated_func = cluster_handlers.retry_request(func) + + with pytest.raises(HomeAssistantError) as exc: + await decorated_func(1, arg2=2) + + assert func.await_count == 3 + assert isinstance(exc.value, HomeAssistantError) + assert str(exc.value) == expected_error diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index d1003418487..7c4198bd881 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -26,6 +26,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import CoreState, HomeAssistant, State +from homeassistant.exceptions import HomeAssistantError from .common import ( async_enable_traffic, @@ -236,7 +237,7 @@ async def test_shade( # close from UI command fails with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): - with pytest.raises(asyncio.TimeoutError): + with pytest.raises(HomeAssistantError): await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, @@ -261,7 +262,7 @@ async def test_shade( assert ATTR_CURRENT_POSITION not in hass.states.get(entity_id).attributes await send_attributes_report(hass, cluster_level, {0: 0}) with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): - with pytest.raises(asyncio.TimeoutError): + with pytest.raises(HomeAssistantError): await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, @@ -285,7 +286,7 @@ async def test_shade( # set position UI command fails with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): - with pytest.raises(asyncio.TimeoutError): + with pytest.raises(HomeAssistantError): await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, @@ -326,7 +327,7 @@ async def test_shade( # test cover stop with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError): - with pytest.raises(asyncio.TimeoutError): + with pytest.raises(HomeAssistantError): await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, @@ -395,7 +396,7 @@ async def test_keen_vent( p2 = patch.object(cluster_level, "request", return_value=[4, 0]) with p1, p2: - with pytest.raises(asyncio.TimeoutError): + with pytest.raises(HomeAssistantError): await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index d938512981f..46cdff180e9 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -113,7 +113,9 @@ async def test_get_actions(hass: HomeAssistant, device_ias) -> None: ieee_address = str(device_ias[0].ieee) ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device({(DOMAIN, ieee_address)}) + reg_device = ha_device_registry.async_get_device( + identifiers={(DOMAIN, ieee_address)} + ) ha_entity_registry = er.async_get(hass) siren_level_select = ha_entity_registry.async_get( "select.fakemanufacturer_fakemodel_default_siren_level" @@ -175,7 +177,7 @@ async def test_get_inovelli_actions(hass: HomeAssistant, device_inovelli) -> Non inovelli_ieee_address = str(device_inovelli[0].ieee) ha_device_registry = dr.async_get(hass) inovelli_reg_device = ha_device_registry.async_get_device( - {(DOMAIN, inovelli_ieee_address)} + identifiers={(DOMAIN, inovelli_ieee_address)} ) ha_entity_registry = er.async_get(hass) inovelli_button = ha_entity_registry.async_get("button.inovelli_vzm31_sn_identify") @@ -265,9 +267,11 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: inovelli_ieee_address = str(inovelli_zha_device.ieee) ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device({(DOMAIN, ieee_address)}) + reg_device = ha_device_registry.async_get_device( + identifiers={(DOMAIN, ieee_address)} + ) inovelli_reg_device = ha_device_registry.async_get_device( - {(DOMAIN, inovelli_ieee_address)} + identifiers={(DOMAIN, inovelli_ieee_address)} ) cluster = inovelli_zigpy_device.endpoints[1].in_clusters[0xFC31] diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 85e012c5bfb..22f62cb977a 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -105,7 +105,9 @@ async def test_triggers(hass: HomeAssistant, mock_devices) -> None: ieee_address = str(zha_device.ieee) ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) + reg_device = ha_device_registry.async_get_device( + identifiers={("zha", ieee_address)} + ) triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, reg_device.id @@ -171,7 +173,9 @@ async def test_no_triggers(hass: HomeAssistant, mock_devices) -> None: ieee_address = str(zha_device.ieee) ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) + reg_device = ha_device_registry.async_get_device( + identifiers={("zha", ieee_address)} + ) triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, reg_device.id @@ -203,7 +207,9 @@ async def test_if_fires_on_event(hass: HomeAssistant, mock_devices, calls) -> No ieee_address = str(zha_device.ieee) ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) + reg_device = ha_device_registry.async_get_device( + identifiers={("zha", ieee_address)} + ) assert await async_setup_component( hass, @@ -312,7 +318,9 @@ async def test_exception_no_triggers( ieee_address = str(zha_device.ieee) ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) + reg_device = ha_device_registry.async_get_device( + identifiers={("zha", ieee_address)} + ) await async_setup_component( hass, @@ -359,7 +367,9 @@ async def test_exception_bad_trigger( ieee_address = str(zha_device.ieee) ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) + reg_device = ha_device_registry.async_get_device( + identifiers={("zha", ieee_address)} + ) await async_setup_component( hass, diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 5ec555d88df..0bb06ea723b 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -94,7 +94,7 @@ async def test_diagnostics_for_device( zha_device: ZHADevice = await zha_device_joined(zigpy_device) dev_reg = async_get(hass) - device = dev_reg.async_get_device({("zha", str(zha_device.ieee))}) + device = dev_reg.async_get_device(identifiers={("zha", str(zha_device.ieee))}) assert device diagnostics_data = await get_diagnostics_for_device( hass, hass_client, config_entry, device diff --git a/tests/components/zha/test_logbook.py b/tests/components/zha/test_logbook.py index 3d20749baac..44495cf0e15 100644 --- a/tests/components/zha/test_logbook.py +++ b/tests/components/zha/test_logbook.py @@ -78,7 +78,9 @@ async def test_zha_logbook_event_device_with_triggers( ieee_address = str(zha_device.ieee) ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) + reg_device = ha_device_registry.async_get_device( + identifiers={("zha", ieee_address)} + ) hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) @@ -154,7 +156,9 @@ async def test_zha_logbook_event_device_no_triggers( zigpy_device, zha_device = mock_devices ieee_address = str(zha_device.ieee) ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) + reg_device = ha_device_registry.async_get_device( + identifiers={("zha", ieee_address)} + ) hass.config.components.add("recorder") assert await async_setup_component(hass, "logbook", {}) diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 057921f80a9..6f36ee624e9 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -18,7 +18,8 @@ if typing.TYPE_CHECKING: MANUFACTURER = "mock manufacturer" MODEL = "mock model" -QUIRK_CLASS = "mock.class" +QUIRK_CLASS = "mock.test.quirk.class" +QUIRK_CLASS_SHORT = "quirk.class" @pytest.fixture @@ -209,6 +210,12 @@ def cluster_handlers(cluster_handler): ), False, ), + ( + registries.MatchRule( + cluster_handler_names="on_off", quirk_classes=QUIRK_CLASS_SHORT + ), + True, + ), ], ) def test_registry_matching(rule, matched, cluster_handlers) -> None: diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 4ccf7323148..bba5ee124ba 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -3013,11 +3013,6 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "Illuminance", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_illuminance", }, - ("sensor", "00:11:22:33:44:55:66:77-1-1"): { - DEV_SIG_CLUSTER_HANDLERS: ["power"], - DEV_SIG_ENT_MAP_CLASS: "Battery", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_sensor_motion_aq2_battery", - }, ("sensor", "00:11:22:33:44:55:66:77-1-2"): { DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], DEV_SIG_ENT_MAP_CLASS: "DeviceTemperature", diff --git a/tests/components/zodiac/test_config_flow.py b/tests/components/zodiac/test_config_flow.py new file mode 100644 index 00000000000..18a512e0b45 --- /dev/null +++ b/tests/components/zodiac/test_config_flow.py @@ -0,0 +1,70 @@ +"""Tests for the Zodiac config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.zodiac.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + + with patch( + "homeassistant.components.zodiac.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("title") == "Zodiac" + assert result.get("data") == {} + assert result.get("options") == {} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) +async def test_single_instance_allowed( + hass: HomeAssistant, + source: str, +) -> None: + """Test we abort if already setup.""" + mock_config_entry = MockConfigEntry(domain=DOMAIN) + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source} + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "single_instance_allowed" + + +async def test_import_flow( + hass: HomeAssistant, +) -> None: + """Test the import configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={}, + ) + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("title") == "Zodiac" + assert result.get("data") == {} + assert result.get("options") == {} diff --git a/tests/components/zodiac/test_sensor.py b/tests/components/zodiac/test_sensor.py index dbb1d2739a5..9fa151c87d5 100644 --- a/tests/components/zodiac/test_sensor.py +++ b/tests/components/zodiac/test_sensor.py @@ -24,6 +24,8 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from tests.common import MockConfigEntry + DAY1 = datetime(2020, 11, 15, tzinfo=dt_util.UTC) DAY2 = datetime(2020, 4, 20, tzinfo=dt_util.UTC) DAY3 = datetime(2020, 4, 21, tzinfo=dt_util.UTC) @@ -37,13 +39,17 @@ DAY3 = datetime(2020, 4, 21, tzinfo=dt_util.UTC) (DAY3, SIGN_TAURUS, ELEMENT_EARTH, MODALITY_FIXED), ], ) -async def test_zodiac_day(hass: HomeAssistant, now, sign, element, modality) -> None: +async def test_zodiac_day( + hass: HomeAssistant, now: datetime, sign: str, element: str, modality: str +) -> None: """Test the zodiac sensor.""" hass.config.set_time_zone("UTC") - config = {DOMAIN: {}} + MockConfigEntry( + domain=DOMAIN, + ).add_to_hass(hass) with patch("homeassistant.components.zodiac.sensor.utcnow", return_value=now): - assert await async_setup_component(hass, DOMAIN, config) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() state = hass.states.get("sensor.zodiac") diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 3da63419a4b..606dda30b24 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -35,6 +35,7 @@ CLIMATE_DANFOSS_LC13_ENTITY = "climate.living_connect_z_thermostat" CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY = "climate.thermostatic_valve" CLIMATE_FLOOR_THERMOSTAT_ENTITY = "climate.floor_thermostat" CLIMATE_MAIN_HEAT_ACTIONNER = "climate.main_heat_actionner" +CLIMATE_AIDOO_HVAC_UNIT_ENTITY = "climate.aidoo_control_hvac_unit" BULB_6_MULTI_COLOR_LIGHT_ENTITY = "light.bulb_6_multi_color" EATON_RF9640_ENTITY = "light.allloaddimmer" AEON_SMART_SWITCH_LIGHT_ENTITY = "light.smart_switch_6" diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 68484111802..0eb4ec775f9 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -365,6 +365,14 @@ def climate_adc_t3000_state_fixture(): return json.loads(load_fixture("zwave_js/climate_adc_t3000_state.json")) +@pytest.fixture(name="climate_airzone_aidoo_control_hvac_unit_state", scope="session") +def climate_airzone_aidoo_control_hvac_unit_state_fixture(): + """Load the climate Airzone Aidoo Control HVAC Unit state fixture data.""" + return json.loads( + load_fixture("zwave_js/climate_airzone_aidoo_control_hvac_unit_state.json") + ) + + @pytest.fixture(name="climate_danfoss_lc_13_state", scope="session") def climate_danfoss_lc_13_state_fixture(): """Load Danfoss (LC-13) electronic radiator thermostat node state fixture data.""" @@ -826,6 +834,16 @@ def climate_adc_t3000_missing_fan_mode_states_fixture(client, climate_adc_t3000_ return node +@pytest.fixture(name="climate_airzone_aidoo_control_hvac_unit") +def climate_airzone_aidoo_control_hvac_unit_fixture( + client, climate_airzone_aidoo_control_hvac_unit_state +): + """Mock a climate Airzone Aidoo Control HVAC node.""" + node = Node(client, copy.deepcopy(climate_airzone_aidoo_control_hvac_unit_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="climate_danfoss_lc_13") def climate_danfoss_lc_13_fixture(client, climate_danfoss_lc_13_state): """Mock a climate radio danfoss LC-13 node.""" diff --git a/tests/components/zwave_js/fixtures/climate_airzone_aidoo_control_hvac_unit_state.json b/tests/components/zwave_js/fixtures/climate_airzone_aidoo_control_hvac_unit_state.json new file mode 100644 index 00000000000..b5afa1131a8 --- /dev/null +++ b/tests/components/zwave_js/fixtures/climate_airzone_aidoo_control_hvac_unit_state.json @@ -0,0 +1,818 @@ +{ + "nodeId": 12, + "index": 0, + "installerIcon": 4608, + "userIcon": 4608, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 1102, + "productId": 1, + "productType": 4, + "firmwareVersion": "10.20.1", + "zwavePlusVersion": 2, + "deviceConfig": { + "filename": "/data/db/devices/0x044e/AZAI6WSPFU2.json", + "isEmbedded": true, + "manufacturer": "Airzone", + "manufacturerId": 1102, + "label": "AZAI6WSPFU2", + "description": "Aidoo Control HVAC unit", + "devices": [ + { + "productType": 4, + "productId": 1 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "compat": { + "overrideFloatEncoding": { + "precision": 1 + } + } + }, + "label": "AZAI6WSPFU2", + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 12, + "index": 0, + "installerIcon": 4608, + "userIcon": 4608, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 8, + "label": "Thermostat" + }, + "specific": { + "key": 6, + "label": "General Thermostat V2" + }, + "mandatorySupportedCCs": [32, 114, 64, 67, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 64, + "name": "Thermostat Mode", + "version": 3, + "isSecure": false + }, + { + "id": 67, + "name": "Thermostat Setpoint", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 68, + "name": "Thermostat Fan Mode", + "version": 2, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": false + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 29 + }, + { + "endpoint": 0, + "commandClass": 64, + "commandClassName": "Thermostat Mode", + "property": "mode", + "propertyName": "mode", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Thermostat mode", + "min": 0, + "max": 255, + "states": { + "0": "Off", + "1": "Heat", + "2": "Cool", + "6": "Fan", + "8": "Dry", + "10": "Auto changeover" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 64, + "commandClassName": "Thermostat Mode", + "property": "manufacturerData", + "propertyName": "manufacturerData", + "ccVersion": 3, + "metadata": { + "type": "buffer", + "readable": true, + "writeable": false, + "label": "Manufacturer data", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "property": "setpoint", + "propertyKey": 1, + "propertyName": "setpoint", + "propertyKeyName": "Heating", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Setpoint (Heating)", + "ccSpecific": { + "setpointType": 1 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "property": "setpoint", + "propertyKey": 2, + "propertyName": "setpoint", + "propertyKeyName": "Cooling", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Setpoint (Cooling)", + "ccSpecific": { + "setpointType": 2 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 23 + }, + { + "endpoint": 0, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "property": "setpoint", + "propertyKey": 8, + "propertyName": "setpoint", + "propertyKeyName": "Dry Air", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Setpoint (Dry Air)", + "ccSpecific": { + "setpointType": 8 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 23 + }, + { + "endpoint": 0, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "property": "setpoint", + "propertyKey": 10, + "propertyName": "setpoint", + "propertyKeyName": "Auto Changeover", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Setpoint (Auto Changeover)", + "ccSpecific": { + "setpointType": 10 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 32 + }, + { + "endpoint": 0, + "commandClass": 68, + "commandClassName": "Thermostat Fan Mode", + "property": "mode", + "propertyName": "mode", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Thermostat fan mode", + "min": 0, + "max": 255, + "states": { + "1": "Low", + "3": "High", + "4": "Auto medium", + "5": "Medium" + }, + "stateful": true, + "secret": false + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Hardware status", + "propertyName": "System", + "propertyKeyName": "Hardware status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Hardware status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "System hardware failure (with failure code)" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1102 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.16" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["10.20"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.16.3" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "10.16.3" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 297 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.16.3" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 297 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "10.20.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 43707 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify", + "states": { + "true": "Identify" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Timeout", + "stateful": true, + "secret": false + } + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 8, + "label": "Thermostat" + }, + "specific": { + "key": 6, + "label": "General Thermostat V2" + }, + "mandatorySupportedCCs": [32, 114, 64, 67, 134], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x044e:0x0004:0x0001:10.20.1", + "statistics": { + "commandsTX": 69, + "commandsRX": 497, + "commandsDroppedRX": 0, + "commandsDroppedTX": 2, + "timeoutResponse": 0, + "rtt": 81 + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index c6a0f7a845d..ebdf2112435 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -94,7 +94,7 @@ def get_device(hass: HomeAssistant, node): """Get device ID for a node.""" dev_reg = dr.async_get(hass) device_id = get_device_id(node.client.driver, node) - return dev_reg.async_get_device({device_id}) + return dev_reg.async_get_device(identifiers={device_id}) async def test_no_driver( @@ -462,7 +462,7 @@ async def test_node_comments( ws_client = await hass_ws_client(hass) dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device({(DOMAIN, "3245146787-35")}) + device = dev_reg.async_get_device(identifiers={(DOMAIN, "3245146787-35")}) assert device await ws_client.send_json( diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index d3f38aaa307..23d34c131b8 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -18,6 +18,7 @@ from homeassistant.components.climate import ( ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_PRESET_MODE, + ATTR_PRESET_MODES, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, @@ -39,8 +40,10 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from .common import ( + CLIMATE_AIDOO_HVAC_UNIT_ENTITY, CLIMATE_DANFOSS_LC13_ENTITY, CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY, CLIMATE_FLOOR_THERMOSTAT_ENTITY, @@ -694,3 +697,95 @@ async def test_thermostat_unknown_values( state = hass.states.get(CLIMATE_RADIO_THERMOSTAT_ENTITY) assert ATTR_HVAC_ACTION not in state.attributes + + +async def test_thermostat_dry_and_fan_both_hvac_mode_and_preset( + hass: HomeAssistant, + client, + climate_airzone_aidoo_control_hvac_unit, + integration, +) -> None: + """Test that dry and fan modes are both available as hvac mode and preset.""" + state = hass.states.get(CLIMATE_AIDOO_HVAC_UNIT_ENTITY) + assert state + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.HEAT_COOL, + ] + assert state.attributes[ATTR_PRESET_MODES] == [ + PRESET_NONE, + "Fan", + "Dry", + ] + + +async def test_thermostat_raise_repair_issue_and_warning_when_setting_dry_preset( + hass: HomeAssistant, + client, + climate_airzone_aidoo_control_hvac_unit, + integration, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test raise of repair issue and warning when setting Dry preset.""" + state = hass.states.get(CLIMATE_AIDOO_HVAC_UNIT_ENTITY) + assert state + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: CLIMATE_AIDOO_HVAC_UNIT_ENTITY, + ATTR_PRESET_MODE: "Dry", + }, + blocking=True, + ) + + issue_id = f"dry_fan_presets_deprecation_{CLIMATE_AIDOO_HVAC_UNIT_ENTITY}" + issue_registry = ir.async_get(hass) + + assert issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=issue_id, + ) + assert ( + "Dry and Fan preset modes are deprecated and will be removed in Home Assistant 2024.2. Please use the corresponding Dry and Fan HVAC modes instead" + in caplog.text + ) + + +async def test_thermostat_raise_repair_issue_and_warning_when_setting_fan_preset( + hass: HomeAssistant, + client, + climate_airzone_aidoo_control_hvac_unit, + integration, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test raise of repair issue and warning when setting Fan preset.""" + state = hass.states.get(CLIMATE_AIDOO_HVAC_UNIT_ENTITY) + assert state + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + { + ATTR_ENTITY_ID: CLIMATE_AIDOO_HVAC_UNIT_ENTITY, + ATTR_PRESET_MODE: "Fan", + }, + blocking=True, + ) + + issue_id = f"dry_fan_presets_deprecation_{CLIMATE_AIDOO_HVAC_UNIT_ENTITY}" + issue_registry = ir.async_get(hass) + + assert issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=issue_id, + ) + assert ( + "Dry and Fan preset modes are deprecated and will be removed in Home Assistant 2024.2. Please use the corresponding Dry and Fan HVAC modes instead" + in caplog.text + ) diff --git a/tests/components/zwave_js/test_device_action.py b/tests/components/zwave_js/test_device_action.py index ccb65c1d8fa..ce2b916b7a1 100644 --- a/tests/components/zwave_js/test_device_action.py +++ b/tests/components/zwave_js/test_device_action.py @@ -15,7 +15,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.setup import async_setup_component from tests.common import async_get_device_automations @@ -27,40 +31,47 @@ async def test_get_actions( lock_schlage_be469: Node, integration: ConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test we get the expected actions from a zwave_js node.""" node = lock_schlage_be469 driver = client.driver assert driver - device = device_registry.async_get_device({get_device_id(driver, node)}) + device = device_registry.async_get_device(identifiers={get_device_id(driver, node)}) assert device + binary_sensor = entity_registry.async_get( + "binary_sensor.touchscreen_deadbolt_low_battery_level" + ) + assert binary_sensor + lock = entity_registry.async_get("lock.touchscreen_deadbolt") + assert lock expected_actions = [ { "domain": DOMAIN, "type": "clear_lock_usercode", "device_id": device.id, - "entity_id": "lock.touchscreen_deadbolt", + "entity_id": lock.id, "metadata": {"secondary": False}, }, { "domain": DOMAIN, "type": "set_lock_usercode", "device_id": device.id, - "entity_id": "lock.touchscreen_deadbolt", + "entity_id": lock.id, "metadata": {"secondary": False}, }, { "domain": DOMAIN, "type": "refresh_value", "device_id": device.id, - "entity_id": "binary_sensor.touchscreen_deadbolt_low_battery_level", + "entity_id": binary_sensor.id, "metadata": {"secondary": True}, }, { "domain": DOMAIN, "type": "refresh_value", "device_id": device.id, - "entity_id": "lock.touchscreen_deadbolt", + "entity_id": lock.id, "metadata": {"secondary": False}, }, { @@ -94,7 +105,7 @@ async def test_get_actions( # Test that we don't return actions for a controller node device = device_registry.async_get_device( - {get_device_id(driver, client.driver.controller.nodes[1])} + identifiers={get_device_id(driver, client.driver.controller.nodes[1])} ) assert device assert ( @@ -114,7 +125,7 @@ async def test_get_actions_meter( node = aeon_smart_switch_6 driver = client.driver assert driver - device = device_registry.async_get_device({get_device_id(driver, node)}) + device = device_registry.async_get_device(identifiers={get_device_id(driver, node)}) assert device actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device.id @@ -129,15 +140,19 @@ async def test_actions( climate_radio_thermostat_ct100_plus: Node, integration: ConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test actions.""" node = climate_radio_thermostat_ct100_plus driver = client.driver assert driver device_id = get_device_id(driver, node) - device = device_registry.async_get_device({device_id}) + device = device_registry.async_get_device(identifiers={device_id}) assert device + climate = entity_registry.async_get("climate.z_wave_thermostat") + assert climate + assert await async_setup_component( hass, automation.DOMAIN, @@ -152,7 +167,7 @@ async def test_actions( "domain": DOMAIN, "type": "refresh_value", "device_id": device.id, - "entity_id": "climate.z_wave_thermostat", + "entity_id": climate.id, }, }, { @@ -273,21 +288,25 @@ async def test_actions( assert args[2] == 1 -async def test_actions_multiple_calls( +async def test_actions_legacy( hass: HomeAssistant, client: Client, climate_radio_thermostat_ct100_plus: Node, integration: ConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: - """Test actions can be called multiple times and still work.""" + """Test actions.""" node = climate_radio_thermostat_ct100_plus driver = client.driver assert driver device_id = get_device_id(driver, node) - device = device_registry.async_get_device({device_id}) + device = device_registry.async_get_device(identifiers={device_id}) assert device + climate = entity_registry.async_get("climate.z_wave_thermostat") + assert climate + assert await async_setup_component( hass, automation.DOMAIN, @@ -302,7 +321,64 @@ async def test_actions_multiple_calls( "domain": DOMAIN, "type": "refresh_value", "device_id": device.id, - "entity_id": "climate.z_wave_thermostat", + "entity_id": climate.entity_id, + }, + }, + ] + }, + ) + + with patch("zwave_js_server.model.node.Node.async_poll_value") as mock_call: + hass.bus.async_fire("test_event_refresh_value") + await hass.async_block_till_done() + mock_call.assert_called_once() + args = mock_call.call_args_list[0][0] + assert len(args) == 1 + assert args[0].value_id == "13-64-1-mode" + + # Call action a second time to confirm that it works (this was previously a bug) + with patch("zwave_js_server.model.node.Node.async_poll_value") as mock_call: + hass.bus.async_fire("test_event_refresh_value") + await hass.async_block_till_done() + mock_call.assert_called_once() + args = mock_call.call_args_list[0][0] + assert len(args) == 1 + assert args[0].value_id == "13-64-1-mode" + + +async def test_actions_multiple_calls( + hass: HomeAssistant, + client: Client, + climate_radio_thermostat_ct100_plus: Node, + integration: ConfigEntry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test actions can be called multiple times and still work.""" + node = climate_radio_thermostat_ct100_plus + driver = client.driver + assert driver + device_id = get_device_id(driver, node) + device = device_registry.async_get_device({device_id}) + assert device + climate = entity_registry.async_get("climate.z_wave_thermostat") + assert climate + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event_refresh_value", + }, + "action": { + "domain": DOMAIN, + "type": "refresh_value", + "device_id": device.id, + "entity_id": climate.id, }, }, ] @@ -326,14 +402,17 @@ async def test_lock_actions( lock_schlage_be469: Node, integration: ConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test actions for locks.""" node = lock_schlage_be469 driver = client.driver assert driver device_id = get_device_id(driver, node) - device = device_registry.async_get_device({device_id}) + device = device_registry.async_get_device(identifiers={device_id}) assert device + lock = entity_registry.async_get("lock.touchscreen_deadbolt") + assert lock assert await async_setup_component( hass, @@ -349,7 +428,7 @@ async def test_lock_actions( "domain": DOMAIN, "type": "clear_lock_usercode", "device_id": device.id, - "entity_id": "lock.touchscreen_deadbolt", + "entity_id": lock.id, "code_slot": 1, }, }, @@ -362,7 +441,7 @@ async def test_lock_actions( "domain": DOMAIN, "type": "set_lock_usercode", "device_id": device.id, - "entity_id": "lock.touchscreen_deadbolt", + "entity_id": lock.id, "code_slot": 1, "usercode": "1234", }, @@ -397,14 +476,17 @@ async def test_reset_meter_action( aeon_smart_switch_6: Node, integration: ConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test reset_meter action.""" node = aeon_smart_switch_6 driver = client.driver assert driver device_id = get_device_id(driver, node) - device = device_registry.async_get_device({device_id}) + device = device_registry.async_get_device(identifiers={device_id}) assert device + sensor = entity_registry.async_get("sensor.smart_switch_6_electric_consumed_kwh") + assert sensor assert await async_setup_component( hass, @@ -420,7 +502,7 @@ async def test_reset_meter_action( "domain": DOMAIN, "type": "reset_meter", "device_id": device.id, - "entity_id": "sensor.smart_switch_6_electric_consumed_kwh", + "entity_id": sensor.id, }, }, ] @@ -448,7 +530,7 @@ async def test_get_action_capabilities( ) -> None: """Test we get the expected action capabilities.""" device = device_registry.async_get_device( - {get_device_id(client.driver, climate_radio_thermostat_ct100_plus)} + identifiers={get_device_id(client.driver, climate_radio_thermostat_ct100_plus)} ) assert device @@ -615,9 +697,12 @@ async def test_get_action_capabilities_lock_triggers( lock_schlage_be469: Node, integration: ConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test we get the expected action capabilities for lock triggers.""" device = dr.async_entries_for_config_entry(device_registry, integration.entry_id)[0] + lock = entity_registry.async_get("lock.touchscreen_deadbolt") + assert lock # Test clear_lock_usercode capabilities = await device_action.async_get_action_capabilities( @@ -626,7 +711,7 @@ async def test_get_action_capabilities_lock_triggers( "platform": "device", "domain": DOMAIN, "device_id": device.id, - "entity_id": "lock.touchscreen_deadbolt", + "entity_id": lock.id, "type": "clear_lock_usercode", }, ) @@ -643,7 +728,7 @@ async def test_get_action_capabilities_lock_triggers( "platform": "device", "domain": DOMAIN, "device_id": device.id, - "entity_id": "lock.touchscreen_deadbolt", + "entity_id": lock.id, "type": "set_lock_usercode", }, ) @@ -663,12 +748,13 @@ async def test_get_action_capabilities_meter_triggers( aeon_smart_switch_6: Node, integration: ConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test we get the expected action capabilities for meter triggers.""" node = aeon_smart_switch_6 driver = client.driver assert driver - device = device_registry.async_get_device({get_device_id(driver, node)}) + device = device_registry.async_get_device(identifiers={get_device_id(driver, node)}) assert device capabilities = await device_action.async_get_action_capabilities( hass, @@ -676,7 +762,7 @@ async def test_get_action_capabilities_meter_triggers( "platform": "device", "domain": DOMAIN, "device_id": device.id, - "entity_id": "sensor.meter", + "entity_id": "123456789", # The entity is not checked "type": "reset_meter", }, ) @@ -716,19 +802,23 @@ async def test_unavailable_entity_actions( lock_schlage_be469: Node, integration: ConfigEntry, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test unavailable entities are not included in actions list.""" - entity_id_unavailable = "binary_sensor.touchscreen_deadbolt_home_security_intrusion" + entity_id_unavailable = "binary_sensor.touchscreen_deadbolt_low_battery_level" hass.states.async_set(entity_id_unavailable, STATE_UNAVAILABLE, force_update=True) await hass.async_block_till_done() node = lock_schlage_be469 driver = client.driver assert driver - device = device_registry.async_get_device({get_device_id(driver, node)}) + device = device_registry.async_get_device(identifiers={get_device_id(driver, node)}) assert device + binary_sensor = entity_registry.async_get(entity_id_unavailable) + assert binary_sensor actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device.id ) assert not any( action.get("entity_id") == entity_id_unavailable for action in actions ) + assert not any(action.get("entity_id") == binary_sensor.id for action in actions) diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index 11213d9c375..f7aacec36ac 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -42,7 +42,7 @@ async def test_get_conditions( ) -> None: """Test we get the expected onditions from a zwave_js.""" device = device_registry.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device config_value = list(lock_schlage_be469.get_configuration_values().values())[0] @@ -82,7 +82,7 @@ async def test_get_conditions( # Test that we don't return actions for a controller node device = device_registry.async_get_device( - {get_device_id(client.driver, client.driver.controller.nodes[1])} + identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} ) assert device assert ( @@ -103,7 +103,7 @@ async def test_node_status_state( ) -> None: """Test for node_status conditions.""" device = device_registry.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -268,7 +268,7 @@ async def test_config_parameter_state( ) -> None: """Test for config_parameter conditions.""" device = device_registry.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -388,7 +388,7 @@ async def test_value_state( ) -> None: """Test for value conditions.""" device = device_registry.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -439,7 +439,7 @@ async def test_get_condition_capabilities_node_status( ) -> None: """Test we don't get capabilities from a node_status condition.""" device = device_registry.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -479,7 +479,7 @@ async def test_get_condition_capabilities_value( ) -> None: """Test we get the expected capabilities from a value condition.""" device = device_registry.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -532,7 +532,7 @@ async def test_get_condition_capabilities_config_parameter( """Test we get the expected capabilities from a config_parameter condition.""" node = climate_radio_thermostat_ct100_plus device = device_registry.async_get_device( - {get_device_id(client.driver, climate_radio_thermostat_ct100_plus)} + identifiers={get_device_id(client.driver, climate_radio_thermostat_ct100_plus)} ) assert device @@ -617,7 +617,7 @@ async def test_failure_scenarios( ) -> None: """Test failure scenarios.""" device = device_registry.async_get_device( - {get_device_id(client.driver, hank_binary_switch)} + identifiers={get_device_id(client.driver, hank_binary_switch)} ) assert device diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index 8209564579c..8551427cf3e 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -41,7 +41,7 @@ async def test_no_controller_triggers(hass: HomeAssistant, client, integration) """Test that we do not get triggers for the controller.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, client.driver.controller.nodes[1])} + identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} ) assert device assert ( @@ -58,7 +58,7 @@ async def test_get_notification_notification_triggers( """Test we get the expected triggers from a zwave_js device with the Notification CC.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device expected_trigger = { @@ -82,7 +82,7 @@ async def test_if_notification_notification_fires( node: Node = lock_schlage_be469 dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -178,7 +178,7 @@ async def test_get_trigger_capabilities_notification_notification( """Test we get the expected capabilities from a notification.notification trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device capabilities = await device_trigger.async_get_trigger_capabilities( @@ -212,7 +212,7 @@ async def test_if_entry_control_notification_fires( node: Node = lock_schlage_be469 dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -307,7 +307,7 @@ async def test_get_trigger_capabilities_entry_control_notification( """Test we get the expected capabilities from a notification.entry_control trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device capabilities = await device_trigger.async_get_trigger_capabilities( @@ -338,14 +338,14 @@ async def test_get_node_status_triggers( """Test we get the expected triggers from a device with node status sensor enabled.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device ent_reg = async_get_ent_reg(hass) entity_id = async_get_node_status_sensor_entity_id( hass, device.id, ent_reg, dev_reg ) - ent_reg.async_update_entity(entity_id, **{"disabled_by": None}) + entity = ent_reg.async_update_entity(entity_id, **{"disabled_by": None}) await hass.config_entries.async_reload(integration.entry_id) await hass.async_block_till_done() @@ -354,7 +354,7 @@ async def test_get_node_status_triggers( "domain": DOMAIN, "type": "state.node_status", "device_id": device.id, - "entity_id": entity_id, + "entity_id": entity.id, "metadata": {"secondary": True}, } triggers = await async_get_device_automations( @@ -365,6 +365,85 @@ async def test_get_node_status_triggers( async def test_if_node_status_change_fires( hass: HomeAssistant, client, lock_schlage_be469, integration, calls +) -> None: + """Test for node_status trigger firing.""" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, lock_schlage_be469)} + ) + assert device + ent_reg = async_get_ent_reg(hass) + entity_id = async_get_node_status_sensor_entity_id( + hass, device.id, ent_reg, dev_reg + ) + entity = ent_reg.async_update_entity(entity_id, **{"disabled_by": None}) + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # from + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": entity.id, + "type": "state.node_status", + "from": "alive", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "state.node_status - " + "{{ trigger.platform}} - " + "{{ trigger.from_state.state }}" + ) + }, + }, + }, + # no from or to + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": entity.id, + "type": "state.node_status", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "state.node_status2 - " + "{{ trigger.platform}} - " + "{{ trigger.from_state.state }}" + ) + }, + }, + }, + ] + }, + ) + + # Test status change + event = Event( + "dead", data={"source": "node", "event": "dead", "nodeId": node.node_id} + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data["some"] == "state.node_status - device - alive" + assert calls[1].data["some"] == "state.node_status2 - device - alive" + + +async def test_if_node_status_change_fires_legacy( + hass: HomeAssistant, client, lock_schlage_be469, integration, calls ) -> None: """Test for node_status trigger firing.""" node: Node = lock_schlage_be469 @@ -448,7 +527,7 @@ async def test_get_trigger_capabilities_node_status( """Test we get the expected capabilities from a node_status trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device ent_reg = async_get_ent_reg(hass) @@ -506,7 +585,7 @@ async def test_get_basic_value_notification_triggers( """Test we get the expected triggers from a zwave_js device with the Basic CC.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, ge_in_wall_dimmer_switch)} + identifiers={get_device_id(client.driver, ge_in_wall_dimmer_switch)} ) assert device expected_trigger = { @@ -534,7 +613,7 @@ async def test_if_basic_value_notification_fires( node: Node = ge_in_wall_dimmer_switch dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, ge_in_wall_dimmer_switch)} + identifiers={get_device_id(client.driver, ge_in_wall_dimmer_switch)} ) assert device @@ -645,7 +724,7 @@ async def test_get_trigger_capabilities_basic_value_notification( """Test we get the expected capabilities from a value_notification.basic trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, ge_in_wall_dimmer_switch)} + identifiers={get_device_id(client.driver, ge_in_wall_dimmer_switch)} ) assert device capabilities = await device_trigger.async_get_trigger_capabilities( @@ -683,7 +762,7 @@ async def test_get_central_scene_value_notification_triggers( """Test we get the expected triggers from a zwave_js device with the Central Scene CC.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, wallmote_central_scene)} + identifiers={get_device_id(client.driver, wallmote_central_scene)} ) assert device expected_trigger = { @@ -711,7 +790,7 @@ async def test_if_central_scene_value_notification_fires( node: Node = wallmote_central_scene dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, wallmote_central_scene)} + identifiers={get_device_id(client.driver, wallmote_central_scene)} ) assert device @@ -828,7 +907,7 @@ async def test_get_trigger_capabilities_central_scene_value_notification( """Test we get the expected capabilities from a value_notification.central_scene trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, wallmote_central_scene)} + identifiers={get_device_id(client.driver, wallmote_central_scene)} ) assert device capabilities = await device_trigger.async_get_trigger_capabilities( @@ -865,7 +944,7 @@ async def test_get_scene_activation_value_notification_triggers( """Test we get the expected triggers from a zwave_js device with the SceneActivation CC.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, hank_binary_switch)} + identifiers={get_device_id(client.driver, hank_binary_switch)} ) assert device expected_trigger = { @@ -893,7 +972,7 @@ async def test_if_scene_activation_value_notification_fires( node: Node = hank_binary_switch dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, hank_binary_switch)} + identifiers={get_device_id(client.driver, hank_binary_switch)} ) assert device @@ -1004,7 +1083,7 @@ async def test_get_trigger_capabilities_scene_activation_value_notification( """Test we get the expected capabilities from a value_notification.scene_activation trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, hank_binary_switch)} + identifiers={get_device_id(client.driver, hank_binary_switch)} ) assert device capabilities = await device_trigger.async_get_trigger_capabilities( @@ -1042,7 +1121,7 @@ async def test_get_value_updated_value_triggers( """Test we get the zwave_js.value_updated.value trigger from a zwave_js device.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device expected_trigger = { @@ -1065,7 +1144,7 @@ async def test_if_value_updated_value_fires( node: Node = lock_schlage_be469 dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -1157,7 +1236,7 @@ async def test_value_updated_value_no_driver( node: Node = lock_schlage_be469 dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device driver = client.driver @@ -1227,7 +1306,7 @@ async def test_get_trigger_capabilities_value_updated_value( """Test we get the expected capabilities from a zwave_js.value_updated.value trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device capabilities = await device_trigger.async_get_trigger_capabilities( @@ -1278,7 +1357,7 @@ async def test_get_value_updated_config_parameter_triggers( """Test we get the zwave_js.value_updated.config_parameter trigger from a zwave_js device.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device expected_trigger = { @@ -1306,7 +1385,7 @@ async def test_if_value_updated_config_parameter_fires( node: Node = lock_schlage_be469 dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -1376,7 +1455,7 @@ async def test_get_trigger_capabilities_value_updated_config_parameter_range( """Test we get the expected capabilities from a range zwave_js.value_updated.config_parameter trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device capabilities = await device_trigger.async_get_trigger_capabilities( @@ -1421,7 +1500,7 @@ async def test_get_trigger_capabilities_value_updated_config_parameter_enumerate """Test we get the expected capabilities from an enumerated zwave_js.value_updated.config_parameter trigger.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device capabilities = await device_trigger.async_get_trigger_capabilities( @@ -1477,7 +1556,7 @@ async def test_failure_scenarios( dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, hank_binary_switch)} + identifiers={get_device_id(client.driver, hank_binary_switch)} ) assert device diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index aa5ec77b798..4454e38e0d8 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -58,7 +58,9 @@ async def test_device_diagnostics( ) -> None: """Test the device level diagnostics data dump.""" dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device({get_device_id(client.driver, multisensor_6)}) + device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, multisensor_6)} + ) assert device # Create mock config entry for fake entity @@ -151,7 +153,9 @@ async def test_device_diagnostics_missing_primary_value( ) -> None: """Test that device diagnostics handles an entity with a missing primary value.""" dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device({get_device_id(client.driver, multisensor_6)}) + device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, multisensor_6)} + ) assert device entity_id = "sensor.multisensor_6_air_temperature" @@ -240,7 +244,7 @@ async def test_device_diagnostics_secret_value( client.driver.controller.emit("node added", {"node": node}) await hass.async_block_till_done() dev_reg = dr.async_get(hass) - device = dev_reg.async_get_device({get_device_id(client.driver, node)}) + device = dev_reg.async_get_device(identifiers={get_device_id(client.driver, node)}) assert device diagnostics_data = await get_diagnostics_for_device( diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index a33ee75661c..3ec1f113b3e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -976,7 +976,9 @@ async def test_removed_device( assert len(device_entries) == 2 entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id) assert len(entity_entries) == 60 - assert dev_reg.async_get_device({get_device_id(driver, old_node)}) is None + assert ( + dev_reg.async_get_device(identifiers={get_device_id(driver, old_node)}) is None + ) async def test_suggested_area(hass: HomeAssistant, client, eaton_rf9640_dimmer) -> None: diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index a8671edbe64..54638358fe7 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -410,7 +410,9 @@ async def test_bulk_set_config_parameters( ) -> None: """Test the bulk_set_partial_config_parameters service.""" dev_reg = async_get_dev_reg(hass) - device = dev_reg.async_get_device({get_device_id(client.driver, multisensor_6)}) + device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, multisensor_6)} + ) assert device # Test setting config parameter by property and property_key await hass.services.async_call( @@ -757,7 +759,7 @@ async def test_set_value( """Test set_value service.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, climate_danfoss_lc_13)} + identifiers={get_device_id(client.driver, climate_danfoss_lc_13)} ) assert device @@ -1103,11 +1105,11 @@ async def test_multicast_set_value( # Test using area ID dev_reg = async_get_dev_reg(hass) device_eurotronic = dev_reg.async_get_device( - {get_device_id(client.driver, climate_eurotronic_spirit_z)} + identifiers={get_device_id(client.driver, climate_eurotronic_spirit_z)} ) assert device_eurotronic device_danfoss = dev_reg.async_get_device( - {get_device_id(client.driver, climate_danfoss_lc_13)} + identifiers={get_device_id(client.driver, climate_danfoss_lc_13)} ) assert device_danfoss area_reg = async_get_area_reg(hass) @@ -1416,7 +1418,7 @@ async def test_ping( """Test ping service.""" dev_reg = async_get_dev_reg(hass) device_radio_thermostat = dev_reg.async_get_device( - { + identifiers={ get_device_id( client.driver, climate_radio_thermostat_ct100_plus_different_endpoints ) @@ -1424,7 +1426,7 @@ async def test_ping( ) assert device_radio_thermostat device_danfoss = dev_reg.async_get_device( - {get_device_id(client.driver, climate_danfoss_lc_13)} + identifiers={get_device_id(client.driver, climate_danfoss_lc_13)} ) assert device_danfoss @@ -1566,7 +1568,7 @@ async def test_invoke_cc_api( """Test invoke_cc_api service.""" dev_reg = async_get_dev_reg(hass) device_radio_thermostat = dev_reg.async_get_device( - { + identifiers={ get_device_id( client.driver, climate_radio_thermostat_ct100_plus_different_endpoints ) @@ -1574,7 +1576,7 @@ async def test_invoke_cc_api( ) assert device_radio_thermostat device_danfoss = dev_reg.async_get_device( - {get_device_id(client.driver, climate_danfoss_lc_13)} + identifiers={get_device_id(client.driver, climate_danfoss_lc_13)} ) assert device_danfoss diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index eae9d6f5416..501ad13cbaa 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -35,7 +35,7 @@ async def test_zwave_js_value_updated( node: Node = lock_schlage_be469 dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -459,7 +459,7 @@ async def test_zwave_js_event( node: Node = lock_schlage_be469 dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device @@ -1013,7 +1013,7 @@ async def test_zwave_js_trigger_config_entry_unloaded( """Test zwave_js triggers bypass dynamic validation when needed.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device( - {get_device_id(client.driver, lock_schlage_be469)} + identifiers={get_device_id(client.driver, lock_schlage_be469)} ) assert device diff --git a/tests/components/zwave_me/test_remove_stale_devices.py b/tests/components/zwave_me/test_remove_stale_devices.py index dca28929b3b..d5496255add 100644 --- a/tests/components/zwave_me/test_remove_stale_devices.py +++ b/tests/components/zwave_me/test_remove_stale_devices.py @@ -60,7 +60,7 @@ async def test_remove_stale_devices( assert ( bool( device_registry.async_get_device( - { + identifiers={ ( "zwave_me", f"{config_entry.unique_id}-{identifier}", diff --git a/tests/conftest.py b/tests/conftest.py index 922e42c7a7e..40fd1c2eef0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -111,7 +111,7 @@ asyncio.set_event_loop_policy = lambda policy: None def _utcnow() -> datetime.datetime: """Make utcnow patchable by freezegun.""" - return datetime.datetime.now(datetime.timezone.utc) + return datetime.datetime.now(datetime.UTC) dt_util.utcnow = _utcnow # type: ignore[assignment] @@ -1104,21 +1104,34 @@ def mock_get_source_ip() -> Generator[None, None, None]: @pytest.fixture def mock_zeroconf() -> Generator[None, None, None]: """Mock zeroconf.""" - with patch("homeassistant.components.zeroconf.HaZeroconf", autospec=True), patch( + from zeroconf import DNSCache # pylint: disable=import-outside-toplevel + + with patch( + "homeassistant.components.zeroconf.HaZeroconf", autospec=True + ) as mock_zc, patch( "homeassistant.components.zeroconf.HaAsyncServiceBrowser", autospec=True ): - yield + zc = mock_zc.return_value + # DNSCache has strong Cython type checks, and MagicMock does not work + # so we must mock the class directly + zc.cache = DNSCache() + yield mock_zc @pytest.fixture def mock_async_zeroconf(mock_zeroconf: None) -> Generator[None, None, None]: """Mock AsyncZeroconf.""" + from zeroconf import DNSCache # pylint: disable=import-outside-toplevel + with patch("homeassistant.components.zeroconf.HaAsyncZeroconf") as mock_aiozc: zc = mock_aiozc.return_value zc.async_unregister_service = AsyncMock() zc.async_register_service = AsyncMock() zc.async_update_service = AsyncMock() zc.zeroconf.async_wait_for_start = AsyncMock() + # DNSCache has strong Cython type checks, and MagicMock does not work + # so we must mock the class directly + zc.zeroconf.cache = DNSCache() zc.zeroconf.done = False zc.async_close = AsyncMock() zc.ha_async_close = AsyncMock() diff --git a/tests/fixtures/london_air.json b/tests/fixtures/london_air.json index 3a3d9afb643..7045a90e6e9 100644 --- a/tests/fixtures/london_air.json +++ b/tests/fixtures/london_air.json @@ -3,6 +3,14 @@ "@GroupName": "London", "@TimeToLive": "38", "LocalAuthority": [ + { + "@LocalAuthorityCode": "7", + "@LocalAuthorityName": "City of London", + "@LaCentreLatitude": "51.51333", + "@LaCentreLongitude": "-0.088947", + "@LaCentreLatitudeWGS84": "6712603.132989", + "@LaCentreLongitudeWGS84": "-9901.534748" + }, { "@LocalAuthorityCode": "24", "@LocalAuthorityName": "Merton", diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 91ef17d526d..3b9b3cf6558 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -8,9 +8,10 @@ from homeassistant.helpers.check_config import ( CheckConfigError, async_check_ha_config_file, ) +import homeassistant.helpers.config_validation as cv from homeassistant.requirements import RequirementsNotFound -from tests.common import mock_platform, patch_yaml_files +from tests.common import MockModule, mock_integration, mock_platform, patch_yaml_files _LOGGER = logging.getLogger(__name__) @@ -246,3 +247,20 @@ bla: assert err.domain == "bla" assert err.message == "Unexpected error calling config validator: Broken" assert err.config == {"value": 1} + + +async def test_removed_yaml_support(hass: HomeAssistant) -> None: + """Test config validation check with removed CONFIG_SCHEMA without raise if present.""" + mock_integration( + hass, + MockModule( + domain="bla", config_schema=cv.removed("bla", raise_if_present=False) + ), + False, + ) + files = {YAML_CONFIG_FILE: BASE_CONFIG + "bla:\n platform: demo"} + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant"} diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 5ea6df42349..b5c8cc1716e 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -127,6 +127,35 @@ def test_url() -> None: assert schema(value) +def test_configuration_url() -> None: + """Test URL.""" + schema = vol.Schema(cv.configuration_url) + + for value in ( + "invalid", + None, + 100, + "htp://ha.io", + "http//ha.io", + "http://??,**", + "https://??,**", + "homeassistant://??,**", + ): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + for value in ( + "http://localhost", + "https://localhost/test/index.html", + "http://home-assistant.io", + "http://home-assistant.io/test/", + "https://community.home-assistant.io/", + "homeassistant://api", + "homeassistant://api/hassio_ingress/XXXXXXX", + ): + assert schema(value) + + def test_url_no_path() -> None: """Test URL.""" schema = vol.Schema(cv.url_no_path) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index e183bd4c380..9ebee025bd5 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1,14 +1,16 @@ """Tests for the Device Registry.""" +from contextlib import nullcontext import time from typing import Any from unittest.mock import patch import pytest +from yarl import URL from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.exceptions import RequiredParameterMissing +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -118,7 +120,7 @@ async def test_requirement_for_identifier_or_connection( assert entry assert entry2 - with pytest.raises(RequiredParameterMissing) as exc_info: + with pytest.raises(HomeAssistantError): device_registry.async_get_or_create( config_entry_id="1234", connections=set(), @@ -127,8 +129,6 @@ async def test_requirement_for_identifier_or_connection( model="model", ) - assert exc_info.value.parameter_names == ["identifiers", "connections"] - async def test_multiple_config_entries(device_registry: dr.DeviceRegistry) -> None: """Make sure we do not get duplicate entries.""" @@ -173,7 +173,7 @@ async def test_loading_from_storage( { "area_id": "12345A", "config_entries": ["1234"], - "configuration_url": "configuration_url", + "configuration_url": "https://example.com/config", "connections": [["Zigbee", "01.23.45.67.89"]], "disabled_by": dr.DeviceEntryDisabler.USER, "entry_type": dr.DeviceEntryType.SERVICE, @@ -215,7 +215,7 @@ async def test_loading_from_storage( assert entry == dr.DeviceEntry( area_id="12345A", config_entries={"1234"}, - configuration_url="configuration_url", + configuration_url="https://example.com/config", connections={("Zigbee", "01.23.45.67.89")}, disabled_by=dr.DeviceEntryDisabler.USER, entry_type=dr.DeviceEntryType.SERVICE, @@ -530,8 +530,10 @@ async def test_removing_config_entries( assert entry2.config_entries == {"123", "456"} device_registry.async_clear_config_entry("123") - entry = device_registry.async_get_device({("bridgeid", "0123")}) - entry3_removed = device_registry.async_get_device({("bridgeid", "4567")}) + entry = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) + entry3_removed = device_registry.async_get_device( + identifiers={("bridgeid", "4567")} + ) assert entry.config_entries == {"456"} assert entry3_removed is None @@ -664,7 +666,7 @@ async def test_removing_area_id(device_registry: dr.DeviceRegistry) -> None: entry_w_area = device_registry.async_update_device(entry.id, area_id="12345A") device_registry.async_clear_area_id("12345A") - entry_wo_area = device_registry.async_get_device({("bridgeid", "0123")}) + entry_wo_area = device_registry.async_get_device(identifiers={("bridgeid", "0123")}) assert not entry_wo_area.area_id assert entry_w_area != entry_wo_area @@ -692,7 +694,7 @@ async def test_specifying_via_device_create(device_registry: dr.DeviceRegistry) assert light.via_device_id == via.id device_registry.async_remove_device(via.id) - light = device_registry.async_get_device({("hue", "456")}) + light = device_registry.async_get_device(identifiers={("hue", "456")}) assert light.via_device_id is None @@ -821,9 +823,9 @@ async def test_loading_saving_data( assert list(device_registry.devices) == list(registry2.devices) assert list(device_registry.deleted_devices) == list(registry2.deleted_devices) - new_via = registry2.async_get_device({("hue", "0123")}) - new_light = registry2.async_get_device({("hue", "456")}) - new_light4 = registry2.async_get_device({("hue", "abc")}) + new_via = registry2.async_get_device(identifiers={("hue", "0123")}) + new_light = registry2.async_get_device(identifiers={("hue", "456")}) + new_light4 = registry2.async_get_device(identifiers={("hue", "abc")}) assert orig_via == new_via assert orig_light == new_light @@ -839,7 +841,7 @@ async def test_loading_saving_data( assert old.entry_type is new.entry_type # Ensure a save/load cycle does not keep suggested area - new_kitchen_light = registry2.async_get_device({("hue", "999")}) + new_kitchen_light = registry2.async_get_device(identifiers={("hue", "999")}) assert orig_kitchen_light.suggested_area == "Kitchen" orig_kitchen_light_witout_suggested_area = device_registry.async_update_device( @@ -916,7 +918,7 @@ async def test_update( updated_entry = device_registry.async_update_device( entry.id, area_id="12345A", - configuration_url="configuration_url", + configuration_url="https://example.com/config", disabled_by=dr.DeviceEntryDisabler.USER, entry_type=dr.DeviceEntryType.SERVICE, hw_version="hw_version", @@ -935,7 +937,7 @@ async def test_update( assert updated_entry == dr.DeviceEntry( area_id="12345A", config_entries={"1234"}, - configuration_url="configuration_url", + configuration_url="https://example.com/config", connections={("mac", "12:34:56:ab:cd:ef")}, disabled_by=dr.DeviceEntryDisabler.USER, entry_type=dr.DeviceEntryType.SERVICE, @@ -951,15 +953,19 @@ async def test_update( via_device_id="98765B", ) - assert device_registry.async_get_device({("hue", "456")}) is None - assert device_registry.async_get_device({("bla", "123")}) is None + assert device_registry.async_get_device(identifiers={("hue", "456")}) is None + assert device_registry.async_get_device(identifiers={("bla", "123")}) is None - assert device_registry.async_get_device({("hue", "654")}) == updated_entry - assert device_registry.async_get_device({("bla", "321")}) == updated_entry + assert ( + device_registry.async_get_device(identifiers={("hue", "654")}) == updated_entry + ) + assert ( + device_registry.async_get_device(identifiers={("bla", "321")}) == updated_entry + ) assert ( device_registry.async_get_device( - {}, {(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")} + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")} ) == updated_entry ) @@ -1032,7 +1038,7 @@ async def test_update_remove_config_entries( assert updated_entry.config_entries == {"456"} assert removed_entry is None - removed_entry = device_registry.async_get_device({("bridgeid", "4567")}) + removed_entry = device_registry.async_get_device(identifiers={("bridgeid", "4567")}) assert removed_entry is None @@ -1137,10 +1143,10 @@ async def test_cleanup_device_registry( dr.async_cleanup(hass, device_registry, ent_reg) - assert device_registry.async_get_device({("hue", "d1")}) is not None - assert device_registry.async_get_device({("hue", "d2")}) is not None - assert device_registry.async_get_device({("hue", "d3")}) is not None - assert device_registry.async_get_device({("something", "d4")}) is None + assert device_registry.async_get_device(identifiers={("hue", "d1")}) is not None + assert device_registry.async_get_device(identifiers={("hue", "d2")}) is not None + assert device_registry.async_get_device(identifiers={("hue", "d3")}) is not None + assert device_registry.async_get_device(identifiers={("something", "d4")}) is None async def test_cleanup_device_registry_removes_expired_orphaned_devices( @@ -1456,7 +1462,8 @@ async def test_get_or_create_empty_then_set_default_values( ) -> None: """Test creating an entry, then setting default name, model, manufacturer.""" entry = device_registry.async_get_or_create( - identifiers={("bridgeid", "0123")}, config_entry_id="1234" + config_entry_id="1234", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) assert entry.name is None assert entry.model is None @@ -1464,7 +1471,7 @@ async def test_get_or_create_empty_then_set_default_values( entry = device_registry.async_get_or_create( config_entry_id="1234", - identifiers={("bridgeid", "0123")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, default_name="default name 1", default_model="default model 1", default_manufacturer="default manufacturer 1", @@ -1475,7 +1482,7 @@ async def test_get_or_create_empty_then_set_default_values( entry = device_registry.async_get_or_create( config_entry_id="1234", - identifiers={("bridgeid", "0123")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, default_name="default name 2", default_model="default model 2", default_manufacturer="default manufacturer 2", @@ -1490,7 +1497,8 @@ async def test_get_or_create_empty_then_update( ) -> None: """Test creating an entry, then setting name, model, manufacturer.""" entry = device_registry.async_get_or_create( - identifiers={("bridgeid", "0123")}, config_entry_id="1234" + config_entry_id="1234", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) assert entry.name is None assert entry.model is None @@ -1498,7 +1506,7 @@ async def test_get_or_create_empty_then_update( entry = device_registry.async_get_or_create( config_entry_id="1234", - identifiers={("bridgeid", "0123")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, name="name 1", model="model 1", manufacturer="manufacturer 1", @@ -1509,7 +1517,7 @@ async def test_get_or_create_empty_then_update( entry = device_registry.async_get_or_create( config_entry_id="1234", - identifiers={("bridgeid", "0123")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, default_name="default name 1", default_model="default model 1", default_manufacturer="default manufacturer 1", @@ -1525,7 +1533,7 @@ async def test_get_or_create_sets_default_values( """Test creating an entry, then setting default name, model, manufacturer.""" entry = device_registry.async_get_or_create( config_entry_id="1234", - identifiers={("bridgeid", "0123")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, default_name="default name 1", default_model="default model 1", default_manufacturer="default manufacturer 1", @@ -1536,7 +1544,7 @@ async def test_get_or_create_sets_default_values( entry = device_registry.async_get_or_create( config_entry_id="1234", - identifiers={("bridgeid", "0123")}, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, default_name="default name 2", default_model="default model 2", default_manufacturer="default manufacturer 2", @@ -1664,3 +1672,102 @@ async def test_only_disable_device_if_all_config_entries_are_disabled( entry1 = device_registry.async_get(entry1.id) assert not entry1.disabled + + +@pytest.mark.parametrize( + ("configuration_url", "expectation"), + [ + ("http://localhost", nullcontext()), + ("http://localhost:8123", nullcontext()), + ("https://example.com", nullcontext()), + ("http://localhost/config", nullcontext()), + ("http://localhost:8123/config", nullcontext()), + ("https://example.com/config", nullcontext()), + ("homeassistant://config", nullcontext()), + (URL("http://localhost"), nullcontext()), + (URL("http://localhost:8123"), nullcontext()), + (URL("https://example.com"), nullcontext()), + (URL("http://localhost/config"), nullcontext()), + (URL("http://localhost:8123/config"), nullcontext()), + (URL("https://example.com/config"), nullcontext()), + (URL("homeassistant://config"), nullcontext()), + (None, nullcontext()), + ("http://", pytest.raises(ValueError)), + ("https://", pytest.raises(ValueError)), + ("gopher://localhost", pytest.raises(ValueError)), + ("homeassistant://", pytest.raises(ValueError)), + (URL("http://"), pytest.raises(ValueError)), + (URL("https://"), pytest.raises(ValueError)), + (URL("gopher://localhost"), pytest.raises(ValueError)), + (URL("homeassistant://"), pytest.raises(ValueError)), + # Exception implements __str__ + (Exception("https://example.com"), nullcontext()), + (Exception("https://"), pytest.raises(ValueError)), + (Exception(), pytest.raises(ValueError)), + ], +) +async def test_device_info_configuration_url_validation( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + configuration_url: str | URL | None, + expectation, +) -> None: + """Test configuration URL of device info is properly validated.""" + with expectation: + device_registry.async_get_or_create( + config_entry_id="1234", + identifiers={("something", "1234")}, + name="name", + configuration_url=configuration_url, + ) + + update_device = device_registry.async_get_or_create( + config_entry_id="5678", + identifiers={("something", "5678")}, + name="name", + ) + with expectation: + device_registry.async_update_device( + update_device.id, configuration_url=configuration_url + ) + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_loading_invalid_configuration_url_from_storage( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test loading stored devices with an invalid URL.""" + hass_storage[dr.STORAGE_KEY] = { + "version": dr.STORAGE_VERSION_MAJOR, + "minor_version": dr.STORAGE_VERSION_MINOR, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": ["1234"], + "configuration_url": "invalid", + "connections": [], + "disabled_by": None, + "entry_type": dr.DeviceEntryType.SERVICE, + "hw_version": None, + "id": "abcdefghijklm", + "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "manufacturer": None, + "model": None, + "name_by_user": None, + "name": None, + "sw_version": None, + "via_device_id": None, + } + ], + "deleted_devices": [], + }, + } + + await dr.async_load(hass) + registry = dr.async_get(hass) + assert len(registry.devices) == 1 + entry = registry.async_get_or_create( + config_entry_id="1234", identifiers={("serial", "12:34:56:AB:CD:EF")} + ) + assert entry.configuration_url == "invalid" diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 60d47ca9a44..0d9ee76ac62 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -972,6 +972,7 @@ async def _test_friendly_name( platform = MockPlatform(async_setup_entry=async_setup_entry) config_entry = MockConfigEntry(entry_id="super-mock-id") + config_entry.add_to_hass(hass) entity_platform = MockEntityPlatform( hass, platform_name=config_entry.domain, platform=platform ) @@ -1002,8 +1003,7 @@ async def _test_friendly_name( (True, "Entity Blu", "Device Bla", "Device Bla Entity Blu", False), (True, None, "Device Bla", "Device Bla", False), (True, "Entity Blu", UNDEFINED, "Entity Blu", False), - # Not valid on RC - # (True, "Entity Blu", None, "Mock Title Entity Blu", False), + (True, "Entity Blu", None, "Mock Title Entity Blu", False), ), ) async def test_friendly_name_attr( diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 46806510f40..3eaad662d8b 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1108,7 +1108,7 @@ async def test_device_info_called(hass: HomeAssistant) -> None: assert len(hass.states.async_entity_ids()) == 2 - device = registry.async_get_device({("hue", "1234")}) + device = registry.async_get_device(identifiers={("hue", "1234")}) assert device is not None assert device.identifiers == {("hue", "1234")} assert device.configuration_url == "http://192.168.0.100/config" @@ -1162,64 +1162,15 @@ async def test_device_info_not_overrides(hass: HomeAssistant) -> None: assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - device2 = registry.async_get_device(set(), {(dr.CONNECTION_NETWORK_MAC, "abcd")}) + device2 = registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, "abcd")} + ) assert device2 is not None assert device.id == device2.id assert device2.manufacturer == "test-manufacturer" assert device2.model == "test-model" -async def test_device_info_invalid_url( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test device info is forwarded correctly.""" - registry = dr.async_get(hass) - registry.async_get_or_create( - config_entry_id="123", - connections=set(), - identifiers={("hue", "via-id")}, - manufacturer="manufacturer", - model="via", - ) - - async def async_setup_entry(hass, config_entry, async_add_entities): - """Mock setup entry method.""" - async_add_entities( - [ - # Valid device info, but invalid url - MockEntity( - unique_id="qwer", - device_info={ - "identifiers": {("hue", "1234")}, - "configuration_url": "foo://192.168.0.100/config", - }, - ), - ] - ) - return True - - platform = MockPlatform(async_setup_entry=async_setup_entry) - config_entry = MockConfigEntry(entry_id="super-mock-id") - entity_platform = MockEntityPlatform( - hass, platform_name=config_entry.domain, platform=platform - ) - - assert await entity_platform.async_setup_entry(config_entry) - await hass.async_block_till_done() - - assert len(hass.states.async_entity_ids()) == 1 - - device = registry.async_get_device({("hue", "1234")}) - assert device is not None - assert device.identifiers == {("hue", "1234")} - assert device.configuration_url is None - - assert ( - "Ignoring invalid device configuration_url 'foo://192.168.0.100/config'" - in caplog.text - ) - - async def test_device_info_homeassistant_url( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1260,7 +1211,7 @@ async def test_device_info_homeassistant_url( assert len(hass.states.async_entity_ids()) == 1 - device = registry.async_get_device({("mqtt", "1234")}) + device = registry.async_get_device(identifiers={("mqtt", "1234")}) assert device is not None assert device.identifiers == {("mqtt", "1234")} assert device.configuration_url == "homeassistant://config/mqtt" @@ -1307,7 +1258,7 @@ async def test_device_info_change_to_no_url( assert len(hass.states.async_entity_ids()) == 1 - device = registry.async_get_device({("mqtt", "1234")}) + device = registry.async_get_device(identifiers={("mqtt", "1234")}) assert device is not None assert device.identifiers == {("mqtt", "1234")} assert device.configuration_url is None @@ -1477,11 +1428,19 @@ async def test_platform_with_no_setup( in caplog.text ) issue = issue_registry.async_get_issue( - domain="mock-integration", + domain="homeassistant", issue_id="platform_integration_no_support_mock-integration_mock-platform", ) assert issue - assert issue.translation_key == "platform_integration_no_support" + assert issue.issue_domain == "mock-platform" + assert issue.learn_more_url is None + assert issue.translation_key == "no_platform_setup" + assert issue.translation_placeholders == { + "domain": "mock-integration", + "platform": "mock-platform", + "platform_key": "platform: mock-platform", + "yaml_example": "```yaml\nmock-integration:\n - platform: mock-platform\n```", + } async def test_platforms_sharing_services(hass: HomeAssistant) -> None: @@ -1827,3 +1786,106 @@ async def test_translated_device_class_name_influences_entity_id( assert len(hass.states.async_entity_ids()) == 1 assert registry.async_get(expected_entity_id) is not None + + +@pytest.mark.parametrize( + ( + "config_entry_title", + "entity_device_name", + "entity_device_default_name", + "expected_device_name", + ), + [ + ("Mock Config Entry Title", None, None, "Mock Config Entry Title"), + ("Mock Config Entry Title", "", None, "Mock Config Entry Title"), + ("Mock Config Entry Title", None, "Hello", "Hello"), + ("Mock Config Entry Title", "Mock Device Name", None, "Mock Device Name"), + ], +) +async def test_device_name_defaulting_config_entry( + hass: HomeAssistant, + config_entry_title: str, + entity_device_name: str, + entity_device_default_name: str, + expected_device_name: str, +) -> None: + """Test setting the device name based on input info.""" + device_info = { + "connections": {(dr.CONNECTION_NETWORK_MAC, "1234")}, + } + + if entity_device_default_name: + device_info["default_name"] = entity_device_default_name + else: + device_info["name"] = entity_device_name + + class DeviceNameEntity(Entity): + _attr_unique_id = "qwer" + _attr_device_info = device_info + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities([DeviceNameEntity()]) + return True + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(title=config_entry_title, entry_id="super-mock-id") + config_entry.add_to_hass(hass) + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_device(connections={(dr.CONNECTION_NETWORK_MAC, "1234")}) + assert device is not None + assert device.name == expected_device_name + + +@pytest.mark.parametrize( + ("device_info"), + [ + # No identifiers + {}, + {"name": "bla"}, + {"default_name": "bla"}, + # Match multiple types + { + "identifiers": {("hue", "1234")}, + "name": "bla", + "default_name": "yo", + }, + ], +) +async def test_device_type_error_checking( + hass: HomeAssistant, + device_info: dict, +) -> None: + """Test catching invalid device info.""" + + class DeviceNameEntity(Entity): + _attr_unique_id = "qwer" + _attr_device_info = device_info + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities([DeviceNameEntity()]) + return True + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry( + title="Mock Config Entry Title", entry_id="super-mock-id" + ) + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + + dev_reg = dr.async_get(hass) + assert len(dev_reg.devices) == 0 + # Entity should still be registered + ent_reg = er.async_get(hass) + assert ent_reg.async_get("test_domain.test_qwer") is not None diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 3740a6b177a..9436226b335 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -21,6 +21,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.event import ( + EventStateChangedData, TrackStates, TrackTemplate, TrackTemplateResult, @@ -45,6 +46,7 @@ from homeassistant.helpers.event import ( track_point_in_utc_time, ) from homeassistant.helpers.template import Template, result_as_boolean +from homeassistant.helpers.typing import EventType from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -298,21 +300,21 @@ async def test_async_track_state_change_filtered(hass: HomeAssistant) -> None: multiple_entity_id_tracker = [] @ha.callback - def single_run_callback(event): - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + def single_run_callback(event: EventType[EventStateChangedData]) -> None: + old_state = event.data["old_state"] + new_state = event.data["new_state"] single_entity_id_tracker.append((old_state, new_state)) @ha.callback - def multiple_run_callback(event): - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + def multiple_run_callback(event: EventType[EventStateChangedData]) -> None: + old_state = event.data["old_state"] + new_state = event.data["new_state"] multiple_entity_id_tracker.append((old_state, new_state)) @ha.callback - def callback_that_throws(event): + def callback_that_throws(event: EventType[EventStateChangedData]) -> None: raise ValueError track_single = async_track_state_change_filtered( @@ -434,21 +436,21 @@ async def test_async_track_state_change_event(hass: HomeAssistant) -> None: multiple_entity_id_tracker = [] @ha.callback - def single_run_callback(event): - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + def single_run_callback(event: EventType[EventStateChangedData]) -> None: + old_state = event.data["old_state"] + new_state = event.data["new_state"] single_entity_id_tracker.append((old_state, new_state)) @ha.callback - def multiple_run_callback(event): - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + def multiple_run_callback(event: EventType[EventStateChangedData]) -> None: + old_state = event.data["old_state"] + new_state = event.data["new_state"] multiple_entity_id_tracker.append((old_state, new_state)) @ha.callback - def callback_that_throws(event): + def callback_that_throws(event: EventType[EventStateChangedData]) -> None: raise ValueError unsub_single = async_track_state_change_event( @@ -542,16 +544,16 @@ async def test_async_track_state_added_domain(hass: HomeAssistant) -> None: multiple_entity_id_tracker = [] @ha.callback - def single_run_callback(event): - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + def single_run_callback(event: EventType[EventStateChangedData]) -> None: + old_state = event.data["old_state"] + new_state = event.data["new_state"] single_entity_id_tracker.append((old_state, new_state)) @ha.callback - def multiple_run_callback(event): - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + def multiple_run_callback(event: EventType[EventStateChangedData]) -> None: + old_state = event.data["old_state"] + new_state = event.data["new_state"] multiple_entity_id_tracker.append((old_state, new_state)) @@ -654,16 +656,16 @@ async def test_async_track_state_removed_domain(hass: HomeAssistant) -> None: multiple_entity_id_tracker = [] @ha.callback - def single_run_callback(event): - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + def single_run_callback(event: EventType[EventStateChangedData]) -> None: + old_state = event.data["old_state"] + new_state = event.data["new_state"] single_entity_id_tracker.append((old_state, new_state)) @ha.callback - def multiple_run_callback(event): - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + def multiple_run_callback(event: EventType[EventStateChangedData]) -> None: + old_state = event.data["old_state"] + new_state = event.data["new_state"] multiple_entity_id_tracker.append((old_state, new_state)) @@ -736,16 +738,16 @@ async def test_async_track_state_removed_domain_match_all(hass: HomeAssistant) - match_all_entity_id_tracker = [] @ha.callback - def single_run_callback(event): - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + def single_run_callback(event: EventType[EventStateChangedData]) -> None: + old_state = event.data["old_state"] + new_state = event.data["new_state"] single_entity_id_tracker.append((old_state, new_state)) @ha.callback - def match_all_run_callback(event): - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + def match_all_run_callback(event: EventType[EventStateChangedData]) -> None: + old_state = event.data["old_state"] + new_state = event.data["new_state"] match_all_entity_id_tracker.append((old_state, new_state)) @@ -963,7 +965,10 @@ async def test_track_template_result(hass: HomeAssistant) -> None: "{{(states.sensor.test.state|int) + test }}", hass ) - def specific_run_callback(event, updates): + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: track_result = updates.pop() specific_runs.append(int(track_result.result)) @@ -972,7 +977,10 @@ async def test_track_template_result(hass: HomeAssistant) -> None: ) @ha.callback - def wildcard_run_callback(event, updates): + def wildcard_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: track_result = updates.pop() wildcard_runs.append( (int(track_result.last_result or 0), int(track_result.result)) @@ -982,7 +990,10 @@ async def test_track_template_result(hass: HomeAssistant) -> None: hass, [TrackTemplate(template_condition, None)], wildcard_run_callback ) - async def wildercard_run_callback(event, updates): + async def wildercard_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: track_result = updates.pop() wildercard_runs.append( (int(track_result.last_result or 0), int(track_result.result)) @@ -1049,7 +1060,10 @@ async def test_track_template_result_none(hass: HomeAssistant) -> None: "{{(state_attr('sensor.test', 'battery')|int(default=0)) + test }}", hass ) - def specific_run_callback(event, updates): + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: track_result = updates.pop() result = int(track_result.result) if track_result.result is not None else None specific_runs.append(result) @@ -1059,7 +1073,10 @@ async def test_track_template_result_none(hass: HomeAssistant) -> None: ) @ha.callback - def wildcard_run_callback(event, updates): + def wildcard_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: track_result = updates.pop() last_result = ( int(track_result.last_result) @@ -1073,7 +1090,10 @@ async def test_track_template_result_none(hass: HomeAssistant) -> None: hass, [TrackTemplate(template_condition, None)], wildcard_run_callback ) - async def wildercard_run_callback(event, updates): + async def wildercard_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: track_result = updates.pop() last_result = ( int(track_result.last_result) @@ -1120,7 +1140,10 @@ async def test_track_template_result_super_template(hass: HomeAssistant) -> None "{{(states.sensor.test.state|int) + test }}", hass ) - def specific_run_callback(event, updates): + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_condition: specific_runs.append(int(track_result.result)) @@ -1138,7 +1161,10 @@ async def test_track_template_result_super_template(hass: HomeAssistant) -> None ) @ha.callback - def wildcard_run_callback(event, updates): + def wildcard_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_condition: wildcard_runs.append( @@ -1157,7 +1183,10 @@ async def test_track_template_result_super_template(hass: HomeAssistant) -> None has_super_template=True, ) - async def wildercard_run_callback(event, updates): + async def wildercard_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_condition_var: wildercard_runs.append( @@ -1270,7 +1299,10 @@ async def test_track_template_result_super_template_initially_false( hass.states.async_set("sensor.test", "unavailable") await hass.async_block_till_done() - def specific_run_callback(event, updates): + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_condition: specific_runs.append(int(track_result.result)) @@ -1288,7 +1320,10 @@ async def test_track_template_result_super_template_initially_false( ) @ha.callback - def wildcard_run_callback(event, updates): + def wildcard_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_condition: wildcard_runs.append( @@ -1307,7 +1342,10 @@ async def test_track_template_result_super_template_initially_false( has_super_template=True, ) - async def wildercard_run_callback(event, updates): + async def wildercard_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_condition_var: wildercard_runs.append( @@ -1432,7 +1470,10 @@ async def test_track_template_result_super_template_2( return result_as_boolean(result) - def specific_run_callback(event, updates): + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_condition: specific_runs.append(int(track_result.result)) @@ -1452,7 +1493,10 @@ async def test_track_template_result_super_template_2( ) @ha.callback - def wildcard_run_callback(event, updates): + def wildcard_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_condition: wildcard_runs.append( @@ -1473,7 +1517,10 @@ async def test_track_template_result_super_template_2( has_super_template=True, ) - async def wildercard_run_callback(event, updates): + async def wildercard_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_condition_var: wildercard_runs.append( @@ -1578,7 +1625,10 @@ async def test_track_template_result_super_template_2_initially_false( return result_as_boolean(result) - def specific_run_callback(event, updates): + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_condition: specific_runs.append(int(track_result.result)) @@ -1598,7 +1648,10 @@ async def test_track_template_result_super_template_2_initially_false( ) @ha.callback - def wildcard_run_callback(event, updates): + def wildcard_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_condition: wildcard_runs.append( @@ -1619,7 +1672,10 @@ async def test_track_template_result_super_template_2_initially_false( has_super_template=True, ) - async def wildercard_run_callback(event, updates): + async def wildercard_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_condition_var: wildercard_runs.append( @@ -1699,7 +1755,10 @@ async def test_track_template_result_complex(hass: HomeAssistant) -> None: """ template_complex = Template(template_complex_str, hass) - def specific_run_callback(event, updates): + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: specific_runs.append(updates.pop().result) hass.states.async_set("light.one", "on") @@ -1852,7 +1911,10 @@ async def test_track_template_result_with_wildcard(hass: HomeAssistant) -> None: """ template_complex = Template(template_complex_str, hass) - def specific_run_callback(event, updates): + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: specific_runs.append(updates.pop().result) hass.states.async_set("cover.office_drapes", "closed") @@ -1904,7 +1966,10 @@ async def test_track_template_result_with_group(hass: HomeAssistant) -> None: """ template_complex = Template(template_complex_str, hass) - def specific_run_callback(event, updates): + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: specific_runs.append(updates.pop().result) info = async_track_template_result( @@ -1961,7 +2026,10 @@ async def test_track_template_result_and_conditional(hass: HomeAssistant) -> Non template = Template(template_str, hass) - def specific_run_callback(event, updates): + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: specific_runs.append(updates.pop().result) info = async_track_template_result( @@ -2026,7 +2094,10 @@ async def test_track_template_result_and_conditional_upper_case( template = Template(template_str, hass) - def specific_run_callback(event, updates): + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: specific_runs.append(updates.pop().result) info = async_track_template_result( @@ -2085,7 +2156,10 @@ async def test_track_template_result_iterator(hass: HomeAssistant) -> None: iterator_runs = [] @ha.callback - def iterator_callback(event, updates): + def iterator_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: iterator_runs.append(updates.pop().result) async_track_template_result( @@ -2118,7 +2192,10 @@ async def test_track_template_result_iterator(hass: HomeAssistant) -> None: filter_runs = [] @ha.callback - def filter_callback(event, updates): + def filter_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: filter_runs.append(updates.pop().result) info = async_track_template_result( @@ -2168,7 +2245,10 @@ async def test_track_template_result_errors( not_exist_runs = [] @ha.callback - def syntax_error_listener(event, updates): + def syntax_error_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: track_result = updates.pop() syntax_error_runs.append( ( @@ -2188,7 +2268,10 @@ async def test_track_template_result_errors( assert "TemplateSyntaxError" in caplog.text @ha.callback - def not_exist_runs_error_listener(event, updates): + def not_exist_runs_error_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: template_track = updates.pop() not_exist_runs.append( ( @@ -2253,7 +2336,10 @@ async def test_track_template_result_transient_errors( sometimes_error_runs = [] @ha.callback - def sometimes_error_listener(event, updates): + def sometimes_error_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: track_result = updates.pop() sometimes_error_runs.append( ( @@ -2298,7 +2384,10 @@ async def test_static_string(hass: HomeAssistant) -> None: refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: refresh_runs.append(updates.pop().result) info = async_track_template_result( @@ -2318,7 +2407,10 @@ async def test_track_template_rate_limit(hass: HomeAssistant) -> None: refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: refresh_runs.append(updates.pop().result) info = async_track_template_result( @@ -2377,7 +2469,10 @@ async def test_track_template_rate_limit_super(hass: HomeAssistant) -> None: refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_refresh: refresh_runs.append(track_result.result) @@ -2450,7 +2545,10 @@ async def test_track_template_rate_limit_super_2(hass: HomeAssistant) -> None: refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_refresh: refresh_runs.append(track_result.result) @@ -2519,7 +2617,10 @@ async def test_track_template_rate_limit_super_3(hass: HomeAssistant) -> None: refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for track_result in updates: if track_result.template is template_refresh: refresh_runs.append(track_result.result) @@ -2590,7 +2691,10 @@ async def test_track_template_rate_limit_suppress_listener(hass: HomeAssistant) refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: refresh_runs.append(updates.pop().result) info = async_track_template_result( @@ -2687,7 +2791,10 @@ async def test_track_template_rate_limit_five(hass: HomeAssistant) -> None: refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: refresh_runs.append(updates.pop().result) info = async_track_template_result( @@ -2723,7 +2830,10 @@ async def test_track_template_has_default_rate_limit(hass: HomeAssistant) -> Non refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: refresh_runs.append(updates.pop().result) info = async_track_template_result( @@ -2764,7 +2874,10 @@ async def test_track_template_unavailable_states_has_default_rate_limit( refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: refresh_runs.append(updates.pop().result) info = async_track_template_result( @@ -2805,7 +2918,10 @@ async def test_specifically_referenced_entity_is_not_rate_limited( refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: refresh_runs.append(updates.pop().result) info = async_track_template_result( @@ -2848,7 +2964,10 @@ async def test_track_two_templates_with_different_rate_limits( } @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: for update in updates: refresh_runs[update.template].append(update.result) @@ -2909,7 +3028,10 @@ async def test_string(hass: HomeAssistant) -> None: refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: refresh_runs.append(updates.pop().result) info = async_track_template_result( @@ -2929,7 +3051,10 @@ async def test_track_template_result_refresh_cancel(hass: HomeAssistant) -> None refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: refresh_runs.append(updates.pop().result) info = async_track_template_result( @@ -2991,7 +3116,10 @@ async def test_async_track_template_result_multiple_templates( refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: refresh_runs.append(updates) async_track_template_result( @@ -3052,7 +3180,10 @@ async def test_async_track_template_result_multiple_templates_mixing_domain( refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: refresh_runs.append(updates) async_track_template_result( @@ -3137,7 +3268,10 @@ async def test_track_template_with_time(hass: HomeAssistant) -> None: specific_runs = [] template_complex = Template("{{ states.switch.test.state and now() }}", hass) - def specific_run_callback(event, updates): + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: specific_runs.append(updates.pop().result) info = async_track_template_result( @@ -3167,7 +3301,10 @@ async def test_track_template_with_time_default(hass: HomeAssistant) -> None: specific_runs = [] template_complex = Template("{{ now() }}", hass) - def specific_run_callback(event, updates): + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: specific_runs.append(updates.pop().result) info = async_track_template_result( @@ -3216,7 +3353,10 @@ async def test_track_template_with_time_that_leaves_scope(hass: HomeAssistant) - hass, ) - def specific_run_callback(event, updates): + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: specific_runs.append(updates.pop().result) info = async_track_template_result( @@ -3281,7 +3421,10 @@ async def test_async_track_template_result_multiple_templates_mixing_listeners( refresh_runs = [] @ha.callback - def refresh_listener(event, updates): + def refresh_listener( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: refresh_runs.append(updates) now = dt_util.utcnow() @@ -4302,16 +4445,16 @@ async def test_track_state_change_event_chain_multple_entity( tracker_unsub = [] @ha.callback - def chained_single_run_callback(event): - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + def chained_single_run_callback(event: EventType[EventStateChangedData]) -> None: + old_state = event.data["old_state"] + new_state = event.data["new_state"] chained_tracker_called.append((old_state, new_state)) @ha.callback - def single_run_callback(event): - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + def single_run_callback(event: EventType[EventStateChangedData]) -> None: + old_state = event.data["old_state"] + new_state = event.data["new_state"] tracker_called.append((old_state, new_state)) @@ -4356,16 +4499,16 @@ async def test_track_state_change_event_chain_single_entity( tracker_unsub = [] @ha.callback - def chained_single_run_callback(event): - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + def chained_single_run_callback(event: EventType[EventStateChangedData]) -> None: + old_state = event.data["old_state"] + new_state = event.data["new_state"] chained_tracker_called.append((old_state, new_state)) @ha.callback - def single_run_callback(event): - old_state = event.data.get("old_state") - new_state = event.data.get("new_state") + def single_run_callback(event: EventType[EventStateChangedData]) -> None: + old_state = event.data["old_state"] + new_state = event.data["new_state"] tracker_called.append((old_state, new_state)) diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 56e931b4345..fa0a14b8fbb 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -232,17 +232,21 @@ async def test_hass_starting(hass: HomeAssistant) -> None: entity.hass = hass entity.entity_id = "input_boolean.b1" + all_states = hass.states.async_all() + assert len(all_states) == 0 + hass.states.async_set("input_boolean.b1", "on") + # Mock that only b1 is present this run - states = [State("input_boolean.b1", "on")] with patch( "homeassistant.helpers.restore_state.Store.async_save" - ) as mock_write_data, patch.object(hass.states, "async_all", return_value=states): + ) as mock_write_data: state = await entity.async_get_last_state() await hass.async_block_till_done() assert state is not None assert state.entity_id == "input_boolean.b1" assert state.state == "on" + hass.states.async_remove("input_boolean.b1") # Assert that no data was written yet, since hass is still starting. assert not mock_write_data.called @@ -293,15 +297,20 @@ async def test_dump_data(hass: HomeAssistant) -> None: "input_boolean.b5": StoredState(State("input_boolean.b5", "off"), None, now), } + for state in states: + hass.states.async_set(state.entity_id, state.state, state.attributes) + with patch( "homeassistant.helpers.restore_state.Store.async_save" - ) as mock_write_data, patch.object(hass.states, "async_all", return_value=states): + ) as mock_write_data: await data.async_dump_states() assert mock_write_data.called args = mock_write_data.mock_calls[0][1] written_states = args[0] + for state in states: + hass.states.async_remove(state.entity_id) # b0 should not be written, since it didn't extend RestoreEntity # b1 should be written, since it is present in the current run # b2 should not be written, since it is not registered with the helper @@ -319,9 +328,12 @@ async def test_dump_data(hass: HomeAssistant) -> None: # Test that removed entities are not persisted await entity.async_remove() + for state in states: + hass.states.async_set(state.entity_id, state.state, state.attributes) + with patch( "homeassistant.helpers.restore_state.Store.async_save" - ) as mock_write_data, patch.object(hass.states, "async_all", return_value=states): + ) as mock_write_data: await data.async_dump_states() assert mock_write_data.called @@ -355,10 +367,13 @@ async def test_dump_error(hass: HomeAssistant) -> None: data = async_get(hass) + for state in states: + hass.states.async_set(state.entity_id, state.state, state.attributes) + with patch( "homeassistant.helpers.restore_state.Store.async_save", side_effect=HomeAssistantError, - ) as mock_write_data, patch.object(hass.states, "async_all", return_value=states): + ) as mock_write_data: await data.async_dump_states() assert mock_write_data.called diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index c518ad227a7..c1d5f76ea78 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -235,6 +235,22 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> ("light.abc123", "blah.blah", FAKE_UUID), (None,), ), + ( + { + "filter": [ + { + "supported_features": [ + [ + "light.LightEntityFeature.EFFECT", + "light.LightEntityFeature.TRANSITION", + ] + ] + }, + ] + }, + ("light.abc123", "blah.blah", FAKE_UUID), + (None,), + ), ( { "filter": [ @@ -565,6 +581,7 @@ def test_object_selector_schema(schema, valid_selections, invalid_selections) -> ({}, ("abc123",), (None,)), ({"multiline": True}, (), ()), ({"multiline": False, "type": "email"}, (), ()), + ({"prefix": "before", "suffix": "after"}, (), ()), ), ) def test_text_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -730,6 +747,11 @@ def test_icon_selector_schema(schema, valid_selections, invalid_selections) -> N ("abc",), (None,), ), + ( + {"include_default": True}, + ("abc",), + (None,), + ), ), ) def test_theme_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -1001,3 +1023,29 @@ def test_conversation_agent_selector_schema( ) -> None: """Test conversation agent selector.""" _test_selector("conversation_agent", schema, valid_selections, invalid_selections) + + +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + ( + ( + {}, + ( + [ + { + "condition": "numeric_state", + "entity_id": ["sensor.temperature"], + "below": 20, + } + ], + [], + ), + ("abc"), + ), + ), +) +def test_condition_selector_schema( + schema, valid_selections, invalid_selections +) -> None: + """Test condition sequence selector.""" + _test_selector("condition", schema, valid_selections, invalid_selections) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 36f87b7553b..56ee3f74140 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,6 +1,8 @@ """Test service helpers.""" from collections import OrderedDict +from collections.abc import Iterable from copy import deepcopy +from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest @@ -556,13 +558,52 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: logger = hass.components.logger logger_config = {logger.DOMAIN: {}} - await async_setup_component(hass, logger.DOMAIN, logger_config) - descriptions = await service.async_get_all_descriptions(hass) + + async def async_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, Any]: + """Return all backend translations.""" + translation_key_prefix = f"component.{logger.DOMAIN}.services.set_default_level" + return { + f"{translation_key_prefix}.name": "Translated name", + f"{translation_key_prefix}.description": "Translated description", + f"{translation_key_prefix}.fields.level.name": "Field name", + f"{translation_key_prefix}.fields.level.description": "Field description", + f"{translation_key_prefix}.fields.level.example": "Field example", + } + + with patch( + "homeassistant.helpers.service.translation.async_get_translations", + side_effect=async_get_translations, + ): + await async_setup_component(hass, logger.DOMAIN, logger_config) + descriptions = await service.async_get_all_descriptions(hass) assert len(descriptions) == 2 - assert "description" in descriptions[logger.DOMAIN]["set_level"] - assert "fields" in descriptions[logger.DOMAIN]["set_level"] + assert descriptions[logger.DOMAIN]["set_default_level"]["name"] == "Translated name" + assert ( + descriptions[logger.DOMAIN]["set_default_level"]["description"] + == "Translated description" + ) + assert ( + descriptions[logger.DOMAIN]["set_default_level"]["fields"]["level"]["name"] + == "Field name" + ) + assert ( + descriptions[logger.DOMAIN]["set_default_level"]["fields"]["level"][ + "description" + ] + == "Field description" + ) + assert ( + descriptions[logger.DOMAIN]["set_default_level"]["fields"]["level"]["example"] + == "Field example" + ) hass.services.async_register(logger.DOMAIN, "new_service", lambda x: None, None) service.async_set_service_schema( @@ -602,7 +643,6 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: "another_service_with_response", {"description": "response service"}, ) - descriptions = await service.async_get_all_descriptions(hass) assert "another_new_service" in descriptions[logger.DOMAIN] assert "service_with_optional_response" in descriptions[logger.DOMAIN] @@ -642,6 +682,9 @@ async def test_async_get_all_descriptions_failing_integration( with patch( "homeassistant.helpers.service.async_get_integrations", return_value={"logger": ImportError}, + ), patch( + "homeassistant.helpers.service.translation.async_get_translations", + return_value={}, ): descriptions = await service.async_get_all_descriptions(hass) @@ -721,6 +764,7 @@ async def test_async_get_all_descriptions_dynamically_created_services( "description": "", "fields": {}, "name": "", + "response": {"optional": True}, } @@ -1378,9 +1422,9 @@ async def test_entity_service_call_warn_referenced( ) await service.entity_service_call(hass, {}, "", call) assert ( - "Unable to find referenced areas non-existent-area, devices" - " non-existent-device, entities non.existent" in caplog.text - ) + "Referenced areas non-existent-area, devices non-existent-device, " + "entities non.existent are missing or not currently available" + ) in caplog.text async def test_async_extract_entities_warn_referenced( @@ -1399,9 +1443,9 @@ async def test_async_extract_entities_warn_referenced( extracted = await service.async_extract_entities(hass, {}, call) assert len(extracted) == 0 assert ( - "Unable to find referenced areas non-existent-area, devices" - " non-existent-device, entities non.existent" in caplog.text - ) + "Referenced areas non-existent-area, devices non-existent-device, " + "entities non.existent are missing or not currently available" + ) in caplog.text async def test_async_extract_config_entry_ids(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_state.py b/tests/helpers/test_state.py index 1919586daa3..255fba0e7e7 100644 --- a/tests/helpers/test_state.py +++ b/tests/helpers/test_state.py @@ -1,9 +1,7 @@ """Test state helpers.""" import asyncio -from datetime import timedelta -from unittest.mock import Mock, patch +from unittest.mock import patch -from freezegun import freeze_time import pytest from homeassistant.components.sun import STATE_ABOVE_HORIZON, STATE_BELOW_HORIZON @@ -21,34 +19,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import state -from homeassistant.util import dt as dt_util from tests.common import async_mock_service -async def test_async_track_states( - hass: HomeAssistant, mock_integration_frame: Mock -) -> None: - """Test AsyncTrackStates context manager.""" - point1 = dt_util.utcnow() - point2 = point1 + timedelta(seconds=5) - point3 = point2 + timedelta(seconds=5) - - with freeze_time(point2) as freezer, state.AsyncTrackStates(hass) as states: - freezer.move_to(point1) - hass.states.async_set("light.test", "on") - - freezer.move_to(point2) - hass.states.async_set("light.test2", "on") - state2 = hass.states.get("light.test2") - - freezer.move_to(point3) - hass.states.async_set("light.test3", "on") - state3 = hass.states.get("light.test3") - - assert [state2, state3] == sorted(states, key=lambda state: state.entity_id) - - async def test_call_to_component(hass: HomeAssistant) -> None: """Test calls to components state reproduction functions.""" with patch( @@ -82,29 +56,6 @@ async def test_call_to_component(hass: HomeAssistant) -> None: ) -async def test_get_changed_since( - hass: HomeAssistant, mock_integration_frame: Mock -) -> None: - """Test get_changed_since.""" - point1 = dt_util.utcnow() - point2 = point1 + timedelta(seconds=5) - point3 = point2 + timedelta(seconds=5) - - with freeze_time(point1) as freezer: - hass.states.async_set("light.test", "on") - state1 = hass.states.get("light.test") - - freezer.move_to(point2) - hass.states.async_set("light.test2", "on") - state2 = hass.states.get("light.test2") - - freezer.move_to(point3) - hass.states.async_set("light.test3", "on") - state3 = hass.states.get("light.test3") - - assert [state2, state3] == state.get_changed_since([state1, state2, state3], point2) - - async def test_reproduce_with_no_entity(hass: HomeAssistant) -> None: """Test reproduce_state with no entity.""" calls = async_mock_service(hass, "light", SERVICE_TURN_ON) diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 76dfbdbeb46..81953c7d785 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta import json +import os from typing import Any, NamedTuple from unittest.mock import Mock, patch @@ -12,8 +13,9 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_FINAL_WRITE, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import CoreState, HomeAssistant -from homeassistant.helpers import storage +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, CoreState, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir, storage from homeassistant.util import dt as dt_util from homeassistant.util.color import RGBColor @@ -548,3 +550,156 @@ async def test_saving_load_round_trip(tmpdir: py.path.local) -> None: } await hass.async_stop(force=True) + + +async def test_loading_corrupt_core_file( + tmpdir: py.path.local, caplog: pytest.LogCaptureFixture +) -> None: + """Test we handle unrecoverable corruption in a core file.""" + loop = asyncio.get_running_loop() + hass = await async_test_home_assistant(loop) + + tmp_storage = await hass.async_add_executor_job(tmpdir.mkdir, "temp_storage") + hass.config.config_dir = tmp_storage + + storage_key = "core.anything" + store = storage.Store( + hass, MOCK_VERSION_2, storage_key, minor_version=MOCK_MINOR_VERSION_1 + ) + await store.async_save({"hello": "world"}) + storage_path = os.path.join(tmp_storage, ".storage") + store_file = os.path.join(storage_path, store.key) + + data = await store.async_load() + assert data == {"hello": "world"} + + def _corrupt_store(): + with open(store_file, "w") as f: + f.write("corrupt") + + await hass.async_add_executor_job(_corrupt_store) + + data = await store.async_load() + assert data is None + assert "Unrecoverable error decoding storage" in caplog.text + + issue_registry = ir.async_get(hass) + found_issue = None + issue_entry = None + for (domain, issue), entry in issue_registry.issues.items(): + if domain == HOMEASSISTANT_DOMAIN and issue.startswith( + f"storage_corruption_{storage_key}_" + ): + found_issue = issue + issue_entry = entry + break + + assert found_issue is not None + assert issue_entry is not None + assert issue_entry.is_fixable is True + assert issue_entry.translation_placeholders["storage_key"] == storage_key + assert issue_entry.issue_domain == HOMEASSISTANT_DOMAIN + assert ( + issue_entry.translation_placeholders["error"] + == "unexpected character: line 1 column 1 (char 0)" + ) + + files = await hass.async_add_executor_job( + os.listdir, os.path.join(tmp_storage, ".storage") + ) + assert ".corrupt" in files[0] + + await hass.async_stop(force=True) + + +async def test_loading_corrupt_file_known_domain( + tmpdir: py.path.local, caplog: pytest.LogCaptureFixture +) -> None: + """Test we handle unrecoverable corruption for a known domain.""" + loop = asyncio.get_running_loop() + hass = await async_test_home_assistant(loop) + hass.config.components.add("testdomain") + storage_key = "testdomain.testkey" + + tmp_storage = await hass.async_add_executor_job(tmpdir.mkdir, "temp_storage") + hass.config.config_dir = tmp_storage + + store = storage.Store( + hass, MOCK_VERSION_2, storage_key, minor_version=MOCK_MINOR_VERSION_1 + ) + await store.async_save({"hello": "world"}) + storage_path = os.path.join(tmp_storage, ".storage") + store_file = os.path.join(storage_path, store.key) + + data = await store.async_load() + assert data == {"hello": "world"} + + def _corrupt_store(): + with open(store_file, "w") as f: + f.write('{"valid":"json"}..with..corrupt') + + await hass.async_add_executor_job(_corrupt_store) + + data = await store.async_load() + assert data is None + assert "Unrecoverable error decoding storage" in caplog.text + + issue_registry = ir.async_get(hass) + found_issue = None + issue_entry = None + for (domain, issue), entry in issue_registry.issues.items(): + if domain == HOMEASSISTANT_DOMAIN and issue.startswith( + f"storage_corruption_{storage_key}_" + ): + found_issue = issue + issue_entry = entry + break + + assert found_issue is not None + assert issue_entry is not None + assert issue_entry.is_fixable is True + assert issue_entry.translation_placeholders["storage_key"] == storage_key + assert issue_entry.issue_domain == "testdomain" + assert ( + issue_entry.translation_placeholders["error"] + == "unexpected content after document: line 1 column 17 (char 16)" + ) + + files = await hass.async_add_executor_job( + os.listdir, os.path.join(tmp_storage, ".storage") + ) + assert ".corrupt" in files[0] + + await hass.async_stop(force=True) + + +async def test_os_error_is_fatal(tmpdir: py.path.local) -> None: + """Test OSError during load is fatal.""" + loop = asyncio.get_running_loop() + hass = await async_test_home_assistant(loop) + + tmp_storage = await hass.async_add_executor_job(tmpdir.mkdir, "temp_storage") + hass.config.config_dir = tmp_storage + + store = storage.Store( + hass, MOCK_VERSION_2, MOCK_KEY, minor_version=MOCK_MINOR_VERSION_1 + ) + await store.async_save({"hello": "world"}) + + with pytest.raises(OSError), patch( + "homeassistant.helpers.storage.json_util.load_json", side_effect=OSError + ): + await store.async_load() + + base_os_error = OSError() + base_os_error.errno = 30 + home_assistant_error = HomeAssistantError() + home_assistant_error.__cause__ = base_os_error + + with pytest.raises(HomeAssistantError), patch( + "homeassistant.helpers.storage.json_util.load_json", + side_effect=home_assistant_error, + ): + await store.async_load() + + await hass.async_stop(force=True) diff --git a/tests/helpers/test_system_info.py b/tests/helpers/test_system_info.py index ba43386b821..ebb0cc35c20 100644 --- a/tests/helpers/test_system_info.py +++ b/tests/helpers/test_system_info.py @@ -1,10 +1,23 @@ """Tests for the system info helper.""" import json +import os from unittest.mock import patch +import pytest + from homeassistant.const import __version__ as current_version from homeassistant.core import HomeAssistant -from homeassistant.helpers.system_info import async_get_system_info +from homeassistant.helpers.system_info import async_get_system_info, is_official_image + + +async def test_is_official_image() -> None: + """Test is_official_image.""" + is_official_image.cache_clear() + with patch("homeassistant.helpers.system_info.os.path.isfile", return_value=True): + assert is_official_image() is True + is_official_image.cache_clear() + with patch("homeassistant.helpers.system_info.os.path.isfile", return_value=False): + assert is_official_image() is False async def test_get_system_info(hass: HomeAssistant) -> None: @@ -16,23 +29,77 @@ async def test_get_system_info(hass: HomeAssistant) -> None: assert json.dumps(info) is not None +async def test_get_system_info_supervisor_not_available( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the get system info when supervisor is not available.""" + hass.config.components.add("hassio") + with patch("platform.system", return_value="Linux"), patch( + "homeassistant.helpers.system_info.is_docker_env", return_value=True + ), patch( + "homeassistant.helpers.system_info.is_official_image", return_value=True + ), patch( + "homeassistant.components.hassio.is_hassio", return_value=True + ), patch( + "homeassistant.components.hassio.get_info", return_value=None + ), patch( + "homeassistant.helpers.system_info.cached_get_user", return_value="root" + ): + info = await async_get_system_info(hass) + assert isinstance(info, dict) + assert info["version"] == current_version + assert info["user"] is not None + assert json.dumps(info) is not None + assert info["installation_type"] == "Home Assistant Supervised" + assert "No Home Assistant Supervisor info available" in caplog.text + + +async def test_get_system_info_supervisor_not_loaded(hass: HomeAssistant) -> None: + """Test the get system info when supervisor is not loaded.""" + with patch("platform.system", return_value="Linux"), patch( + "homeassistant.helpers.system_info.is_docker_env", return_value=True + ), patch( + "homeassistant.helpers.system_info.is_official_image", return_value=True + ), patch( + "homeassistant.components.hassio.get_info", return_value=None + ), patch.dict( + os.environ, {"SUPERVISOR": "127.0.0.1"} + ): + info = await async_get_system_info(hass) + assert isinstance(info, dict) + assert info["version"] == current_version + assert info["user"] is not None + assert json.dumps(info) is not None + assert info["installation_type"] == "Unsupported Third Party Container" + + async def test_container_installationtype(hass: HomeAssistant) -> None: """Test container installation type.""" with patch("platform.system", return_value="Linux"), patch( - "os.path.isfile", return_value=True - ), patch("homeassistant.helpers.system_info.getuser", return_value="root"): + "homeassistant.helpers.system_info.is_docker_env", return_value=True + ), patch( + "homeassistant.helpers.system_info.is_official_image", return_value=True + ), patch( + "homeassistant.helpers.system_info.cached_get_user", return_value="root" + ): info = await async_get_system_info(hass) assert info["installation_type"] == "Home Assistant Container" with patch("platform.system", return_value="Linux"), patch( - "os.path.isfile", side_effect=lambda file: file == "/.dockerenv" - ), patch("homeassistant.helpers.system_info.getuser", return_value="user"): + "homeassistant.helpers.system_info.is_docker_env", return_value=True + ), patch( + "homeassistant.helpers.system_info.is_official_image", return_value=False + ), patch( + "homeassistant.helpers.system_info.cached_get_user", return_value="user" + ): info = await async_get_system_info(hass) assert info["installation_type"] == "Unsupported Third Party Container" async def test_getuser_keyerror(hass: HomeAssistant) -> None: """Test getuser keyerror.""" - with patch("homeassistant.helpers.system_info.getuser", side_effect=KeyError): + with patch( + "homeassistant.helpers.system_info.cached_get_user", side_effect=KeyError + ): info = await async_get_system_info(hass) assert info["user"] is None diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 73854147372..0c3f0e4469a 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -3889,6 +3889,8 @@ def test_state_with_unit_and_rounding(hass: HomeAssistant) -> None: hass.states.async_set("sensor.test", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) hass.states.async_set("sensor.test2", "23", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + hass.states.async_set("sensor.test3", "-0.0", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) + hass.states.async_set("sensor.test4", "-0", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) # state_with_unit property tpl = template.Template("{{ states.sensor.test.state_with_unit }}", hass) @@ -3905,6 +3907,8 @@ def test_state_with_unit_and_rounding(hass: HomeAssistant) -> None: # AllStates.__call__ and rounded=True tpl7 = template.Template("{{ states('sensor.test', rounded=True) }}", hass) tpl8 = template.Template("{{ states('sensor.test2', rounded=True) }}", hass) + tpl9 = template.Template("{{ states('sensor.test3', rounded=True) }}", hass) + tpl10 = template.Template("{{ states('sensor.test4', rounded=True) }}", hass) assert tpl.async_render() == "23.00 beers" assert tpl2.async_render() == "23 beers" @@ -3914,6 +3918,8 @@ def test_state_with_unit_and_rounding(hass: HomeAssistant) -> None: assert tpl6.async_render() == "23 beers" assert tpl7.async_render() == 23.0 assert tpl8.async_render() == 23 + assert tpl9.async_render() == 0.0 + assert tpl10.async_render() == 0 hass.states.async_set("sensor.test", "23.015", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) hass.states.async_set("sensor.test2", "23.015", {ATTR_UNIT_OF_MEASUREMENT: "beers"}) @@ -4527,20 +4533,22 @@ async def test_render_to_info_with_exception(hass: HomeAssistant) -> None: async def test_lru_increases_with_many_entities(hass: HomeAssistant) -> None: """Test that the template internal LRU cache increases with many entities.""" # We do not actually want to record 4096 entities so we mock the entity count - mock_entity_count = 4096 + mock_entity_count = 16 assert template.CACHED_TEMPLATE_LRU.get_size() == template.CACHED_TEMPLATE_STATES assert ( template.CACHED_TEMPLATE_NO_COLLECT_LRU.get_size() == template.CACHED_TEMPLATE_STATES ) + template.CACHED_TEMPLATE_LRU.set_size(8) + template.CACHED_TEMPLATE_NO_COLLECT_LRU.set_size(8) template.async_setup(hass) - with patch.object( - hass.states, "async_entity_ids_count", return_value=mock_entity_count - ): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done() + for i in range(mock_entity_count): + hass.states.async_set(f"sensor.sensor{i}", "on") + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() assert template.CACHED_TEMPLATE_LRU.get_size() == int( round(mock_entity_count * template.ENTITY_COUNT_GROWTH_FACTOR) @@ -4550,9 +4558,12 @@ async def test_lru_increases_with_many_entities(hass: HomeAssistant) -> None: ) await hass.async_stop() - with patch.object(hass.states, "async_entity_ids_count", return_value=8192): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=20)) - await hass.async_block_till_done() + + for i in range(mock_entity_count): + hass.states.async_set(f"sensor.sensor_add_{i}", "on") + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=20)) + await hass.async_block_till_done() assert template.CACHED_TEMPLATE_LRU.get_size() == int( round(mock_entity_count * template.ENTITY_COUNT_GROWTH_FACTOR) diff --git a/tests/testing_config/custom_components/test/event.py b/tests/testing_config/custom_components/test/event.py new file mode 100644 index 00000000000..9acb24f37cf --- /dev/null +++ b/tests/testing_config/custom_components/test/event.py @@ -0,0 +1,42 @@ +"""Provide a mock event platform. + +Call init before using it in your tests to ensure clean test data. +""" +from homeassistant.components.event import EventEntity + +from tests.common import MockEntity + +ENTITIES = [] + + +class MockEventEntity(MockEntity, EventEntity): + """Mock EventEntity class.""" + + @property + def event_types(self) -> list[str]: + """Return a list of possible events.""" + return self._handle("event_types") + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockEventEntity( + name="doorbell", + unique_id="unique_doorbell", + event_types=["short_press", "long_press"], + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index a5c49fb92c2..e2d026ec840 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -4,9 +4,12 @@ Call init before using it in your tests to ensure clean test data. """ from __future__ import annotations +from typing import Any + from homeassistant.components.weather import ( ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_IS_DAYTIME, ATTR_FORECAST_NATIVE_APPARENT_TEMP, ATTR_FORECAST_NATIVE_DEW_POINT, ATTR_FORECAST_NATIVE_PRECIPITATION, @@ -19,6 +22,7 @@ from homeassistant.components.weather import ( ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_UV_INDEX, ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_SPEED, Forecast, @@ -111,6 +115,11 @@ class MockWeather(MockEntity, WeatherEntity): """Return the cloud coverage in %.""" return self._handle("cloud_coverage") + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return self._handle("uv_index") + @property def native_visibility(self) -> float | None: """Return the visibility.""" @@ -214,9 +223,39 @@ class MockWeatherCompat(MockEntity, WeatherEntity): class MockWeatherMockForecast(MockWeather): """Mock weather class with mocked forecast.""" + def __init__(self, **values: Any) -> None: + """Initialize.""" + super().__init__(**values) + self.forecast_list: list[Forecast] | None = [ + { + ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, + ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, + ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_UV_INDEX: self.uv_index, + ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( + "native_precipitation" + ), + ATTR_FORECAST_HUMIDITY: self.humidity, + } + ] + @property def forecast(self) -> list[Forecast] | None: """Return the forecast.""" + return self.forecast_list + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the forecast_daily.""" + return self.forecast_list + + async def async_forecast_twice_daily(self) -> list[Forecast] | None: + """Return the forecast_twice_daily.""" return [ { ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, @@ -228,6 +267,29 @@ class MockWeatherMockForecast(MockWeather): ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_UV_INDEX: self.uv_index, + ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( + "native_precipitation" + ), + ATTR_FORECAST_HUMIDITY: self.humidity, + ATTR_FORECAST_IS_DAYTIME: self._values.get("is_daytime"), + } + ] + + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the forecast_hourly.""" + return [ + { + ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, + ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, + ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_UV_INDEX: self.uv_index, ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( "native_precipitation" ), diff --git a/tests/util/test_enum.py b/tests/util/test_enum.py index 61e8471b9d8..e975960bbe0 100644 --- a/tests/util/test_enum.py +++ b/tests/util/test_enum.py @@ -1,10 +1,9 @@ """Test enum helpers.""" -from enum import Enum, IntEnum, IntFlag +from enum import Enum, IntEnum, IntFlag, StrEnum from typing import Any import pytest -from homeassistant.backports.enum import StrEnum from homeassistant.util.enum import try_parse_enum diff --git a/tests/util/test_timeout.py b/tests/util/test_timeout.py index e89c6cd3f02..f301cd3c634 100644 --- a/tests/util/test_timeout.py +++ b/tests/util/test_timeout.py @@ -31,9 +31,8 @@ async def test_simple_global_timeout_freeze() -> None: """Test a simple global timeout freeze.""" timeout = TimeoutManager() - async with timeout.async_timeout(0.2): - async with timeout.async_freeze(): - await asyncio.sleep(0.3) + async with timeout.async_timeout(0.2), timeout.async_freeze(): + await asyncio.sleep(0.3) async def test_simple_zone_timeout_freeze_inside_executor_job( @@ -46,9 +45,10 @@ async def test_simple_zone_timeout_freeze_inside_executor_job( with timeout.freeze("recorder"): time.sleep(0.3) - async with timeout.async_timeout(1.0): - async with timeout.async_timeout(0.2, zone_name="recorder"): - await hass.async_add_executor_job(_some_sync_work) + async with timeout.async_timeout(1.0), timeout.async_timeout( + 0.2, zone_name="recorder" + ): + await hass.async_add_executor_job(_some_sync_work) async def test_simple_global_timeout_freeze_inside_executor_job( @@ -75,9 +75,10 @@ async def test_mix_global_timeout_freeze_and_zone_freeze_inside_executor_job( with timeout.freeze("recorder"): time.sleep(0.3) - async with timeout.async_timeout(0.1): - async with timeout.async_timeout(0.2, zone_name="recorder"): - await hass.async_add_executor_job(_some_sync_work) + async with timeout.async_timeout(0.1), timeout.async_timeout( + 0.2, zone_name="recorder" + ): + await hass.async_add_executor_job(_some_sync_work) async def test_mix_global_timeout_freeze_and_zone_freeze_different_order( @@ -108,9 +109,10 @@ async def test_mix_global_timeout_freeze_and_zone_freeze_other_zone_inside_execu with pytest.raises(asyncio.TimeoutError): async with timeout.async_timeout(0.1): - async with timeout.async_timeout(0.2, zone_name="recorder"): - async with timeout.async_timeout(0.2, zone_name="not_recorder"): - await hass.async_add_executor_job(_some_sync_work) + async with timeout.async_timeout( + 0.2, zone_name="recorder" + ), timeout.async_timeout(0.2, zone_name="not_recorder"): + await hass.async_add_executor_job(_some_sync_work) async def test_mix_global_timeout_freeze_and_zone_freeze_inside_executor_job_second_job_outside_zone_context( @@ -136,9 +138,8 @@ async def test_simple_global_timeout_freeze_with_executor_job( """Test a simple global timeout freeze with executor job.""" timeout = TimeoutManager() - async with timeout.async_timeout(0.2): - async with timeout.async_freeze(): - await hass.async_add_executor_job(lambda: time.sleep(0.3)) + async with timeout.async_timeout(0.2), timeout.async_freeze(): + await hass.async_add_executor_job(lambda: time.sleep(0.3)) async def test_simple_global_timeout_freeze_reset() -> None: @@ -185,18 +186,16 @@ async def test_simple_zone_timeout_freeze() -> None: """Test a simple zone timeout freeze.""" timeout = TimeoutManager() - async with timeout.async_timeout(0.2, "test"): - async with timeout.async_freeze("test"): - await asyncio.sleep(0.3) + async with timeout.async_timeout(0.2, "test"), timeout.async_freeze("test"): + await asyncio.sleep(0.3) async def test_simple_zone_timeout_freeze_without_timeout() -> None: """Test a simple zone timeout freeze on a zone that does not have a timeout set.""" timeout = TimeoutManager() - async with timeout.async_timeout(0.1, "test"): - async with timeout.async_freeze("test"): - await asyncio.sleep(0.3) + async with timeout.async_timeout(0.1, "test"), timeout.async_freeze("test"): + await asyncio.sleep(0.3) async def test_simple_zone_timeout_freeze_reset() -> None: @@ -214,29 +213,28 @@ async def test_mix_zone_timeout_freeze_and_global_freeze() -> None: """Test a mix zone timeout freeze and global freeze.""" timeout = TimeoutManager() - async with timeout.async_timeout(0.2, "test"): - async with timeout.async_freeze("test"): - async with timeout.async_freeze(): - await asyncio.sleep(0.3) + async with timeout.async_timeout(0.2, "test"), timeout.async_freeze( + "test" + ), timeout.async_freeze(): + await asyncio.sleep(0.3) async def test_mix_global_and_zone_timeout_freeze_() -> None: """Test a mix zone timeout freeze and global freeze.""" timeout = TimeoutManager() - async with timeout.async_timeout(0.2, "test"): - async with timeout.async_freeze(): - async with timeout.async_freeze("test"): - await asyncio.sleep(0.3) + async with timeout.async_timeout( + 0.2, "test" + ), timeout.async_freeze(), timeout.async_freeze("test"): + await asyncio.sleep(0.3) async def test_mix_zone_timeout_freeze() -> None: """Test a mix zone timeout global freeze.""" timeout = TimeoutManager() - async with timeout.async_timeout(0.2, "test"): - async with timeout.async_freeze(): - await asyncio.sleep(0.3) + async with timeout.async_timeout(0.2, "test"), timeout.async_freeze(): + await asyncio.sleep(0.3) async def test_mix_zone_timeout() -> None: