diff --git a/.coveragerc b/.coveragerc index 27aed1e1009..44e424260c1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -173,6 +173,7 @@ omit = homeassistant/components/coinbase/sensor.py homeassistant/components/comed_hourly_pricing/sensor.py homeassistant/components/comelit/__init__.py + homeassistant/components/comelit/alarm_control_panel.py homeassistant/components/comelit/const.py homeassistant/components/comelit/cover.py homeassistant/components/comelit/coordinator.py @@ -337,7 +338,6 @@ omit = homeassistant/components/escea/__init__.py homeassistant/components/escea/climate.py homeassistant/components/escea/discovery.py - homeassistant/components/esphome/bluetooth/* homeassistant/components/esphome/manager.py homeassistant/components/etherscan/sensor.py homeassistant/components/eufy/* @@ -364,8 +364,6 @@ omit = homeassistant/components/faa_delays/binary_sensor.py homeassistant/components/faa_delays/coordinator.py homeassistant/components/familyhub/camera.py - homeassistant/components/fastdotcom/sensor.py - homeassistant/components/fastdotcom/__init__.py homeassistant/components/ffmpeg/camera.py homeassistant/components/fibaro/__init__.py homeassistant/components/fibaro/binary_sensor.py @@ -404,6 +402,9 @@ omit = homeassistant/components/fjaraskupan/sensor.py homeassistant/components/fleetgo/device_tracker.py homeassistant/components/flexit/climate.py + homeassistant/components/flexit_bacnet/__init__.py + homeassistant/components/flexit_bacnet/const.py + homeassistant/components/flexit_bacnet/climate.py homeassistant/components/flic/binary_sensor.py homeassistant/components/flick_electric/__init__.py homeassistant/components/flick_electric/sensor.py @@ -419,6 +420,7 @@ omit = homeassistant/components/fortios/device_tracker.py homeassistant/components/foscam/__init__.py homeassistant/components/foscam/camera.py + homeassistant/components/foscam/coordinator.py homeassistant/components/foursquare/* homeassistant/components/free_mobile/notify.py homeassistant/components/freebox/camera.py @@ -536,6 +538,7 @@ omit = homeassistant/components/hvv_departures/binary_sensor.py homeassistant/components/hvv_departures/sensor.py homeassistant/components/ialarm/alarm_control_panel.py + homeassistant/components/iammeter/const.py homeassistant/components/iammeter/sensor.py homeassistant/components/iaqualink/binary_sensor.py homeassistant/components/iaqualink/climate.py @@ -754,6 +757,9 @@ omit = homeassistant/components/motion_blinds/cover.py homeassistant/components/motion_blinds/entity.py homeassistant/components/motion_blinds/sensor.py + homeassistant/components/motionmount/__init__.py + homeassistant/components/motionmount/entity.py + homeassistant/components/motionmount/number.py homeassistant/components/mpd/media_player.py homeassistant/components/mqtt_room/sensor.py homeassistant/components/msteams/notify.py @@ -799,7 +805,8 @@ omit = homeassistant/components/netgear/sensor.py homeassistant/components/netgear/switch.py homeassistant/components/netgear/update.py - homeassistant/components/netgear_lte/* + homeassistant/components/netgear_lte/__init__.py + homeassistant/components/netgear_lte/notify.py homeassistant/components/netio/switch.py homeassistant/components/neurio_energy/sensor.py homeassistant/components/nexia/climate.py @@ -900,6 +907,9 @@ omit = homeassistant/components/opple/light.py homeassistant/components/oru/* homeassistant/components/orvibo/switch.py + homeassistant/components/osoenergy/__init__.py + homeassistant/components/osoenergy/const.py + homeassistant/components/osoenergy/water_heater.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py homeassistant/components/overkiz/__init__.py @@ -1026,6 +1036,12 @@ omit = homeassistant/components/recorder/repack.py homeassistant/components/recswitch/switch.py homeassistant/components/reddit/sensor.py + homeassistant/components/refoss/__init__.py + homeassistant/components/refoss/bridge.py + homeassistant/components/refoss/coordinator.py + homeassistant/components/refoss/entity.py + homeassistant/components/refoss/switch.py + homeassistant/components/refoss/util.py homeassistant/components/rejseplanen/sensor.py homeassistant/components/remember_the_milk/__init__.py homeassistant/components/remote_rpi_gpio/* @@ -1209,6 +1225,7 @@ omit = homeassistant/components/starline/__init__.py homeassistant/components/starline/account.py homeassistant/components/starline/binary_sensor.py + homeassistant/components/starline/button.py homeassistant/components/starline/device_tracker.py homeassistant/components/starline/entity.py homeassistant/components/starline/lock.py @@ -1226,8 +1243,12 @@ omit = homeassistant/components/stream/fmp4utils.py homeassistant/components/stream/hls.py homeassistant/components/stream/worker.py - homeassistant/components/streamlabswater/* - homeassistant/components/suez_water/* + homeassistant/components/streamlabswater/__init__.py + homeassistant/components/streamlabswater/binary_sensor.py + homeassistant/components/streamlabswater/coordinator.py + homeassistant/components/streamlabswater/sensor.py + homeassistant/components/suez_water/__init__.py + homeassistant/components/suez_water/sensor.py homeassistant/components/supervisord/sensor.py homeassistant/components/supla/* homeassistant/components/surepetcare/__init__.py @@ -1235,6 +1256,8 @@ omit = homeassistant/components/surepetcare/entity.py homeassistant/components/surepetcare/sensor.py homeassistant/components/swiss_hydrological_data/sensor.py + homeassistant/components/swiss_public_transport/__init__.py + homeassistant/components/swiss_public_transport/coordinator.py homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbee/__init__.py @@ -1286,7 +1309,9 @@ omit = homeassistant/components/system_bridge/notify.py homeassistant/components/system_bridge/sensor.py homeassistant/components/system_bridge/update.py + homeassistant/components/systemmonitor/__init__.py homeassistant/components/systemmonitor/sensor.py + homeassistant/components/systemmonitor/util.py homeassistant/components/tado/__init__.py homeassistant/components/tado/binary_sensor.py homeassistant/components/tado/climate.py @@ -1375,10 +1400,6 @@ omit = homeassistant/components/tradfri/light.py 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_train/util.py homeassistant/components/trafikverket_weatherstation/__init__.py homeassistant/components/trafikverket_weatherstation/coordinator.py homeassistant/components/trafikverket_weatherstation/sensor.py @@ -1413,6 +1434,8 @@ omit = homeassistant/components/ukraine_alarm/__init__.py homeassistant/components/ukraine_alarm/binary_sensor.py homeassistant/components/unifiled/* + homeassistant/components/unifi_direct/__init__.py + homeassistant/components/unifi_direct/device_tracker.py homeassistant/components/upb/__init__.py homeassistant/components/upb/light.py homeassistant/components/upc_connect/* diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 2fdde85c8cd..378208fbdf4 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.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.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.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -331,7 +331,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Install Cosign - uses: sigstore/cosign-installer@v3.2.0 + uses: sigstore/cosign-installer@v3.3.0 with: cosign-release: "v2.0.2" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 71030e50074..2255b3f145c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,7 +36,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 6 - HA_SHORT_VERSION: "2023.12" + HA_SHORT_VERSION: "2024.1" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11', '3.12']" # 10.3 is the oldest supported version @@ -225,7 +225,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -269,7 +269,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -309,7 +309,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -348,7 +348,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -443,7 +443,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -511,7 +511,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -543,7 +543,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -576,7 +576,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -620,7 +620,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -702,7 +702,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -854,7 +854,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -978,7 +978,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index e7d9d4cd901..1dc36b9fa34 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v2.22.8 + uses: github/codeql-action/init@v3.22.12 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.8 + uses: github/codeql-action/analyze@v3.22.12 with: category: "/language:python" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c91117cb02d..b51550767b8 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -11,16 +11,16 @@ jobs: if: github.repository_owner == 'home-assistant' runs-on: ubuntu-latest steps: - # The 90 day stale policy for PRs + # The 60 day stale policy for PRs # Used for: # - PRs # - No PRs marked as no-stale # - No issues (-1) - - name: 90 days stale PRs policy - uses: actions/stale@v8.0.0 + - name: 60 days stale PRs policy + uses: actions/stale@v9.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - days-before-stale: 90 + days-before-stale: 60 days-before-close: 7 days-before-issue-stale: -1 days-before-issue-close: -1 @@ -33,7 +33,11 @@ jobs: pull request has been automatically marked as stale because of that and will be closed if no further activity occurs within 7 days. - Thank you for your contributions. + If you are the author of this PR, please leave a comment if you want + to keep it open. Also, please rebase your PR onto the latest dev + branch to ensure that it's up to date with the latest changes. + + Thank you for your contribution! # Generate a token for the GitHub App, we use this method to avoid # hitting API limits for our GitHub actions + have a higher rate limit. @@ -53,7 +57,7 @@ jobs: # - No issues marked as no-stale or help-wanted # - No PRs (-1) - name: 90 days stale issues - uses: actions/stale@v8.0.0 + uses: actions/stale@v9.0.0 with: repo-token: ${{ steps.token.outputs.token }} days-before-stale: 90 @@ -83,7 +87,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v8.0.0 + uses: actions/stale@v9.0.0 with: repo-token: ${{ steps.token.outputs.token }} only-labels: "needs-more-information" diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index f72b71b8802..c8e25cc83ea 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.1 + uses: actions/setup-python@v5.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ae135f30407..79bf7e87903 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.6 + rev: v0.1.8 hooks: - id: ruff args: diff --git a/.strict-typing b/.strict-typing index 3c18a1988f3..aa9c801fbf6 100644 --- a/.strict-typing +++ b/.strict-typing @@ -43,11 +43,14 @@ homeassistant.components.abode.* homeassistant.components.accuweather.* homeassistant.components.acer_projector.* homeassistant.components.actiontec.* +homeassistant.components.adax.* homeassistant.components.adguard.* homeassistant.components.aftership.* homeassistant.components.air_quality.* homeassistant.components.airly.* +homeassistant.components.airnow.* homeassistant.components.airvisual.* +homeassistant.components.airvisual_pro.* homeassistant.components.airzone.* homeassistant.components.airzone_cloud.* homeassistant.components.aladdin_connect.* @@ -59,10 +62,14 @@ homeassistant.components.ambient_station.* homeassistant.components.amcrest.* homeassistant.components.ampio.* homeassistant.components.analytics.* +homeassistant.components.android_ip_webcam.* +homeassistant.components.androidtv_remote.* homeassistant.components.anova.* homeassistant.components.anthemav.* homeassistant.components.apcupsd.* +homeassistant.components.apprise.* homeassistant.components.aqualogic.* +homeassistant.components.aranet.* homeassistant.components.aseko_pool_live.* homeassistant.components.assist_pipeline.* homeassistant.components.asuswrt.* @@ -75,6 +82,7 @@ homeassistant.components.bayesian.* homeassistant.components.binary_sensor.* homeassistant.components.bitcoin.* homeassistant.components.blockchain.* +homeassistant.components.blue_current.* homeassistant.components.bluetooth.* homeassistant.components.bluetooth_tracker.* homeassistant.components.bmw_connected_drive.* @@ -117,9 +125,12 @@ homeassistant.components.elgato.* homeassistant.components.elkm1.* homeassistant.components.emulated_hue.* homeassistant.components.energy.* +homeassistant.components.enigma2.* homeassistant.components.esphome.* homeassistant.components.event.* homeassistant.components.evil_genius_labs.* +homeassistant.components.evohome.* +homeassistant.components.faa_delays.* homeassistant.components.fan.* homeassistant.components.fastdotcom.* homeassistant.components.feedreader.* @@ -127,6 +138,7 @@ homeassistant.components.file_upload.* homeassistant.components.filesize.* homeassistant.components.filter.* homeassistant.components.fitbit.* +homeassistant.components.flexit_bacnet.* homeassistant.components.flux_led.* homeassistant.components.forecast_solar.* homeassistant.components.fritz.* @@ -150,6 +162,7 @@ homeassistant.components.hardkernel.* homeassistant.components.hardware.* homeassistant.components.here_travel_time.* homeassistant.components.history.* +homeassistant.components.holiday.* homeassistant.components.homeassistant.exposed_entities homeassistant.components.homeassistant.triggers.event homeassistant.components.homeassistant_alerts.* @@ -228,6 +241,7 @@ homeassistant.components.modbus.* homeassistant.components.modem_callerid.* homeassistant.components.moon.* homeassistant.components.mopeka.* +homeassistant.components.motionmount.* homeassistant.components.mqtt.* homeassistant.components.mysensors.* homeassistant.components.nam.* @@ -264,6 +278,7 @@ homeassistant.components.proximity.* homeassistant.components.prusalink.* homeassistant.components.pure_energie.* homeassistant.components.purpleair.* +homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* homeassistant.components.qnap_qsw.* homeassistant.components.radarr.* @@ -313,6 +328,8 @@ homeassistant.components.statistics.* homeassistant.components.steamist.* homeassistant.components.stookalert.* homeassistant.components.stream.* +homeassistant.components.streamlabswater.* +homeassistant.components.suez_water.* homeassistant.components.sun.* homeassistant.components.surepetcare.* homeassistant.components.switch.* @@ -323,6 +340,7 @@ homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* homeassistant.components.tag.* homeassistant.components.tailscale.* +homeassistant.components.tailwind.* homeassistant.components.tami4.* homeassistant.components.tautulli.* homeassistant.components.tcp.* @@ -353,6 +371,7 @@ homeassistant.components.uptimerobot.* homeassistant.components.usb.* homeassistant.components.vacuum.* homeassistant.components.vallox.* +homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.vlc_telnet.* homeassistant.components.wake_on_lan.* diff --git a/CODEOWNERS b/CODEOWNERS index 5ecb7d75cc8..494f3d42bee 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -86,6 +86,8 @@ build.json @home-assistant/supervisor /tests/components/anova/ @Lash-L /homeassistant/components/anthemav/ @hyralex /tests/components/anthemav/ @hyralex +/homeassistant/components/aosmith/ @bdr99 +/tests/components/aosmith/ @bdr99 /homeassistant/components/apache_kafka/ @bachya /tests/components/apache_kafka/ @bachya /homeassistant/components/apcupsd/ @yuxincs @@ -153,6 +155,8 @@ build.json @home-assistant/supervisor /tests/components/blebox/ @bbx-a @riokuu /homeassistant/components/blink/ @fronzbot @mkmer /tests/components/blink/ @fronzbot @mkmer +/homeassistant/components/blue_current/ @Floris272 @gleeuwen +/tests/components/blue_current/ @Floris272 @gleeuwen /homeassistant/components/bluemaestro/ @bdraco /tests/components/bluemaestro/ @bdraco /homeassistant/components/blueprint/ @home-assistant/core @@ -193,6 +197,8 @@ build.json @home-assistant/supervisor /tests/components/camera/ @home-assistant/core /homeassistant/components/cast/ @emontnemery /tests/components/cast/ @emontnemery +/homeassistant/components/ccm15/ @ocalvo +/tests/components/ccm15/ @ocalvo /homeassistant/components/cert_expiry/ @jjlawren /tests/components/cert_expiry/ @jjlawren /homeassistant/components/circuit/ @braam @@ -205,8 +211,8 @@ build.json @home-assistant/supervisor /tests/components/cloud/ @home-assistant/cloud /homeassistant/components/cloudflare/ @ludeeus @ctalkington /tests/components/cloudflare/ @ludeeus @ctalkington -/homeassistant/components/co2signal/ @jpbede -/tests/components/co2signal/ @jpbede +/homeassistant/components/co2signal/ @jpbede @VIKTORVAV99 +/tests/components/co2signal/ @jpbede @VIKTORVAV99 /homeassistant/components/coinbase/ @tombrien /tests/components/coinbase/ @tombrien /homeassistant/components/color_extractor/ @GenericStudent @@ -295,6 +301,8 @@ build.json @home-assistant/supervisor /tests/components/dormakaba_dkey/ @emontnemery /homeassistant/components/dremel_3d_printer/ @tkdrob /tests/components/dremel_3d_printer/ @tkdrob +/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer +/tests/components/drop_connect/ @ChandlerSystems @pfrazer /homeassistant/components/dsmr/ @Robbie1221 @frenck /tests/components/dsmr/ @Robbie1221 @frenck /homeassistant/components/dsmr_reader/ @depl0y @glodenox @@ -344,7 +352,7 @@ build.json @home-assistant/supervisor /tests/components/energy/ @home-assistant/core /homeassistant/components/energyzero/ @klaasnicolaas /tests/components/energyzero/ @klaasnicolaas -/homeassistant/components/enigma2/ @fbradyirl +/homeassistant/components/enigma2/ @autinerd /homeassistant/components/enocean/ @bdurrer /tests/components/enocean/ @bdurrer /homeassistant/components/enphase_envoy/ @bdraco @cgarwood @dgomes @joostlek @catsmanac @@ -395,6 +403,8 @@ build.json @home-assistant/supervisor /tests/components/fivem/ @Sander0542 /homeassistant/components/fjaraskupan/ @elupus /tests/components/fjaraskupan/ @elupus +/homeassistant/components/flexit_bacnet/ @lellky @piotrbulinski +/tests/components/flexit_bacnet/ @lellky @piotrbulinski /homeassistant/components/flick_electric/ @ZephireNZ /tests/components/flick_electric/ @ZephireNZ /homeassistant/components/flipr/ @cnico @@ -410,8 +420,8 @@ build.json @home-assistant/supervisor /homeassistant/components/forked_daapd/ @uvjustin /tests/components/forked_daapd/ @uvjustin /homeassistant/components/fortios/ @kimfrellsen -/homeassistant/components/foscam/ @skgsergio -/tests/components/foscam/ @skgsergio +/homeassistant/components/foscam/ @skgsergio @krmarien +/tests/components/foscam/ @skgsergio @krmarien /homeassistant/components/freebox/ @hacf-fr @Quentame /tests/components/freebox/ @hacf-fr @Quentame /homeassistant/components/freedompro/ @stefano055415 @@ -520,6 +530,8 @@ build.json @home-assistant/supervisor /tests/components/hive/ @Rendili @KJonline /homeassistant/components/hlk_sw16/ @jameshilliard /tests/components/hlk_sw16/ @jameshilliard +/homeassistant/components/holiday/ @jrieger +/tests/components/holiday/ @jrieger /homeassistant/components/home_connect/ @DavidMStraub /tests/components/home_connect/ @DavidMStraub /homeassistant/components/home_plus_control/ @chemaaa @@ -803,6 +815,8 @@ build.json @home-assistant/supervisor /tests/components/motion_blinds/ @starkillerOG /homeassistant/components/motioneye/ @dermotduffy /tests/components/motioneye/ @dermotduffy +/homeassistant/components/motionmount/ @RJPoelstra +/tests/components/motionmount/ @RJPoelstra /homeassistant/components/mqtt/ @emontnemery @jbouwh /tests/components/mqtt/ @emontnemery @jbouwh /homeassistant/components/msteams/ @peroyvind @@ -833,6 +847,7 @@ build.json @home-assistant/supervisor /homeassistant/components/netgear/ @hacf-fr @Quentame @starkillerOG /tests/components/netgear/ @hacf-fr @Quentame @starkillerOG /homeassistant/components/netgear_lte/ @tkdrob +/tests/components/netgear_lte/ @tkdrob /homeassistant/components/network/ @home-assistant/core /tests/components/network/ @home-assistant/core /homeassistant/components/nexia/ @bdraco @@ -926,6 +941,8 @@ build.json @home-assistant/supervisor /homeassistant/components/oralb/ @bdraco @Lash-L /tests/components/oralb/ @bdraco @Lash-L /homeassistant/components/oru/ @bvlaicu +/homeassistant/components/osoenergy/ @osohotwateriot +/tests/components/osoenergy/ @osohotwateriot /homeassistant/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core /homeassistant/components/ourgroceries/ @OnFreund @@ -985,8 +1002,8 @@ build.json @home-assistant/supervisor /homeassistant/components/proximity/ @mib1185 /tests/components/proximity/ @mib1185 /homeassistant/components/proxmoxve/ @jhollowe @Corbeno -/homeassistant/components/prusalink/ @balloob -/tests/components/prusalink/ @balloob +/homeassistant/components/prusalink/ @balloob @Skaronator +/tests/components/prusalink/ @balloob @Skaronator /homeassistant/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45 /homeassistant/components/pure_energie/ @klaasnicolaas @@ -1003,8 +1020,8 @@ build.json @home-assistant/supervisor /tests/components/pvoutput/ @frenck /homeassistant/components/pvpc_hourly_pricing/ @azogue /tests/components/pvpc_hourly_pricing/ @azogue -/homeassistant/components/qbittorrent/ @geoffreylagaisse -/tests/components/qbittorrent/ @geoffreylagaisse +/homeassistant/components/qbittorrent/ @geoffreylagaisse @finder39 +/tests/components/qbittorrent/ @geoffreylagaisse @finder39 /homeassistant/components/qingping/ @bdraco @skgsergio /tests/components/qingping/ @bdraco @skgsergio /homeassistant/components/qld_bushfire/ @exxamalte @@ -1046,6 +1063,8 @@ build.json @home-assistant/supervisor /tests/components/recorder/ @home-assistant/core /homeassistant/components/recovery_mode/ @home-assistant/core /tests/components/recovery_mode/ @home-assistant/core +/homeassistant/components/refoss/ @ashionky +/tests/components/refoss/ @ashionky /homeassistant/components/rejseplanen/ @DarkFox /homeassistant/components/remote/ @home-assistant/core /tests/components/remote/ @home-assistant/core @@ -1058,6 +1077,8 @@ build.json @home-assistant/supervisor /homeassistant/components/repairs/ @home-assistant/core /tests/components/repairs/ @home-assistant/core /homeassistant/components/repetier/ @ShadowBr0ther +/homeassistant/components/rest_command/ @jpbede +/tests/components/rest_command/ @jpbede /homeassistant/components/rflink/ @javicalle /tests/components/rflink/ @javicalle /homeassistant/components/rfxtrx/ @danielhiversen @elupus @RobBie1221 @@ -1243,13 +1264,17 @@ build.json @home-assistant/supervisor /homeassistant/components/subaru/ @G-Two /tests/components/subaru/ @G-Two /homeassistant/components/suez_water/ @ooii +/tests/components/suez_water/ @ooii /homeassistant/components/sun/ @Swamp-Ig /tests/components/sun/ @Swamp-Ig +/homeassistant/components/sunweg/ @rokam +/tests/components/sunweg/ @rokam /homeassistant/components/supla/ @mwegrzynek /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen /homeassistant/components/swiss_hydrological_data/ @fabaff -/homeassistant/components/swiss_public_transport/ @fabaff +/homeassistant/components/swiss_public_transport/ @fabaff @miaucl +/tests/components/swiss_public_transport/ @fabaff @miaucl /homeassistant/components/switch/ @home-assistant/core /tests/components/switch/ @home-assistant/core /homeassistant/components/switch_as_x/ @home-assistant/core @@ -1272,12 +1297,16 @@ build.json @home-assistant/supervisor /homeassistant/components/synology_srm/ @aerialls /homeassistant/components/system_bridge/ @timmo001 /tests/components/system_bridge/ @timmo001 -/homeassistant/components/tado/ @michaelarnauts @chiefdragon -/tests/components/tado/ @michaelarnauts @chiefdragon +/homeassistant/components/systemmonitor/ @gjohansson-ST +/tests/components/systemmonitor/ @gjohansson-ST +/homeassistant/components/tado/ @michaelarnauts @chiefdragon @erwindouna +/tests/components/tado/ @michaelarnauts @chiefdragon @erwindouna /homeassistant/components/tag/ @balloob @dmulcahey /tests/components/tag/ @balloob @dmulcahey /homeassistant/components/tailscale/ @frenck /tests/components/tailscale/ @frenck +/homeassistant/components/tailwind/ @frenck +/tests/components/tailwind/ @frenck /homeassistant/components/tami4/ @Guy293 /tests/components/tami4/ @Guy293 /homeassistant/components/tankerkoenig/ @guillempages @mib1185 @@ -1293,6 +1322,8 @@ build.json @home-assistant/supervisor /tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core /homeassistant/components/tesla_wall_connector/ @einarhauks /tests/components/tesla_wall_connector/ @einarhauks +/homeassistant/components/tessie/ @Bre77 +/tests/components/tessie/ @Bre77 /homeassistant/components/text/ @home-assistant/core /tests/components/text/ @home-assistant/core /homeassistant/components/tfiac/ @fredrike @mellado @@ -1360,6 +1391,7 @@ build.json @home-assistant/supervisor /tests/components/ukraine_alarm/ @PaulAnnekov /homeassistant/components/unifi/ @Kane610 /tests/components/unifi/ @Kane610 +/homeassistant/components/unifi_direct/ @tofuSCHNITZEL /homeassistant/components/unifiled/ @florisvdk /homeassistant/components/unifiprotect/ @AngellusMortis @bdraco /tests/components/unifiprotect/ @AngellusMortis @bdraco @@ -1388,6 +1420,8 @@ build.json @home-assistant/supervisor /tests/components/vacuum/ @home-assistant/core /homeassistant/components/vallox/ @andre-richter @slovdahl @viiru- /tests/components/vallox/ @andre-richter @slovdahl @viiru- +/homeassistant/components/valve/ @home-assistant/core +/tests/components/valve/ @home-assistant/core /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra /homeassistant/components/velux/ @Julius2342 @@ -1396,8 +1430,8 @@ build.json @home-assistant/supervisor /homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus -/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey -/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey +/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja +/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja /homeassistant/components/vicare/ @CFenner /tests/components/vicare/ @CFenner /homeassistant/components/vilfo/ @ManneW diff --git a/Dockerfile b/Dockerfile index 97eeb5b0dfa..43b21ab3ba8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ FROM ${BUILD_FROM} # Synchronize with homeassistant/core.py:async_stop ENV \ - S6_SERVICES_GRACETIME=220000 + S6_SERVICES_GRACETIME=240000 ARG QEMU_CPU diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 0998ac6274c..83b2f18719f 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -27,6 +27,7 @@ from .const import ( from .exceptions import HomeAssistantError from .helpers import ( area_registry, + config_validation as cv, device_registry, entity, entity_registry, @@ -473,7 +474,9 @@ async def async_mount_local_lib_path(config_dir: str) -> str: def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: """Get domains of components to set up.""" # Filter out the repeating and common config section [homeassistant] - domains = {key.partition(" ")[0] for key in config if key != core.DOMAIN} + domains = { + domain for key in config if (domain := cv.domain_key(key)) != core.DOMAIN + } # Add config entry domains if not hass.config.recovery_mode: diff --git a/homeassistant/brands/flexit.json b/homeassistant/brands/flexit.json new file mode 100644 index 00000000000..4c61c5eeb07 --- /dev/null +++ b/homeassistant/brands/flexit.json @@ -0,0 +1,5 @@ +{ + "domain": "flexit", + "name": "Flexit", + "integrations": ["flexit", "flexit_bacnet"] +} diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index bceed215428..1b1dbe8b30a 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -27,7 +27,7 @@ ABODE_TEMPERATURE_UNIT_HA_UNIT = { } -@dataclass +@dataclass(frozen=True) class AbodeSensorDescriptionMixin: """Mixin for Abode sensor.""" @@ -35,7 +35,7 @@ class AbodeSensorDescriptionMixin: native_unit_of_measurement_fn: Callable[[AbodeSense], str] -@dataclass +@dataclass(frozen=True) class AbodeSensorDescription(SensorEntityDescription, AbodeSensorDescriptionMixin): """Class describing Abode sensor entities.""" diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index c983f0bc291..2219c5de4b6 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -45,14 +45,14 @@ from .const import ( PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class AccuWeatherSensorDescriptionMixin: """Mixin for AccuWeather sensor.""" value_fn: Callable[[dict[str, Any]], str | int | float | None] -@dataclass +@dataclass(frozen=True) class AccuWeatherSensorDescription( SensorEntityDescription, AccuWeatherSensorDescriptionMixin ): diff --git a/homeassistant/components/adax/__init__.py b/homeassistant/components/adax/__init__.py index 511fb746216..cf60d40631c 100644 --- a/homeassistant/components/adax/__init__.py +++ b/homeassistant/components/adax/__init__.py @@ -24,7 +24,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # convert title and unique_id to string if config_entry.version == 1: if isinstance(config_entry.unique_id, int): - hass.config_entries.async_update_entry( + hass.config_entries.async_update_entry( # type: ignore[unreachable] config_entry, unique_id=str(config_entry.unique_id), title=str(config_entry.title), diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index 7587bfc0799..34812f9e449 100644 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -137,7 +137,7 @@ class LocalAdaxDevice(ClimateEntity): _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, adax_data_handler, unique_id): + def __init__(self, adax_data_handler: AdaxLocal, unique_id: str) -> None: """Initialize the heater.""" self._adax_data_handler = adax_data_handler self._attr_unique_id = unique_id diff --git a/homeassistant/components/adax/config_flow.py b/homeassistant/components/adax/config_flow.py index b9e8ef1abca..b614c968d48 100644 --- a/homeassistant/components/adax/config_flow.py +++ b/homeassistant/components/adax/config_flow.py @@ -36,7 +36,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 2 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" data_schema = vol.Schema( { @@ -59,7 +61,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_local() return await self.async_step_cloud() - async def async_step_local(self, user_input=None): + async def async_step_local( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the local step.""" data_schema = vol.Schema( {vol.Required(WIFI_SSID): str, vol.Required(WIFI_PSWD): str} diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index 523e1b73e16..c8ec5023533 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -22,7 +22,7 @@ SCAN_INTERVAL = timedelta(seconds=300) PARALLEL_UPDATES = 4 -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class AdGuardHomeEntityDescription(SensorEntityDescription): """Describes AdGuard Home sensor entity.""" diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py index 944a3c7b269..4b6fe06cdab 100644 --- a/homeassistant/components/adguard/switch.py +++ b/homeassistant/components/adguard/switch.py @@ -21,7 +21,7 @@ SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 1 -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class AdGuardHomeSwitchEntityDescription(SwitchEntityDescription): """Describes AdGuard Home switch entity.""" diff --git a/homeassistant/components/advantage_air/__init__.py b/homeassistant/components/advantage_air/__init__.py index 4dbc2edad8d..1383ea7c054 100644 --- a/homeassistant/components/advantage_air/__init__.py +++ b/homeassistant/components/advantage_air/__init__.py @@ -8,6 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ADVANTAGE_AIR_RETRY, DOMAIN @@ -26,6 +27,7 @@ PLATFORMS = [ ] _LOGGER = logging.getLogger(__name__) +REQUEST_REFRESH_DELAY = 0.5 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -51,6 +53,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name="Advantage Air", update_method=async_get, update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL), + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 8244472f2b4..a488ba8b362 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + ADVANTAGE_AIR_AUTOFAN_ENABLED, ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, @@ -39,16 +40,6 @@ ADVANTAGE_AIR_HVAC_MODES = { } HASS_HVAC_MODES = {v: k for k, v in ADVANTAGE_AIR_HVAC_MODES.items()} -ADVANTAGE_AIR_FAN_MODES = { - "autoAA": FAN_AUTO, - "low": FAN_LOW, - "medium": FAN_MEDIUM, - "high": FAN_HIGH, -} -HASS_FAN_MODES = {v: k for k, v in ADVANTAGE_AIR_FAN_MODES.items()} -FAN_SPEEDS = {FAN_LOW: 30, FAN_MEDIUM: 60, FAN_HIGH: 100} - -ADVANTAGE_AIR_AUTOFAN = "aaAutoFanModeEnabled" ADVANTAGE_AIR_MYZONE = "MyZone" ADVANTAGE_AIR_MYAUTO = "MyAuto" ADVANTAGE_AIR_MYAUTO_ENABLED = "myAutoModeEnabled" @@ -56,6 +47,7 @@ ADVANTAGE_AIR_MYTEMP = "MyTemp" ADVANTAGE_AIR_MYTEMP_ENABLED = "climateControlModeEnabled" ADVANTAGE_AIR_HEAT_TARGET = "myAutoHeatTargetTemp" ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp" +ADVANTAGE_AIR_MYFAN = "autoAA" PARALLEL_UPDATES = 0 @@ -85,27 +77,25 @@ async def async_setup_entry( class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """AdvantageAir AC unit.""" - _attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH] + _attr_fan_modes = [FAN_LOW, FAN_MEDIUM, FAN_HIGH, FAN_AUTO] _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = PRECISION_WHOLE _attr_max_temp = 32 _attr_min_temp = 16 _attr_name = None - _attr_hvac_modes = [ - HVACMode.OFF, - HVACMode.COOL, - HVACMode.HEAT, - HVACMode.FAN_ONLY, - HVACMode.DRY, - ] - - _attr_supported_features = ClimateEntityFeature.FAN_MODE - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: """Initialize an AdvantageAir AC unit.""" super().__init__(instance, ac_key) + self._attr_supported_features = ClimateEntityFeature.FAN_MODE + self._attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + ] # Set supported features and HVAC modes based on current operating mode if self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED): # MyAuto @@ -118,10 +108,6 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): # MyZone self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE - # Add "ezfan" mode if supported - if self._ac.get(ADVANTAGE_AIR_AUTOFAN): - self._attr_fan_modes += [FAN_AUTO] - @property def current_temperature(self) -> float | None: """Return the selected zones current temperature.""" @@ -151,7 +137,7 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): @property def fan_mode(self) -> str | None: """Return the current fan modes.""" - return ADVANTAGE_AIR_FAN_MODES.get(self._ac["fan"]) + return FAN_AUTO if self._ac["fan"] == ADVANTAGE_AIR_MYFAN else self._ac["fan"] @property def target_temperature_high(self) -> float | None: @@ -189,7 +175,11 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set the Fan Mode.""" - await self.async_update_ac({"fan": HASS_FAN_MODES.get(fan_mode)}) + if fan_mode == FAN_AUTO and self._ac.get(ADVANTAGE_AIR_AUTOFAN_ENABLED): + mode = ADVANTAGE_AIR_MYFAN + else: + mode = fan_mode + await self.async_update_ac({"fan": mode}) async def async_set_temperature(self, **kwargs: Any) -> None: """Set the Temperature.""" diff --git a/homeassistant/components/advantage_air/const.py b/homeassistant/components/advantage_air/const.py index 5c044481ca0..80ce9b6eaa1 100644 --- a/homeassistant/components/advantage_air/const.py +++ b/homeassistant/components/advantage_air/const.py @@ -5,3 +5,4 @@ ADVANTAGE_AIR_STATE_OPEN = "open" ADVANTAGE_AIR_STATE_CLOSE = "close" ADVANTAGE_AIR_STATE_ON = "on" ADVANTAGE_AIR_STATE_OFF = "off" +ADVANTAGE_AIR_AUTOFAN_ENABLED = "aaAutoFanModeEnabled" diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index 691db99769b..9079e69ae09 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -30,7 +30,7 @@ class AdvantageAirEntity(CoordinatorEntity): async def update_handle(*values): try: if await func(*keys, *values): - await self.coordinator.async_refresh() + await self.coordinator.async_request_refresh() except ApiError as err: raise HomeAssistantError(err) from err diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py index 7234ca36305..abc9b795d43 100644 --- a/homeassistant/components/advantage_air/switch.py +++ b/homeassistant/components/advantage_air/switch.py @@ -7,6 +7,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( + ADVANTAGE_AIR_AUTOFAN_ENABLED, ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN, @@ -29,6 +30,8 @@ async def async_setup_entry( for ac_key, ac_device in aircons.items(): if ac_device["info"]["freshAirStatus"] != "none": entities.append(AdvantageAirFreshAir(instance, ac_key)) + if ADVANTAGE_AIR_AUTOFAN_ENABLED in ac_device["info"]: + entities.append(AdvantageAirMyFan(instance, ac_key)) if things := instance.coordinator.data.get("myThings"): for thing in things["things"].values(): if thing["channelDipState"] == 8: # 8 = Other relay @@ -62,6 +65,32 @@ class AdvantageAirFreshAir(AdvantageAirAcEntity, SwitchEntity): await self.async_update_ac({"freshAirStatus": ADVANTAGE_AIR_STATE_OFF}) +class AdvantageAirMyFan(AdvantageAirAcEntity, SwitchEntity): + """Representation of Advantage Air MyFan control.""" + + _attr_icon = "mdi:fan-auto" + _attr_name = "MyFan" + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + """Initialize an Advantage Air MyFan control.""" + super().__init__(instance, ac_key) + self._attr_unique_id += "-myfan" + + @property + def is_on(self) -> bool: + """Return the MyFan status.""" + return self._ac[ADVANTAGE_AIR_AUTOFAN_ENABLED] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn MyFan on.""" + await self.async_update_ac({ADVANTAGE_AIR_AUTOFAN_ENABLED: True}) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn MyFan off.""" + await self.async_update_ac({ADVANTAGE_AIR_AUTOFAN_ENABLED: False}) + + class AdvantageAirRelay(AdvantageAirThingEntity, SwitchEntity): """Representation of Advantage Air Thing.""" diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index dbf3df823e3..a58faaf6f6b 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -1,6 +1,8 @@ """Config flow for AEMET OpenData.""" from __future__ import annotations +from typing import Any + from aemet_opendata.exceptions import AuthError from aemet_opendata.interface import AEMET, ConnectionOptions import voluptuous as vol @@ -8,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, @@ -29,7 +32,9 @@ OPTIONS_FLOW = { class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for AEMET OpenData.""" - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} diff --git a/homeassistant/components/aep_ohio/__init__.py b/homeassistant/components/aep_ohio/__init__.py new file mode 100644 index 00000000000..a602f1d794a --- /dev/null +++ b/homeassistant/components/aep_ohio/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: AEP Ohio.""" diff --git a/homeassistant/components/aep_ohio/manifest.json b/homeassistant/components/aep_ohio/manifest.json new file mode 100644 index 00000000000..9b85e537fc8 --- /dev/null +++ b/homeassistant/components/aep_ohio/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "aep_ohio", + "name": "AEP Ohio", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/aep_texas/__init__.py b/homeassistant/components/aep_texas/__init__.py new file mode 100644 index 00000000000..c8ff9829e22 --- /dev/null +++ b/homeassistant/components/aep_texas/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: AEP Texas.""" diff --git a/homeassistant/components/aep_texas/manifest.json b/homeassistant/components/aep_texas/manifest.json new file mode 100644 index 00000000000..5de0e0ffd77 --- /dev/null +++ b/homeassistant/components/aep_texas/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "aep_texas", + "name": "AEP Texas", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/agent_dvr/config_flow.py b/homeassistant/components/agent_dvr/config_flow.py index 8da3a497ceb..9143d40352f 100644 --- a/homeassistant/components/agent_dvr/config_flow.py +++ b/homeassistant/components/agent_dvr/config_flow.py @@ -1,5 +1,6 @@ """Config flow to configure Agent devices.""" from contextlib import suppress +from typing import Any from agent import AgentConnectionError, AgentError from agent.a import Agent @@ -7,6 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, SERVER_URL @@ -18,11 +20,9 @@ DEFAULT_PORT = 8090 class AgentFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle an Agent config flow.""" - def __init__(self): - """Initialize the Agent config flow.""" - self.device_config = {} - - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle an Agent config flow.""" errors = {} @@ -49,13 +49,15 @@ class AgentFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) - self.device_config = { + device_config = { CONF_HOST: host, CONF_PORT: port, SERVER_URL: server_origin, } - return await self._create_entry(agent_client.name) + return self.async_create_entry( + title=agent_client.name, data=device_config + ) errors["base"] = "cannot_connect" @@ -66,11 +68,6 @@ class AgentFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", - description_placeholders=self.device_config, data_schema=vol.Schema(data), errors=errors, ) - - async def _create_entry(self, server_name): - """Create entry for device.""" - return self.async_create_entry(title=server_name, data=self.device_config) diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 864c36f171a..6105b277088 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -56,7 +56,7 @@ from .const import ( PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class AirlySensorEntityDescription(SensorEntityDescription): """Class describing Airly sensor entities.""" diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index d72d145f7de..a6fa7aa5088 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -6,8 +6,17 @@ from pyairnow import WebServiceAPI from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError import voluptuous as vol -from homeassistant import config_entries, core, data_entry_flow, exceptions +from homeassistant import core +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlow, + OptionsFlowWithConfigEntry, +) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -16,7 +25,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -46,12 +55,14 @@ async def validate_input(hass: core.HomeAssistant, data): return True -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class AirNowConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for AirNow.""" VERSION = 2 - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: @@ -108,18 +119,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @core.callback def async_get_options_flow( - config_entry: config_entries.ConfigEntry, - ) -> config_entries.OptionsFlow: + config_entry: ConfigEntry, + ) -> OptionsFlow: """Return the options flow.""" - return OptionsFlowHandler(config_entry) + return AirNowOptionsFlowHandler(config_entry) -class OptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): +class AirNowOptionsFlowHandler(OptionsFlowWithConfigEntry): """Handle an options flow for AirNow.""" async def async_step_init( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(data=user_input) @@ -141,13 +152,13 @@ class OptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): ) -class CannotConnect(exceptions.HomeAssistantError): +class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" -class InvalidAuth(exceptions.HomeAssistantError): +class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" -class InvalidLocation(exceptions.HomeAssistantError): +class InvalidLocation(HomeAssistantError): """Error to indicate the location is invalid.""" diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index e89afc2f7ce..4bdaadff0da 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -1,11 +1,15 @@ """DataUpdateCoordinator for the AirNow integration.""" +from datetime import timedelta import logging +from typing import Any +from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectorError from pyairnow import WebServiceAPI from pyairnow.conv import aqi_to_concentration from pyairnow.errors import AirNowError +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -31,12 +35,19 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -class AirNowDataUpdateCoordinator(DataUpdateCoordinator): +class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """The AirNow update coordinator.""" def __init__( - self, hass, session, api_key, latitude, longitude, distance, update_interval - ): + self, + hass: HomeAssistant, + session: ClientSession, + api_key: str, + latitude: float, + longitude: float, + distance: int, + update_interval: timedelta, + ) -> None: """Initialize.""" self.latitude = latitude self.longitude = longitude @@ -46,7 +57,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator): super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - async def _async_update_data(self): + async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" data = {} try: diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index c6ab27a8497..9c154dc0712 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -51,7 +51,7 @@ ATTR_LEVEL = "level" ATTR_STATION = "reporting_station" -@dataclass +@dataclass(frozen=True) class AirNowEntityDescriptionMixin: """Mixin for required keys.""" @@ -59,7 +59,7 @@ class AirNowEntityDescriptionMixin: extra_state_attributes_fn: Callable[[Any], dict[str, str]] | None -@dataclass +@dataclass(frozen=True) class AirNowEntityDescription(SensorEntityDescription, AirNowEntityDescriptionMixin): """Describes Airnow sensor entity.""" diff --git a/homeassistant/components/airq/config_flow.py b/homeassistant/components/airq/config_flow.py index 41eda912e98..33d76ec75bc 100644 --- a/homeassistant/components/airq/config_flow.py +++ b/homeassistant/components/airq/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from aioairq import AirQ, InvalidAuth, InvalidInput +from aioairq import AirQ, InvalidAuth from aiohttp.client_exceptions import ClientConnectionError import voluptuous as vol @@ -42,44 +42,32 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} session = async_get_clientsession(self.hass) + airq = AirQ(user_input[CONF_IP_ADDRESS], user_input[CONF_PASSWORD], session) try: - airq = AirQ(user_input[CONF_IP_ADDRESS], user_input[CONF_PASSWORD], session) - except InvalidInput: + await airq.validate() + except ClientConnectionError: _LOGGER.debug( - "%s does not appear to be a valid IP address or mDNS name", + ( + "Failed to connect to device %s. Check the IP address / device" + " ID as well as whether the device is connected to power and" + " the WiFi" + ), user_input[CONF_IP_ADDRESS], ) - errors["base"] = "invalid_input" + errors["base"] = "cannot_connect" + except InvalidAuth: + _LOGGER.debug( + "Incorrect password for device %s", user_input[CONF_IP_ADDRESS] + ) + errors["base"] = "invalid_auth" else: - try: - await airq.validate() - except ClientConnectionError: - _LOGGER.debug( - ( - "Failed to connect to device %s. Check the IP address / device" - " ID as well as whether the device is connected to power and" - " the WiFi" - ), - user_input[CONF_IP_ADDRESS], - ) - errors["base"] = "cannot_connect" - except InvalidAuth: - _LOGGER.debug( - "Incorrect password for device %s", user_input[CONF_IP_ADDRESS] - ) - errors["base"] = "invalid_auth" - else: - _LOGGER.debug( - "Successfully connected to %s", user_input[CONF_IP_ADDRESS] - ) + _LOGGER.debug("Successfully connected to %s", user_input[CONF_IP_ADDRESS]) - device_info = await airq.fetch_device_info() - await self.async_set_unique_id(device_info["id"]) - self._abort_if_unique_id_configured() + device_info = await airq.fetch_device_info() + await self.async_set_unique_id(device_info["id"]) + self._abort_if_unique_id_configured() - return self.async_create_entry( - title=device_info["name"], data=user_input - ) + return self.async_create_entry(title=device_info["name"], data=user_input) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json index 156f167913b..2b23928aba8 100644 --- a/homeassistant/components/airq/manifest.json +++ b/homeassistant/components/airq/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioairq"], - "requirements": ["aioairq==0.3.1"] + "requirements": ["aioairq==0.3.2"] } diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index 9974307b4cd..f1fdfb289dd 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -37,14 +37,14 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class AirQEntityDescriptionMixin: """Class for keys required by AirQ entity.""" value: Callable[[dict], float | int | None] -@dataclass +@dataclass(frozen=True) class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin): """Describes AirQ sensor entity.""" diff --git a/homeassistant/components/airthings/__init__.py b/homeassistant/components/airthings/__init__.py index 423e890a855..d596c1db757 100644 --- a/homeassistant/components/airthings/__init__.py +++ b/homeassistant/components/airthings/__init__.py @@ -7,12 +7,12 @@ import logging from airthings import Airthings, AirthingsError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_ID, CONF_SECRET, DOMAIN +from .const import CONF_SECRET, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/airthings/config_flow.py b/homeassistant/components/airthings/config_flow.py index f07f7164f2b..62f66213a0f 100644 --- a/homeassistant/components/airthings/config_flow.py +++ b/homeassistant/components/airthings/config_flow.py @@ -8,10 +8,11 @@ import airthings import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_ID from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_ID, CONF_SECRET, DOMAIN +from .const import CONF_SECRET, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/airthings/const.py b/homeassistant/components/airthings/const.py index 70de549141b..5f846fbb31d 100644 --- a/homeassistant/components/airthings/const.py +++ b/homeassistant/components/airthings/const.py @@ -2,5 +2,4 @@ DOMAIN = "airthings" -CONF_ID = "id" CONF_SECRET = "secret" diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index aaeb91cf30b..c4797713bb8 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -1,6 +1,7 @@ """Support for airthings ble sensors.""" from __future__ import annotations +import dataclasses import logging from airthings_ble import AirthingsDevice @@ -167,10 +168,13 @@ async def async_setup_entry( # we need to change some units sensors_mapping = SENSORS_MAPPING_TEMPLATE.copy() if not is_metric: - for val in sensors_mapping.values(): + for key, val in sensors_mapping.items(): if val.native_unit_of_measurement is not VOLUME_BECQUEREL: continue - val.native_unit_of_measurement = VOLUME_PICOCURIE + sensors_mapping[key] = dataclasses.replace( + val, + native_unit_of_measurement=VOLUME_PICOCURIE, + ) entities = [] _LOGGER.debug("got sensors: %s", coordinator.data.sensors) diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index e07400f2764..1d5babee6d7 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -19,6 +19,7 @@ from homeassistant.components import automation from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_COUNTRY, CONF_IP_ADDRESS, CONF_LATITUDE, CONF_LONGITUDE, @@ -44,7 +45,6 @@ from homeassistant.helpers.update_coordinator import ( from .const import ( CONF_CITY, - CONF_COUNTRY, CONF_GEOGRAPHIES, CONF_INTEGRATION_TYPE, DOMAIN, diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index 893726fc022..23a26e2cca6 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -19,6 +19,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_SHOW_ON_MAP, @@ -35,7 +36,6 @@ from homeassistant.helpers.schema_config_entry_flow import ( from . import async_get_geography_id from .const import ( CONF_CITY, - CONF_COUNTRY, CONF_INTEGRATION_TYPE, DOMAIN, INTEGRATION_TYPE_GEOGRAPHY_COORDS, diff --git a/homeassistant/components/airvisual/const.py b/homeassistant/components/airvisual/const.py index 8e2c08eb896..0afa7d32d41 100644 --- a/homeassistant/components/airvisual/const.py +++ b/homeassistant/components/airvisual/const.py @@ -9,6 +9,5 @@ INTEGRATION_TYPE_GEOGRAPHY_NAME = "Geographical Location by Name" INTEGRATION_TYPE_NODE_PRO = "AirVisual Node/Pro" CONF_CITY = "city" -CONF_COUNTRY = "country" CONF_GEOGRAPHIES = "geographies" CONF_INTEGRATION_TYPE = "integration_type" diff --git a/homeassistant/components/airvisual/diagnostics.py b/homeassistant/components/airvisual/diagnostics.py index c273dbe7a55..05e716367bb 100644 --- a/homeassistant/components/airvisual/diagnostics.py +++ b/homeassistant/components/airvisual/diagnostics.py @@ -7,6 +7,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_STATE, @@ -15,7 +16,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_CITY, CONF_COUNTRY, DOMAIN +from .const import CONF_CITY, DOMAIN CONF_COORDINATES = "coordinates" CONF_TITLE = "title" diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 1f0c5aa1baa..ab80e154903 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, + CONF_COUNTRY, CONF_LATITUDE, CONF_LONGITUDE, CONF_SHOW_ON_MAP, @@ -25,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import AirVisualEntity -from .const import CONF_CITY, CONF_COUNTRY, DOMAIN +from .const import CONF_CITY, DOMAIN ATTR_CITY = "city" ATTR_COUNTRY = "country" diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py index 188647b7338..6a8e32bc32c 100644 --- a/homeassistant/components/airvisual_pro/sensor.py +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -26,7 +26,7 @@ from . import AirVisualProData, AirVisualProEntity from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class AirVisualProMeasurementKeyMixin: """Define an entity description mixin to include a measurement key.""" @@ -35,7 +35,7 @@ class AirVisualProMeasurementKeyMixin: ] -@dataclass +@dataclass(frozen=True) class AirVisualProMeasurementDescription( SensorEntityDescription, AirVisualProMeasurementKeyMixin ): diff --git a/homeassistant/components/airzone/binary_sensor.py b/homeassistant/components/airzone/binary_sensor.py index cee0bb19691..488c2c96132 100644 --- a/homeassistant/components/airzone/binary_sensor.py +++ b/homeassistant/components/airzone/binary_sensor.py @@ -29,7 +29,7 @@ from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneSystemEntity, AirzoneZoneEntity -@dataclass +@dataclass(frozen=True) class AirzoneBinarySensorEntityDescription(BinarySensorEntityDescription): """A class that describes airzone binary sensor entities.""" diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index 78b4dee3b72..6f69d4454ee 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -26,7 +26,7 @@ from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneEntity, AirzoneZoneEntity -@dataclass +@dataclass(frozen=True) class AirzoneSelectDescriptionMixin: """Define an entity description mixin for select entities.""" @@ -34,7 +34,7 @@ class AirzoneSelectDescriptionMixin: options_dict: dict[str, int] -@dataclass +@dataclass(frozen=True) class AirzoneSelectDescription(SelectEntityDescription, AirzoneSelectDescriptionMixin): """Class to describe an Airzone select entity.""" diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index 2a182b7b487..9f99e49f650 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -34,7 +34,7 @@ from .entity import ( ) -@dataclass +@dataclass(frozen=True) class AirzoneBinarySensorEntityDescription(BinarySensorEntityDescription): """A class that describes Airzone Cloud binary sensor entities.""" diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 604ac61300d..f4104a39365 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, PlatformNotReady +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -33,6 +34,34 @@ async def async_setup_entry( async_add_entities( (AladdinDevice(acc, door, config_entry) for door in doors), ) + remove_stale_devices(hass, config_entry, doors) + + +def remove_stale_devices( + hass: HomeAssistant, config_entry: ConfigEntry, devices: list[dict] +) -> None: + """Remove stale devices from device registry.""" + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + all_device_ids = {f"{door['device_id']}-{door['door_number']}" for door in devices} + + for device_entry in device_entries: + device_id: str | None = None + + for identifier in device_entry.identifiers: + if identifier[0] == DOMAIN: + device_id = identifier[1] + break + + if device_id is None or device_id not in all_device_ids: + # If device_id is None an invalid device entry was found for this config entry. + # If the device_id is not in existing device ids it's a stale device entry. + # Remove config entry from this device entry in either case. + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=config_entry.entry_id + ) class AladdinDevice(CoverEntity): diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 83f8e0167e8..344c77dcb73 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", "iot_class": "cloud_polling", "loggers": ["aladdin_connect"], + "quality_scale": "platinum", "requirements": ["AIOAladdinConnect==0.1.58"] } diff --git a/homeassistant/components/aladdin_connect/sensor.py b/homeassistant/components/aladdin_connect/sensor.py index e3a1f2d443c..0a264edc8c2 100644 --- a/homeassistant/components/aladdin_connect/sensor.py +++ b/homeassistant/components/aladdin_connect/sensor.py @@ -23,14 +23,14 @@ from .const import DOMAIN from .model import DoorDevice -@dataclass +@dataclass(frozen=True) class AccSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable -@dataclass +@dataclass(frozen=True) class AccSensorEntityDescription( SensorEntityDescription, AccSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index f3e02465c13..9c53f2b7fd0 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -1,10 +1,10 @@ """Component to interface with an alarm control panel.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta +from functools import partial import logging -from typing import Any, Final, final +from typing import TYPE_CHECKING, Any, Final, final import voluptuous as vol @@ -23,26 +23,41 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from .const import ( # noqa: F401 + _DEPRECATED_FORMAT_NUMBER, + _DEPRECATED_FORMAT_TEXT, + _DEPRECATED_SUPPORT_ALARM_ARM_AWAY, + _DEPRECATED_SUPPORT_ALARM_ARM_CUSTOM_BYPASS, + _DEPRECATED_SUPPORT_ALARM_ARM_HOME, + _DEPRECATED_SUPPORT_ALARM_ARM_NIGHT, + _DEPRECATED_SUPPORT_ALARM_ARM_VACATION, + _DEPRECATED_SUPPORT_ALARM_TRIGGER, ATTR_CHANGED_BY, ATTR_CODE_ARM_REQUIRED, DOMAIN, - FORMAT_NUMBER, - FORMAT_TEXT, - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_CUSTOM_BYPASS, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, - SUPPORT_ALARM_TRIGGER, AlarmControlPanelEntityFeature, CodeFormat, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + +# As we import constants of the cost module here, we need to add the following +# functions to check for deprecated constants again +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + _LOGGER: Final = logging.getLogger(__name__) SCAN_INTERVAL: Final = timedelta(seconds=30) @@ -121,12 +136,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class AlarmControlPanelEntityDescription(EntityDescription): +class AlarmControlPanelEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes alarm control panel entities.""" -class AlarmControlPanelEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "code_format", + "changed_by", + "code_arm_required", + "supported_features", +} + + +class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """An abstract class for alarm control entities.""" entity_description: AlarmControlPanelEntityDescription @@ -137,17 +159,17 @@ class AlarmControlPanelEntity(Entity): AlarmControlPanelEntityFeature(0) ) - @property + @cached_property def code_format(self) -> CodeFormat | None: """Code format or None if no code is required.""" return self._attr_code_format - @property + @cached_property def changed_by(self) -> str | None: """Last change triggered by.""" return self._attr_changed_by - @property + @cached_property def code_arm_required(self) -> bool: """Whether the code is required for arm actions.""" return self._attr_code_arm_required @@ -208,10 +230,15 @@ class AlarmControlPanelEntity(Entity): """Send arm custom bypass command.""" await self.hass.async_add_executor_job(self.alarm_arm_custom_bypass, code) - @property + @cached_property def supported_features(self) -> AlarmControlPanelEntityFeature: """Return the list of supported features.""" - return self._attr_supported_features + features = self._attr_supported_features + if type(features) is int: # noqa: E721 + new_features = AlarmControlPanelEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features @final @property diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py index f14a1ce66e0..90bbcba1314 100644 --- a/homeassistant/components/alarm_control_panel/const.py +++ b/homeassistant/components/alarm_control_panel/const.py @@ -1,7 +1,14 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum +from functools import partial from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + DOMAIN: Final = "alarm_control_panel" ATTR_CHANGED_BY: Final = "changed_by" @@ -15,10 +22,10 @@ class CodeFormat(StrEnum): NUMBER = "number" -# These constants are deprecated as of Home Assistant 2022.5 +# These constants are deprecated as of Home Assistant 2022.5, can be removed in 2025.1 # Please use the CodeFormat enum instead. -FORMAT_TEXT: Final = "text" -FORMAT_NUMBER: Final = "number" +_DEPRECATED_FORMAT_TEXT: Final = DeprecatedConstantEnum(CodeFormat.TEXT, "2025.1") +_DEPRECATED_FORMAT_NUMBER: Final = DeprecatedConstantEnum(CodeFormat.NUMBER, "2025.1") class AlarmControlPanelEntityFeature(IntFlag): @@ -34,12 +41,28 @@ class AlarmControlPanelEntityFeature(IntFlag): # These constants are deprecated as of Home Assistant 2022.5 # Please use the AlarmControlPanelEntityFeature enum instead. -SUPPORT_ALARM_ARM_HOME: Final = 1 -SUPPORT_ALARM_ARM_AWAY: Final = 2 -SUPPORT_ALARM_ARM_NIGHT: Final = 4 -SUPPORT_ALARM_TRIGGER: Final = 8 -SUPPORT_ALARM_ARM_CUSTOM_BYPASS: Final = 16 -SUPPORT_ALARM_ARM_VACATION: Final = 32 +_DEPRECATED_SUPPORT_ALARM_ARM_HOME: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_HOME, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_AWAY: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_AWAY, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_NIGHT: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_NIGHT, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_TRIGGER: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.TRIGGER, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_CUSTOM_BYPASS: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, "2025.1" +) +_DEPRECATED_SUPPORT_ALARM_ARM_VACATION: Final = DeprecatedConstantEnum( + AlarmControlPanelEntityFeature.ARM_VACATION, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) CONDITION_TRIGGERED: Final = "is_triggered" CONDITION_DISARMED: Final = "is_disarmed" diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index e453be88934..9c068bb3327 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -28,13 +28,7 @@ from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import ATTR_CODE_ARM_REQUIRED, DOMAIN -from .const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, - SUPPORT_ALARM_TRIGGER, -) +from .const import AlarmControlPanelEntityFeature ACTION_TYPES: Final[set[str]] = { "arm_away", @@ -82,16 +76,16 @@ async def async_get_actions( } # Add actions for each entity that belongs to this integration - if supported_features & SUPPORT_ALARM_ARM_AWAY: + if supported_features & AlarmControlPanelEntityFeature.ARM_AWAY: actions.append({**base_action, CONF_TYPE: "arm_away"}) - if supported_features & SUPPORT_ALARM_ARM_HOME: + if supported_features & AlarmControlPanelEntityFeature.ARM_HOME: actions.append({**base_action, CONF_TYPE: "arm_home"}) - if supported_features & SUPPORT_ALARM_ARM_NIGHT: + if supported_features & AlarmControlPanelEntityFeature.ARM_NIGHT: actions.append({**base_action, CONF_TYPE: "arm_night"}) - if supported_features & SUPPORT_ALARM_ARM_VACATION: + if supported_features & AlarmControlPanelEntityFeature.ARM_VACATION: actions.append({**base_action, CONF_TYPE: "arm_vacation"}) actions.append({**base_action, CONF_TYPE: "disarm"}) - if supported_features & SUPPORT_ALARM_TRIGGER: + if supported_features & AlarmControlPanelEntityFeature.TRIGGER: actions.append({**base_action, CONF_TYPE: "trigger"}) return actions diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py index ee8cb57f568..e3c627d17a3 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -39,11 +39,7 @@ from .const import ( CONDITION_ARMED_VACATION, CONDITION_DISARMED, CONDITION_TRIGGERED, - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_CUSTOM_BYPASS, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, + AlarmControlPanelEntityFeature, ) CONDITION_TYPES: Final[set[str]] = { @@ -90,15 +86,15 @@ async def async_get_conditions( {**base_condition, CONF_TYPE: CONDITION_DISARMED}, {**base_condition, CONF_TYPE: CONDITION_TRIGGERED}, ] - if supported_features & SUPPORT_ALARM_ARM_HOME: + if supported_features & AlarmControlPanelEntityFeature.ARM_HOME: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_HOME}) - if supported_features & SUPPORT_ALARM_ARM_AWAY: + if supported_features & AlarmControlPanelEntityFeature.ARM_AWAY: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_AWAY}) - if supported_features & SUPPORT_ALARM_ARM_NIGHT: + if supported_features & AlarmControlPanelEntityFeature.ARM_NIGHT: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_NIGHT}) - if supported_features & SUPPORT_ALARM_ARM_VACATION: + if supported_features & AlarmControlPanelEntityFeature.ARM_VACATION: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_VACATION}) - if supported_features & SUPPORT_ALARM_ARM_CUSTOM_BYPASS: + if supported_features & AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS: conditions.append( {**base_condition, CONF_TYPE: CONDITION_ARMED_CUSTOM_BYPASS} ) diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index fc3850dce30..e5141a1dfd5 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -29,12 +29,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import DOMAIN -from .const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, -) +from .const import AlarmControlPanelEntityFeature BASIC_TRIGGER_TYPES: Final[set[str]] = {"triggered", "disarmed", "arming"} TRIGGER_TYPES: Final[set[str]] = BASIC_TRIGGER_TYPES | { @@ -82,28 +77,28 @@ async def async_get_triggers( } for trigger in BASIC_TRIGGER_TYPES ] - if supported_features & SUPPORT_ALARM_ARM_HOME: + if supported_features & AlarmControlPanelEntityFeature.ARM_HOME: triggers.append( { **base_trigger, CONF_TYPE: "armed_home", } ) - if supported_features & SUPPORT_ALARM_ARM_AWAY: + if supported_features & AlarmControlPanelEntityFeature.ARM_AWAY: triggers.append( { **base_trigger, CONF_TYPE: "armed_away", } ) - if supported_features & SUPPORT_ALARM_ARM_NIGHT: + if supported_features & AlarmControlPanelEntityFeature.ARM_NIGHT: triggers.append( { **base_trigger, CONF_TYPE: "armed_night", } ) - if supported_features & SUPPORT_ALARM_ARM_VACATION: + if supported_features & AlarmControlPanelEntityFeature.ARM_VACATION: triggers.append( { **base_trigger, diff --git a/homeassistant/components/alarm_control_panel/significant_change.py b/homeassistant/components/alarm_control_panel/significant_change.py new file mode 100644 index 00000000000..bde6d151393 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/significant_change.py @@ -0,0 +1,41 @@ +"""Helper to test significant Alarm Control Panel state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback + +from . import ATTR_CHANGED_BY, ATTR_CODE_ARM_REQUIRED + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_CHANGED_BY, + ATTR_CODE_ARM_REQUIRED, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + if changed_attrs: + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/alarmdecoder/config_flow.py b/homeassistant/components/alarmdecoder/config_flow.py index 9a4b9ae1098..1b2bcf083ba 100644 --- a/homeassistant/components/alarmdecoder/config_flow.py +++ b/homeassistant/components/alarmdecoder/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from adext import AdExt from alarmdecoder.devices import SerialDevice, SocketDevice @@ -12,8 +13,10 @@ from homeassistant import config_entries from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import ( CONF_ALT_NIGHT_MODE, @@ -66,7 +69,9 @@ class AlarmDecoderFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for AlarmDecoder.""" return AlarmDecoderOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" if user_input is not None: self.protocol = user_input[CONF_PROTOCOL] @@ -83,7 +88,9 @@ class AlarmDecoderFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ), ) - async def async_step_protocol(self, user_input=None): + async def async_step_protocol( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle AlarmDecoder protocol setup.""" errors = {} if user_input is not None: @@ -146,15 +153,18 @@ class AlarmDecoderFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class AlarmDecoderOptionsFlowHandler(config_entries.OptionsFlow): """Handle AlarmDecoder options.""" + selected_zone: str | None = None + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize AlarmDecoder options flow.""" self.arm_options = config_entry.options.get(OPTIONS_ARM, DEFAULT_ARM_OPTIONS) self.zone_options = config_entry.options.get( OPTIONS_ZONES, DEFAULT_ZONE_OPTIONS ) - self.selected_zone = None - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is not None: if user_input[EDIT_KEY] == EDIT_SETTINGS: @@ -173,7 +183,9 @@ class AlarmDecoderOptionsFlowHandler(config_entries.OptionsFlow): ), ) - async def async_step_arm_settings(self, user_input=None): + async def async_step_arm_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Arming options form.""" if user_input is not None: return self.async_create_entry( @@ -200,7 +212,9 @@ class AlarmDecoderOptionsFlowHandler(config_entries.OptionsFlow): ), ) - async def async_step_zone_select(self, user_input=None): + async def async_step_zone_select( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Zone selection form.""" errors = _validate_zone_input(user_input) @@ -216,7 +230,9 @@ class AlarmDecoderOptionsFlowHandler(config_entries.OptionsFlow): errors=errors, ) - async def async_step_zone_details(self, user_input=None): + async def async_step_zone_details( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Zone details form.""" errors = _validate_zone_input(user_input) @@ -293,7 +309,7 @@ class AlarmDecoderOptionsFlowHandler(config_entries.OptionsFlow): ) -def _validate_zone_input(zone_input): +def _validate_zone_input(zone_input: dict[str, Any] | None) -> dict[str, str]: if not zone_input: return {} errors = {} @@ -327,7 +343,7 @@ def _validate_zone_input(zone_input): return errors -def _fix_input_types(zone_input): +def _fix_input_types(zone_input: dict[str, Any]) -> dict[str, Any]: """Convert necessary keys to int. Since ConfigFlow inputs of type int cannot default to an empty string, we collect the values below as @@ -341,7 +357,9 @@ def _fix_input_types(zone_input): return zone_input -def _device_already_added(current_entries, user_input, protocol): +def _device_already_added( + current_entries: list[ConfigEntry], user_input: dict[str, Any], protocol: str | None +) -> bool: """Determine if entry has already been added to HA.""" user_host = user_input.get(CONF_HOST) user_port = user_input.get(CONF_PORT) diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index 219553b3563..2a9637772b1 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -36,6 +36,15 @@ CONF_FLASH_BRIEFINGS = "flash_briefings" CONF_SMART_HOME = "smart_home" DEFAULT_LOCALE = "en-US" +# Alexa Smart Home API send events gateway endpoints +# https://developer.amazon.com/en-US/docs/alexa/smarthome/send-events.html#endpoints +VALID_ENDPOINTS = [ + "https://api.amazonalexa.com/v3/events", + "https://api.eu.amazonalexa.com/v3/events", + "https://api.fe.amazonalexa.com/v3/events", +] + + ALEXA_ENTITY_SCHEMA = vol.Schema( { vol.Optional(CONF_DESCRIPTION): cv.string, @@ -46,7 +55,7 @@ ALEXA_ENTITY_SCHEMA = vol.Schema( SMART_HOME_SCHEMA = vol.Schema( { - vol.Optional(CONF_ENDPOINT): cv.string, + vol.Optional(CONF_ENDPOINT): vol.All(vol.Lower, vol.In(VALID_ENDPOINTS)), vol.Optional(CONF_CLIENT_ID): cv.string, vol.Optional(CONF_CLIENT_SECRET): cv.string, vol.Optional(CONF_LOCALE, default=DEFAULT_LOCALE): vol.In( diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 0856c39946b..502912ee8de 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -19,6 +19,8 @@ from homeassistant.components import ( number, timer, vacuum, + valve, + water_heater, ) from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, @@ -435,7 +437,8 @@ class AlexaPowerController(AlexaCapability): is_on = self.entity.state == vacuum.STATE_CLEANING elif self.entity.domain == timer.DOMAIN: is_on = self.entity.state != STATE_IDLE - + elif self.entity.domain == water_heater.DOMAIN: + is_on = self.entity.state not in (STATE_OFF, STATE_UNKNOWN) else: is_on = self.entity.state != STATE_OFF @@ -938,6 +941,9 @@ class AlexaTemperatureSensor(AlexaCapability): if self.entity.domain == climate.DOMAIN: unit = self.hass.config.units.temperature_unit temp = self.entity.attributes.get(climate.ATTR_CURRENT_TEMPERATURE) + elif self.entity.domain == water_heater.DOMAIN: + unit = self.hass.config.units.temperature_unit + temp = self.entity.attributes.get(water_heater.ATTR_CURRENT_TEMPERATURE) if temp is None or temp in (STATE_UNAVAILABLE, STATE_UNKNOWN): return None @@ -1108,6 +1114,8 @@ class AlexaThermostatController(AlexaCapability): supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE: properties.append({"name": "targetSetpoint"}) + if supported & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE: + properties.append({"name": "targetSetpoint"}) if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: properties.append({"name": "lowerSetpoint"}) properties.append({"name": "upperSetpoint"}) @@ -1127,6 +1135,8 @@ class AlexaThermostatController(AlexaCapability): return None if name == "thermostatMode": + if self.entity.domain == water_heater.DOMAIN: + return None preset = self.entity.attributes.get(climate.ATTR_PRESET_MODE) mode: dict[str, str] | str | None @@ -1176,9 +1186,13 @@ class AlexaThermostatController(AlexaCapability): ThermostatMode Values. ThermostatMode Value must be AUTO, COOL, HEAT, ECO, OFF, or CUSTOM. + Water heater devices do not return thermostat modes. """ + if self.entity.domain == water_heater.DOMAIN: + return None + supported_modes: list[str] = [] - hvac_modes = self.entity.attributes[climate.ATTR_HVAC_MODES] + hvac_modes = self.entity.attributes.get(climate.ATTR_HVAC_MODES, []) for mode in hvac_modes: if thermostat_mode := API_THERMOSTAT_MODES.get(mode): supported_modes.append(thermostat_mode) @@ -1408,6 +1422,16 @@ class AlexaModeController(AlexaCapability): if mode in self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES, []): return f"{humidifier.ATTR_MODE}.{mode}" + # Water heater operation mode + if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": + operation_mode = self.entity.attributes.get( + water_heater.ATTR_OPERATION_MODE, None + ) + if operation_mode in self.entity.attributes.get( + water_heater.ATTR_OPERATION_LIST, [] + ): + return f"{water_heater.ATTR_OPERATION_MODE}.{operation_mode}" + # Cover Position if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": # Return state instead of position when using ModeController. @@ -1421,6 +1445,19 @@ class AlexaModeController(AlexaCapability): ): return f"{cover.ATTR_POSITION}.{mode}" + # Valve position state + if self.instance == f"{valve.DOMAIN}.state": + # Return state instead of position when using ModeController. + state = self.entity.state + if state in ( + valve.STATE_OPEN, + valve.STATE_OPENING, + valve.STATE_CLOSED, + valve.STATE_CLOSING, + STATE_UNKNOWN, + ): + return f"state.{state}" + return None def configuration(self) -> dict[str, Any] | None: @@ -1478,6 +1515,26 @@ class AlexaModeController(AlexaCapability): ) return self._resource.serialize_capability_resources() + # Water heater operation modes + if self.instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": + self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False) + operation_modes = self.entity.attributes.get( + water_heater.ATTR_OPERATION_LIST, [] + ) + for operation_mode in operation_modes: + self._resource.add_mode( + f"{water_heater.ATTR_OPERATION_MODE}.{operation_mode}", + [operation_mode], + ) + # Devices with a single mode completely break Alexa discovery, + # add a fake preset (see issue #53832). + if len(operation_modes) == 1: + self._resource.add_mode( + f"{water_heater.ATTR_OPERATION_MODE}.{PRESET_MODE_NA}", + [PRESET_MODE_NA], + ) + return self._resource.serialize_capability_resources() + # Cover Position Resources if self.instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": self._resource = AlexaModeResource( @@ -1497,6 +1554,32 @@ class AlexaModeController(AlexaCapability): ) return self._resource.serialize_capability_resources() + # Valve position resources + if self.instance == f"{valve.DOMAIN}.state": + supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + self._resource = AlexaModeResource( + ["Preset", AlexaGlobalCatalog.SETTING_PRESET], False + ) + modes = 0 + if supported_features & valve.ValveEntityFeature.OPEN: + self._resource.add_mode( + f"state.{valve.STATE_OPEN}", + ["Open", AlexaGlobalCatalog.SETTING_PRESET], + ) + modes += 1 + if supported_features & valve.ValveEntityFeature.CLOSE: + self._resource.add_mode( + f"state.{valve.STATE_CLOSED}", + ["Closed", AlexaGlobalCatalog.SETTING_PRESET], + ) + modes += 1 + + # Alexa requiers at least 2 modes + if modes == 1: + self._resource.add_mode(f"state.{PRESET_MODE_NA}", [PRESET_MODE_NA]) + + return self._resource.serialize_capability_resources() + return {} def semantics(self) -> dict[str, Any] | None: @@ -1535,6 +1618,34 @@ class AlexaModeController(AlexaCapability): return self._semantics.serialize_semantics() + # Valve Position + if self.instance == f"{valve.DOMAIN}.state": + close_labels = [AlexaSemantics.ACTION_CLOSE] + open_labels = [AlexaSemantics.ACTION_OPEN] + self._semantics = AlexaSemantics() + + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_CLOSED], + f"state.{valve.STATE_CLOSED}", + ) + self._semantics.add_states_to_value( + [AlexaSemantics.STATES_OPEN], + f"state.{valve.STATE_OPEN}", + ) + + self._semantics.add_action_to_directive( + close_labels, + "SetMode", + {"mode": f"state.{valve.STATE_CLOSED}"}, + ) + self._semantics.add_action_to_directive( + open_labels, + "SetMode", + {"mode": f"state.{valve.STATE_OPEN}"}, + ) + + return self._semantics.serialize_semantics() + return None @@ -1648,6 +1759,10 @@ class AlexaRangeController(AlexaCapability): ) return speed_index + # Valve Position + if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + return self.entity.attributes.get(valve.ATTR_CURRENT_POSITION) + return None def configuration(self) -> dict[str, Any] | None: @@ -1771,6 +1886,17 @@ class AlexaRangeController(AlexaCapability): return self._resource.serialize_capability_resources() + # Valve Position Resources + if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + self._resource = AlexaPresetResource( + ["Opening", AlexaGlobalCatalog.SETTING_OPENING], + min_value=0, + max_value=100, + precision=1, + unit=AlexaGlobalCatalog.UNIT_PERCENT, + ) + return self._resource.serialize_capability_resources() + return {} def semantics(self) -> dict[str, Any] | None: @@ -1847,6 +1973,25 @@ class AlexaRangeController(AlexaCapability): ) return self._semantics.serialize_semantics() + # Valve Position + if self.instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + close_labels = [AlexaSemantics.ACTION_CLOSE] + open_labels = [AlexaSemantics.ACTION_OPEN] + self._semantics = AlexaSemantics() + + self._semantics.add_states_to_value([AlexaSemantics.STATES_CLOSED], value=0) + self._semantics.add_states_to_range( + [AlexaSemantics.STATES_OPEN], min_value=1, max_value=100 + ) + + self._semantics.add_action_to_directive( + close_labels, "SetRangeValue", {"rangeValue": 0} + ) + self._semantics.add_action_to_directive( + open_labels, "SetRangeValue", {"rangeValue": 100} + ) + return self._semantics.serialize_semantics() + return None @@ -1920,6 +2065,10 @@ class AlexaToggleController(AlexaCapability): is_on = bool(self.entity.attributes.get(fan.ATTR_OSCILLATING)) return "ON" if is_on else "OFF" + # Stop Valve + if self.instance == f"{valve.DOMAIN}.stop": + return "OFF" + return None def capability_resources(self) -> dict[str, list[dict[str, Any]]]: @@ -1932,6 +2081,10 @@ class AlexaToggleController(AlexaCapability): ) return self._resource.serialize_capability_resources() + if self.instance == f"{valve.DOMAIN}.stop": + self._resource = AlexaCapabilityResource(["Stop"]) + return self._resource.serialize_capability_resources() + return {} diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index da0bd8b36aa..d0e265b8454 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -32,6 +32,8 @@ from homeassistant.components import ( switch, timer, vacuum, + valve, + water_heater, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -248,6 +250,9 @@ class DisplayCategory: # Indicates a vacuum cleaner. VACUUM_CLEANER = "VACUUM_CLEANER" + # Indicates a water heater. + WATER_HEATER = "WATER_HEATER" + # Indicates a network-connected wearable device, such as an Apple Watch, # Fitbit, or Samsung Gear. WEARABLE = "WEARABLE" @@ -456,23 +461,46 @@ class ButtonCapabilities(AlexaEntity): @ENTITY_ADAPTERS.register(climate.DOMAIN) +@ENTITY_ADAPTERS.register(water_heater.DOMAIN) class ClimateCapabilities(AlexaEntity): """Class to represent Climate capabilities.""" def default_display_categories(self) -> list[str]: """Return the display categories for this entity.""" + if self.entity.domain == water_heater.DOMAIN: + return [DisplayCategory.WATER_HEATER] return [DisplayCategory.THERMOSTAT] def interfaces(self) -> Generator[AlexaCapability, None, None]: """Yield the supported interfaces.""" # If we support two modes, one being off, we allow turning on too. - if climate.HVACMode.OFF in self.entity.attributes.get( - climate.ATTR_HVAC_MODES, [] + supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if ( + self.entity.domain == climate.DOMAIN + and climate.HVACMode.OFF + in self.entity.attributes.get(climate.ATTR_HVAC_MODES, []) + or self.entity.domain == water_heater.DOMAIN + and (supported_features & water_heater.WaterHeaterEntityFeature.ON_OFF) ): yield AlexaPowerController(self.entity) - yield AlexaThermostatController(self.hass, self.entity) - yield AlexaTemperatureSensor(self.hass, self.entity) + if ( + self.entity.domain == climate.DOMAIN + or self.entity.domain == water_heater.DOMAIN + and ( + supported_features + & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + ) + ): + yield AlexaThermostatController(self.hass, self.entity) + yield AlexaTemperatureSensor(self.hass, self.entity) + if self.entity.domain == water_heater.DOMAIN and ( + supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + ): + yield AlexaModeController( + self.entity, + instance=f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}", + ) yield AlexaEndpointHealth(self.hass, self.entity) yield Alexa(self.entity) @@ -949,6 +977,31 @@ class VacuumCapabilities(AlexaEntity): yield Alexa(self.entity) +@ENTITY_ADAPTERS.register(valve.DOMAIN) +class ValveCapabilities(AlexaEntity): + """Class to represent Valve capabilities.""" + + def default_display_categories(self) -> list[str]: + """Return the display categories for this entity.""" + return [DisplayCategory.OTHER] + + def interfaces(self) -> Generator[AlexaCapability, None, None]: + """Yield the supported interfaces.""" + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & valve.ValveEntityFeature.SET_POSITION: + yield AlexaRangeController( + self.entity, instance=f"{valve.DOMAIN}.{valve.ATTR_POSITION}" + ) + elif supported & ( + valve.ValveEntityFeature.CLOSE | valve.ValveEntityFeature.OPEN + ): + yield AlexaModeController(self.entity, instance=f"{valve.DOMAIN}.state") + if supported & valve.ValveEntityFeature.STOP: + yield AlexaToggleController(self.entity, instance=f"{valve.DOMAIN}.stop") + yield AlexaEndpointHealth(self.hass, self.entity) + yield Alexa(self.entity) + + @ENTITY_ADAPTERS.register(camera.DOMAIN) class CameraCapabilities(AlexaEntity): """Class to represent Camera capabilities.""" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 2796c10795b..5613da52db5 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -22,6 +22,8 @@ from homeassistant.components import ( number, timer, vacuum, + valve, + water_heater, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -80,6 +82,23 @@ from .state_report import AlexaDirective, AlexaResponse, async_enable_proactive_ _LOGGER = logging.getLogger(__name__) DIRECTIVE_NOT_SUPPORTED = "Entity does not support directive" + +MIN_MAX_TEMP = { + climate.DOMAIN: { + "min_temp": climate.ATTR_MIN_TEMP, + "max_temp": climate.ATTR_MAX_TEMP, + }, + water_heater.DOMAIN: { + "min_temp": water_heater.ATTR_MIN_TEMP, + "max_temp": water_heater.ATTR_MAX_TEMP, + }, +} + +SERVICE_SET_TEMPERATURE = { + climate.DOMAIN: climate.SERVICE_SET_TEMPERATURE, + water_heater.DOMAIN: water_heater.SERVICE_SET_TEMPERATURE, +} + HANDLERS: Registry[ tuple[str, str], Callable[ @@ -804,8 +823,10 @@ async def async_api_set_target_temp( ) -> AlexaResponse: """Process a set target temperature request.""" entity = directive.entity - min_temp = entity.attributes[climate.ATTR_MIN_TEMP] - max_temp = entity.attributes[climate.ATTR_MAX_TEMP] + domain = entity.domain + + min_temp = entity.attributes[MIN_MAX_TEMP[domain]["min_temp"]] + max_temp = entity.attributes["max_temp"] unit = hass.config.units.temperature_unit data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} @@ -849,9 +870,11 @@ async def async_api_set_target_temp( } ) + service = SERVICE_SET_TEMPERATURE[domain] + await hass.services.async_call( entity.domain, - climate.SERVICE_SET_TEMPERATURE, + service, data, blocking=False, context=context, @@ -867,11 +890,12 @@ async def async_api_adjust_target_temp( directive: AlexaDirective, context: ha.Context, ) -> AlexaResponse: - """Process an adjust target temperature request.""" + """Process an adjust target temperature request for climates and water heaters.""" data: dict[str, Any] entity = directive.entity - min_temp = entity.attributes[climate.ATTR_MIN_TEMP] - max_temp = entity.attributes[climate.ATTR_MAX_TEMP] + domain = entity.domain + min_temp = entity.attributes[MIN_MAX_TEMP[domain]["min_temp"]] + max_temp = entity.attributes[MIN_MAX_TEMP[domain]["max_temp"]] unit = hass.config.units.temperature_unit temp_delta = temperature_from_object( @@ -932,9 +956,11 @@ async def async_api_adjust_target_temp( } ) + service = SERVICE_SET_TEMPERATURE[domain] + await hass.services.async_call( entity.domain, - climate.SERVICE_SET_TEMPERATURE, + service, data, blocking=False, context=context, @@ -1163,6 +1189,23 @@ async def async_api_set_mode( msg = f"Entity '{entity.entity_id}' does not support Mode '{mode}'" raise AlexaInvalidValueError(msg) + # Water heater operation mode + elif instance == f"{water_heater.DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": + operation_mode = mode.split(".")[1] + operation_modes: list[str] | None = entity.attributes.get( + water_heater.ATTR_OPERATION_LIST + ) + if ( + operation_mode != PRESET_MODE_NA + and operation_modes + and operation_mode in operation_modes + ): + service = water_heater.SERVICE_SET_OPERATION_MODE + data[water_heater.ATTR_OPERATION_MODE] = operation_mode + else: + msg = f"Entity '{entity.entity_id}' does not support Operation mode '{operation_mode}'" + raise AlexaInvalidValueError(msg) + # Cover Position elif instance == f"{cover.DOMAIN}.{cover.ATTR_POSITION}": position = mode.split(".")[1] @@ -1174,6 +1217,15 @@ async def async_api_set_mode( elif position == "custom": service = cover.SERVICE_STOP_COVER + # Valve position state + elif instance == f"{valve.DOMAIN}.state": + position = mode.split(".")[1] + + if position == valve.STATE_CLOSED: + service = valve.SERVICE_CLOSE_VALVE + elif position == valve.STATE_OPEN: + service = valve.SERVICE_OPEN_VALVE + if not service: raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) @@ -1224,15 +1276,22 @@ async def async_api_toggle_on( instance = directive.instance domain = entity.domain - # Fan Oscillating - if instance != f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": - raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) + data: dict[str, Any] - service = fan.SERVICE_OSCILLATE - data: dict[str, Any] = { - ATTR_ENTITY_ID: entity.entity_id, - fan.ATTR_OSCILLATING: True, - } + # Fan Oscillating + if instance == f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}": + service = fan.SERVICE_OSCILLATE + data = { + ATTR_ENTITY_ID: entity.entity_id, + fan.ATTR_OSCILLATING: True, + } + elif instance == f"{valve.DOMAIN}.stop": + service = valve.SERVICE_STOP_VALVE + data = { + ATTR_ENTITY_ID: entity.entity_id, + } + else: + raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) await hass.services.async_call( domain, service, data, blocking=False, context=context @@ -1375,6 +1434,17 @@ async def async_api_set_range( data[vacuum.ATTR_FAN_SPEED] = speed + # Valve Position + elif instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + range_value = int(range_value) + if supported & valve.ValveEntityFeature.CLOSE and range_value == 0: + service = valve.SERVICE_CLOSE_VALVE + elif supported & valve.ValveEntityFeature.OPEN and range_value == 100: + service = valve.SERVICE_OPEN_VALVE + else: + service = valve.SERVICE_SET_VALVE_POSITION + data[valve.ATTR_POSITION] = range_value + else: raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) @@ -1520,6 +1590,21 @@ async def async_api_adjust_range( ) data[vacuum.ATTR_FAN_SPEED] = response_value = speed + # Valve Position + elif instance == f"{valve.DOMAIN}.{valve.ATTR_POSITION}": + range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) + service = valve.SERVICE_SET_VALVE_POSITION + if not (current := entity.attributes.get(valve.ATTR_POSITION)): + msg = f"Unable to determine {entity.entity_id} current position" + raise AlexaInvalidValueError(msg) + position = response_value = min(100, max(0, range_delta + current)) + if position == 100: + service = valve.SERVICE_OPEN_VALVE + elif position == 0: + service = valve.SERVICE_CLOSE_VALVE + else: + data[valve.ATTR_POSITION] = position + else: raise AlexaInvalidDirectiveError(DIRECTIVE_NOT_SUPPORTED) diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index f3cda887150..5f92e5a9117 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -4,7 +4,6 @@ import logging from homeassistant.const import Platform DOMAIN = "amberelectric" -CONF_API_TOKEN = "api_token" CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" CONF_SITE_NMI = "site_nmi" diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py index 0d259cf337a..3d05ab2bb07 100644 --- a/homeassistant/components/ambiclimate/config_flow.py +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -1,5 +1,6 @@ """Config flow for Ambiclimate.""" import logging +from typing import Any from aiohttp import web import ambiclimate @@ -7,7 +8,8 @@ import ambiclimate from homeassistant import config_entries from homeassistant.components.http import HomeAssistantView from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.network import get_url from homeassistant.helpers.storage import Store @@ -26,7 +28,9 @@ _LOGGER = logging.getLogger(__name__) @callback -def register_flow_implementation(hass, client_id, client_secret): +def register_flow_implementation( + hass: HomeAssistant, client_id: str, client_secret: str +) -> None: """Register a ambiclimate implementation. client_id: Client id. @@ -50,7 +54,9 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._registered_view = False self._oauth = None - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle external yaml configuration.""" self._async_abort_entries_match() @@ -62,7 +68,9 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_auth() - async def async_step_auth(self, user_input=None): + async def async_step_auth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow start.""" self._async_abort_entries_match() @@ -83,7 +91,7 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_code(self, code=None): + async def async_step_code(self, code: str | None = None) -> FlowResult: """Received code for authentication.""" self._async_abort_entries_match() @@ -95,7 +103,7 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title="Ambiclimate", data=config) - async def _get_token_info(self, code): + async def _get_token_info(self, code: str | None) -> dict[str, Any] | None: oauth = self._generate_oauth() try: token_info = await oauth.get_access_token(code) @@ -103,16 +111,16 @@ class AmbiclimateFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Failed to get access token") return None - store = Store(self.hass, STORAGE_VERSION, STORAGE_KEY) + store = Store[dict[str, Any]](self.hass, STORAGE_VERSION, STORAGE_KEY) await store.async_save(token_info) return token_info - def _generate_view(self): + def _generate_view(self) -> None: self.hass.http.register_view(AmbiclimateAuthCallbackView()) self._registered_view = True - def _generate_oauth(self): + def _generate_oauth(self) -> ambiclimate.AmbiclimateOAuth: config = self.hass.data[DATA_AMBICLIMATE_IMPL] clientsession = async_get_clientsession(self.hass) callback_url = self._cb_url() diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py index 49ff43bcc7e..8bdfe0fd642 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -63,14 +63,14 @@ TYPE_RELAY8 = "relay8" TYPE_RELAY9 = "relay9" -@dataclass +@dataclass(frozen=True) class AmbientBinarySensorDescriptionMixin: """Define an entity description mixin for binary sensors.""" on_state: Literal[0, 1] -@dataclass +@dataclass(frozen=True) class AmbientBinarySensorDescription( BinarySensorEntityDescription, AmbientBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index e71a5cda538..a0b6b4f6527 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -35,7 +35,7 @@ if TYPE_CHECKING: from . import AmcrestDevice -@dataclass +@dataclass(frozen=True) class AmcrestSensorEntityDescription(BinarySensorEntityDescription): """Describe Amcrest sensor entity.""" diff --git a/homeassistant/components/android_ip_webcam/sensor.py b/homeassistant/components/android_ip_webcam/sensor.py index 7a08d774f6a..d7a821d956a 100644 --- a/homeassistant/components/android_ip_webcam/sensor.py +++ b/homeassistant/components/android_ip_webcam/sensor.py @@ -23,14 +23,14 @@ from .coordinator import AndroidIPCamDataUpdateCoordinator from .entity import AndroidIPCamBaseEntity -@dataclass +@dataclass(frozen=True) class AndroidIPWebcamSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[PyDroidIPCam], StateType] -@dataclass +@dataclass(frozen=True) class AndroidIPWebcamSensorEntityDescription( SensorEntityDescription, AndroidIPWebcamSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/android_ip_webcam/switch.py b/homeassistant/components/android_ip_webcam/switch.py index 1eca19fe395..bae84739079 100644 --- a/homeassistant/components/android_ip_webcam/switch.py +++ b/homeassistant/components/android_ip_webcam/switch.py @@ -18,7 +18,7 @@ from .coordinator import AndroidIPCamDataUpdateCoordinator from .entity import AndroidIPCamBaseEntity -@dataclass +@dataclass(frozen=True) class AndroidIPWebcamSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -26,7 +26,7 @@ class AndroidIPWebcamSwitchEntityDescriptionMixin: off_func: Callable[[PyDroidIPCam], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class AndroidIPWebcamSwitchEntityDescription( SwitchEntityDescription, AndroidIPWebcamSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 1fec605d8e1..496b4e51e4f 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -160,7 +160,7 @@ def adb_decorator( """ def _adb_decorator( - func: _FuncType[_ADBDeviceT, _P, _R] + func: _FuncType[_ADBDeviceT, _P, _R], ) -> _ReturnFuncType[_ADBDeviceT, _P, _R]: """Wrap the provided ADB method and catch exceptions.""" diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index 9471504808c..c78321589a9 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -14,7 +14,7 @@ from androidtvremote2 import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import DOMAIN @@ -69,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @callback - def on_hass_stop(event) -> None: + def on_hass_stop(event: Event) -> None: """Stop push updates when hass stops.""" api.disconnect() diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index 6336aa61e1c..b7657e26249 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -23,14 +23,14 @@ from .entity import AnovaDescriptionEntity from .models import AnovaData -@dataclass +@dataclass(frozen=True) class AnovaSensorEntityDescriptionMixin: """Describes the mixin variables for anova sensors.""" value_fn: Callable[[APCUpdateSensor], float | int | str] -@dataclass +@dataclass(frozen=True) class AnovaSensorEntityDescription( SensorEntityDescription, AnovaSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/anthemav/config_flow.py b/homeassistant/components/anthemav/config_flow.py index e75c67cb2c5..892c40cde0e 100644 --- a/homeassistant/components/anthemav/config_flow.py +++ b/homeassistant/components/anthemav/config_flow.py @@ -10,18 +10,12 @@ from anthemav.device_error import DeviceError import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import format_mac -from .const import ( - CONF_MODEL, - DEFAULT_NAME, - DEFAULT_PORT, - DEVICE_TIMEOUT_SECONDS, - DOMAIN, -) +from .const import DEFAULT_NAME, DEFAULT_PORT, DEVICE_TIMEOUT_SECONDS, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/anthemav/const.py b/homeassistant/components/anthemav/const.py index 2b1ff753fba..7cf586fb05d 100644 --- a/homeassistant/components/anthemav/const.py +++ b/homeassistant/components/anthemav/const.py @@ -1,6 +1,6 @@ """Constants for the Anthem A/V Receivers integration.""" ANTHEMAV_UPDATE_SIGNAL = "anthemav_update" -CONF_MODEL = "model" + DEFAULT_NAME = "Anthem AV" DEFAULT_PORT = 14999 DOMAIN = "anthemav" diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 91f8536d348..c13e6389bfc 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -13,13 +13,13 @@ from homeassistant.components.media_player import ( MediaPlayerState, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC +from homeassistant.const import CONF_MAC, CONF_MODEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ANTHEMAV_UPDATE_SIGNAL, CONF_MODEL, DOMAIN, MANUFACTURER +from .const import ANTHEMAV_UPDATE_SIGNAL, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py new file mode 100644 index 00000000000..b75a4ad7295 --- /dev/null +++ b/homeassistant/components/aosmith/__init__.py @@ -0,0 +1,73 @@ +"""The A. O. Smith integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from py_aosmith import AOSmithAPIClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client, device_registry as dr + +from .const import DOMAIN +from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WATER_HEATER] + + +@dataclass +class AOSmithData: + """Data for the A. O. Smith integration.""" + + client: AOSmithAPIClient + status_coordinator: AOSmithStatusCoordinator + energy_coordinator: AOSmithEnergyCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up A. O. Smith from a config entry.""" + email = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] + + session = aiohttp_client.async_get_clientsession(hass) + client = AOSmithAPIClient(email, password, session) + + status_coordinator = AOSmithStatusCoordinator(hass, client) + await status_coordinator.async_config_entry_first_refresh() + + device_registry = dr.async_get(hass) + for junction_id, status_data in status_coordinator.data.items(): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, junction_id)}, + manufacturer="A. O. Smith", + name=status_data.get("name"), + model=status_data.get("model"), + serial_number=status_data.get("serial"), + suggested_area=status_data.get("install", {}).get("location"), + sw_version=status_data.get("data", {}).get("firmwareVersion"), + ) + + energy_coordinator = AOSmithEnergyCoordinator( + hass, client, list(status_coordinator.data) + ) + await energy_coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AOSmithData( + client, + status_coordinator, + energy_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/aosmith/config_flow.py b/homeassistant/components/aosmith/config_flow.py new file mode 100644 index 00000000000..899b7382359 --- /dev/null +++ b/homeassistant/components/aosmith/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for A. O. Smith integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from py_aosmith import AOSmithAPIClient, AOSmithInvalidCredentialsException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for A. O. Smith.""" + + VERSION = 1 + + _reauth_email: str | None = None + + async def _async_validate_credentials( + self, email: str, password: str + ) -> str | None: + """Validate the credentials. Return an error string, or None if successful.""" + session = aiohttp_client.async_get_clientsession(self.hass) + client = AOSmithAPIClient(email, password, session) + + try: + await client.get_devices() + except AOSmithInvalidCredentialsException: + return "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return "unknown" + + return 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: + unique_id = user_input[CONF_EMAIL].lower() + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + error = await self._async_validate_credentials( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + if error is None: + return self.async_create_entry( + title=user_input[CONF_EMAIL], data=user_input + ) + + errors["base"] = error + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth if the user credentials have changed.""" + self._reauth_email = entry_data[CONF_EMAIL] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user's reauth credentials.""" + errors: dict[str, str] = {} + if user_input is not None and self._reauth_email is not None: + email = self._reauth_email + password = user_input[CONF_PASSWORD] + entry_id = self.context["entry_id"] + + if entry := self.hass.config_entries.async_get_entry(entry_id): + error = await self._async_validate_credentials(email, password) + if error is None: + self.hass.config_entries.async_update_entry( + entry, + data=entry.data | user_input, + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + errors["base"] = error + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + description_placeholders={CONF_EMAIL: self._reauth_email}, + errors=errors, + ) diff --git a/homeassistant/components/aosmith/const.py b/homeassistant/components/aosmith/const.py new file mode 100644 index 00000000000..c0c693e0dac --- /dev/null +++ b/homeassistant/components/aosmith/const.py @@ -0,0 +1,25 @@ +"""Constants for the A. O. Smith integration.""" + +from datetime import timedelta + +DOMAIN = "aosmith" + +AOSMITH_MODE_ELECTRIC = "ELECTRIC" +AOSMITH_MODE_HEAT_PUMP = "HEAT_PUMP" +AOSMITH_MODE_HYBRID = "HYBRID" +AOSMITH_MODE_VACATION = "VACATION" + +# Update interval to be used for normal background updates. +REGULAR_INTERVAL = timedelta(seconds=30) + +# Update interval to be used while a mode or setpoint change is in progress. +FAST_INTERVAL = timedelta(seconds=1) + +# Update interval to be used for energy usage data. +ENERGY_USAGE_INTERVAL = timedelta(minutes=10) + +HOT_WATER_STATUS_MAP = { + "LOW": "low", + "MEDIUM": "medium", + "HIGH": "high", +} diff --git a/homeassistant/components/aosmith/coordinator.py b/homeassistant/components/aosmith/coordinator.py new file mode 100644 index 00000000000..7d6053cc86e --- /dev/null +++ b/homeassistant/components/aosmith/coordinator.py @@ -0,0 +1,83 @@ +"""The data update coordinator for the A. O. Smith integration.""" +import logging +from typing import Any + +from py_aosmith import ( + AOSmithAPIClient, + AOSmithInvalidCredentialsException, + AOSmithUnknownException, +) + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, ENERGY_USAGE_INTERVAL, FAST_INTERVAL, REGULAR_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class AOSmithStatusCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Coordinator for device status, updating with a frequent interval.""" + + def __init__(self, hass: HomeAssistant, client: AOSmithAPIClient) -> None: + """Initialize the coordinator.""" + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=REGULAR_INTERVAL) + self.client = client + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Fetch latest data from the device status endpoint.""" + try: + devices = await self.client.get_devices() + except AOSmithInvalidCredentialsException as err: + raise ConfigEntryAuthFailed from err + except AOSmithUnknownException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + mode_pending = any( + device.get("data", {}).get("modePending") for device in devices + ) + setpoint_pending = any( + device.get("data", {}).get("temperatureSetpointPending") + for device in devices + ) + + if mode_pending or setpoint_pending: + self.update_interval = FAST_INTERVAL + else: + self.update_interval = REGULAR_INTERVAL + + return {device.get("junctionId"): device for device in devices} + + +class AOSmithEnergyCoordinator(DataUpdateCoordinator[dict[str, float]]): + """Coordinator for energy usage data, updating with a slower interval.""" + + def __init__( + self, + hass: HomeAssistant, + client: AOSmithAPIClient, + junction_ids: list[str], + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=ENERGY_USAGE_INTERVAL + ) + self.client = client + self.junction_ids = junction_ids + + async def _async_update_data(self) -> dict[str, float]: + """Fetch latest data from the energy usage endpoint.""" + energy_usage_by_junction_id: dict[str, float] = {} + + for junction_id in self.junction_ids: + try: + energy_usage = await self.client.get_energy_use_data(junction_id) + except AOSmithInvalidCredentialsException as err: + raise ConfigEntryAuthFailed from err + except AOSmithUnknownException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + energy_usage_by_junction_id[junction_id] = energy_usage.get("lifetimeKwh") + + return energy_usage_by_junction_id diff --git a/homeassistant/components/aosmith/entity.py b/homeassistant/components/aosmith/entity.py new file mode 100644 index 00000000000..107e5d7e944 --- /dev/null +++ b/homeassistant/components/aosmith/entity.py @@ -0,0 +1,62 @@ +"""The base entity for the A. O. Smith integration.""" +from typing import TypeVar + +from py_aosmith import AOSmithAPIClient + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator + +_AOSmithCoordinatorT = TypeVar( + "_AOSmithCoordinatorT", bound=AOSmithStatusCoordinator | AOSmithEnergyCoordinator +) + + +class AOSmithEntity(CoordinatorEntity[_AOSmithCoordinatorT]): + """Base entity for A. O. Smith.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: _AOSmithCoordinatorT, junction_id: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.junction_id = junction_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, junction_id)}, + ) + + @property + def client(self) -> AOSmithAPIClient: + """Shortcut to get the API client.""" + return self.coordinator.client + + +class AOSmithStatusEntity(AOSmithEntity[AOSmithStatusCoordinator]): + """Base entity for entities that use data from the status coordinator.""" + + @property + def device(self): + """Shortcut to get the device status from the coordinator data.""" + return self.coordinator.data.get(self.junction_id) + + @property + def device_data(self): + """Shortcut to get the device data within the device status.""" + device = self.device + return None if device is None else device.get("data", {}) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.device_data.get("isOnline") is True + + +class AOSmithEnergyEntity(AOSmithEntity[AOSmithEnergyCoordinator]): + """Base entity for entities that use data from the energy coordinator.""" + + @property + def energy_usage(self) -> float | None: + """Shortcut to get the energy usage from the coordinator data.""" + return self.coordinator.data.get(self.junction_id) diff --git a/homeassistant/components/aosmith/manifest.json b/homeassistant/components/aosmith/manifest.json new file mode 100644 index 00000000000..895b03cf7fd --- /dev/null +++ b/homeassistant/components/aosmith/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "aosmith", + "name": "A. O. Smith", + "codeowners": ["@bdr99"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/aosmith", + "iot_class": "cloud_polling", + "requirements": ["py-aosmith==1.0.1"] +} diff --git a/homeassistant/components/aosmith/sensor.py b/homeassistant/components/aosmith/sensor.py new file mode 100644 index 00000000000..b0606d2dca4 --- /dev/null +++ b/homeassistant/components/aosmith/sensor.py @@ -0,0 +1,106 @@ +"""The sensor platform for the A. O. Smith integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfEnergy +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AOSmithData +from .const import DOMAIN, HOT_WATER_STATUS_MAP +from .coordinator import AOSmithEnergyCoordinator, AOSmithStatusCoordinator +from .entity import AOSmithEnergyEntity, AOSmithStatusEntity + + +@dataclass(frozen=True, kw_only=True) +class AOSmithStatusSensorEntityDescription(SensorEntityDescription): + """Entity description class for sensors using data from the status coordinator.""" + + value_fn: Callable[[dict[str, Any]], str | int | None] + + +STATUS_ENTITY_DESCRIPTIONS: tuple[AOSmithStatusSensorEntityDescription, ...] = ( + AOSmithStatusSensorEntityDescription( + key="hot_water_availability", + translation_key="hot_water_availability", + icon="mdi:water-thermometer", + device_class=SensorDeviceClass.ENUM, + options=["low", "medium", "high"], + value_fn=lambda device: HOT_WATER_STATUS_MAP.get( + device.get("data", {}).get("hotWaterStatus") + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up A. O. Smith sensor platform.""" + data: AOSmithData = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + AOSmithStatusSensorEntity(data.status_coordinator, description, junction_id) + for description in STATUS_ENTITY_DESCRIPTIONS + for junction_id in data.status_coordinator.data + ) + + async_add_entities( + AOSmithEnergySensorEntity(data.energy_coordinator, junction_id) + for junction_id in data.status_coordinator.data + ) + + +class AOSmithStatusSensorEntity(AOSmithStatusEntity, SensorEntity): + """Class for sensor entities that use data from the status coordinator.""" + + entity_description: AOSmithStatusSensorEntityDescription + + def __init__( + self, + coordinator: AOSmithStatusCoordinator, + description: AOSmithStatusSensorEntityDescription, + junction_id: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, junction_id) + self.entity_description = description + self._attr_unique_id = f"{description.key}_{junction_id}" + + @property + def native_value(self) -> str | int | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.device) + + +class AOSmithEnergySensorEntity(AOSmithEnergyEntity, SensorEntity): + """Class for the energy sensor entity.""" + + _attr_translation_key = "energy_usage" + _attr_device_class = SensorDeviceClass.ENERGY + _attr_state_class = SensorStateClass.TOTAL_INCREASING + _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR + _attr_suggested_display_precision = 1 + + def __init__( + self, + coordinator: AOSmithEnergyCoordinator, + junction_id: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, junction_id) + self._attr_unique_id = f"energy_usage_{junction_id}" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.energy_usage diff --git a/homeassistant/components/aosmith/strings.json b/homeassistant/components/aosmith/strings.json new file mode 100644 index 00000000000..0ca4e2e9094 --- /dev/null +++ b/homeassistant/components/aosmith/strings.json @@ -0,0 +1,43 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Please enter your A. O. Smith credentials." + }, + "reauth_confirm": { + "description": "Please update your password for {email}", + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "entity": { + "sensor": { + "hot_water_availability": { + "name": "Hot water availability", + "state": { + "low": "Low", + "medium": "Medium", + "high": "High" + } + }, + "energy_usage": { + "name": "Energy usage" + } + } + } +} diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py new file mode 100644 index 00000000000..8c42048d439 --- /dev/null +++ b/homeassistant/components/aosmith/water_heater.py @@ -0,0 +1,151 @@ +"""The water heater platform for the A. O. Smith integration.""" + +from typing import Any + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HEAT_PUMP, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import AOSmithData +from .const import ( + AOSMITH_MODE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP, + AOSMITH_MODE_HYBRID, + AOSMITH_MODE_VACATION, + DOMAIN, +) +from .coordinator import AOSmithStatusCoordinator +from .entity import AOSmithStatusEntity + +MODE_HA_TO_AOSMITH = { + STATE_OFF: AOSMITH_MODE_VACATION, + STATE_ECO: AOSMITH_MODE_HYBRID, + STATE_ELECTRIC: AOSMITH_MODE_ELECTRIC, + STATE_HEAT_PUMP: AOSMITH_MODE_HEAT_PUMP, +} +MODE_AOSMITH_TO_HA = { + AOSMITH_MODE_ELECTRIC: STATE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP: STATE_HEAT_PUMP, + AOSMITH_MODE_HYBRID: STATE_ECO, + AOSMITH_MODE_VACATION: STATE_OFF, +} + +# Operation mode to use when exiting away mode +DEFAULT_OPERATION_MODE = AOSMITH_MODE_HYBRID + +DEFAULT_SUPPORT_FLAGS = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up A. O. Smith water heater platform.""" + data: AOSmithData = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + AOSmithWaterHeaterEntity(data.status_coordinator, junction_id) + for junction_id in data.status_coordinator.data + ) + + +class AOSmithWaterHeaterEntity(AOSmithStatusEntity, WaterHeaterEntity): + """The water heater entity for the A. O. Smith integration.""" + + _attr_name = None + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + _attr_min_temp = 95 + + def __init__( + self, + coordinator: AOSmithStatusCoordinator, + junction_id: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator, junction_id) + self._attr_unique_id = junction_id + + @property + def operation_list(self) -> list[str]: + """Return the list of supported operation modes.""" + op_modes = [] + for mode_dict in self.device_data.get("modes", []): + mode_name = mode_dict.get("mode") + ha_mode = MODE_AOSMITH_TO_HA.get(mode_name) + + # Filtering out STATE_OFF since it is handled by away mode + if ha_mode is not None and ha_mode != STATE_OFF: + op_modes.append(ha_mode) + + return op_modes + + @property + def supported_features(self) -> WaterHeaterEntityFeature: + """Return the list of supported features.""" + supports_vacation_mode = any( + mode_dict.get("mode") == AOSMITH_MODE_VACATION + for mode_dict in self.device_data.get("modes", []) + ) + + if supports_vacation_mode: + return DEFAULT_SUPPORT_FLAGS | WaterHeaterEntityFeature.AWAY_MODE + + return DEFAULT_SUPPORT_FLAGS + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self.device_data.get("temperatureSetpoint") + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.device_data.get("temperatureSetpointMaximum") + + @property + def current_operation(self) -> str: + """Return the current operation mode.""" + return MODE_AOSMITH_TO_HA.get(self.device_data.get("mode"), STATE_OFF) + + @property + def is_away_mode_on(self): + """Return True if away mode is on.""" + return self.device_data.get("mode") == AOSMITH_MODE_VACATION + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + aosmith_mode = MODE_HA_TO_AOSMITH.get(operation_mode) + if aosmith_mode is not None: + await self.client.update_mode(self.junction_id, aosmith_mode) + + await self.coordinator.async_request_refresh() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get("temperature") + await self.client.update_setpoint(self.junction_id, temperature) + + await self.coordinator.async_request_refresh() + + async def async_turn_away_mode_on(self) -> None: + """Turn away mode on.""" + await self.client.update_mode(self.junction_id, AOSMITH_MODE_VACATION) + + await self.coordinator.async_request_refresh() + + async def async_turn_away_mode_off(self) -> None: + """Turn away mode off.""" + await self.client.update_mode(self.junction_id, DEFAULT_OPERATION_MODE) + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index 321da56095a..98d464ec526 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -7,8 +7,9 @@ from datetime import timedelta import logging from typing import Final -from apcaccess import status +import aioapcaccess +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo @@ -32,6 +33,8 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): updates from the server. """ + config_entry: ConfigEntry + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: """Initialize the data object.""" super().__init__( @@ -70,13 +73,10 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): return self.data.get("SERIALNO") @property - def device_info(self) -> DeviceInfo | None: + def device_info(self) -> DeviceInfo: """Return the DeviceInfo of this APC UPS, if serial number is available.""" - if not self.ups_serial_no: - return None - return DeviceInfo( - identifiers={(DOMAIN, self.ups_serial_no)}, + identifiers={(DOMAIN, self.ups_serial_no or self.config_entry.entry_id)}, model=self.ups_model, manufacturer="APC", name=self.ups_name if self.ups_name else "APC UPS", @@ -90,13 +90,8 @@ class APCUPSdCoordinator(DataUpdateCoordinator[OrderedDict[str, str]]): Note that the result dict uses upper case for each resource, where our integration uses lower cases as keys internally. """ - async with asyncio.timeout(10): try: - raw = await self.hass.async_add_executor_job( - status.get, self._host, self._port - ) - result: OrderedDict[str, str] = status.parse(raw) - return result - except OSError as error: + return await aioapcaccess.request_status(self._host, self._port) + except (OSError, asyncio.IncompleteReadError) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json index 55b66f0c0a0..b20e0c8aacf 100644 --- a/homeassistant/components/apcupsd/manifest.json +++ b/homeassistant/components/apcupsd/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["apcaccess"], "quality_scale": "silver", - "requirements": ["apcaccess==0.0.13"] + "requirements": ["aioapcaccess==0.4.2"] } diff --git a/homeassistant/components/appalachianpower/__init__.py b/homeassistant/components/appalachianpower/__init__.py new file mode 100644 index 00000000000..2e3180ba29f --- /dev/null +++ b/homeassistant/components/appalachianpower/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Appalachian Power.""" diff --git a/homeassistant/components/appalachianpower/manifest.json b/homeassistant/components/appalachianpower/manifest.json new file mode 100644 index 00000000000..884bd14c3fd --- /dev/null +++ b/homeassistant/components/appalachianpower/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "appalachianpower", + "name": "Appalachian Power", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/apprise/notify.py b/homeassistant/components/apprise/notify.py index b215f93aeb1..e4b350c4da8 100644 --- a/homeassistant/components/apprise/notify.py +++ b/homeassistant/components/apprise/notify.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import apprise import voluptuous as vol @@ -61,11 +62,11 @@ def get_service( class AppriseNotificationService(BaseNotificationService): """Implement the notification service for Apprise.""" - def __init__(self, a_obj): + def __init__(self, a_obj: apprise.Apprise) -> None: """Initialize the service.""" self.apprise = a_obj - def send_message(self, message="", **kwargs): + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a specified target. If no target/tags are specified, then services are notified as is diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 955293a938e..90f87bfde23 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -26,7 +26,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN, UPDATE_TOPIC, AquaLogicProcessor -@dataclass +@dataclass(frozen=True) class AquaLogicSensorEntityDescription(SensorEntityDescription): """Describes AquaLogic sensor entity.""" diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index ad11b4bdbdc..23d3b64fdca 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -39,7 +39,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class AranetSensorEntityDescription(SensorEntityDescription): """Class to describe an Aranet sensor entity.""" diff --git a/homeassistant/components/aseko_pool_live/binary_sensor.py b/homeassistant/components/aseko_pool_live/binary_sensor.py index 3e0e57fffac..cc91b6b97a6 100644 --- a/homeassistant/components/aseko_pool_live/binary_sensor.py +++ b/homeassistant/components/aseko_pool_live/binary_sensor.py @@ -20,14 +20,14 @@ from .coordinator import AsekoDataUpdateCoordinator from .entity import AsekoEntity -@dataclass +@dataclass(frozen=True) class AsekoBinarySensorDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[Unit], bool] -@dataclass +@dataclass(frozen=True) class AsekoBinarySensorEntityDescription( BinarySensorEntityDescription, AsekoBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 6d00f26ee15..7f6bef6e3c0 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -31,6 +31,7 @@ from .pipeline import ( async_get_pipeline, async_get_pipelines, async_setup_pipeline_store, + async_update_pipeline, ) from .websocket_api import async_register_websocket_api @@ -40,6 +41,7 @@ __all__ = ( "async_get_pipelines", "async_setup", "async_pipeline_from_audio_stream", + "async_update_pipeline", "AudioSettings", "Pipeline", "PipelineEvent", diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 26d599da836..71136dcdecb 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -43,6 +43,7 @@ from homeassistant.helpers.collection import ( ) from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import ( dt as dt_util, language as language_util, @@ -115,6 +116,7 @@ async def _async_resolve_default_pipeline_settings( hass: HomeAssistant, stt_engine_id: str | None, tts_engine_id: str | None, + pipeline_name: str, ) -> dict[str, str | None]: """Resolve settings for a default pipeline. @@ -123,7 +125,6 @@ async def _async_resolve_default_pipeline_settings( """ conversation_language = "en" pipeline_language = "en" - pipeline_name = "Home Assistant" stt_engine = None stt_language = None tts_engine = None @@ -195,9 +196,6 @@ async def _async_resolve_default_pipeline_settings( ) tts_engine_id = None - if stt_engine_id == "cloud" and tts_engine_id == "cloud": - pipeline_name = "Home Assistant Cloud" - return { "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": conversation_language, @@ -221,12 +219,17 @@ async def _async_create_default_pipeline( The default pipeline will use the homeassistant conversation agent and the default stt / tts engines. """ - pipeline_settings = await _async_resolve_default_pipeline_settings(hass, None, None) + pipeline_settings = await _async_resolve_default_pipeline_settings( + hass, stt_engine_id=None, tts_engine_id=None, pipeline_name="Home Assistant" + ) return await pipeline_store.async_create_item(pipeline_settings) async def async_create_default_pipeline( - hass: HomeAssistant, stt_engine_id: str, tts_engine_id: str + hass: HomeAssistant, + stt_engine_id: str, + tts_engine_id: str, + pipeline_name: str, ) -> Pipeline | None: """Create a pipeline with default settings. @@ -236,7 +239,7 @@ async def async_create_default_pipeline( pipeline_data: PipelineData = hass.data[DOMAIN] pipeline_store = pipeline_data.pipeline_store pipeline_settings = await _async_resolve_default_pipeline_settings( - hass, stt_engine_id, tts_engine_id + hass, stt_engine_id, tts_engine_id, pipeline_name=pipeline_name ) if ( pipeline_settings["stt_engine"] != stt_engine_id @@ -274,6 +277,48 @@ def async_get_pipelines(hass: HomeAssistant) -> Iterable[Pipeline]: return pipeline_data.pipeline_store.data.values() +async def async_update_pipeline( + hass: HomeAssistant, + pipeline: Pipeline, + *, + conversation_engine: str | UndefinedType = UNDEFINED, + conversation_language: str | UndefinedType = UNDEFINED, + language: str | UndefinedType = UNDEFINED, + name: str | UndefinedType = UNDEFINED, + stt_engine: str | None | UndefinedType = UNDEFINED, + stt_language: str | None | UndefinedType = UNDEFINED, + tts_engine: str | None | UndefinedType = UNDEFINED, + tts_language: str | None | UndefinedType = UNDEFINED, + tts_voice: str | None | UndefinedType = UNDEFINED, + wake_word_entity: str | None | UndefinedType = UNDEFINED, + wake_word_id: str | None | UndefinedType = UNDEFINED, +) -> None: + """Update a pipeline.""" + pipeline_data: PipelineData = hass.data[DOMAIN] + + updates: dict[str, Any] = pipeline.to_json() + updates.pop("id") + # Refactor this once we bump to Python 3.12 + # and have https://peps.python.org/pep-0692/ + for key, val in ( + ("conversation_engine", conversation_engine), + ("conversation_language", conversation_language), + ("language", language), + ("name", name), + ("stt_engine", stt_engine), + ("stt_language", stt_language), + ("tts_engine", tts_engine), + ("tts_language", tts_language), + ("tts_voice", tts_voice), + ("wake_word_entity", wake_word_entity), + ("wake_word_id", wake_word_id), + ): + if val is not UNDEFINED: + updates[key] = val + + await pipeline_data.pipeline_store.async_update_item(pipeline.id, updates) + + class PipelineEventType(StrEnum): """Event types emitted during a pipeline run.""" diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index 228da7f1a36..53a0b5d06b5 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -41,6 +41,7 @@ from .const import ( SENSORS_LOAD_AVG, SENSORS_RATES, SENSORS_TEMPERATURES, + SENSORS_TEMPERATURES_LEGACY, ) SENSORS_TYPE_BYTES = "sensors_bytes" @@ -277,7 +278,7 @@ class AsusWrtLegacyBridge(AsusWrtBridge): 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]] + return [SENSORS_TEMPERATURES_LEGACY[i] for i in range(3) if availability[i]] @handle_errors_and_zip((IndexError, OSError, ValueError), SENSORS_BYTES) async def _get_bytes(self) -> Any: diff --git a/homeassistant/components/asuswrt/const.py b/homeassistant/components/asuswrt/const.py index a4cd6cde94c..a60046b50c2 100644 --- a/homeassistant/components/asuswrt/const.py +++ b/homeassistant/components/asuswrt/const.py @@ -30,4 +30,5 @@ SENSORS_BYTES = ["sensor_rx_bytes", "sensor_tx_bytes"] SENSORS_CONNECTED_DEVICE = ["sensor_connected_device"] SENSORS_LOAD_AVG = ["sensor_load_avg1", "sensor_load_avg5", "sensor_load_avg15"] SENSORS_RATES = ["sensor_rx_rates", "sensor_tx_rates"] -SENSORS_TEMPERATURES = ["2.4GHz", "5.0GHz", "CPU"] +SENSORS_TEMPERATURES_LEGACY = ["2.4GHz", "5.0GHz", "CPU"] +SENSORS_TEMPERATURES = [*SENSORS_TEMPERATURES_LEGACY, "5.0GHz_2", "6.0GHz"] diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 9ed09cee67f..f4b2e3386e9 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioasuswrt", "asyncssh"], - "requirements": ["aioasuswrt==1.4.0", "pyasuswrt==0.1.20"] + "requirements": ["aioasuswrt==1.4.0", "pyasuswrt==0.1.21"] } diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 4f9ec0af411..f1296befbba 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -38,7 +38,7 @@ from .const import ( from .router import AsusWrtRouter -@dataclass +@dataclass(frozen=True) class AsusWrtSensorEntityDescription(SensorEntityDescription): """A class that describes AsusWrt sensor entities.""" @@ -156,6 +156,26 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, suggested_display_precision=1, ), + AsusWrtSensorEntityDescription( + key=SENSORS_TEMPERATURES[3], + translation_key="5ghz_2_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + ), + AsusWrtSensorEntityDescription( + key=SENSORS_TEMPERATURES[4], + translation_key="6ghz_temperature", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + ), ) diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json index 8a3207ec7cb..4c8386dcd00 100644 --- a/homeassistant/components/asuswrt/strings.json +++ b/homeassistant/components/asuswrt/strings.json @@ -82,6 +82,12 @@ }, "cpu_temperature": { "name": "CPU Temperature" + }, + "5ghz_2_temperature": { + "name": "5GHz Temperature (Radio 2)" + }, + "6ghz_temperature": { + "name": "6GHz Temperature" } } }, diff --git a/homeassistant/components/atag/config_flow.py b/homeassistant/components/atag/config_flow.py index ecebec717f4..8dd7020acfb 100644 --- a/homeassistant/components/atag/config_flow.py +++ b/homeassistant/components/atag/config_flow.py @@ -1,9 +1,12 @@ """Config flow for the Atag component.""" +from typing import Any + import pyatag import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import DOMAIN @@ -19,7 +22,9 @@ class AtagConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None): + 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: @@ -39,7 +44,7 @@ class AtagConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=atag.id, data=user_input) - async def _show_form(self, errors=None): + async def _show_form(self, errors: dict[str, str] | None = None) -> FlowResult: """Show the form to the user.""" return self.async_show_form( step_id="user", diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index b19a9833a47..144666844e7 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -105,12 +105,12 @@ def _native_datetime() -> datetime: return datetime.now() -@dataclass +@dataclass(frozen=True) class AugustBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes August binary_sensor entity.""" -@dataclass +@dataclass(frozen=True) class AugustDoorbellRequiredKeysMixin: """Mixin for required keys.""" @@ -118,7 +118,7 @@ class AugustDoorbellRequiredKeysMixin: is_time_based: bool -@dataclass +@dataclass(frozen=True) class AugustDoorbellBinarySensorEntityDescription( BinarySensorEntityDescription, AugustDoorbellRequiredKeysMixin ): diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index 0028db55415..f22b16008d3 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -80,20 +80,24 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Store an AugustGateway().""" self._august_gateway: AugustGateway | None = None self._aiohttp_session: aiohttp.ClientSession | None = None self._user_auth_details: dict[str, Any] = {} self._needs_reset = True - self._mode = None + self._mode: str | None = None super().__init__() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" return await self.async_step_user_validate() - async def async_step_user_validate(self, user_input=None): + async def async_step_user_validate( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle authentication.""" errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} @@ -177,7 +181,9 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._needs_reset = True return await self.async_step_reauth_validate() - async def async_step_reauth_validate(self, user_input=None): + async def async_step_reauth_validate( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle reauth and validation.""" errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index aacebb4bb5c..d0f2a27522d 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.10.0", "yalexs-ble==2.3.2"] + "requirements": ["yalexs==1.10.0", "yalexs-ble==2.4.0"] } diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 7f6e0c51995..1896a91c54f 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -63,14 +63,14 @@ def _retrieve_linked_keypad_battery_state(detail: KeypadDetail) -> int | None: _T = TypeVar("_T", LockDetail, KeypadDetail) -@dataclass +@dataclass(frozen=True) class AugustRequiredKeysMixin(Generic[_T]): """Mixin for required keys.""" value_fn: Callable[[_T], int | None] -@dataclass +@dataclass(frozen=True) class AugustSensorEntityDescription( SensorEntityDescription, AugustRequiredKeysMixin[_T] ): diff --git a/homeassistant/components/aurora/config_flow.py b/homeassistant/components/aurora/config_flow.py index 8fa4b285758..95e66ff226e 100644 --- a/homeassistant/components/aurora/config_flow.py +++ b/homeassistant/components/aurora/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from aiohttp import ClientError from auroranoaa import AuroraForecast @@ -10,6 +11,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, @@ -45,7 +47,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 43e3bd2ad5c..39abba4ada5 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -76,6 +76,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): power_watts = self.client.measure(3, True) temperature_c = self.client.measure(21) energy_wh = self.client.cumulated_energy(5) + [alarm, *_] = self.client.alarms() except AuroraTimeoutError: self.available = False _LOGGER.debug("No response from inverter (could be dark)") @@ -86,6 +87,7 @@ class AuroraAbbDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float]]): data["instantaneouspower"] = round(power_watts, 1) data["temp"] = round(temperature_c, 1) data["totalenergy"] = round(energy_wh / 1000, 2) + data["alarm"] = alarm self.available = True finally: diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index 0e7d0c06a4e..80b0fd656b6 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -5,6 +5,8 @@ from collections.abc import Mapping import logging from typing import Any +from aurorapy.mapping import Mapping as AuroraMapping + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -36,8 +38,16 @@ from .const import ( ) _LOGGER = logging.getLogger(__name__) +ALARM_STATES = list(AuroraMapping.ALARM_STATES.values()) SENSOR_TYPES = [ + SensorEntityDescription( + key="alarm", + device_class=SensorDeviceClass.ENUM, + options=ALARM_STATES, + entity_category=EntityCategory.DIAGNOSTIC, + translation_key="alarm", + ), SensorEntityDescription( key="instantaneouspower", device_class=SensorDeviceClass.POWER, diff --git a/homeassistant/components/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json index 50b6e0db502..63ea1cfefd4 100644 --- a/homeassistant/components/aurora_abb_powerone/strings.json +++ b/homeassistant/components/aurora_abb_powerone/strings.json @@ -21,11 +21,14 @@ }, "entity": { "sensor": { + "alarm": { + "name": "Alarm status" + }, "power_output": { - "name": "Power Output" + "name": "Power output" }, "total_energy": { - "name": "Total Energy" + "name": "Total energy" } } } diff --git a/homeassistant/components/aussie_broadband/sensor.py b/homeassistant/components/aussie_broadband/sensor.py index aff232f2934..efc8ae99ef9 100644 --- a/homeassistant/components/aussie_broadband/sensor.py +++ b/homeassistant/components/aussie_broadband/sensor.py @@ -23,7 +23,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, SERVICE_ID -@dataclass +@dataclass(frozen=True) class SensorValueEntityDescription(SensorEntityDescription): """Class describing Aussie Broadband sensor entities.""" diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 84f7f3aca52..4e6fa477ed2 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -5,6 +5,7 @@ from abc import ABC, abstractmethod import asyncio from collections.abc import Callable, Mapping from dataclasses import dataclass +from functools import partial import logging from typing import Any, Protocol, cast @@ -55,6 +56,11 @@ from homeassistant.exceptions import ( ) from homeassistant.helpers import condition import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -130,9 +136,20 @@ class IfAction(Protocol): # AutomationActionType, AutomationTriggerData, # and AutomationTriggerInfo are deprecated as of 2022.9. -AutomationActionType = TriggerActionType -AutomationTriggerData = TriggerData -AutomationTriggerInfo = TriggerInfo +# Can be removed in 2025.1 +_DEPRECATED_AutomationActionType = DeprecatedConstant( + TriggerActionType, "TriggerActionType", "2025.1" +) +_DEPRECATED_AutomationTriggerData = DeprecatedConstant( + TriggerData, "TriggerData", "2025.1" +) +_DEPRECATED_AutomationTriggerInfo = DeprecatedConstant( + TriggerInfo, "TriggerInfo", "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) @bind_hass diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index ed801772e6d..ff0fe43ea26 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -11,7 +11,7 @@ from voluptuous.humanize import humanize_error from homeassistant.components import blueprint from homeassistant.components.trace import TRACE_CONFIG_SCHEMA -from homeassistant.config import config_without_domain +from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( CONF_ALIAS, CONF_CONDITION, @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, config_validation as cv, script +from homeassistant.helpers import config_validation as cv, script from homeassistant.helpers.condition import async_validate_conditions_config from homeassistant.helpers.trigger import async_validate_trigger_config from homeassistant.helpers.typing import ConfigType diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 2a09a8d4e70..698850d6a49 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -50,14 +50,14 @@ from .coordinator import AwairDataUpdateCoordinator, AwairResult DUST_ALIASES = [API_PM25, API_PM10] -@dataclass +@dataclass(frozen=True) class AwairRequiredKeysMixin: """Mixin for required keys.""" unique_id_tag: str -@dataclass +@dataclass(frozen=True) class AwairSensorEntityDescription(SensorEntityDescription, AwairRequiredKeysMixin): """Describes Awair sensor entity.""" diff --git a/homeassistant/components/aws/config_flow.py b/homeassistant/components/aws/config_flow.py index 1854afc6231..e0829ef2914 100644 --- a/homeassistant/components/aws/config_flow.py +++ b/homeassistant/components/aws/config_flow.py @@ -1,6 +1,10 @@ """Config flow for AWS component.""" +from collections.abc import Mapping +from typing import Any + from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN @@ -10,7 +14,7 @@ class AWSFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, user_input): + async def async_step_import(self, user_input: Mapping[str, Any]) -> FlowResult: """Import a config entry.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index dc3d0e5b04b..edd06d69d2e 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -32,7 +32,7 @@ PLATFORMS = [Platform.SENSOR] BUILDS_QUERY: Final = "?queryOrder=queueTimeDescending&maxBuildsPerDefinition=1" -@dataclass +@dataclass(frozen=True) class AzureDevOpsEntityDescription(EntityDescription): """Class describing Azure DevOps entities.""" diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index ac884f73d68..6daf9b434df 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -17,14 +17,14 @@ from . import AzureDevOpsDeviceEntity, AzureDevOpsEntityDescription from .const import CONF_ORG, DOMAIN -@dataclass +@dataclass(frozen=True) class AzureDevOpsSensorEntityDescriptionMixin: """Mixin class for required Azure DevOps sensor description keys.""" build_key: int -@dataclass +@dataclass(frozen=True) class AzureDevOpsSensorEntityDescription( AzureDevOpsEntityDescription, SensorEntityDescription, diff --git a/homeassistant/components/baf/binary_sensor.py b/homeassistant/components/baf/binary_sensor.py index a68e80c3ac2..50e8cd78629 100644 --- a/homeassistant/components/baf/binary_sensor.py +++ b/homeassistant/components/baf/binary_sensor.py @@ -21,14 +21,14 @@ from .entity import BAFEntity from .models import BAFData -@dataclass +@dataclass(frozen=True) class BAFBinarySensorDescriptionMixin: """Required values for BAF binary sensors.""" value_fn: Callable[[Device], bool | None] -@dataclass +@dataclass(frozen=True) class BAFBinarySensorDescription( BinarySensorEntityDescription, BAFBinarySensorDescriptionMixin, diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index 7fd1c9ed290..9dd4180c7e1 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -22,14 +22,14 @@ from .entity import BAFEntity from .models import BAFData -@dataclass +@dataclass(frozen=True) class BAFNumberDescriptionMixin: """Required values for BAF sensors.""" value_fn: Callable[[Device], int | None] -@dataclass +@dataclass(frozen=True) class BAFNumberDescription(NumberEntityDescription, BAFNumberDescriptionMixin): """Class describing BAF sensor entities.""" diff --git a/homeassistant/components/baf/sensor.py b/homeassistant/components/baf/sensor.py index d8111804142..5c8d8f2979b 100644 --- a/homeassistant/components/baf/sensor.py +++ b/homeassistant/components/baf/sensor.py @@ -28,14 +28,14 @@ from .entity import BAFEntity from .models import BAFData -@dataclass +@dataclass(frozen=True) class BAFSensorDescriptionMixin: """Required values for BAF sensors.""" value_fn: Callable[[Device], int | float | str | None] -@dataclass +@dataclass(frozen=True) class BAFSensorDescription( SensorEntityDescription, BAFSensorDescriptionMixin, diff --git a/homeassistant/components/baf/switch.py b/homeassistant/components/baf/switch.py index ed4e635ece3..ccb8aee36e5 100644 --- a/homeassistant/components/baf/switch.py +++ b/homeassistant/components/baf/switch.py @@ -18,14 +18,14 @@ from .entity import BAFEntity from .models import BAFData -@dataclass +@dataclass(frozen=True) class BAFSwitchDescriptionMixin: """Required values for BAF sensors.""" value_fn: Callable[[Device], bool | None] -@dataclass +@dataclass(frozen=True) class BAFSwitchDescription( SwitchEntityDescription, BAFSwitchDescriptionMixin, diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py index 7462d051643..ec7a9fe484a 100644 --- a/homeassistant/components/balboa/binary_sensor.py +++ b/homeassistant/components/balboa/binary_sensor.py @@ -33,7 +33,7 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass +@dataclass(frozen=True) class BalboaBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -41,7 +41,7 @@ class BalboaBinarySensorEntityDescriptionMixin: on_off_icons: tuple[str, str] -@dataclass +@dataclass(frozen=True) class BalboaBinarySensorEntityDescription( BinarySensorEntityDescription, BalboaBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 101436c0f31..e0af12514da 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -7,7 +7,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "Hostname or IP address of your Balboa Spa Wifi Device. For example, 192.168.1.58." + "host": "Hostname or IP address of your Balboa Spa Wi-Fi Device. For example, 192.168.1.58." } } }, diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index a84cbc18756..3a32a1afb57 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -1,11 +1,11 @@ """Component to interface with binary sensors.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import StrEnum +from functools import partial import logging -from typing import Literal, final +from typing import TYPE_CHECKING, Literal, final import voluptuous as vol @@ -17,12 +17,23 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) + DOMAIN = "binary_sensor" SCAN_INTERVAL = timedelta(seconds=30) @@ -122,34 +133,94 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(BinarySensorDeviceClass)) # DEVICE_CLASS* below are deprecated as of 2021.12 # use the BinarySensorDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in BinarySensorDeviceClass] -DEVICE_CLASS_BATTERY = BinarySensorDeviceClass.BATTERY.value -DEVICE_CLASS_BATTERY_CHARGING = BinarySensorDeviceClass.BATTERY_CHARGING.value -DEVICE_CLASS_CO = BinarySensorDeviceClass.CO.value -DEVICE_CLASS_COLD = BinarySensorDeviceClass.COLD.value -DEVICE_CLASS_CONNECTIVITY = BinarySensorDeviceClass.CONNECTIVITY.value -DEVICE_CLASS_DOOR = BinarySensorDeviceClass.DOOR.value -DEVICE_CLASS_GARAGE_DOOR = BinarySensorDeviceClass.GARAGE_DOOR.value -DEVICE_CLASS_GAS = BinarySensorDeviceClass.GAS.value -DEVICE_CLASS_HEAT = BinarySensorDeviceClass.HEAT.value -DEVICE_CLASS_LIGHT = BinarySensorDeviceClass.LIGHT.value -DEVICE_CLASS_LOCK = BinarySensorDeviceClass.LOCK.value -DEVICE_CLASS_MOISTURE = BinarySensorDeviceClass.MOISTURE.value -DEVICE_CLASS_MOTION = BinarySensorDeviceClass.MOTION.value -DEVICE_CLASS_MOVING = BinarySensorDeviceClass.MOVING.value -DEVICE_CLASS_OCCUPANCY = BinarySensorDeviceClass.OCCUPANCY.value -DEVICE_CLASS_OPENING = BinarySensorDeviceClass.OPENING.value -DEVICE_CLASS_PLUG = BinarySensorDeviceClass.PLUG.value -DEVICE_CLASS_POWER = BinarySensorDeviceClass.POWER.value -DEVICE_CLASS_PRESENCE = BinarySensorDeviceClass.PRESENCE.value -DEVICE_CLASS_PROBLEM = BinarySensorDeviceClass.PROBLEM.value -DEVICE_CLASS_RUNNING = BinarySensorDeviceClass.RUNNING.value -DEVICE_CLASS_SAFETY = BinarySensorDeviceClass.SAFETY.value -DEVICE_CLASS_SMOKE = BinarySensorDeviceClass.SMOKE.value -DEVICE_CLASS_SOUND = BinarySensorDeviceClass.SOUND.value -DEVICE_CLASS_TAMPER = BinarySensorDeviceClass.TAMPER.value -DEVICE_CLASS_UPDATE = BinarySensorDeviceClass.UPDATE.value -DEVICE_CLASS_VIBRATION = BinarySensorDeviceClass.VIBRATION.value -DEVICE_CLASS_WINDOW = BinarySensorDeviceClass.WINDOW.value +_DEPRECATED_DEVICE_CLASS_BATTERY = DeprecatedConstantEnum( + BinarySensorDeviceClass.BATTERY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_BATTERY_CHARGING = DeprecatedConstantEnum( + BinarySensorDeviceClass.BATTERY_CHARGING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_CO = DeprecatedConstantEnum( + BinarySensorDeviceClass.CO, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_COLD = DeprecatedConstantEnum( + BinarySensorDeviceClass.COLD, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_CONNECTIVITY = DeprecatedConstantEnum( + BinarySensorDeviceClass.CONNECTIVITY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DOOR = DeprecatedConstantEnum( + BinarySensorDeviceClass.DOOR, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_GARAGE_DOOR = DeprecatedConstantEnum( + BinarySensorDeviceClass.GARAGE_DOOR, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_GAS = DeprecatedConstantEnum( + BinarySensorDeviceClass.GAS, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_HEAT = DeprecatedConstantEnum( + BinarySensorDeviceClass.HEAT, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_LIGHT = DeprecatedConstantEnum( + BinarySensorDeviceClass.LIGHT, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_LOCK = DeprecatedConstantEnum( + BinarySensorDeviceClass.LOCK, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_MOISTURE = DeprecatedConstantEnum( + BinarySensorDeviceClass.MOISTURE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_MOTION = DeprecatedConstantEnum( + BinarySensorDeviceClass.MOTION, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_MOVING = DeprecatedConstantEnum( + BinarySensorDeviceClass.MOVING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_OCCUPANCY = DeprecatedConstantEnum( + BinarySensorDeviceClass.OCCUPANCY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_OPENING = DeprecatedConstantEnum( + BinarySensorDeviceClass.OPENING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PLUG = DeprecatedConstantEnum( + BinarySensorDeviceClass.PLUG, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_POWER = DeprecatedConstantEnum( + BinarySensorDeviceClass.POWER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PRESENCE = DeprecatedConstantEnum( + BinarySensorDeviceClass.PRESENCE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_PROBLEM = DeprecatedConstantEnum( + BinarySensorDeviceClass.PROBLEM, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_RUNNING = DeprecatedConstantEnum( + BinarySensorDeviceClass.RUNNING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SAFETY = DeprecatedConstantEnum( + BinarySensorDeviceClass.SAFETY, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SMOKE = DeprecatedConstantEnum( + BinarySensorDeviceClass.SMOKE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SOUND = DeprecatedConstantEnum( + BinarySensorDeviceClass.SOUND, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_TAMPER = DeprecatedConstantEnum( + BinarySensorDeviceClass.TAMPER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_UPDATE = DeprecatedConstantEnum( + BinarySensorDeviceClass.UPDATE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_VIBRATION = DeprecatedConstantEnum( + BinarySensorDeviceClass.VIBRATION, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum( + BinarySensorDeviceClass.WINDOW, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) # mypy: disallow-any-generics @@ -176,14 +247,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class BinarySensorEntityDescription(EntityDescription): +class BinarySensorEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes binary sensor entities.""" device_class: BinarySensorDeviceClass | None = None -class BinarySensorEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "is_on", +} + + +class BinarySensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Represent a binary sensor.""" entity_description: BinarySensorEntityDescription @@ -206,7 +282,7 @@ class BinarySensorEntity(Entity): """ return self.device_class is not None - @property + @cached_property def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -215,7 +291,7 @@ class BinarySensorEntity(Entity): return self.entity_description.device_class return None - @property + @cached_property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self._attr_is_on diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 8598868e2dc..b2a23b0aa31 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -32,7 +32,7 @@ BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), - # Camera Armed sensor is depreciated covered by switch and will be removed in 2023.6. + # Camera Armed sensor is deprecated covered by switch and will be removed in 2023.6. BinarySensorEntityDescription( key=TYPE_CAMERA_ARMED, translation_key="camera_armed", diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index f507364f17f..4d05aea88a5 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -8,17 +8,26 @@ import logging from typing import Any from requests.exceptions import ChunkedEncodingError +import voluptuous as vol from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_platform +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_BRAND, DOMAIN, SERVICE_TRIGGER +from .const import ( + DEFAULT_BRAND, + DOMAIN, + SERVICE_SAVE_RECENT_CLIPS, + SERVICE_SAVE_VIDEO, + SERVICE_TRIGGER, +) from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -43,6 +52,16 @@ async def async_setup_entry( platform = entity_platform.async_get_current_platform() platform.async_register_entity_service(SERVICE_TRIGGER, {}, "trigger_camera") + platform.async_register_entity_service( + SERVICE_SAVE_RECENT_CLIPS, + {vol.Required(CONF_FILE_PATH): cv.string}, + "save_recent_clips", + ) + platform.async_register_entity_service( + SERVICE_SAVE_VIDEO, + {vol.Required(CONF_FILENAME): cv.string}, + "save_video", + ) class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): @@ -64,7 +83,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): manufacturer=DEFAULT_BRAND, model=camera.camera_type, ) - _LOGGER.debug("Initialized blink camera %s", self.name) + _LOGGER.debug("Initialized blink camera %s", self._camera.name) @property def extra_state_attributes(self) -> Mapping[str, Any] | None: @@ -121,3 +140,39 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): except TypeError: _LOGGER.debug("No cached image for %s", self._camera.name) return None + + async def save_recent_clips(self, file_path) -> None: + """Save multiple recent clips to output directory.""" + if not self.hass.config.is_allowed_path(file_path): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": file_path}, + ) + + try: + await self._camera.save_recent_clips(output_dir=file_path) + except OSError as err: + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err + + async def save_video(self, filename) -> None: + """Handle save video service calls.""" + if not self.hass.config.is_allowed_path(filename): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="no_path", + translation_placeholders={"target": filename}, + ) + + try: + await self._camera.video_to_file(filename) + except OSError as err: + raise ServiceValidationError( + str(err), + translation_domain=DOMAIN, + translation_key="cant_write", + ) from err diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index 64b05e1ba27..7aa3d0d388e 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -7,7 +7,6 @@ DEVICE_ID = "Home Assistant" CONF_MIGRATE = "migrate" CONF_CAMERA = "camera" CONF_ALARM_CONTROL_PANEL = "alarm_control_panel" -CONF_DEVICE_ID = "device_id" DEFAULT_BRAND = "Blink" DEFAULT_ATTRIBUTION = "Data provided by immedia-semi.com" DEFAULT_SCAN_INTERVAL = 300 @@ -25,6 +24,7 @@ SERVICE_TRIGGER = "trigger_camera" SERVICE_SAVE_VIDEO = "save_video" SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips" SERVICE_SEND_PIN = "send_pin" +ATTR_CONFIG_ENTRY_ID = "config_entry_id" PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index dae2f0ad951..5c034cdb7c5 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -4,25 +4,16 @@ from __future__ import annotations import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import ( - ATTR_DEVICE_ID, - CONF_FILE_PATH, - CONF_FILENAME, - CONF_NAME, - CONF_PIN, -) +from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.device_registry as dr - -from .const import ( - DOMAIN, - SERVICE_REFRESH, - SERVICE_SAVE_RECENT_CLIPS, - SERVICE_SAVE_VIDEO, - SERVICE_SEND_PIN, +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + issue_registry as ir, ) + +from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_REFRESH, SERVICE_SEND_PIN from .coordinator import BlinkUpdateCoordinator SERVICE_UPDATE_SCHEMA = vol.Schema( @@ -30,26 +21,12 @@ SERVICE_UPDATE_SCHEMA = vol.Schema( vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), } ) -SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_FILENAME): cv.string, - } -) SERVICE_SEND_PIN_SCHEMA = vol.Schema( { - vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Required(ATTR_CONFIG_ENTRY_ID): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_PIN): cv.string, } ) -SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_FILE_PATH): cv.string, - } -) def setup_services(hass: HomeAssistant) -> None: @@ -94,57 +71,22 @@ def setup_services(hass: HomeAssistant) -> None: coordinators.append(hass.data[DOMAIN][config_entry.entry_id]) return coordinators - async def async_handle_save_video_service(call: ServiceCall) -> None: - """Handle save video service calls.""" - camera_name = call.data[CONF_NAME] - video_path = call.data[CONF_FILENAME] - if not hass.config.is_allowed_path(video_path): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_path", - translation_placeholders={"target": video_path}, - ) - - for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): - all_cameras = coordinator.api.cameras - if camera_name in all_cameras: - try: - await all_cameras[camera_name].video_to_file(video_path) - except OSError as err: - raise ServiceValidationError( - str(err), - translation_domain=DOMAIN, - translation_key="cant_write", - ) from err - - async def async_handle_save_recent_clips_service(call: ServiceCall) -> None: - """Save multiple recent clips to output directory.""" - camera_name = call.data[CONF_NAME] - clips_dir = call.data[CONF_FILE_PATH] - if not hass.config.is_allowed_path(clips_dir): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="no_path", - translation_placeholders={"target": clips_dir}, - ) - - for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): - all_cameras = coordinator.api.cameras - if camera_name in all_cameras: - try: - await all_cameras[camera_name].save_recent_clips( - output_dir=clips_dir - ) - except OSError as err: - raise ServiceValidationError( - str(err), - translation_domain=DOMAIN, - translation_key="cant_write", - ) from err - async def send_pin(call: ServiceCall): """Call blink to send new pin.""" - for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): + for entry_id in call.data[ATTR_CONFIG_ENTRY_ID]: + if not (config_entry := hass.config_entries.async_get_entry(entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if config_entry.state != ConfigEntryState.LOADED: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": config_entry.title}, + ) + coordinator = hass.data[DOMAIN][entry_id] await coordinator.api.auth.send_auth_key( coordinator.api, call.data[CONF_PIN], @@ -152,22 +94,24 @@ def setup_services(hass: HomeAssistant) -> None: async def blink_refresh(call: ServiceCall): """Call blink to refresh info.""" + ir.async_create_issue( + hass, + DOMAIN, + "service_deprecation", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation", + ) + for coordinator in collect_coordinators(call.data[ATTR_DEVICE_ID]): await coordinator.api.refresh(force_cache=True) # Register all the above services + # Refresh service is deprecated and will be removed in 7/2024 service_mapping = [ (blink_refresh, SERVICE_REFRESH, SERVICE_UPDATE_SCHEMA), - ( - async_handle_save_video_service, - SERVICE_SAVE_VIDEO, - SERVICE_SAVE_VIDEO_SCHEMA, - ), - ( - async_handle_save_recent_clips_service, - SERVICE_SAVE_RECENT_CLIPS, - SERVICE_SAVE_RECENT_CLIPS_SCHEMA, - ), (send_pin, SERVICE_SEND_PIN, SERVICE_SEND_PIN_SCHEMA), ] diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index aaecde64353..87083a990ef 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -9,25 +9,17 @@ blink_update: integration: blink trigger_camera: - fields: - device_id: - required: true - selector: - device: - integration: blink + target: + entity: + integration: blink + domain: camera save_video: + target: + entity: + integration: blink + domain: camera fields: - device_id: - required: true - selector: - device: - integration: blink - name: - required: true - example: "Living Room" - selector: - text: filename: required: true example: "/tmp/video.mp4" @@ -35,17 +27,11 @@ save_video: text: save_recent_clips: + target: + entity: + integration: blink + domain: camera fields: - device_id: - required: true - selector: - device: - integration: blink - name: - required: true - example: "Living Room" - selector: - text: file_path: required: true example: "/tmp" @@ -54,10 +40,10 @@ save_recent_clips: send_pin: fields: - device_id: + config_entry_id: required: true selector: - device: + config_entry: integration: blink pin: example: "abc123" diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index fc0450dc8ea..a875fb3e343 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -67,29 +67,15 @@ }, "trigger_camera": { "name": "Trigger camera", - "description": "Requests camera to take new image.", - "fields": { - "device_id": { - "name": "Device ID", - "description": "The Blink device id." - } - } + "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)." - }, - "device_id": { - "name": "Device ID", - "description": "The Blink device id." } } }, @@ -97,17 +83,9 @@ "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)." - }, - "device_id": { - "name": "Device ID", - "description": "The Blink device id." } } }, @@ -119,19 +97,16 @@ "name": "Pin", "description": "PIN received from blink. Leave empty if you only received a verification email." }, - "device_id": { - "name": "Device ID", - "description": "The Blink device id." + "config_entry_id": { + "name": "Integration ID", + "description": "The Blink Integration id." } } } }, "exceptions": { - "invalid_device": { - "message": "Device '{target}' is not a {domain} device" - }, - "device_not_found": { - "message": "Device '{target}' not found in device registry" + "integration_not_found": { + "message": "Integration '{target}' not found in registry" }, "no_path": { "message": "Can't write to directory {target}, no access to path!" @@ -142,5 +117,18 @@ "not_loaded": { "message": "{target} is not loaded" } + }, + "issues": { + "service_deprecation": { + "title": "Blink update service is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::blink::issues::service_deprecation::title%]", + "description": "Blink update service is deprecated and will be removed.\nPlease update your automations and scripts to use `Home Assistant Core Integration: Update entity`." + } + } + } + } } } diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py new file mode 100644 index 00000000000..0dfa67f097d --- /dev/null +++ b/homeassistant/components/blue_current/__init__.py @@ -0,0 +1,178 @@ +"""The Blue Current integration.""" +from __future__ import annotations + +from contextlib import suppress +from datetime import datetime +from typing import Any + +from bluecurrent_api import Client +from bluecurrent_api.exceptions import ( + BlueCurrentException, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later + +from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE + +PLATFORMS = [Platform.SENSOR] +CHARGE_POINTS = "CHARGE_POINTS" +DATA = "data" +SMALL_DELAY = 1 +LARGE_DELAY = 20 + +GRID = "GRID" +OBJECT = "object" +VALUE_TYPES = ["CH_STATUS"] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up Blue Current as a config entry.""" + hass.data.setdefault(DOMAIN, {}) + client = Client() + api_token = config_entry.data[CONF_API_TOKEN] + connector = Connector(hass, config_entry, client) + + try: + await connector.connect(api_token) + except InvalidApiToken: + LOGGER.error("Invalid Api token") + return False + except BlueCurrentException as err: + raise ConfigEntryNotReady from err + + hass.async_create_task(connector.start_loop()) + await client.get_charge_points() + + await client.wait_for_response() + hass.data[DOMAIN][config_entry.entry_id] = connector + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + config_entry.async_on_unload(connector.disconnect) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload the Blue Current config entry.""" + + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +class Connector: + """Define a class that connects to the Blue Current websocket API.""" + + def __init__( + self, hass: HomeAssistant, config: ConfigEntry, client: Client + ) -> None: + """Initialize.""" + self.config: ConfigEntry = config + self.hass: HomeAssistant = hass + self.client: Client = client + self.charge_points: dict[str, dict] = {} + self.grid: dict[str, Any] = {} + self.available = False + + async def connect(self, token: str) -> None: + """Register on_data and connect to the websocket.""" + await self.client.connect(token) + self.available = True + + async def on_data(self, message: dict) -> None: + """Handle received data.""" + + async def handle_charge_points(data: list) -> None: + """Loop over the charge points and get their data.""" + for entry in data: + evse_id = entry[EVSE_ID] + model = entry[MODEL_TYPE] + name = entry[ATTR_NAME] + self.add_charge_point(evse_id, model, name) + await self.get_charge_point_data(evse_id) + await self.client.get_grid_status(data[0][EVSE_ID]) + + object_name: str = message[OBJECT] + + # gets charge point ids + if object_name == CHARGE_POINTS: + charge_points_data: list = message[DATA] + await handle_charge_points(charge_points_data) + + # gets charge point key / values + elif object_name in VALUE_TYPES: + value_data: dict = message[DATA] + evse_id = value_data.pop(EVSE_ID) + self.update_charge_point(evse_id, value_data) + + # gets grid key / values + elif GRID in object_name: + data: dict = message[DATA] + self.grid = data + self.dispatch_grid_update_signal() + + async def get_charge_point_data(self, evse_id: str) -> None: + """Get all the data of a charge point.""" + await self.client.get_status(evse_id) + + def add_charge_point(self, evse_id: str, model: str, name: str) -> None: + """Add a charge point to charge_points.""" + self.charge_points[evse_id] = {MODEL_TYPE: model, ATTR_NAME: name} + + def update_charge_point(self, evse_id: str, data: dict) -> None: + """Update the charge point data.""" + self.charge_points[evse_id].update(data) + self.dispatch_value_update_signal(evse_id) + + def dispatch_value_update_signal(self, evse_id: str) -> None: + """Dispatch a value signal.""" + async_dispatcher_send(self.hass, f"{DOMAIN}_value_update_{evse_id}") + + def dispatch_grid_update_signal(self) -> None: + """Dispatch a grid signal.""" + async_dispatcher_send(self.hass, f"{DOMAIN}_grid_update") + + async def start_loop(self) -> None: + """Start the receive loop.""" + try: + await self.client.start_loop(self.on_data) + except BlueCurrentException as err: + LOGGER.warning( + "Disconnected from the Blue Current websocket. Retrying to connect in background. %s", + err, + ) + + async_call_later(self.hass, SMALL_DELAY, self.reconnect) + + async def reconnect(self, _event_time: datetime | None = None) -> None: + """Keep trying to reconnect to the websocket.""" + try: + await self.connect(self.config.data[CONF_API_TOKEN]) + LOGGER.info("Reconnected to the Blue Current websocket") + self.hass.async_create_task(self.start_loop()) + await self.client.get_charge_points() + except RequestLimitReached: + self.available = False + async_call_later( + self.hass, self.client.get_next_reset_delta(), self.reconnect + ) + except WebsocketError: + self.available = False + async_call_later(self.hass, LARGE_DELAY, self.reconnect) + + async def disconnect(self) -> None: + """Disconnect from the websocket.""" + with suppress(WebsocketError): + await self.client.disconnect() diff --git a/homeassistant/components/blue_current/config_flow.py b/homeassistant/components/blue_current/config_flow.py new file mode 100644 index 00000000000..32a6c177b49 --- /dev/null +++ b/homeassistant/components/blue_current/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for Blue Current integration.""" +from __future__ import annotations + +from typing import Any + +from bluecurrent_api import Client +from bluecurrent_api.exceptions import ( + AlreadyConnected, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_TOKEN): str}) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle the config flow for Blue Current.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + client = Client() + api_token = user_input[CONF_API_TOKEN] + + try: + customer_id = await client.validate_api_token(api_token) + email = await client.get_email() + except WebsocketError: + errors["base"] = "cannot_connect" + except RequestLimitReached: + errors["base"] = "limit_reached" + except AlreadyConnected: + errors["base"] = "already_connected" + except InvalidApiToken: + errors["base"] = "invalid_token" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + else: + await self.async_set_unique_id(customer_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=email, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/blue_current/const.py b/homeassistant/components/blue_current/const.py new file mode 100644 index 00000000000..008e6efa872 --- /dev/null +++ b/homeassistant/components/blue_current/const.py @@ -0,0 +1,10 @@ +"""Constants for the Blue Current integration.""" + +import logging + +DOMAIN = "blue_current" + +LOGGER = logging.getLogger(__package__) + +EVSE_ID = "evse_id" +MODEL_TYPE = "model_type" diff --git a/homeassistant/components/blue_current/entity.py b/homeassistant/components/blue_current/entity.py new file mode 100644 index 00000000000..300f2191cdc --- /dev/null +++ b/homeassistant/components/blue_current/entity.py @@ -0,0 +1,63 @@ +"""Entity representing a Blue Current charge point.""" +from homeassistant.const import ATTR_NAME +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import Connector +from .const import DOMAIN, MODEL_TYPE + + +class BlueCurrentEntity(Entity): + """Define a base Blue Current entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, connector: Connector, signal: str) -> None: + """Initialize the entity.""" + self.connector: Connector = connector + self.signal: str = signal + self.has_value: bool = False + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + @callback + def update() -> None: + """Update the state.""" + self.update_from_latest_data() + self.async_write_ha_state() + + self.async_on_remove(async_dispatcher_connect(self.hass, self.signal, update)) + + self.update_from_latest_data() + + @property + def available(self) -> bool: + """Return entity availability.""" + return self.connector.available and self.has_value + + @callback + def update_from_latest_data(self) -> None: + """Update the entity from the latest data.""" + raise NotImplementedError + + +class ChargepointEntity(BlueCurrentEntity): + """Define a base charge point entity.""" + + def __init__(self, connector: Connector, evse_id: str) -> None: + """Initialize the entity.""" + chargepoint_name = connector.charge_points[evse_id][ATTR_NAME] + + self.evse_id = evse_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, evse_id)}, + name=chargepoint_name if chargepoint_name != "" else evse_id, + manufacturer="Blue Current", + model=connector.charge_points[evse_id][MODEL_TYPE], + ) + + super().__init__(connector, f"{DOMAIN}_value_update_{self.evse_id}") diff --git a/homeassistant/components/blue_current/manifest.json b/homeassistant/components/blue_current/manifest.json new file mode 100644 index 00000000000..bff8a057f08 --- /dev/null +++ b/homeassistant/components/blue_current/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "blue_current", + "name": "Blue Current", + "codeowners": ["@Floris272", "@gleeuwen"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/blue_current", + "iot_class": "cloud_push", + "issue_tracker": "https://github.com/bluecurrent/ha-bluecurrent/issues", + "requirements": ["bluecurrent-api==1.0.6"] +} diff --git a/homeassistant/components/blue_current/sensor.py b/homeassistant/components/blue_current/sensor.py new file mode 100644 index 00000000000..326caa70f54 --- /dev/null +++ b/homeassistant/components/blue_current/sensor.py @@ -0,0 +1,296 @@ +"""Support for Blue Current sensors.""" +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CURRENCY_EURO, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Connector +from .const import DOMAIN +from .entity import BlueCurrentEntity, ChargepointEntity + +TIMESTAMP_KEYS = ("start_datetime", "stop_datetime", "offline_since") + +SENSORS = ( + SensorEntityDescription( + key="actual_v1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="actual_v1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_v2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="actual_v2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_v3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="actual_v3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="avg_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="avg_voltage", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_p1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="actual_p1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_p2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="actual_p2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_p3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="actual_p3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="avg_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="avg_current", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="total_kw", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + translation_key="total_kw", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_kwh", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + translation_key="actual_kwh", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="start_datetime", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="start_datetime", + ), + SensorEntityDescription( + key="stop_datetime", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="stop_datetime", + ), + SensorEntityDescription( + key="offline_since", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="offline_since", + ), + SensorEntityDescription( + key="total_cost", + native_unit_of_measurement=CURRENCY_EURO, + device_class=SensorDeviceClass.MONETARY, + translation_key="total_cost", + ), + SensorEntityDescription( + key="vehicle_status", + icon="mdi:car", + device_class=SensorDeviceClass.ENUM, + options=["standby", "vehicle_detected", "ready", "no_power", "vehicle_error"], + translation_key="vehicle_status", + ), + SensorEntityDescription( + key="activity", + icon="mdi:ev-station", + device_class=SensorDeviceClass.ENUM, + options=["available", "charging", "unavailable", "error", "offline"], + translation_key="activity", + ), + SensorEntityDescription( + key="max_usage", + translation_key="max_usage", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="smartcharging_max_usage", + translation_key="smartcharging_max_usage", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="max_offline", + translation_key="max_offline", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="current_left", + translation_key="current_left", + icon="mdi:gauge", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), +) + +GRID_SENSORS = ( + SensorEntityDescription( + key="grid_actual_p1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_actual_p1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_actual_p2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_actual_p2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_actual_p3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_actual_p3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_avg_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_avg_current", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_max_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_max_current", + state_class=SensorStateClass.MEASUREMENT, + ), +) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Blue Current sensors.""" + connector: Connector = hass.data[DOMAIN][entry.entry_id] + sensor_list: list[SensorEntity] = [] + for evse_id in connector.charge_points: + for sensor in SENSORS: + sensor_list.append(ChargePointSensor(connector, sensor, evse_id)) + + for grid_sensor in GRID_SENSORS: + sensor_list.append(GridSensor(connector, grid_sensor)) + + async_add_entities(sensor_list) + + +class ChargePointSensor(ChargepointEntity, SensorEntity): + """Define a charge point sensor.""" + + def __init__( + self, + connector: Connector, + sensor: SensorEntityDescription, + evse_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(connector, evse_id) + + self.key = sensor.key + self.entity_description = sensor + self._attr_unique_id = f"{sensor.key}_{evse_id}" + + @callback + def update_from_latest_data(self) -> None: + """Update the sensor from the latest data.""" + + new_value = self.connector.charge_points[self.evse_id].get(self.key) + + if new_value is not None: + if self.key in TIMESTAMP_KEYS and not ( + self._attr_native_value is None or self._attr_native_value < new_value + ): + return + self.has_value = True + self._attr_native_value = new_value + + elif self.key not in TIMESTAMP_KEYS: + self.has_value = False + + +class GridSensor(BlueCurrentEntity, SensorEntity): + """Define a grid sensor.""" + + def __init__( + self, + connector: Connector, + sensor: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(connector, f"{DOMAIN}_grid_update") + + self.key = sensor.key + self.entity_description = sensor + self._attr_unique_id = sensor.key + + @callback + def update_from_latest_data(self) -> None: + """Update the grid sensor from the latest data.""" + + new_value = self.connector.grid.get(self.key) + + if new_value is not None: + self.has_value = True + self._attr_native_value = new_value + + else: + self.has_value = False diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json new file mode 100644 index 00000000000..10c114e5f1c --- /dev/null +++ b/homeassistant/components/blue_current/strings.json @@ -0,0 +1,117 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "description": "Enter your Blue Current api token", + "title": "Authentication" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "limit_reached": "Request limit reached", + "invalid_token": "Invalid token", + "no_cards_found": "No charge cards found", + "already_connected": "Already connected", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "entity": { + "sensor": { + "activity": { + "name": "Activity", + "state": { + "available": "Available", + "charging": "Charging", + "unavailable": "Unavailable", + "error": "Error", + "offline": "Offline" + } + }, + "vehicle_status": { + "name": "Vehicle status", + "state": { + "standby": "Standby", + "vehicle_detected": "Detected", + "ready": "Ready", + "no_power": "No power", + "vehicle_error": "Error" + } + }, + "actual_v1": { + "name": "Voltage phase 1" + }, + "actual_v2": { + "name": "Voltage phase 2" + }, + "actual_v3": { + "name": "Voltage phase 3" + }, + "avg_voltage": { + "name": "Average voltage" + }, + "actual_p1": { + "name": "Current phase 1" + }, + "actual_p2": { + "name": "Current phase 2" + }, + "actual_p3": { + "name": "Current phase 3" + }, + "avg_current": { + "name": "Average current" + }, + "total_kw": { + "name": "Total power" + }, + "actual_kwh": { + "name": "Energy usage" + }, + "start_datetime": { + "name": "Started on" + }, + "stop_datetime": { + "name": "Stopped on" + }, + "offline_since": { + "name": "Offline since" + }, + "total_cost": { + "name": "Total cost" + }, + "max_usage": { + "name": "Max usage" + }, + "smartcharging_max_usage": { + "name": "Smart charging max usage" + }, + "max_offline": { + "name": "Offline max usage" + }, + "current_left": { + "name": "Remaining current" + }, + "grid_actual_p1": { + "name": "Grid current phase 1" + }, + "grid_actual_p2": { + "name": "Grid current phase 2" + }, + "grid_actual_p3": { + "name": "Grid current phase 3" + }, + "grid_avg_current": { + "name": "Average grid current" + }, + "grid_max_current": { + "name": "Max grid current" + } + } + } +} diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index ddf57aa6eee..63a1c1b45f0 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -215,7 +215,7 @@ class DomainBlueprints: def _load_blueprint(self, blueprint_path) -> Blueprint: """Load a blueprint.""" try: - blueprint_data = yaml.load_yaml(self.blueprint_folder / blueprint_path) + blueprint_data = yaml.load_yaml_dict(self.blueprint_folder / blueprint_path) except FileNotFoundError as err: raise FailedToLoad( self.domain, @@ -225,7 +225,6 @@ class DomainBlueprints: except HomeAssistantError as err: raise FailedToLoad(self.domain, blueprint_path, err) from err - assert isinstance(blueprint_data, dict) return Blueprint( blueprint_data, expected_domain=self.domain, path=blueprint_path ) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index c59249e8bd5..2dd4f06ecdf 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -21,6 +21,17 @@ from bluetooth_adapters import ( adapter_unique_name, get_adapters, ) +from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME +from habluetooth import ( + BaseHaRemoteScanner, + BaseHaScanner, + BluetoothScannerDevice, + BluetoothScanningMode, + HaBluetoothConnector, + HaScanner, + ScannerStartError, + set_manager, +) from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from homeassistant.components import usb @@ -59,7 +70,6 @@ from .api import ( async_set_fallback_availability_interval, async_track_unavailable, ) -from .base_scanner import BaseHaRemoteScanner, BaseHaScanner, BluetoothScannerDevice from .const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, CONF_ADAPTER, @@ -71,15 +81,9 @@ from .const import ( LINUX_FIRMWARE_LOAD_FALLBACK_SECONDS, SOURCE_LOCAL, ) -from .manager import BluetoothManager +from .manager import HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher, IntegrationMatcher -from .models import ( - BluetoothCallback, - BluetoothChange, - BluetoothScanningMode, - HaBluetoothConnector, -) -from .scanner import MONOTONIC_TIME, HaScanner, ScannerStartError +from .models import BluetoothCallback, BluetoothChange from .storage import BluetoothStorage if TYPE_CHECKING: @@ -102,8 +106,9 @@ __all__ = [ "async_scanner_by_source", "async_scanner_count", "async_scanner_devices_by_address", + "async_get_advertisement_callback", "BaseHaScanner", - "BaseHaRemoteScanner", + "HomeAssistantRemoteScanner", "BluetoothCallbackMatcher", "BluetoothChange", "BluetoothServiceInfo", @@ -112,6 +117,7 @@ __all__ = [ "BluetoothCallback", "BluetoothScannerDevice", "HaBluetoothConnector", + "BaseHaRemoteScanner", "SOURCE_LOCAL", "FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS", "MONOTONIC_TIME", @@ -139,11 +145,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await bluetooth_storage.async_setup() slot_manager = BleakSlotManager() await slot_manager.async_setup() - manager = BluetoothManager( + manager = HomeAssistantBluetoothManager( hass, integration_matcher, bluetooth_adapters, bluetooth_storage, slot_manager ) + set_manager(manager) await manager.async_setup() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, manager.async_stop) + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, lambda event: manager.async_stop() + ) hass.data[DATA_MANAGER] = models.MANAGER = manager adapters = await manager.async_get_bluetooth_adapters() @@ -279,9 +288,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: passive = entry.options.get(CONF_PASSIVE) mode = BluetoothScanningMode.PASSIVE if passive else BluetoothScanningMode.ACTIVE - new_info_callback = async_get_advertisement_callback(hass) - manager: BluetoothManager = hass.data[DATA_MANAGER] - scanner = HaScanner(hass, mode, adapter, address, new_info_callback) + manager: HomeAssistantBluetoothManager = hass.data[DATA_MANAGER] + scanner = HaScanner(mode, adapter, address) try: scanner.async_setup() except RuntimeError as err: @@ -295,7 +303,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: adapters = await manager.async_get_bluetooth_adapters() details = adapters[adapter] slots: int = details.get(ADAPTER_CONNECTION_SLOTS) or DEFAULT_CONNECTION_SLOTS - entry.async_on_unload(async_register_scanner(hass, scanner, True, slots)) + entry.async_on_unload(async_register_scanner(hass, scanner, connection_slots=slots)) await async_update_device(hass, entry, adapter, details) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = scanner entry.async_on_unload(entry.add_update_listener(async_update_listener)) diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index cdf51d34978..174e5c66ce8 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -9,10 +9,10 @@ import logging from typing import Any, Generic, TypeVar from bleak import BleakError +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from homeassistant.util.dt import monotonic_time_coarse from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .passive_update_coordinator import PassiveBluetoothDataUpdateCoordinator diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index a3f5e20a9e9..3a13dda28a8 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -9,10 +9,10 @@ import logging from typing import Any, Generic, TypeVar from bleak import BleakError +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from homeassistant.util.dt import monotonic_time_coarse from . import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak from .passive_update_processor import PassiveBluetoothProcessorCoordinator diff --git a/homeassistant/components/bluetooth/advertisement_tracker.py b/homeassistant/components/bluetooth/advertisement_tracker.py deleted file mode 100644 index f17bcf938f5..00000000000 --- a/homeassistant/components/bluetooth/advertisement_tracker.py +++ /dev/null @@ -1,82 +0,0 @@ -"""The bluetooth integration advertisement tracker.""" -from __future__ import annotations - -from typing import Any - -from homeassistant.core import callback - -from .models import BluetoothServiceInfoBleak - -ADVERTISING_TIMES_NEEDED = 16 - -# Each scanner may buffer incoming packets so -# we need to give a bit of leeway before we -# mark a device unavailable -TRACKER_BUFFERING_WOBBLE_SECONDS = 5 - - -class AdvertisementTracker: - """Tracker to determine the interval that a device is advertising.""" - - __slots__ = ("intervals", "fallback_intervals", "sources", "_timings") - - def __init__(self) -> None: - """Initialize the tracker.""" - self.intervals: dict[str, float] = {} - self.fallback_intervals: dict[str, float] = {} - self.sources: dict[str, str] = {} - self._timings: dict[str, list[float]] = {} - - @callback - def async_diagnostics(self) -> dict[str, dict[str, Any]]: - """Return diagnostics.""" - return { - "intervals": self.intervals, - "fallback_intervals": self.fallback_intervals, - "sources": self.sources, - "timings": self._timings, - } - - @callback - def async_collect(self, service_info: BluetoothServiceInfoBleak) -> None: - """Collect timings for the tracker. - - For performance reasons, it is the responsibility of the - caller to check if the device already has an interval set or - the source has changed before calling this function. - """ - address = service_info.address - self.sources[address] = service_info.source - timings = self._timings.setdefault(address, []) - timings.append(service_info.time) - if len(timings) != ADVERTISING_TIMES_NEEDED: - return - - max_time_between_advertisements = timings[1] - timings[0] - for i in range(2, len(timings)): - time_between_advertisements = timings[i] - timings[i - 1] - if time_between_advertisements > max_time_between_advertisements: - max_time_between_advertisements = time_between_advertisements - - # We now know the maximum time between advertisements - self.intervals[address] = max_time_between_advertisements - del self._timings[address] - - @callback - def async_remove_address(self, address: str) -> None: - """Remove the tracker.""" - self.intervals.pop(address, None) - self.sources.pop(address, None) - self._timings.pop(address, None) - - @callback - def async_remove_fallback_interval(self, address: str) -> None: - """Remove fallback interval.""" - self.fallback_intervals.pop(address, None) - - @callback - def async_remove_source(self, source: str) -> None: - """Remove the tracker.""" - for address, tracked_source in list(self.sources.items()): - if tracked_source == source: - self.async_remove_address(address) diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 9d24428e3d2..29054a54e72 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -9,29 +9,28 @@ from asyncio import Future from collections.abc import Callable, Iterable from typing import TYPE_CHECKING, cast +from habluetooth import ( + BaseHaScanner, + BluetoothScannerDevice, + BluetoothScanningMode, + HaBleakScannerWrapper, +) from home_assistant_bluetooth import BluetoothServiceInfoBleak from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback -from .base_scanner import BaseHaScanner, BluetoothScannerDevice from .const import DATA_MANAGER -from .manager import BluetoothManager +from .manager import HomeAssistantBluetoothManager from .match import BluetoothCallbackMatcher -from .models import ( - BluetoothCallback, - BluetoothChange, - BluetoothScanningMode, - ProcessAdvertisementCallback, -) -from .wrappers import HaBleakScannerWrapper +from .models import BluetoothCallback, BluetoothChange, ProcessAdvertisementCallback if TYPE_CHECKING: from bleak.backends.device import BLEDevice -def _get_manager(hass: HomeAssistant) -> BluetoothManager: +def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager: """Get the bluetooth manager.""" - return cast(BluetoothManager, hass.data[DATA_MANAGER]) + return cast(HomeAssistantBluetoothManager, hass.data[DATA_MANAGER]) @hass_callback @@ -182,13 +181,10 @@ def async_rediscover_address(hass: HomeAssistant, address: str) -> None: def async_register_scanner( hass: HomeAssistant, scanner: BaseHaScanner, - connectable: bool, connection_slots: int | None = None, ) -> CALLBACK_TYPE: """Register a BleakScanner.""" - return _get_manager(hass).async_register_scanner( - scanner, connectable, connection_slots - ) + return _get_manager(hass).async_register_scanner(scanner, connection_slots) @hass_callback diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py deleted file mode 100644 index 637ebbaf867..00000000000 --- a/homeassistant/components/bluetooth/base_scanner.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Base classes for HA Bluetooth scanners for bluetooth.""" -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Callable, Generator -from contextlib import contextmanager -from dataclasses import dataclass -import datetime -from datetime import timedelta -import logging -from typing import Any, Final - -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData -from bleak_retry_connector import NO_RSSI_VALUE -from bluetooth_adapters import DiscoveredDeviceAdvertisementData, adapter_human_name -from home_assistant_bluetooth import BluetoothServiceInfoBleak - -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import ( - CALLBACK_TYPE, - Event, - HomeAssistant, - callback as hass_callback, -) -from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util -from homeassistant.util.dt import monotonic_time_coarse - -from . import models -from .const import ( - CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, - SCANNER_WATCHDOG_INTERVAL, - SCANNER_WATCHDOG_TIMEOUT, -) -from .models import HaBluetoothConnector - -MONOTONIC_TIME: Final = monotonic_time_coarse -_LOGGER = logging.getLogger(__name__) - - -@dataclass(slots=True) -class BluetoothScannerDevice: - """Data for a bluetooth device from a given scanner.""" - - scanner: BaseHaScanner - ble_device: BLEDevice - advertisement: AdvertisementData - - -class BaseHaScanner(ABC): - """Base class for Ha Scanners.""" - - __slots__ = ( - "hass", - "adapter", - "connectable", - "source", - "connector", - "_connecting", - "name", - "scanning", - "_last_detection", - "_start_time", - "_cancel_watchdog", - ) - - def __init__( - self, - hass: HomeAssistant, - source: str, - adapter: str, - connector: HaBluetoothConnector | None = None, - ) -> None: - """Initialize the scanner.""" - self.hass = hass - self.connectable = False - self.source = source - self.connector = connector - self._connecting = 0 - self.adapter = adapter - self.name = adapter_human_name(adapter, source) if adapter != source else source - self.scanning = True - self._last_detection = 0.0 - self._start_time = 0.0 - self._cancel_watchdog: CALLBACK_TYPE | None = None - - @hass_callback - def _async_stop_scanner_watchdog(self) -> None: - """Stop the scanner watchdog.""" - if self._cancel_watchdog: - self._cancel_watchdog() - self._cancel_watchdog = None - - @hass_callback - def _async_setup_scanner_watchdog(self) -> None: - """If something has restarted or updated, we need to restart the scanner.""" - self._start_time = self._last_detection = MONOTONIC_TIME() - if not self._cancel_watchdog: - self._cancel_watchdog = async_track_time_interval( - self.hass, - self._async_scanner_watchdog, - SCANNER_WATCHDOG_INTERVAL, - name=f"{self.name} Bluetooth scanner watchdog", - ) - - @hass_callback - def _async_watchdog_triggered(self) -> bool: - """Check if the watchdog has been triggered.""" - time_since_last_detection = MONOTONIC_TIME() - self._last_detection - _LOGGER.debug( - "%s: Scanner watchdog time_since_last_detection: %s", - self.name, - time_since_last_detection, - ) - return time_since_last_detection > SCANNER_WATCHDOG_TIMEOUT - - @hass_callback - def _async_scanner_watchdog(self, now: datetime.datetime) -> None: - """Check if the scanner is running. - - Override this method if you need to do something else when the watchdog - is triggered. - """ - if self._async_watchdog_triggered(): - _LOGGER.info( - ( - "%s: Bluetooth scanner has gone quiet for %ss, check logs on the" - " scanner device for more information" - ), - self.name, - SCANNER_WATCHDOG_TIMEOUT, - ) - self.scanning = False - return - self.scanning = not self._connecting - - @contextmanager - def connecting(self) -> Generator[None, None, None]: - """Context manager to track connecting state.""" - self._connecting += 1 - self.scanning = not self._connecting - try: - yield - finally: - self._connecting -= 1 - self.scanning = not self._connecting - - @property - @abstractmethod - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - - @property - @abstractmethod - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and their advertisement data.""" - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - device_adv_datas = self.discovered_devices_and_advertisement_data.values() - return { - "name": self.name, - "start_time": self._start_time, - "source": self.source, - "scanning": self.scanning, - "type": self.__class__.__name__, - "last_detection": self._last_detection, - "monotonic_time": MONOTONIC_TIME(), - "discovered_devices_and_advertisement_data": [ - { - "name": device.name, - "address": device.address, - "rssi": advertisement_data.rssi, - "advertisement_data": advertisement_data, - "details": device.details, - } - for device, advertisement_data in device_adv_datas - ], - } - - -class BaseHaRemoteScanner(BaseHaScanner): - """Base class for a Home Assistant remote BLE scanner.""" - - __slots__ = ( - "_new_info_callback", - "_discovered_device_advertisement_datas", - "_discovered_device_timestamps", - "_details", - "_expire_seconds", - "_storage", - ) - - def __init__( - self, - hass: HomeAssistant, - scanner_id: str, - name: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], - connector: HaBluetoothConnector | None, - connectable: bool, - ) -> None: - """Initialize the scanner.""" - super().__init__(hass, scanner_id, name, connector) - self._new_info_callback = new_info_callback - self._discovered_device_advertisement_datas: dict[ - str, tuple[BLEDevice, AdvertisementData] - ] = {} - self._discovered_device_timestamps: dict[str, float] = {} - self.connectable = connectable - self._details: dict[str, str | HaBluetoothConnector] = {"source": scanner_id} - # Scanners only care about connectable devices. The manager - # will handle taking care of availability for non-connectable devices - self._expire_seconds = CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS - assert models.MANAGER is not None - self._storage = models.MANAGER.storage - - @hass_callback - def async_setup(self) -> CALLBACK_TYPE: - """Set up the scanner.""" - if history := self._storage.async_get_advertisement_history(self.source): - self._discovered_device_advertisement_datas = ( - history.discovered_device_advertisement_datas - ) - self._discovered_device_timestamps = history.discovered_device_timestamps - # Expire anything that is too old - self._async_expire_devices(dt_util.utcnow()) - - cancel_track = async_track_time_interval( - self.hass, - self._async_expire_devices, - timedelta(seconds=30), - name=f"{self.name} Bluetooth scanner device expire", - ) - cancel_stop = self.hass.bus.async_listen( - EVENT_HOMEASSISTANT_STOP, self._async_save_history - ) - self._async_setup_scanner_watchdog() - - @hass_callback - def _cancel() -> None: - self._async_save_history() - self._async_stop_scanner_watchdog() - cancel_track() - cancel_stop() - - return _cancel - - @hass_callback - def _async_save_history(self, event: Event | None = None) -> None: - """Save the history.""" - self._storage.async_set_advertisement_history( - self.source, - DiscoveredDeviceAdvertisementData( - self.connectable, - self._expire_seconds, - self._discovered_device_advertisement_datas, - self._discovered_device_timestamps, - ), - ) - - @hass_callback - def _async_expire_devices(self, _datetime: datetime.datetime) -> None: - """Expire old devices.""" - now = MONOTONIC_TIME() - expired = [ - address - for address, timestamp in self._discovered_device_timestamps.items() - if now - timestamp > self._expire_seconds - ] - for address in expired: - del self._discovered_device_advertisement_datas[address] - del self._discovered_device_timestamps[address] - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - device_adv_datas = self._discovered_device_advertisement_datas.values() - return [ - device_advertisement_data[0] - for device_advertisement_data in device_adv_datas - ] - - @property - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and advertisement data.""" - return self._discovered_device_advertisement_datas - - @hass_callback - def _async_on_advertisement( - self, - address: str, - rssi: int, - local_name: str | None, - service_uuids: list[str], - service_data: dict[str, bytes], - manufacturer_data: dict[int, bytes], - tx_power: int | None, - details: dict[Any, Any], - advertisement_monotonic_time: float, - ) -> None: - """Call the registered callback.""" - self.scanning = not self._connecting - self._last_detection = advertisement_monotonic_time - 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 - prev_device = prev_discovery[0] - prev_advertisement = prev_discovery[1] - prev_service_uuids = prev_advertisement.service_uuids - prev_service_data = prev_advertisement.service_data - prev_manufacturer_data = prev_advertisement.manufacturer_data - prev_name = prev_device.name - - if prev_name and (not local_name or len(prev_name) > len(local_name)): - local_name = prev_name - - if service_uuids and service_uuids != prev_service_uuids: - service_uuids = list({*service_uuids, *prev_service_uuids}) - elif not service_uuids: - service_uuids = prev_service_uuids - - if service_data and service_data != prev_service_data: - service_data = prev_service_data | service_data - elif not service_data: - service_data = prev_service_data - - if manufacturer_data and manufacturer_data != prev_manufacturer_data: - manufacturer_data = prev_manufacturer_data | manufacturer_data - elif not manufacturer_data: - manufacturer_data = prev_manufacturer_data - # - # Bleak updates the BLEDevice via create_or_update_device. - # We need to do the same to ensure integrations that already - # have the BLEDevice object get the updated details when they - # change. - # - # https://github.com/hbldh/bleak/blob/222618b7747f0467dbb32bd3679f8cfaa19b1668/bleak/backends/scanner.py#L203 - # - device = prev_device - device.name = local_name - device.details = self._details | details - # pylint: disable-next=protected-access - device._rssi = rssi # deprecated, will be removed in newer bleak - - advertisement_data = AdvertisementData( - local_name=None if local_name == "" else local_name, - manufacturer_data=manufacturer_data, - service_data=service_data, - service_uuids=service_uuids, - tx_power=NO_RSSI_VALUE if tx_power is None else tx_power, - rssi=rssi, - platform_data=(), - ) - self._discovered_device_advertisement_datas[address] = ( - device, - advertisement_data, - ) - self._discovered_device_timestamps[address] = advertisement_monotonic_time - self._new_info_callback( - BluetoothServiceInfoBleak( - name=local_name or address, - address=address, - rssi=rssi, - manufacturer_data=manufacturer_data, - service_data=service_data, - service_uuids=service_uuids, - source=self.source, - device=device, - advertisement=advertisement_data, - connectable=self.connectable, - time=advertisement_monotonic_time, - ) - ) - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - now = MONOTONIC_TIME() - return await super().async_diagnostics() | { - "storage": self._storage.async_get_advertisement_history_as_dict( - self.source - ), - "connectable": self.connectable, - "discovered_device_timestamps": self._discovered_device_timestamps, - "time_since_last_device_detection": { - address: now - timestamp - for address, timestamp in self._discovered_device_timestamps.items() - }, - } diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index 150239eec02..fa8efabcb1d 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -1,9 +1,15 @@ """Constants for the Bluetooth integration.""" from __future__ import annotations -from datetime import timedelta from typing import Final +from habluetooth import ( # noqa: F401 + CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + SCANNER_WATCHDOG_INTERVAL, + SCANNER_WATCHDOG_TIMEOUT, +) + DOMAIN = "bluetooth" CONF_ADAPTER = "adapter" @@ -19,42 +25,6 @@ UNAVAILABLE_TRACK_SECONDS: Final = 60 * 5 START_TIMEOUT = 15 -# The maximum time between advertisements for a device to be considered -# stale when the advertisement tracker cannot determine the interval. -# -# We have to set this quite high as we don't know -# when devices fall out of the ESPHome device (and other non-local scanners)'s -# stack like we do with BlueZ so its safer to assume its available -# since if it does go out of range and it is in range -# of another device the timeout is much shorter and it will -# switch over to using that adapter anyways. -# -FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 60 * 15 - -# The maximum time between advertisements for a device to be considered -# stale when the advertisement tracker can determine the interval for -# connectable devices. -# -# BlueZ uses 180 seconds by default but we give it a bit more time -# to account for the esp32's bluetooth stack being a bit slower -# than BlueZ's. -CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS: Final = 195 - - -# We must recover before we hit the 180s mark -# where the device is removed from the stack -# or the devices will go unavailable. Since -# we only check every 30s, we need this number -# to be -# 180s Time when device is removed from stack -# - 30s check interval -# - 30s scanner restart time * 2 -# -SCANNER_WATCHDOG_TIMEOUT: Final = 90 -# How often to check if the scanner has reached -# the SCANNER_WATCHDOG_TIMEOUT without seeing anything -SCANNER_WATCHDOG_INTERVAL: Final = timedelta(seconds=30) - # When the linux kernel is configured with # CONFIG_FW_LOADER_USER_HELPER_FALLBACK it diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index ce047747a0c..381beb02520 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -1,21 +1,14 @@ """The bluetooth integration.""" from __future__ import annotations -import asyncio from collections.abc import Callable, Iterable -from datetime import datetime, timedelta +from functools import partial import itertools import logging -from typing import TYPE_CHECKING, Any, Final -from bleak.backends.scanner import AdvertisementDataCallback -from bleak_retry_connector import NO_RSSI_VALUE, RSSI_SWITCH_THRESHOLD, BleakSlotManager -from bluetooth_adapters import ( - ADAPTER_ADDRESS, - ADAPTER_PASSIVE_SCAN, - AdapterDetails, - BluetoothAdapters, -) +from bleak_retry_connector import BleakSlotManager +from bluetooth_adapters import BluetoothAdapters +from habluetooth import BaseHaRemoteScanner, BaseHaScanner, BluetoothManager from homeassistant import config_entries from homeassistant.const import EVENT_LOGGING_CHANGED @@ -26,18 +19,7 @@ from homeassistant.core import ( callback as hass_callback, ) from homeassistant.helpers import discovery_flow -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.util.dt import monotonic_time_coarse -from .advertisement_tracker import ( - TRACKER_BUFFERING_WOBBLE_SECONDS, - AdvertisementTracker, -) -from .base_scanner import BaseHaScanner, BluetoothScannerDevice -from .const import ( - FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, - UNAVAILABLE_TRACK_SECONDS, -) from .match import ( ADDRESS, CALLBACK, @@ -50,81 +32,20 @@ from .match import ( ) from .models import BluetoothCallback, BluetoothChange, BluetoothServiceInfoBleak from .storage import BluetoothStorage -from .usage import install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher from .util import async_load_history_from_system -if TYPE_CHECKING: - from bleak.backends.device import BLEDevice - from bleak.backends.scanner import AdvertisementData - - -FILTER_UUIDS: Final = "UUIDs" - -APPLE_MFR_ID: Final = 76 -APPLE_IBEACON_START_BYTE: Final = 0x02 # iBeacon (tilt_ble) -APPLE_HOMEKIT_START_BYTE: Final = 0x06 # homekit_controller -APPLE_DEVICE_ID_START_BYTE: Final = 0x10 # bluetooth_le_tracker -APPLE_HOMEKIT_NOTIFY_START_BYTE: Final = 0x11 # homekit_controller -APPLE_START_BYTES_WANTED: Final = { - APPLE_IBEACON_START_BYTE, - APPLE_HOMEKIT_START_BYTE, - APPLE_HOMEKIT_NOTIFY_START_BYTE, - APPLE_DEVICE_ID_START_BYTE, -} - -MONOTONIC_TIME: Final = monotonic_time_coarse - _LOGGER = logging.getLogger(__name__) -def _dispatch_bleak_callback( - callback: AdvertisementDataCallback | None, - filters: dict[str, set[str]], - device: BLEDevice, - advertisement_data: AdvertisementData, -) -> None: - """Dispatch the callback.""" - if not callback: - # Callback destroyed right before being called, ignore - return - - if (uuids := filters.get(FILTER_UUIDS)) and not uuids.intersection( - advertisement_data.service_uuids - ): - return - - try: - callback(device, advertisement_data) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in callback: %s", callback) - - -class BluetoothManager: - """Manage Bluetooth.""" +class HomeAssistantBluetoothManager(BluetoothManager): + """Manage Bluetooth for Home Assistant.""" __slots__ = ( "hass", - "_integration_matcher", - "_cancel_unavailable_tracking", - "_cancel_logging_listener", - "_advertisement_tracker", - "_fallback_intervals", - "_intervals", - "_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", - "shutdown", + "_integration_matcher", + "_callback_index", + "_cancel_logging_listener", ) def __init__( @@ -137,458 +58,51 @@ class BluetoothManager: ) -> None: """Init bluetooth manager.""" self.hass = hass - self._integration_matcher = integration_matcher - self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None - self._cancel_logging_listener: CALLBACK_TYPE | None = None - - self._advertisement_tracker = AdvertisementTracker() - self._fallback_intervals = self._advertisement_tracker.fallback_intervals - self._intervals = self._advertisement_tracker.intervals - - self._unavailable_callbacks: dict[ - str, list[Callable[[BluetoothServiceInfoBleak], None]] - ] = {} - self._connectable_unavailable_callbacks: dict[ - str, list[Callable[[BluetoothServiceInfoBleak], None]] - ] = {} - - self._callback_index = BluetoothCallbackMatcherIndex() - self._bleak_callbacks: list[ - tuple[AdvertisementDataCallback, dict[str, set[str]]] - ] = [] - self._all_history: dict[str, BluetoothServiceInfoBleak] = {} - self._connectable_history: dict[str, BluetoothServiceInfoBleak] = {} - self._non_connectable_scanners: list[BaseHaScanner] = [] - self._connectable_scanners: list[BaseHaScanner] = [] - self._adapters: dict[str, AdapterDetails] = {} - self._sources: dict[str, BaseHaScanner] = {} - self._bluetooth_adapters = bluetooth_adapters self.storage = storage - self.slot_manager = slot_manager - self._debug = _LOGGER.isEnabledFor(logging.DEBUG) - self.shutdown = False - - @property - def supports_passive_scan(self) -> bool: - """Return if passive scan is supported.""" - return any(adapter[ADAPTER_PASSIVE_SCAN] for adapter in self._adapters.values()) - - def async_scanner_count(self, connectable: bool = True) -> int: - """Return the number of scanners.""" - if connectable: - return len(self._connectable_scanners) - return len(self._connectable_scanners) + len(self._non_connectable_scanners) - - async def async_diagnostics(self) -> dict[str, Any]: - """Diagnostics for the manager.""" - scanner_diagnostics = await asyncio.gather( - *[ - scanner.async_diagnostics() - for scanner in itertools.chain( - self._non_connectable_scanners, self._connectable_scanners - ) - ] - ) - return { - "adapters": self._adapters, - "slot_manager": self.slot_manager.diagnostics(), - "scanners": scanner_diagnostics, - "connectable_history": [ - service_info.as_dict() - for service_info in self._connectable_history.values() - ], - "all_history": [ - service_info.as_dict() for service_info in self._all_history.values() - ], - "advertisement_tracker": self._advertisement_tracker.async_diagnostics(), - } - - def _find_adapter_by_address(self, address: str) -> str | None: - for adapter, details in self._adapters.items(): - if details[ADAPTER_ADDRESS] == address: - return adapter - return None + self._integration_matcher = integration_matcher + self._callback_index = BluetoothCallbackMatcherIndex() + self._cancel_logging_listener: CALLBACK_TYPE | None = None + super().__init__(bluetooth_adapters, slot_manager) + self._async_logging_changed() @hass_callback - def async_scanner_by_source(self, source: str) -> BaseHaScanner | None: - """Return the scanner for a source.""" - return self._sources.get(source) - - async def async_get_bluetooth_adapters( - self, cached: bool = True - ) -> dict[str, AdapterDetails]: - """Get bluetooth adapters.""" - if not self._adapters or not cached: - if not cached: - await self._bluetooth_adapters.refresh() - self._adapters = self._bluetooth_adapters.adapters - return self._adapters - - async def async_get_adapter_from_address(self, address: str) -> str | None: - """Get adapter from address.""" - if adapter := self._find_adapter_by_address(address): - return adapter - await self._bluetooth_adapters.refresh() - self._adapters = self._bluetooth_adapters.adapters - return self._find_adapter_by_address(address) - - @hass_callback - def _async_logging_changed(self, event: Event) -> None: + def _async_logging_changed(self, event: Event | None = None) -> None: """Handle logging change.""" self._debug = _LOGGER.isEnabledFor(logging.DEBUG) - async def async_setup(self) -> None: - """Set up the bluetooth manager.""" - await self._bluetooth_adapters.refresh() - install_multiple_bleak_catcher() - self._all_history, self._connectable_history = async_load_history_from_system( - self._bluetooth_adapters, self.storage - ) - self._cancel_logging_listener = self.hass.bus.async_listen( - EVENT_LOGGING_CHANGED, self._async_logging_changed - ) - self.async_setup_unavailable_tracking() - seen: set[str] = set() - for address, service_info in itertools.chain( - self._connectable_history.items(), self._all_history.items() - ): - if address in seen: - continue - seen.add(address) + def _async_trigger_matching_discovery( + self, service_info: BluetoothServiceInfoBleak + ) -> None: + """Trigger discovery for matching domains.""" + for domain in self._integration_matcher.match_domains(service_info): + discovery_flow.async_create_flow( + self.hass, + domain, + {"source": config_entries.SOURCE_BLUETOOTH}, + service_info, + ) + + @hass_callback + def async_rediscover_address(self, address: str) -> None: + """Trigger discovery of devices which have already been seen.""" + self._integration_matcher.async_clear_address(address) + if service_info := self._connectable_history.get(address): + self._async_trigger_matching_discovery(service_info) + return + if service_info := self._all_history.get(address): self._async_trigger_matching_discovery(service_info) - @hass_callback - def async_stop(self, event: Event) -> None: - """Stop the Bluetooth integration at shutdown.""" - _LOGGER.debug("Stopping bluetooth manager") - self.shutdown = True - if self._cancel_unavailable_tracking: - self._cancel_unavailable_tracking() - self._cancel_unavailable_tracking = None - if self._cancel_logging_listener: - self._cancel_logging_listener() - self._cancel_logging_listener = None - uninstall_multiple_bleak_catcher() - - @hass_callback - def async_scanner_devices_by_address( - self, address: str, connectable: bool - ) -> list[BluetoothScannerDevice]: - """Get BluetoothScannerDevice by address.""" - if not connectable: - scanners: Iterable[BaseHaScanner] = itertools.chain( - self._connectable_scanners, self._non_connectable_scanners - ) - else: - scanners = self._connectable_scanners - return [ - BluetoothScannerDevice(scanner, *device_adv) - for scanner in scanners - if ( - device_adv := scanner.discovered_devices_and_advertisement_data.get( - address - ) - ) - ] - - @hass_callback - def _async_all_discovered_addresses(self, connectable: bool) -> Iterable[str]: - """Return all of discovered addresses. - - Include addresses from all the scanners including duplicates. - """ - yield from itertools.chain.from_iterable( - scanner.discovered_devices_and_advertisement_data - for scanner in self._connectable_scanners - ) - if not connectable: - yield from itertools.chain.from_iterable( - scanner.discovered_devices_and_advertisement_data - for scanner in self._non_connectable_scanners - ) - - @hass_callback - def async_discovered_devices(self, connectable: bool) -> list[BLEDevice]: - """Return all of combined best path to discovered from all the scanners.""" - histories = self._connectable_history if connectable else self._all_history - return [history.device for history in histories.values()] - - @hass_callback - def async_setup_unavailable_tracking(self) -> None: - """Set up the unavailable tracking.""" - self._cancel_unavailable_tracking = async_track_time_interval( - self.hass, - self._async_check_unavailable, - timedelta(seconds=UNAVAILABLE_TRACK_SECONDS), - name="Bluetooth manager unavailable tracking", - ) - - @hass_callback - def _async_check_unavailable(self, now: datetime) -> None: - """Watch for unavailable devices and cleanup state history.""" - monotonic_now = MONOTONIC_TIME() - connectable_history = self._connectable_history - all_history = self._all_history - tracker = self._advertisement_tracker - intervals = tracker.intervals - - for connectable in (True, False): - if connectable: - unavailable_callbacks = self._connectable_unavailable_callbacks - else: - unavailable_callbacks = self._unavailable_callbacks - history = connectable_history if connectable else all_history - disappeared = set(history).difference( - self._async_all_discovered_addresses(connectable) - ) - for address in disappeared: - if not connectable: - # - # For non-connectable devices we also check the device has exceeded - # the advertising interval before we mark it as unavailable - # since it may have gone to sleep and since we do not need an active - # connection to it we can only determine its availability - # by the lack of advertisements - if advertising_interval := ( - intervals.get(address) or self._fallback_intervals.get(address) - ): - advertising_interval += TRACKER_BUFFERING_WOBBLE_SECONDS - else: - advertising_interval = ( - FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS - ) - time_since_seen = monotonic_now - all_history[address].time - if time_since_seen <= advertising_interval: - continue - - # The second loop (connectable=False) is responsible for removing - # the device from all the interval tracking since it is no longer - # available for both connectable and non-connectable - tracker.async_remove_fallback_interval(address) - tracker.async_remove_address(address) - self._integration_matcher.async_clear_address(address) - self._async_dismiss_discoveries(address) - - service_info = history.pop(address) - - if not (callbacks := unavailable_callbacks.get(address)): - continue - - for callback in callbacks: - try: - callback(service_info) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error in unavailable callback") - - def _async_dismiss_discoveries(self, address: str) -> None: - """Dismiss all discoveries for the given address.""" - for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( - BluetoothServiceInfoBleak, - lambda service_info: bool(service_info.address == address), - ): - self.hass.config_entries.flow.async_abort(flow["flow_id"]) - - def _prefer_previous_adv_from_different_source( - self, - old: BluetoothServiceInfoBleak, - new: BluetoothServiceInfoBleak, - ) -> bool: - """Prefer previous advertisement from a different source if it is better.""" - if new.time - old.time > ( - stale_seconds := self._intervals.get( - new.address, - self._fallback_intervals.get( - new.address, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS - ), - ) - ): - # If the old advertisement is stale, any new advertisement is preferred - if self._debug: - _LOGGER.debug( - ( - "%s (%s): Switching from %s to %s (time elapsed:%s > stale" - " seconds:%s)" - ), - new.name, - new.address, - self._async_describe_source(old), - self._async_describe_source(new), - new.time - old.time, - stale_seconds, - ) - return False - if (new.rssi or NO_RSSI_VALUE) - RSSI_SWITCH_THRESHOLD > ( - old.rssi or NO_RSSI_VALUE - ): - # If new advertisement is RSSI_SWITCH_THRESHOLD more, - # the new one is preferred. - if self._debug: - _LOGGER.debug( - ( - "%s (%s): Switching from %s to %s (new rssi:%s - threshold:%s >" - " old rssi:%s)" - ), - new.name, - new.address, - self._async_describe_source(old), - self._async_describe_source(new), - new.rssi, - RSSI_SWITCH_THRESHOLD, - old.rssi, - ) - return False - return True - - @hass_callback - def scanner_adv_received(self, service_info: BluetoothServiceInfoBleak) -> None: - """Handle a new advertisement from any scanner. - - Callbacks from all the scanners arrive here. - """ - - # Pre-filter noisy apple devices as they can account for 20-35% of the - # traffic on a typical network. - if ( - (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 - - 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. - # - # We need to handle multiple connectable and non-connectable scanners - # and we need to handle the case where a device is connectable on one scanner - # but not on another. - # - # The device may also be connectable only by a scanner that has worse - # signal strength than a non-connectable scanner. - # - # all_history - the history of all advertisements from all scanners with the - # best advertisement from each scanner - # connectable_history - the history of all connectable advertisements from all - # scanners with the best advertisement from each - # connectable scanner - # - if ( - (old_service_info := all_history.get(address)) - and source != old_service_info.source - and (scanner := self._sources.get(old_service_info.source)) - and scanner.scanning - and self._prefer_previous_adv_from_different_source( - old_service_info, service_info - ) - ): - # If we are rejecting the new advertisement and the device is connectable - # but not in the connectable history or the connectable source is the same - # as the new source, we need to add it to the connectable history - if connectable: - if old_connectable_service_info and ( - # If its the same as the preferred source, we are done - # as we know we prefer the old advertisement - # from the check above - (old_connectable_service_info is old_service_info) - # If the old connectable source is different from the preferred - # source, we need to check it as well to see if we prefer - # the old connectable advertisement - or ( - source != old_connectable_service_info.source - and ( - connectable_scanner := self._sources.get( - old_connectable_service_info.source - ) - ) - and connectable_scanner.scanning - and self._prefer_previous_adv_from_different_source( - old_connectable_service_info, service_info - ) - ) - ): - return - - connectable_history[address] = service_info - - return - - if connectable: - connectable_history[address] = service_info - - all_history[address] = service_info - - # Track advertisement intervals to determine when we need to - # switch adapters or mark a device as unavailable - tracker = self._advertisement_tracker - if (last_source := tracker.sources.get(address)) and last_source != source: - # Source changed, remove the old address from the tracker - tracker.async_remove_address(address) - if address not in tracker.intervals: - tracker.async_collect(service_info) - - # If the advertisement data is the same as the last time we saw it, we - # don't need to do anything else unless its connectable and we are missing - # connectable history for the device so we can make it available again - # after unavailable callbacks. - if ( - # Ensure its not a connectable device missing from connectable history - not (connectable and not old_connectable_service_info) - # Than check if advertisement data is the same - and old_service_info - and not ( - service_info.manufacturer_data != old_service_info.manufacturer_data - or service_info.service_data != old_service_info.service_data - or service_info.service_uuids != old_service_info.service_uuids - or service_info.name != old_service_info.name - ) - ): - return - - if not connectable and old_connectable_service_info: - # Since we have a connectable path and our BleakClient will - # route any connection attempts to the connectable path, we - # mark the service_info as connectable so that the callbacks - # will be called and the device can be discovered. - service_info = BluetoothServiceInfoBleak( - name=service_info.name, - address=service_info.address, - rssi=service_info.rssi, - manufacturer_data=service_info.manufacturer_data, - service_data=service_info.service_data, - service_uuids=service_info.service_uuids, - source=service_info.source, - device=service_info.device, - advertisement=service_info.advertisement, - connectable=True, - time=service_info.time, - ) - + def _discover_service_info(self, service_info: BluetoothServiceInfoBleak) -> None: matched_domains = self._integration_matcher.match_domains(service_info) if self._debug: _LOGGER.debug( "%s: %s %s match: %s", self._async_describe_source(service_info), - address, + service_info.address, service_info.advertisement, matched_domains, ) - if (connectable or old_connectable_service_info) and ( - bleak_callbacks := self._bleak_callbacks - ): - # Bleak callbacks must get a connectable device - 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): callback = match[CALLBACK] try: @@ -604,40 +118,33 @@ class BluetoothManager: service_info, ) - @hass_callback - def _async_describe_source(self, service_info: BluetoothServiceInfoBleak) -> str: - """Describe a source.""" - if scanner := self._sources.get(service_info.source): - description = scanner.name - else: - description = service_info.source - if service_info.connectable: - description += " [connectable]" - return description + def _address_disappeared(self, address: str) -> None: + """Dismiss all discoveries for the given address.""" + self._integration_matcher.async_clear_address(address) + for flow in self.hass.config_entries.flow.async_progress_by_init_data_type( + BluetoothServiceInfoBleak, + lambda service_info: bool(service_info.address == address), + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) - @hass_callback - def async_track_unavailable( - self, - callback: Callable[[BluetoothServiceInfoBleak], None], - address: str, - connectable: bool, - ) -> Callable[[], None]: - """Register a callback.""" - if connectable: - unavailable_callbacks = self._connectable_unavailable_callbacks - else: - unavailable_callbacks = self._unavailable_callbacks - unavailable_callbacks.setdefault(address, []).append(callback) + async def async_setup(self) -> None: + """Set up the bluetooth manager.""" + await super().async_setup() + self._all_history, self._connectable_history = async_load_history_from_system( + self._bluetooth_adapters, self.storage + ) + self._cancel_logging_listener = self.hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_logging_changed + ) + seen: set[str] = set() + for address, service_info in itertools.chain( + self._connectable_history.items(), self._all_history.items() + ): + if address in seen: + continue + seen.add(address) + self._async_trigger_matching_discovery(service_info) - @hass_callback - def _async_remove_callback() -> None: - unavailable_callbacks[address].remove(callback) - if not unavailable_callbacks[address]: - del unavailable_callbacks[address] - - return _async_remove_callback - - @hass_callback def async_register_callback( self, callback: BluetoothCallback, @@ -656,7 +163,6 @@ class BluetoothManager: connectable = callback_matcher[CONNECTABLE] self._callback_index.add_callback_matcher(callback_matcher) - @hass_callback def _async_remove_callback() -> None: self._callback_index.remove_callback_matcher(callback_matcher) @@ -681,131 +187,45 @@ class BluetoothManager: return _async_remove_callback @hass_callback - def async_ble_device_from_address( - self, address: str, connectable: bool - ) -> BLEDevice | None: - """Return the BLEDevice if present.""" - histories = self._connectable_history if connectable else self._all_history - if history := histories.get(address): - return history.device - return None + def async_stop(self) -> None: + """Stop the Bluetooth integration at shutdown.""" + _LOGGER.debug("Stopping bluetooth manager") + self._async_save_scanner_histories() + super().async_stop() + if self._cancel_logging_listener: + self._cancel_logging_listener() + self._cancel_logging_listener = None - @hass_callback - def async_address_present(self, address: str, connectable: bool) -> bool: - """Return if the address is present.""" - histories = self._connectable_history if connectable else self._all_history - return address in histories + def _async_save_scanner_histories(self) -> None: + """Save the scanner histories.""" + for scanner in itertools.chain( + self._connectable_scanners, self._non_connectable_scanners + ): + self._async_save_scanner_history(scanner) - @hass_callback - def async_discovered_service_info( - self, connectable: bool - ) -> Iterable[BluetoothServiceInfoBleak]: - """Return all the discovered services info.""" - histories = self._connectable_history if connectable else self._all_history - return histories.values() - - @hass_callback - def async_last_service_info( - self, address: str, connectable: bool - ) -> BluetoothServiceInfoBleak | None: - """Return the last service info for an address.""" - histories = self._connectable_history if connectable else self._all_history - return histories.get(address) - - def _async_trigger_matching_discovery( - self, service_info: BluetoothServiceInfoBleak - ) -> None: - """Trigger discovery for matching domains.""" - for domain in self._integration_matcher.match_domains(service_info): - discovery_flow.async_create_flow( - self.hass, - domain, - {"source": config_entries.SOURCE_BLUETOOTH}, - service_info, + def _async_save_scanner_history(self, scanner: BaseHaScanner) -> None: + """Save the scanner history.""" + if isinstance(scanner, BaseHaRemoteScanner): + self.storage.async_set_advertisement_history( + scanner.source, scanner.serialize_discovered_devices() ) - @hass_callback - def async_rediscover_address(self, address: str) -> None: - """Trigger discovery of devices which have already been seen.""" - self._integration_matcher.async_clear_address(address) - if service_info := self._connectable_history.get(address): - self._async_trigger_matching_discovery(service_info) - return - if service_info := self._all_history.get(address): - self._async_trigger_matching_discovery(service_info) + def _async_unregister_scanner( + self, scanner: BaseHaScanner, unregister: CALLBACK_TYPE + ) -> None: + """Unregister a scanner.""" + unregister() + self._async_save_scanner_history(scanner) def async_register_scanner( self, scanner: BaseHaScanner, - connectable: bool, connection_slots: int | None = None, ) -> CALLBACK_TYPE: - """Register a new scanner.""" - _LOGGER.debug("Registering scanner %s", scanner.name) - if connectable: - scanners = self._connectable_scanners - else: - scanners = self._non_connectable_scanners + """Register a scanner.""" + if isinstance(scanner, BaseHaRemoteScanner): + if history := self.storage.async_get_advertisement_history(scanner.source): + scanner.restore_discovered_devices(history) - def _unregister_scanner() -> None: - _LOGGER.debug("Unregistering scanner %s", scanner.name) - self._advertisement_tracker.async_remove_source(scanner.source) - scanners.remove(scanner) - del self._sources[scanner.source] - if connection_slots: - self.slot_manager.remove_adapter(scanner.adapter) - - scanners.append(scanner) - self._sources[scanner.source] = scanner - if connection_slots: - self.slot_manager.register_adapter(scanner.adapter, connection_slots) - return _unregister_scanner - - @hass_callback - def async_register_bleak_callback( - self, callback: AdvertisementDataCallback, filters: dict[str, set[str]] - ) -> CALLBACK_TYPE: - """Register a callback.""" - callback_entry = (callback, filters) - self._bleak_callbacks.append(callback_entry) - - @hass_callback - def _remove_callback() -> None: - self._bleak_callbacks.remove(callback_entry) - - # Replay the history since otherwise we miss devices - # that were already discovered before the callback was registered - # or we are in passive mode - for history in self._connectable_history.values(): - _dispatch_bleak_callback( - callback, filters, history.device, history.advertisement - ) - - return _remove_callback - - @hass_callback - def async_release_connection_slot(self, device: BLEDevice) -> None: - """Release a connection slot.""" - self.slot_manager.release_slot(device) - - @hass_callback - def async_allocate_connection_slot(self, device: BLEDevice) -> bool: - """Allocate a connection slot.""" - return self.slot_manager.allocate_slot(device) - - @hass_callback - def async_get_learned_advertising_interval(self, address: str) -> float | None: - """Get the learned advertising interval for a MAC address.""" - return self._intervals.get(address) - - @hass_callback - def async_get_fallback_availability_interval(self, address: str) -> float | None: - """Get the fallback availability timeout for a MAC address.""" - return self._fallback_intervals.get(address) - - @hass_callback - def async_set_fallback_availability_interval( - self, address: str, interval: float - ) -> None: - """Override the fallback availability timeout for a MAC address.""" - self._fallback_intervals[address] = interval + unregister = super().async_register_scanner(scanner, connection_slots) + return partial(self._async_unregister_scanner, scanner, unregister) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index c39c28b13f7..c5dec12fe40 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,10 +15,11 @@ "quality_scale": "internal", "requirements": [ "bleak==0.21.1", - "bleak-retry-connector==3.3.0", - "bluetooth-adapters==0.16.1", + "bleak-retry-connector==3.4.0", + "bluetooth-adapters==0.16.2", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.15.0", - "dbus-fast==2.14.0" + "bluetooth-data-tools==1.19.0", + "dbus-fast==2.21.0", + "habluetooth==2.0.1" ] } diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index 1315d0a834a..827006fe19d 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -7,7 +7,7 @@ from functools import lru_cache import re from typing import TYPE_CHECKING, Final, Generic, TypedDict, TypeVar -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU from homeassistant.core import callback from homeassistant.loader import BluetoothMatcher, BluetoothMatcherOptional @@ -15,8 +15,6 @@ from homeassistant.loader import BluetoothMatcher, BluetoothMatcherOptional from .models import BluetoothCallback, BluetoothServiceInfoBleak if TYPE_CHECKING: - from collections.abc import MutableMapping - from bleak.backends.scanner import AdvertisementData @@ -97,10 +95,8 @@ class IntegrationMatcher: self._integration_matchers = integration_matchers # Some devices use a random address so we need to use # an LRU to avoid memory issues. - self._matched: MutableMapping[str, IntegrationMatchHistory] = LRU( - MAX_REMEMBER_ADDRESSES - ) - self._matched_connectable: MutableMapping[str, IntegrationMatchHistory] = LRU( + self._matched: LRU[str, IntegrationMatchHistory] = LRU(MAX_REMEMBER_ADDRESSES) + self._matched_connectable: LRU[str, IntegrationMatchHistory] = LRU( MAX_REMEMBER_ADDRESSES ) self._index = BluetoothMatcherIndex() diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 1856ccd5994..001a47767a1 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -2,38 +2,16 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass from enum import Enum -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING -from bleak import BaseBleakClient from home_assistant_bluetooth import BluetoothServiceInfoBleak -from homeassistant.util.dt import monotonic_time_coarse - if TYPE_CHECKING: - from .manager import BluetoothManager + from .manager import HomeAssistantBluetoothManager -MANAGER: BluetoothManager | None = None - -MONOTONIC_TIME: Final = monotonic_time_coarse - - -@dataclass(slots=True) -class HaBluetoothConnector: - """Data for how to connect a BLEDevice from a given scanner.""" - - client: type[BaseBleakClient] - source: str - can_connect: Callable[[], bool] - - -class BluetoothScanningMode(Enum): - """The mode of scanning for bluetooth devices.""" - - PASSIVE = "passive" - ACTIVE = "active" +MANAGER: HomeAssistantBluetoothManager | None = None BluetoothChange = Enum("BluetoothChange", "ADVERTISEMENT") diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 7dd39c14039..601f78d4c8d 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -7,6 +7,8 @@ from functools import cache import logging from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast +from habluetooth import BluetoothScanningMode + from homeassistant import config_entries from homeassistant.const import ( ATTR_CONNECTIONS, @@ -33,11 +35,7 @@ if TYPE_CHECKING: from homeassistant.helpers.entity_platform import AddEntitiesCallback - from .models import ( - BluetoothChange, - BluetoothScanningMode, - BluetoothServiceInfoBleak, - ) + from .models import BluetoothChange, BluetoothServiceInfoBleak STORAGE_KEY = "bluetooth.passive_update_processor" STORAGE_VERSION = 1 @@ -95,8 +93,11 @@ def deserialize_entity_description( descriptions_class: type[EntityDescription], data: dict[str, Any] ) -> EntityDescription: """Deserialize an entity description.""" + # pylint: disable=protected-access result: dict[str, Any] = {} - for field in cached_fields(descriptions_class): # type: ignore[arg-type] + if hasattr(descriptions_class, "_dataclass"): + descriptions_class = descriptions_class._dataclass + for field in cached_fields(descriptions_class): field_name = field.name # It would be nice if field.type returned the actual # type instead of a str so we could avoid writing this @@ -116,7 +117,7 @@ def serialize_entity_description(description: EntityDescription) -> dict[str, An as_dict = dataclasses.asdict(description) return { field.name: as_dict[field.name] - for field in cached_fields(type(description)) # type: ignore[arg-type] + for field in cached_fields(type(description)) if field.default != as_dict.get(field.name) } diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py deleted file mode 100644 index 896d9dc7958..00000000000 --- a/homeassistant/components/bluetooth/scanner.py +++ /dev/null @@ -1,386 +0,0 @@ -"""The bluetooth integration.""" -from __future__ import annotations - -import asyncio -from collections.abc import Callable -from datetime import datetime -import logging -import platform -from typing import Any - -import bleak -from bleak import BleakError -from bleak.assigned_numbers import AdvertisementDataType -from bleak.backends.bluezdbus.advertisement_monitor import OrPattern -from bleak.backends.bluezdbus.scanner import BlueZScannerArgs -from bleak.backends.device import BLEDevice -from bleak.backends.scanner import AdvertisementData, AdvertisementDataCallback -from bleak_retry_connector import restore_discoveries -from bluetooth_adapters import DEFAULT_ADDRESS -from dbus_fast import InvalidMessageError - -from homeassistant.core import HomeAssistant, callback as hass_callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.package import is_docker_env - -from .base_scanner import MONOTONIC_TIME, BaseHaScanner -from .const import ( - SCANNER_WATCHDOG_INTERVAL, - SCANNER_WATCHDOG_TIMEOUT, - SOURCE_LOCAL, - START_TIMEOUT, -) -from .models import BluetoothScanningMode, BluetoothServiceInfoBleak -from .util import async_reset_adapter - -OriginalBleakScanner = bleak.BleakScanner - -# or_patterns is a workaround for the fact that passive scanning -# needs at least one matcher to be set. The below matcher -# will match all devices. -PASSIVE_SCANNER_ARGS = BlueZScannerArgs( - or_patterns=[ - OrPattern(0, AdvertisementDataType.FLAGS, b"\x06"), - OrPattern(0, AdvertisementDataType.FLAGS, b"\x1a"), - ] -) -_LOGGER = logging.getLogger(__name__) - - -# If the adapter is in a stuck state the following errors are raised: -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 -# with org.freedesktop.DBus.Error.UnknownObject -WAIT_FOR_ADAPTER_TO_INIT_ERRORS = ["org.freedesktop.DBus.Error.UnknownObject"] -ADAPTER_INIT_TIME = 1.5 - -START_ATTEMPTS = 3 - -SCANNING_MODE_TO_BLEAK = { - BluetoothScanningMode.ACTIVE: "active", - BluetoothScanningMode.PASSIVE: "passive", -} - -# The minimum number of seconds to know -# the adapter has not had advertisements -# and we already tried to restart the scanner -# without success when the first time the watch -# dog hit the failure path. -SCANNER_WATCHDOG_MULTIPLE = ( - SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds() -) - - -class ScannerStartError(HomeAssistantError): - """Error to indicate that the scanner failed to start.""" - - -def create_bleak_scanner( - detection_callback: AdvertisementDataCallback, - scanning_mode: BluetoothScanningMode, - adapter: str | None, -) -> bleak.BleakScanner: - """Create a Bleak scanner.""" - scanner_kwargs: dict[str, Any] = { - "detection_callback": detection_callback, - "scanning_mode": SCANNING_MODE_TO_BLEAK[scanning_mode], - } - system = platform.system() - if system == "Linux": - # Only Linux supports multiple adapters - if adapter: - scanner_kwargs["adapter"] = adapter - if scanning_mode == BluetoothScanningMode.PASSIVE: - scanner_kwargs["bluez"] = PASSIVE_SCANNER_ARGS - elif system == "Darwin": - # We want mac address on macOS - scanner_kwargs["cb"] = {"use_bdaddr": True} - _LOGGER.debug("Initializing bluetooth scanner with %s", scanner_kwargs) - - try: - return OriginalBleakScanner(**scanner_kwargs) - except (FileNotFoundError, BleakError) as ex: - raise RuntimeError(f"Failed to initialize Bluetooth: {ex}") from ex - - -class HaScanner(BaseHaScanner): - """Operate and automatically recover a BleakScanner. - - Multiple BleakScanner can be used at the same time - if there are multiple adapters. This is only useful - if the adapters are not located physically next to each other. - - Example use cases are usbip, a long extension cable, usb to bluetooth - over ethernet, usb over ethernet, etc. - """ - - scanner: bleak.BleakScanner - - def __init__( - self, - hass: HomeAssistant, - mode: BluetoothScanningMode, - adapter: str, - address: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], - ) -> None: - """Init bluetooth discovery.""" - self.mac_address = address - source = address if address != DEFAULT_ADDRESS else adapter or SOURCE_LOCAL - super().__init__(hass, source, adapter) - self.connectable = True - self.mode = mode - self._start_stop_lock = asyncio.Lock() - self._new_info_callback = new_info_callback - self.scanning = False - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - return self.scanner.discovered_devices - - @property - def discovered_devices_and_advertisement_data( - self, - ) -> dict[str, tuple[BLEDevice, AdvertisementData]]: - """Return a list of discovered devices and advertisement data.""" - return self.scanner.discovered_devices_and_advertisement_data - - @hass_callback - def async_setup(self) -> None: - """Set up the scanner.""" - self.scanner = create_bleak_scanner( - self._async_detection_callback, self.mode, self.adapter - ) - - async def async_diagnostics(self) -> dict[str, Any]: - """Return diagnostic information about the scanner.""" - base_diag = await super().async_diagnostics() - return base_diag | { - "adapter": self.adapter, - } - - @hass_callback - def _async_detection_callback( - self, - device: BLEDevice, - advertisement_data: AdvertisementData, - ) -> None: - """Call the callback when an advertisement is received. - - Currently this is used to feed the callbacks into the - central manager. - """ - callback_time = MONOTONIC_TIME() - if ( - advertisement_data.local_name - or advertisement_data.manufacturer_data - or advertisement_data.service_data - or advertisement_data.service_uuids - ): - # Don't count empty advertisements - # as the adapter is in a failure - # state if all the data is empty. - self._last_detection = callback_time - self._new_info_callback( - BluetoothServiceInfoBleak( - name=advertisement_data.local_name or device.name or device.address, - address=device.address, - rssi=advertisement_data.rssi, - manufacturer_data=advertisement_data.manufacturer_data, - service_data=advertisement_data.service_data, - service_uuids=advertisement_data.service_uuids, - source=self.source, - device=device, - advertisement=advertisement_data, - connectable=True, - time=callback_time, - ) - ) - - async def async_start(self) -> None: - """Start bluetooth scanner.""" - async with self._start_stop_lock: - await self._async_start() - - async def _async_start(self) -> None: - """Start bluetooth scanner under the lock.""" - for attempt in range(START_ATTEMPTS): - _LOGGER.debug( - "%s: Starting bluetooth discovery attempt: (%s/%s)", - self.name, - attempt + 1, - START_ATTEMPTS, - ) - try: - async with asyncio.timeout(START_TIMEOUT): - await self.scanner.start() # type: ignore[no-untyped-call] - except InvalidMessageError as ex: - _LOGGER.debug( - "%s: Invalid DBus message received: %s", - self.name, - ex, - exc_info=True, - ) - raise ScannerStartError( - f"{self.name}: Invalid DBus message received: {ex}; " - "try restarting `dbus`" - ) from ex - except BrokenPipeError as ex: - _LOGGER.debug( - "%s: DBus connection broken: %s", self.name, ex, exc_info=True - ) - if is_docker_env(): - raise ScannerStartError( - f"{self.name}: DBus connection broken: {ex}; try restarting " - "`bluetooth`, `dbus`, and finally the docker container" - ) from ex - raise ScannerStartError( - f"{self.name}: DBus connection broken: {ex}; try restarting " - "`bluetooth` and `dbus`" - ) from ex - except FileNotFoundError as ex: - _LOGGER.debug( - "%s: FileNotFoundError while starting bluetooth: %s", - self.name, - ex, - exc_info=True, - ) - if is_docker_env(): - raise ScannerStartError( - f"{self.name}: DBus service not found; docker config may " - "be missing `-v /run/dbus:/run/dbus:ro`: {ex}" - ) from ex - raise ScannerStartError( - f"{self.name}: DBus service not found; make sure the DBus socket " - f"is available to Home Assistant: {ex}" - ) from ex - except asyncio.TimeoutError as ex: - if attempt == 0: - await self._async_reset_adapter() - continue - raise ScannerStartError( - f"{self.name}: Timed out starting Bluetooth after" - f" {START_TIMEOUT} seconds" - ) from ex - except BleakError as ex: - error_str = str(ex) - if attempt == 0: - if any( - needs_reset_error in error_str - for needs_reset_error in NEED_RESET_ERRORS - ): - await self._async_reset_adapter() - continue - if attempt != START_ATTEMPTS - 1: - # If we are not out of retry attempts, and the - # adapter is still initializing, wait a bit and try again. - if any( - wait_error in error_str - for wait_error in WAIT_FOR_ADAPTER_TO_INIT_ERRORS - ): - _LOGGER.debug( - "%s: Waiting for adapter to initialize; attempt (%s/%s)", - self.name, - attempt + 1, - START_ATTEMPTS, - ) - await asyncio.sleep(ADAPTER_INIT_TIME) - continue - - _LOGGER.debug( - "%s: BleakError while starting bluetooth; attempt: (%s/%s): %s", - self.name, - attempt + 1, - START_ATTEMPTS, - ex, - exc_info=True, - ) - raise ScannerStartError( - f"{self.name}: Failed to start Bluetooth: {ex}" - ) from ex - - # Everything is fine, break out of the loop - break - - self.scanning = True - self._async_setup_scanner_watchdog() - await restore_discoveries(self.scanner, self.adapter) - - @hass_callback - def _async_scanner_watchdog(self, now: datetime) -> None: - """Check if the scanner is running.""" - if not self._async_watchdog_triggered(): - return - if self._start_stop_lock.locked(): - _LOGGER.debug( - "%s: Scanner is already restarting, deferring restart", - self.name, - ) - return - _LOGGER.info( - "%s: Bluetooth scanner has gone quiet for %ss, restarting", - self.name, - SCANNER_WATCHDOG_TIMEOUT, - ) - # Immediately mark the scanner as not scanning - # since the restart task will have to wait for the lock - self.scanning = False - self.hass.async_create_task(self._async_restart_scanner()) - - async def _async_restart_scanner(self) -> None: - """Restart the scanner.""" - async with self._start_stop_lock: - time_since_last_detection = MONOTONIC_TIME() - self._last_detection - # Stop the scanner but not the watchdog - # since we want to try again later if it's still quiet - await self._async_stop_scanner() - # If there have not been any valid advertisements, - # or the watchdog has hit the failure path multiple times, - # do the reset. - if ( - self._start_time == self._last_detection - or time_since_last_detection > SCANNER_WATCHDOG_MULTIPLE - ): - await self._async_reset_adapter() - try: - await self._async_start() - except ScannerStartError as ex: - _LOGGER.exception( - "%s: Failed to restart Bluetooth scanner: %s", - self.name, - ex, - ) - - async def _async_reset_adapter(self) -> None: - """Reset the adapter.""" - # There is currently nothing the user can do to fix this - # so we log at debug level. If we later come up with a repair - # strategy, we will change this to raise a repair issue as well. - _LOGGER.debug("%s: adapter stopped responding; executing reset", self.name) - result = await async_reset_adapter(self.adapter, self.mac_address) - _LOGGER.debug("%s: adapter reset result: %s", self.name, result) - - async def async_stop(self) -> None: - """Stop bluetooth scanner.""" - async with self._start_stop_lock: - self._async_stop_scanner_watchdog() - await self._async_stop_scanner() - - async def _async_stop_scanner(self) -> None: - """Stop bluetooth discovery under the lock.""" - self.scanning = False - _LOGGER.debug("%s: Stopping bluetooth discovery", self.name) - try: - await self.scanner.stop() # type: ignore[no-untyped-call] - except BleakError as ex: - # This is not fatal, and they may want to reload - # the config entry to restart the scanner if they - # change the bluetooth dongle. - _LOGGER.error("%s: Error stopping scanner: %s", self.name, ex) diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 295e84d4481..2d495a0659c 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -4,6 +4,8 @@ from __future__ import annotations from abc import ABC, abstractmethod import logging +from habluetooth import BluetoothScanningMode + from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from .api import ( @@ -13,7 +15,7 @@ from .api import ( async_track_unavailable, ) from .match import BluetoothCallbackMatcher -from .models import BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak +from .models import BluetoothChange, BluetoothServiceInfoBleak class BasePassiveBluetoothCoordinator(ABC): diff --git a/homeassistant/components/bluetooth/usage.py b/homeassistant/components/bluetooth/usage.py deleted file mode 100644 index d89f0b5b684..00000000000 --- a/homeassistant/components/bluetooth/usage.py +++ /dev/null @@ -1,51 +0,0 @@ -"""bluetooth usage utility to handle multiple instances.""" - -from __future__ import annotations - -import bleak -from bleak.backends.service import BleakGATTServiceCollection -import bleak_retry_connector - -from .wrappers import HaBleakClientWrapper, HaBleakScannerWrapper - -ORIGINAL_BLEAK_SCANNER = bleak.BleakScanner -ORIGINAL_BLEAK_CLIENT = bleak.BleakClient -ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE = ( - bleak_retry_connector.BleakClientWithServiceCache -) -ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT = bleak_retry_connector.BleakClient - - -def install_multiple_bleak_catcher() -> None: - """Wrap the bleak classes to return the shared instance. - - In case multiple instances are detected. - """ - bleak.BleakScanner = HaBleakScannerWrapper # type: ignore[misc, assignment] - bleak.BleakClient = HaBleakClientWrapper # type: ignore[misc] - bleak_retry_connector.BleakClientWithServiceCache = HaBleakClientWithServiceCache # type: ignore[misc,assignment] # noqa: E501 - bleak_retry_connector.BleakClient = HaBleakClientWrapper # type: ignore[misc] # noqa: E501 - - -def uninstall_multiple_bleak_catcher() -> None: - """Unwrap the bleak classes.""" - bleak.BleakScanner = ORIGINAL_BLEAK_SCANNER # type: ignore[misc] - bleak.BleakClient = ORIGINAL_BLEAK_CLIENT # type: ignore[misc] - bleak_retry_connector.BleakClientWithServiceCache = ( # type: ignore[misc] - ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT_WITH_SERVICE_CACHE - ) - bleak_retry_connector.BleakClient = ( # type: ignore[misc] - ORIGINAL_BLEAK_RETRY_CONNECTOR_CLIENT - ) - - -class HaBleakClientWithServiceCache(HaBleakClientWrapper): - """A BleakClient that implements service caching.""" - - def set_cached_services(self, services: BleakGATTServiceCollection | None) -> None: - """Set the cached services. - - No longer used since bleak 0.17+ has service caching built-in. - - This was only kept for backwards compatibility. - """ diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index e78eb51a38c..d531e46f911 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -2,10 +2,9 @@ from __future__ import annotations from bluetooth_adapters import BluetoothAdapters -from bluetooth_auto_recovery import recover_adapter +from bluetooth_data_tools import monotonic_time_coarse from homeassistant.core import callback -from homeassistant.util.dt import monotonic_time_coarse from .models import BluetoothServiceInfoBleak from .storage import BluetoothStorage @@ -69,11 +68,3 @@ def async_load_history_from_system( connectable_loaded_history[address] = service_info return all_loaded_history, connectable_loaded_history - - -async def async_reset_adapter(adapter: str | None, mac_address: str) -> bool | None: - """Reset the adapter.""" - if adapter and adapter.startswith("hci"): - adapter_id = int(adapter[3:]) - return await recover_adapter(adapter_id, mac_address) - return False diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py deleted file mode 100644 index 9de020f163e..00000000000 --- a/homeassistant/components/bluetooth/wrappers.py +++ /dev/null @@ -1,392 +0,0 @@ -"""Bleak wrappers for bluetooth.""" -from __future__ import annotations - -import asyncio -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 ( - AdvertisementData, - AdvertisementDataCallback, - BaseBleakScanner, -) -from bleak_retry_connector import ( - NO_RSSI_VALUE, - ble_device_description, - clear_cache, - device_source, -) - -from homeassistant.core import CALLBACK_TYPE, callback as hass_callback -from homeassistant.helpers.frame import report - -from . import models -from .base_scanner import BaseHaScanner, BluetoothScannerDevice - -FILTER_UUIDS: Final = "UUIDs" -_LOGGER = logging.getLogger(__name__) - - -if TYPE_CHECKING: - from .manager import BluetoothManager - - -@dataclass(slots=True) -class _HaWrappedBleakBackend: - """Wrap bleak backend to make it usable by Home Assistant.""" - - device: BLEDevice - scanner: BaseHaScanner - client: type[BaseBleakClient] - source: str | None - - -class HaBleakScannerWrapper(BaseBleakScanner): - """A wrapper that uses the single instance.""" - - def __init__( - self, - *args: Any, - detection_callback: AdvertisementDataCallback | None = None, - service_uuids: list[str] | None = None, - **kwargs: Any, - ) -> None: - """Initialize the BleakScanner.""" - 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 [], - **kwargs, - } - self._map_filters(*args, **remapped_kwargs) - super().__init__( - detection_callback=detection_callback, service_uuids=service_uuids or [] - ) - - @classmethod - async def discover(cls, timeout: float = 5.0, **kwargs: Any) -> list[BLEDevice]: - """Discover devices.""" - assert models.MANAGER is not None - return list(models.MANAGER.async_discovered_devices(True)) - - async def stop(self, *args: Any, **kwargs: Any) -> None: - """Stop scanning for devices.""" - - async def start(self, *args: Any, **kwargs: Any) -> None: - """Start scanning for devices.""" - - def _map_filters(self, *args: Any, **kwargs: Any) -> bool: - """Map the filters.""" - mapped_filters = {} - if filters := kwargs.get("filters"): - if filter_uuids := filters.get(FILTER_UUIDS): - mapped_filters[FILTER_UUIDS] = set(filter_uuids) - else: - _LOGGER.warning("Only %s filters are supported", FILTER_UUIDS) - if service_uuids := kwargs.get("service_uuids"): - mapped_filters[FILTER_UUIDS] = set(service_uuids) - if mapped_filters == self._mapped_filters: - return False - self._mapped_filters = mapped_filters - return True - - def set_scanning_filter(self, *args: Any, **kwargs: Any) -> None: - """Set the filters to use.""" - if self._map_filters(*args, **kwargs): - self._setup_detection_callback() - - def _cancel_callback(self) -> None: - """Cancel callback.""" - if self._detection_cancel: - self._detection_cancel() - self._detection_cancel = None - - @property - def discovered_devices(self) -> list[BLEDevice]: - """Return a list of discovered devices.""" - assert models.MANAGER is not None - return list(models.MANAGER.async_discovered_devices(True)) - - def register_detection_callback( - self, callback: AdvertisementDataCallback | None - ) -> Callable[[], None]: - """Register a detection callback. - - The callback is called when a device is discovered or has a property changed. - - This method takes the callback and registers it with the long running scanner. - """ - self._advertisement_data_callback = callback - self._setup_detection_callback() - assert self._detection_cancel is not None - return self._detection_cancel - - def _setup_detection_callback(self) -> None: - """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 - - 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( - detection_callback, self._mapped_filters - ) - - def __del__(self) -> None: - """Delete the BleakScanner.""" - if self._detection_cancel: - # Nothing to do if event loop is already closed - with contextlib.suppress(RuntimeError): - asyncio.get_running_loop().call_soon_threadsafe(self._detection_cancel) - - -def _rssi_sorter_with_connection_failure_penalty( - device: BluetoothScannerDevice, - connection_failure_count: dict[BaseHaScanner, int], - rssi_diff: int, -) -> float: - """Get a sorted list of scanner, device, advertisement data. - - Adjusting for previous connection failures. - - When a connection fails, we want to try the next best adapter so we - apply a penalty to the RSSI value to make it less likely to be chosen - for every previous connection failure. - - We use the 51% of the RSSI difference between the first and second - best adapter as the penalty. This ensures we will always try the - best adapter twice before moving on to the next best adapter since - the first failure may be a transient service resolution issue. - """ - base_rssi = device.advertisement.rssi or NO_RSSI_VALUE - if connect_failures := connection_failure_count.get(device.scanner): - if connect_failures > 1 and not rssi_diff: - rssi_diff = 1 - return base_rssi - (rssi_diff * connect_failures * 0.51) - return base_rssi - - -class HaBleakClientWrapper(BleakClient): - """Wrap the BleakClient to ensure it does not shutdown our scanner. - - If an address is passed into BleakClient instead of a BLEDevice, - bleak will quietly start a new scanner under the hood to resolve - the address. This can cause a conflict with our scanner. We need - to handle translating the address to the BLEDevice in this case - to avoid the whole stack from getting stuck in an in progress state - when an integration does this. - """ - - def __init__( # pylint: disable=super-init-not-called - self, - address_or_ble_device: str | BLEDevice, - disconnected_callback: Callable[[BleakClient], None] | None = None, - *args: Any, - timeout: float = 10.0, - **kwargs: Any, - ) -> None: - """Initialize the BleakClient.""" - if isinstance(address_or_ble_device, BLEDevice): - self.__address = address_or_ble_device.address - else: - report( - "attempted to call BleakClient with an address instead of a BLEDevice", - exclude_integrations={"bluetooth"}, - error_if_core=False, - ) - self.__address = address_or_ble_device - self.__disconnected_callback = disconnected_callback - self.__timeout = timeout - self.__connect_failures: dict[BaseHaScanner, int] = {} - self._backend: BaseBleakClient | None = None # type: ignore[assignment] - - @property - def is_connected(self) -> bool: - """Return True if the client is connected to a device.""" - return self._backend is not None and self._backend.is_connected - - async def clear_cache(self) -> bool: - """Clear the GATT cache.""" - if self._backend is not None and hasattr(self._backend, "clear_cache"): - return await self._backend.clear_cache() # type: ignore[no-any-return] - return await clear_cache(self.__address) - - def set_disconnected_callback( - self, - callback: Callable[[BleakClient], None] | None, - **kwargs: Any, - ) -> None: - """Set the disconnect callback.""" - self.__disconnected_callback = callback - if self._backend: - self._backend.set_disconnected_callback( - self._make_disconnected_callback(callback), - **kwargs, - ) - - def _make_disconnected_callback( - self, callback: Callable[[BleakClient], None] | None - ) -> Callable[[], None] | None: - """Make the disconnected callback. - - https://github.com/hbldh/bleak/pull/1256 - The disconnected callback needs to get the top level - BleakClientWrapper instance, not the backend instance. - - The signature of the callback for the backend is: - Callable[[], None] - - To make this work we need to wrap the callback in a partial - that passes the BleakClientWrapper instance as the first - argument. - """ - return None if callback is None else partial(callback, self) - - async def connect(self, **kwargs: Any) -> bool: - """Connect to the specified GATT server.""" - assert models.MANAGER is not None - manager = models.MANAGER - if manager.shutdown: - raise BleakError("Bluetooth is already shutdown") - if debug_logging := _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("%s: Looking for backend to connect", self.__address) - wrapped_backend = self._async_get_best_available_backend_and_device(manager) - device = wrapped_backend.device - scanner = wrapped_backend.scanner - self._backend = wrapped_backend.client( - device, - disconnected_callback=self._make_disconnected_callback( - self.__disconnected_callback - ), - timeout=self.__timeout, - hass=manager.hass, - ) - if debug_logging: - # Only lookup the description if we are going to log it - description = ble_device_description(device) - _, adv = scanner.discovered_devices_and_advertisement_data[device.address] - rssi = adv.rssi - _LOGGER.debug( - "%s: Connecting via %s (last rssi: %s)", description, scanner.name, rssi - ) - connected = None - try: - connected = await super().connect(**kwargs) - finally: - # If we failed to connect and its a local adapter (no source) - # we release the connection slot - if not connected: - self.__connect_failures[scanner] = ( - self.__connect_failures.get(scanner, 0) + 1 - ) - if not wrapped_backend.source: - manager.async_release_connection_slot(device) - - if debug_logging: - _LOGGER.debug( - "%s: Connected via %s (last rssi: %s)", description, scanner.name, rssi - ) - return connected - - @hass_callback - def _async_get_backend_for_ble_device( - self, manager: BluetoothManager, scanner: BaseHaScanner, ble_device: BLEDevice - ) -> _HaWrappedBleakBackend | None: - """Get the backend for a BLEDevice.""" - if not (source := device_source(ble_device)): - # If client is not defined in details - # its the client for this platform - if not manager.async_allocate_connection_slot(ble_device): - return None - cls = get_platform_client_backend_type() - return _HaWrappedBleakBackend(ble_device, scanner, cls, source) - - # Make sure the backend can connect to the device - # as some backends have connection limits - if not scanner.connector or not scanner.connector.can_connect(): - return None - - return _HaWrappedBleakBackend( - ble_device, scanner, scanner.connector.client, source - ) - - @hass_callback - def _async_get_best_available_backend_and_device( - self, manager: BluetoothManager - ) -> _HaWrappedBleakBackend: - """Get a best available backend and device for the given address. - - This method will return the backend with the best rssi - that has a free connection slot. - """ - address = self.__address - devices = manager.async_scanner_devices_by_address(self.__address, True) - sorted_devices = sorted( - devices, - key=lambda device: device.advertisement.rssi or NO_RSSI_VALUE, - reverse=True, - ) - - # If we have connection failures we adjust the rssi sorting - # to prefer the adapter/scanner with the less failures so - # we don't keep trying to connect with an adapter - # that is failing - if self.__connect_failures and len(sorted_devices) > 1: - # We use the rssi diff between to the top two - # to adjust the rssi sorter so that each failure - # will reduce the rssi sorter by the diff amount - rssi_diff = ( - sorted_devices[0].advertisement.rssi - - sorted_devices[1].advertisement.rssi - ) - adjusted_rssi_sorter = partial( - _rssi_sorter_with_connection_failure_penalty, - connection_failure_count=self.__connect_failures, - rssi_diff=rssi_diff, - ) - sorted_devices = sorted( - devices, - key=adjusted_rssi_sorter, - reverse=True, - ) - - for device in sorted_devices: - if backend := self._async_get_backend_for_ble_device( - manager, device.scanner, device.ble_device - ): - return backend - - raise BleakError( - "No backend with an available connection slot that can reach address" - f" {address} was found" - ) - - async def disconnect(self) -> bool: - """Disconnect from the device.""" - if self._backend is None: - return True - return await self._backend.disconnect() diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 0e3750de085..29c4d61e9f7 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -109,14 +109,14 @@ def _format_cbs_report( return result -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[MyBMWVehicle], bool] -@dataclass +@dataclass(frozen=True) class BMWBinarySensorEntityDescription( BinarySensorEntityDescription, BMWRequiredKeysMixin ): diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index c3f066610a9..f2a123fe4a8 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -25,14 +25,14 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" remote_function: Callable[[MyBMWVehicle], Coroutine[Any, Any, RemoteServiceStatus]] -@dataclass +@dataclass(frozen=True) class BMWButtonEntityDescription(ButtonEntityDescription, BMWRequiredKeysMixin): """Class describing BMW button entities.""" diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 2634c6069c9..4e811d48647 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -32,8 +32,6 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): entry.data[CONF_PASSWORD], get_region_from_name(entry.data[CONF_REGION]), observer_position=GPSPosition(hass.config.latitude, hass.config.longitude), - # Force metric system as BMW API apparently only returns metric values now - use_metric_units=True, ) self.read_only = entry.options[CONF_READ_ONLY] self._entry = entry diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py index f37f7627140..0ed732e1dcb 100644 --- a/homeassistant/components/bmw_connected_drive/number.py +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -26,7 +26,7 @@ from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" @@ -34,7 +34,7 @@ class BMWRequiredKeysMixin: remote_service: Callable[[MyBMWVehicle, float | int], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin): """Describes BMW number entity.""" diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 1d8b736f4dd..8823c6552cc 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -22,7 +22,7 @@ from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" @@ -30,7 +30,7 @@ class BMWRequiredKeysMixin: remote_service: Callable[[MyBMWVehicle, str], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin): """Describes BMW sensor entity.""" diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 62854badb20..d486c41ae56 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -28,7 +28,7 @@ from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWSensorEntityDescription(SensorEntityDescription): """Describes BMW sensor entity.""" diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py index 298338dc9fa..e4ce0ba81ff 100644 --- a/homeassistant/components/bmw_connected_drive/switch.py +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -22,7 +22,7 @@ from .coordinator import BMWDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BMWRequiredKeysMixin: """Mixin for required keys.""" @@ -31,7 +31,7 @@ class BMWRequiredKeysMixin: remote_service_off: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class BMWSwitchEntityDescription(SwitchEntityDescription, BMWRequiredKeysMixin): """Describes BMW switch entity.""" diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 1109cf0d311..273ef837f6e 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -21,7 +21,7 @@ from .utils import BondDevice, BondHub STEP_SIZE = 10 -@dataclass +@dataclass(frozen=True) class BondButtonEntityDescriptionMixin: """Mixin to describe a Bond Button entity.""" @@ -29,7 +29,7 @@ class BondButtonEntityDescriptionMixin: argument: int | None -@dataclass +@dataclass(frozen=True) class BondButtonEntityDescription( ButtonEntityDescription, BondButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 3cb81ba40b4..465c4b8966b 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -21,10 +21,10 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DOMAIN, SERVICE_SET_FAN_SPEED_TRACKED_STATE from .entity import BondEntity diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index 25af0628780..03d3ba2f6a9 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -29,7 +29,7 @@ from .const import DATA_SESSION, DOMAIN from .entity import SHCEntity -@dataclass +@dataclass(frozen=True) class SHCSwitchRequiredKeysMixin: """Mixin for SHC switch required keys.""" @@ -38,7 +38,7 @@ class SHCSwitchRequiredKeysMixin: should_poll: bool -@dataclass +@dataclass(frozen=True) class SHCSwitchEntityDescription( SwitchEntityDescription, SHCSwitchRequiredKeysMixin, diff --git a/homeassistant/components/braviatv/button.py b/homeassistant/components/braviatv/button.py index 1f6c9961c51..eb3d2d8797f 100644 --- a/homeassistant/components/braviatv/button.py +++ b/homeassistant/components/braviatv/button.py @@ -19,14 +19,14 @@ from .coordinator import BraviaTVCoordinator from .entity import BraviaTVEntity -@dataclass +@dataclass(frozen=True) class BraviaTVButtonDescriptionMixin: """Mixin to describe a Bravia TV Button entity.""" press_action: Callable[[BraviaTVCoordinator], Coroutine] -@dataclass +@dataclass(frozen=True) class BraviaTVButtonDescription( ButtonEntityDescription, BraviaTVButtonDescriptionMixin ): diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index 3fb6e6b3b40..fd72203b249 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN +from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import instance_id from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -22,7 +22,6 @@ from .const import ( ATTR_CID, ATTR_MAC, ATTR_MODEL, - CONF_CLIENT_ID, CONF_NICKNAME, CONF_USE_PSK, DOMAIN, diff --git a/homeassistant/components/braviatv/const.py b/homeassistant/components/braviatv/const.py index 34b621802f9..aff02aa9e8b 100644 --- a/homeassistant/components/braviatv/const.py +++ b/homeassistant/components/braviatv/const.py @@ -9,7 +9,6 @@ ATTR_MAC: Final = "macAddr" ATTR_MANUFACTURER: Final = "Sony" ATTR_MODEL: Final = "model" -CONF_CLIENT_ID: Final = "client_id" CONF_NICKNAME: Final = "nickname" CONF_USE_PSK: Final = "use_psk" diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index 20b30d1dd11..59219a34eb7 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -19,14 +19,13 @@ from pybravia import ( ) from homeassistant.components.media_player import MediaType -from homeassistant.const import CONF_PIN +from homeassistant.const import CONF_CLIENT_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - CONF_CLIENT_ID, CONF_NICKNAME, CONF_USE_PSK, DOMAIN, @@ -43,7 +42,7 @@ SCAN_INTERVAL: Final = timedelta(seconds=10) def catch_braviatv_errors( - func: Callable[Concatenate[_BraviaTVCoordinatorT, _P], Awaitable[None]] + func: Callable[Concatenate[_BraviaTVCoordinatorT, _P], Awaitable[None]], ) -> Callable[Concatenate[_BraviaTVCoordinatorT, _P], Coroutine[Any, Any, None]]: """Catch Bravia errors.""" diff --git a/homeassistant/components/braviatv/diagnostics.py b/homeassistant/components/braviatv/diagnostics.py new file mode 100644 index 00000000000..f1822b545e9 --- /dev/null +++ b/homeassistant/components/braviatv/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for BraviaTV.""" +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC, CONF_PIN +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import BraviaTVCoordinator + +TO_REDACT = {CONF_MAC, CONF_PIN, "macAddr"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: BraviaTVCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + device_info = await coordinator.client.get_system_info() + + diagnostics_data = { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT), + "device_info": async_redact_data(device_info, TO_REDACT), + } + + return diagnostics_data diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 0f8f94c73c4..27ac97a27dc 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -4,23 +4,18 @@ from __future__ import annotations from asyncio import timeout from datetime import timedelta import logging -import sys -from typing import Any + +from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TYPE, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DATA_CONFIG_ENTRY, DOMAIN, SNMP from .utils import get_snmp_engine -if sys.version_info < (3, 12): - from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError -else: - BrotherSensors = Any - PLATFORMS = [Platform.SENSOR] SCAN_INTERVAL = timedelta(seconds=30) @@ -30,10 +25,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Brother from a config entry.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "Brother Printer is not supported on Python 3.12. Please use Python 3.11." - ) host = entry.data[CONF_HOST] printer_type = entry.data[CONF_TYPE] diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index cba44b68c6a..06b8574dbb4 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "quality_scale": "platinum", - "requirements": ["brother==2.3.0"], + "requirements": ["brother==3.0.0"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index e9554d84207..27e4b7fd715 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -35,14 +35,14 @@ UNIT_PAGES = "p" _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class BrotherSensorRequiredKeysMixin: """Class for Brother entity required keys.""" value: Callable[[BrotherSensors], StateType | datetime] -@dataclass +@dataclass(frozen=True) class BrotherSensorEntityDescription( SensorEntityDescription, BrotherSensorRequiredKeysMixin ): diff --git a/homeassistant/components/brother/utils.py b/homeassistant/components/brother/utils.py index cd472b9b754..47b7ae31a67 100644 --- a/homeassistant/components/brother/utils.py +++ b/homeassistant/components/brother/utils.py @@ -2,7 +2,9 @@ from __future__ import annotations import logging -import sys + +import pysnmp.hlapi.asyncio as hlapi +from pysnmp.hlapi.asyncio.cmdgen import lcd from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback @@ -10,10 +12,6 @@ from homeassistant.helpers import singleton from .const import DOMAIN, SNMP -if sys.version_info < (3, 12): - import pysnmp.hlapi.asyncio as hlapi - from pysnmp.hlapi.asyncio.cmdgen import lcd - _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/brottsplatskartan/config_flow.py b/homeassistant/components/brottsplatskartan/config_flow.py index ac9a764179e..39c7421fa92 100644 --- a/homeassistant/components/brottsplatskartan/config_flow.py +++ b/homeassistant/components/brottsplatskartan/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any import uuid +from brottsplatskartan import AREAS import voluptuous as vol from homeassistant import config_entries @@ -11,7 +12,7 @@ from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector -from .const import AREAS, CONF_APP_ID, CONF_AREA, DEFAULT_NAME, DOMAIN +from .const import CONF_APP_ID, CONF_AREA, DEFAULT_NAME, DOMAIN DATA_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/brottsplatskartan/const.py b/homeassistant/components/brottsplatskartan/const.py index b53a39755a6..94b4a7e7280 100644 --- a/homeassistant/components/brottsplatskartan/const.py +++ b/homeassistant/components/brottsplatskartan/const.py @@ -12,27 +12,3 @@ LOGGER = logging.getLogger(__package__) CONF_AREA = "area" CONF_APP_ID = "app_id" DEFAULT_NAME = "Brottsplatskartan" - -AREAS = [ - "Blekinge län", - "Dalarnas län", - "Gotlands län", - "Gävleborgs län", - "Hallands län", - "Jämtlands län", - "Jönköpings län", - "Kalmar län", - "Kronobergs län", - "Norrbottens län", - "Skåne län", - "Stockholms län", - "Södermanlands län", - "Uppsala län", - "Värmlands län", - "Västerbottens län", - "Västernorrlands län", - "Västmanlands län", - "Västra Götalands län", - "Örebro län", - "Östergötlands län", -] diff --git a/homeassistant/components/brottsplatskartan/manifest.json b/homeassistant/components/brottsplatskartan/manifest.json index 14c4a5e39c2..0a386094bae 100644 --- a/homeassistant/components/brottsplatskartan/manifest.json +++ b/homeassistant/components/brottsplatskartan/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/brottsplatskartan", "iot_class": "cloud_polling", "loggers": ["brottsplatskartan"], - "requirements": ["brottsplatskartan==0.0.1"] + "requirements": ["brottsplatskartan==1.0.5"] } diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index df17832f695..b30b31be985 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections import defaultdict from datetime import timedelta +from typing import Literal from brottsplatskartan import ATTRIBUTION, BrottsplatsKartan @@ -29,9 +30,11 @@ async def async_setup_entry( app = entry.data[CONF_APP_ID] name = entry.title - bpk = BrottsplatsKartan(app=app, area=area, latitude=latitude, longitude=longitude) + bpk = BrottsplatsKartan( + app=app, areas=[area] if area else None, latitude=latitude, longitude=longitude + ) - async_add_entities([BrottsplatskartanSensor(bpk, name, entry.entry_id)], True) + async_add_entities([BrottsplatskartanSensor(bpk, name, entry.entry_id, area)], True) class BrottsplatskartanSensor(SensorEntity): @@ -41,9 +44,12 @@ class BrottsplatskartanSensor(SensorEntity): _attr_has_entity_name = True _attr_name = None - def __init__(self, bpk: BrottsplatsKartan, name: str, entry_id: str) -> None: + def __init__( + self, bpk: BrottsplatsKartan, name: str, entry_id: str, area: str | None + ) -> None: """Initialize the Brottsplatskartan sensor.""" self._brottsplatskartan = bpk + self._area = area self._attr_unique_id = entry_id self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -56,12 +62,19 @@ class BrottsplatskartanSensor(SensorEntity): """Update device state.""" incident_counts: defaultdict[str, int] = defaultdict(int) - incidents = self._brottsplatskartan.get_incidents() + get_incidents: dict[str, list] | Literal[ + False + ] = self._brottsplatskartan.get_incidents() - if incidents is False: + if get_incidents is False: LOGGER.debug("Problems fetching incidents") return + if self._area: + incidents = get_incidents.get(self._area) or [] + else: + incidents = get_incidents.get("latlng") or [] + for incident in incidents: if (incident_type := incident.get("title_type")) is not None: incident_counts[incident_type] += 1 diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index a7729cc256e..be64f01966f 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==3.2.0"] + "requirements": ["bthome-ble==3.3.1"] } diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 439921928d6..1963041bcca 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -10,19 +10,13 @@ import voluptuous as vol from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import ( - CONF_COUNTRY, - CONF_DELTA, - DEFAULT_COUNTRY, - DEFAULT_DELTA, - DEFAULT_DIMENSION, -) +from .const import CONF_DELTA, DEFAULT_COUNTRY, DEFAULT_DELTA, DEFAULT_DIMENSION _LOGGER = logging.getLogger(__name__) @@ -40,7 +34,9 @@ async def async_setup_entry( config = entry.data options = entry.options - country = options.get(CONF_COUNTRY, config.get(CONF_COUNTRY, DEFAULT_COUNTRY)) + country = options.get( + CONF_COUNTRY_CODE, config.get(CONF_COUNTRY_CODE, DEFAULT_COUNTRY) + ) delta = options.get(CONF_DELTA, config.get(CONF_DELTA, DEFAULT_DELTA)) diff --git a/homeassistant/components/buienradar/config_flow.py b/homeassistant/components/buienradar/config_flow.py index 4a81a774b4f..1e77693f7fb 100644 --- a/homeassistant/components/buienradar/config_flow.py +++ b/homeassistant/components/buienradar/config_flow.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector @@ -20,7 +20,6 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from .const import ( - CONF_COUNTRY, CONF_DELTA, CONF_TIMEFRAME, DEFAULT_COUNTRY, @@ -32,7 +31,9 @@ from .const import ( OPTIONS_SCHEMA = vol.Schema( { - vol.Optional(CONF_COUNTRY, default=DEFAULT_COUNTRY): selector.CountrySelector( + vol.Optional( + CONF_COUNTRY_CODE, default=DEFAULT_COUNTRY + ): selector.CountrySelector( selector.CountrySelectorConfig(countries=SUPPORTED_COUNTRY_CODES) ), vol.Optional(CONF_DELTA, default=DEFAULT_DELTA): selector.NumberSelector( diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py index 718812c5c73..c82970ed318 100644 --- a/homeassistant/components/buienradar/const.py +++ b/homeassistant/components/buienradar/const.py @@ -8,7 +8,6 @@ DEFAULT_DIMENSION = 700 DEFAULT_DELTA = 600 CONF_DELTA = "delta" -CONF_COUNTRY = "country_code" CONF_TIMEFRAME = "timeframe" SUPPORTED_COUNTRY_CODES = ["NL", "BE"] diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 901acdcdec1..358348a8077 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -1,11 +1,10 @@ """Component to pressing a button as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import datetime, timedelta from enum import StrEnum import logging -from typing import final +from typing import TYPE_CHECKING, final import voluptuous as vol @@ -23,6 +22,11 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN, SERVICE_PRESS +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -73,14 +77,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class ButtonEntityDescription(EntityDescription): +class ButtonEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes button entities.""" device_class: ButtonDeviceClass | None = None -class ButtonEntity(RestoreEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", +} + + +class ButtonEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Button entity.""" entity_description: ButtonEntityDescription @@ -96,7 +104,7 @@ class ButtonEntity(RestoreEntity): """ return self.device_class is not None - @property + @cached_property def device_class(self) -> ButtonDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index b7089c3da65..90380805c31 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -90,20 +90,6 @@ def _todo_item(resource: caldav.CalendarObjectResource) -> TodoItem | None: ) -def _to_ics_fields(item: TodoItem) -> dict[str, Any]: - """Convert a TodoItem to the set of add or update arguments.""" - item_data: dict[str, Any] = {} - if summary := item.summary: - item_data["summary"] = summary - if status := item.status: - item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") - if due := item.due: - item_data["due"] = due - if description := item.description: - item_data["description"] = description - return item_data - - class WebDavTodoListEntity(TodoListEntity): """CalDAV To-do list entity.""" @@ -140,9 +126,18 @@ class WebDavTodoListEntity(TodoListEntity): async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" + item_data: dict[str, Any] = {} + if summary := item.summary: + item_data["summary"] = summary + if status := item.status: + item_data["status"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") + if due := item.due: + item_data["due"] = due + if description := item.description: + item_data["description"] = description try: await self.hass.async_add_executor_job( - partial(self._calendar.save_todo, **_to_ics_fields(item)), + partial(self._calendar.save_todo, **item_data), ) except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV save error: {err}") from err @@ -159,10 +154,17 @@ class WebDavTodoListEntity(TodoListEntity): except (requests.ConnectionError, DAVError) as err: raise HomeAssistantError(f"CalDAV lookup error: {err}") from err vtodo = todo.icalendar_component # type: ignore[attr-defined] - updated_fields = _to_ics_fields(item) - if "due" in updated_fields: - todo.set_due(updated_fields.pop("due")) # type: ignore[attr-defined] - vtodo.update(**updated_fields) + vtodo["SUMMARY"] = item.summary or "" + if status := item.status: + vtodo["STATUS"] = TODO_STATUS_MAP_INV.get(status, "NEEDS-ACTION") + if due := item.due: + todo.set_due(due) # type: ignore[attr-defined] + else: + vtodo.pop("DUE", None) + if description := item.description: + vtodo["DESCRIPTION"] = description + else: + vtodo.pop("DESCRIPTION", None) try: await self.hass.async_add_executor_job( partial( diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 5b98d372220..41e13b798b6 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -429,7 +429,7 @@ def _api_event_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, Any]: def _list_events_dict_factory( - obj: Iterable[tuple[str, Any]] + obj: Iterable[tuple[str, Any]], ) -> dict[str, JsonValueType]: """Convert CalendarEvent dataclass items to dictionary of attributes.""" return { @@ -818,7 +818,7 @@ async def handle_calendar_event_update( def _validate_timespan( - values: dict[str, Any] + values: dict[str, Any], ) -> tuple[datetime.datetime | datetime.date, datetime.datetime | datetime.date]: """Parse a create event service call and convert the args ofr a create event entity call. diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index bb5a44a530c..7a56292f7bb 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -5,14 +5,15 @@ import asyncio import collections from collections.abc import Awaitable, Callable, Iterable from contextlib import suppress -from dataclasses import asdict, dataclass +from dataclasses import asdict from datetime import datetime, timedelta from enum import IntFlag from functools import partial import logging import os from random import SystemRandom -from typing import Any, Final, cast, final +import time +from typing import TYPE_CHECKING, Any, Final, cast, final from aiohttp import hdrs, web import attr @@ -51,6 +52,11 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_time_interval @@ -60,6 +66,8 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from .const import ( # noqa: F401 + _DEPRECATED_STREAM_TYPE_HLS, + _DEPRECATED_STREAM_TYPE_WEB_RTC, CAMERA_IMAGE_TIMEOUT, CAMERA_STREAM_SOURCE_TIMEOUT, CONF_DURATION, @@ -70,13 +78,16 @@ from .const import ( # noqa: F401 PREF_ORIENTATION, PREF_PRELOAD_STREAM, SERVICE_RECORD, - STREAM_TYPE_HLS, - STREAM_TYPE_WEB_RTC, StreamType, ) from .img_util import scale_jpeg_camera_image from .prefs import CameraPreferences, DynamicStreamSettings # noqa: F401 +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) SERVICE_ENABLE_MOTION: Final = "enable_motion_detection" @@ -105,8 +116,16 @@ class CameraEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Pleease use the CameraEntityFeature enum instead. -SUPPORT_ON_OFF: Final = 1 -SUPPORT_STREAM: Final = 2 +_DEPRECATED_SUPPORT_ON_OFF: Final = DeprecatedConstantEnum( + CameraEntityFeature.ON_OFF, "2025.1" +) +_DEPRECATED_SUPPORT_STREAM: Final = DeprecatedConstantEnum( + CameraEntityFeature.STREAM, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) RTSP_PREFIXES = {"rtsp://", "rtsps://", "rtmp://"} @@ -132,8 +151,7 @@ CAMERA_SERVICE_RECORD: Final = { } -@dataclass -class CameraEntityDescription(EntityDescription): +class CameraEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes camera entities.""" @@ -216,7 +234,7 @@ async def _async_get_stream_image( height: int | None = None, wait_for_next_keyframe: bool = False, ) -> bytes | None: - if not camera.stream and camera.supported_features & SUPPORT_STREAM: + if not camera.stream and CameraEntityFeature.STREAM in camera.supported_features: camera.stream = await camera.async_create_stream() if camera.stream: return await camera.stream.async_get_image( @@ -277,6 +295,7 @@ async def async_get_still_stream( last_image = None while True: + last_fetch = time.monotonic() img_bytes = await image_cb() if not img_bytes: break @@ -290,7 +309,11 @@ async def async_get_still_stream( await write_to_mjpeg_stream(img_bytes) last_image = img_bytes - await asyncio.sleep(interval) + next_fetch = last_fetch + interval + now = time.monotonic() + if next_fetch > now: + sleep_time = next_fetch - now + await asyncio.sleep(sleep_time) return response @@ -394,7 +417,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, preload_stream) @callback - def update_tokens(time: datetime) -> None: + def update_tokens(t: datetime) -> None: """Update tokens of the entities.""" for entity in component.entities: entity.async_update_token() @@ -446,7 +469,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -class Camera(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "brand", + "frame_interval", + "frontend_stream_type", + "is_on", + "is_recording", + "is_streaming", + "model", + "motion_detection_enabled", + "supported_features", +} + + +class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """The base class for camera entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -489,37 +525,50 @@ class Camera(Entity): """Whether or not to use stream to generate stills.""" return False - @property + @cached_property def supported_features(self) -> CameraEntityFeature: """Flag supported features.""" return self._attr_supported_features @property + def supported_features_compat(self) -> CameraEntityFeature: + """Return the supported features as CameraEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = CameraEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + + @cached_property def is_recording(self) -> bool: """Return true if the device is recording.""" return self._attr_is_recording - @property + @cached_property def is_streaming(self) -> bool: """Return true if the device is streaming.""" return self._attr_is_streaming - @property + @cached_property def brand(self) -> str | None: """Return the camera brand.""" return self._attr_brand - @property + @cached_property def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" return self._attr_motion_detection_enabled - @property + @cached_property def model(self) -> str | None: """Return the camera model.""" return self._attr_model - @property + @cached_property def frame_interval(self) -> float: """Return the interval between frames of the mjpeg stream.""" return self._attr_frame_interval @@ -534,7 +583,7 @@ class Camera(Entity): """ if hasattr(self, "_attr_frontend_stream_type"): return self._attr_frontend_stream_type - if not self.supported_features & CameraEntityFeature.STREAM: + if CameraEntityFeature.STREAM not in self.supported_features_compat: return None if self._rtsp_to_webrtc: return StreamType.WEB_RTC @@ -637,7 +686,7 @@ class Camera(Entity): return STATE_STREAMING return STATE_IDLE - @property + @cached_property def is_on(self) -> bool: """Return true if on.""" return self._attr_is_on @@ -722,7 +771,7 @@ class Camera(Entity): async def _async_use_rtsp_to_webrtc(self) -> bool: """Determine if a WebRTC provider can be used for the camera.""" - if not self.supported_features & CameraEntityFeature.STREAM: + if CameraEntityFeature.STREAM not in self.supported_features_compat: return False if DATA_RTSP_TO_WEB_RTC not in self.hass.data: return False diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index f745f60b51a..da41c0b9fab 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -1,7 +1,14 @@ """Constants for Camera component.""" from enum import StrEnum +from functools import partial from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + DOMAIN: Final = "camera" DATA_CAMERA_PREFS: Final = "camera_prefs" @@ -36,5 +43,10 @@ class StreamType(StrEnum): # These constants are deprecated as of Home Assistant 2022.5 # Please use the StreamType enum instead. -STREAM_TYPE_HLS = "hls" -STREAM_TYPE_WEB_RTC = "web_rtc" +_DEPRECATED_STREAM_TYPE_HLS = DeprecatedConstantEnum(StreamType.HLS, "2025.1") +_DEPRECATED_STREAM_TYPE_WEB_RTC = DeprecatedConstantEnum(StreamType.WEB_RTC, "2025.1") + + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) diff --git a/homeassistant/components/camera/significant_change.py b/homeassistant/components/camera/significant_change.py new file mode 100644 index 00000000000..4fc175b0723 --- /dev/null +++ b/homeassistant/components/camera/significant_change.py @@ -0,0 +1,22 @@ +"""Helper to test significant Camera state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + return None diff --git a/homeassistant/components/ccm15/__init__.py b/homeassistant/components/ccm15/__init__.py new file mode 100644 index 00000000000..ae48394c732 --- /dev/null +++ b/homeassistant/components/ccm15/__init__.py @@ -0,0 +1,34 @@ +"""The Midea ccm15 AC Controller integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import CCM15Coordinator + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Midea ccm15 AC Controller from a config entry.""" + + coordinator = CCM15Coordinator( + hass, + entry.data[CONF_HOST], + entry.data[CONF_PORT], + ) + 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/ccm15/climate.py b/homeassistant/components/ccm15/climate.py new file mode 100644 index 00000000000..30896d12299 --- /dev/null +++ b/homeassistant/components/ccm15/climate.py @@ -0,0 +1,160 @@ +"""Climate device for CCM15 coordinator.""" +import logging +from typing import Any + +from ccm15 import CCM15DeviceState + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + PRECISION_WHOLE, + SWING_OFF, + SWING_ON, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONST_CMD_FAN_MAP, CONST_CMD_STATE_MAP, DOMAIN +from .coordinator import CCM15Coordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up all climate.""" + coordinator: CCM15Coordinator = hass.data[DOMAIN][config_entry.entry_id] + + ac_data: CCM15DeviceState = coordinator.data + entities = [ + CCM15Climate(coordinator.get_host(), ac_index, coordinator) + for ac_index in ac_data.devices + ] + async_add_entities(entities) + + +class CCM15Climate(CoordinatorEntity[CCM15Coordinator], ClimateEntity): + """Climate device for CCM15 coordinator.""" + + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_has_entity_name = True + _attr_target_temperature_step = PRECISION_WHOLE + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.AUTO, + ] + _attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] + _attr_swing_modes = [SWING_OFF, SWING_ON] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + ) + _attr_name = None + + def __init__( + self, ac_host: str, ac_index: int, coordinator: CCM15Coordinator + ) -> None: + """Create a climate device managed from a coordinator.""" + super().__init__(coordinator) + self._ac_index: int = ac_index + self._attr_unique_id = f"{ac_host}.{ac_index}" + self._attr_device_info = DeviceInfo( + identifiers={ + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, f"{ac_host}.{ac_index}"), + }, + name=f"Midea {ac_index}", + manufacturer="Midea", + model="CCM15", + ) + + @property + def data(self) -> CCM15DeviceState | None: + """Return device data.""" + return self.coordinator.get_ac_data(self._ac_index) + + @property + def current_temperature(self) -> int | None: + """Return current temperature.""" + if (data := self.data) is not None: + return data.temperature + return None + + @property + def target_temperature(self) -> int | None: + """Return target temperature.""" + if (data := self.data) is not None: + return data.temperature_setpoint + return None + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac mode.""" + if (data := self.data) is not None: + mode = data.ac_mode + return CONST_CMD_STATE_MAP[mode] + return None + + @property + def fan_mode(self) -> str | None: + """Return fan mode.""" + if (data := self.data) is not None: + mode = data.fan_mode + return CONST_CMD_FAN_MAP[mode] + return None + + @property + def swing_mode(self) -> str | None: + """Return swing mode.""" + if (data := self.data) is not None: + return SWING_ON if data.is_swing_on else SWING_OFF + return None + + @property + def available(self) -> bool: + """Return the avalability of the entity.""" + return self.data is not None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the optional state attributes.""" + if (data := self.data) is not None: + return {"error_code": data.error_code} + return {} + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: + await self.coordinator.async_set_temperature(self._ac_index, temperature) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the hvac mode.""" + await self.coordinator.async_set_hvac_mode(self._ac_index, hvac_mode) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the fan mode.""" + await self.coordinator.async_set_fan_mode(self._ac_index, fan_mode) + + async def async_turn_off(self) -> None: + """Turn off.""" + await self.async_set_hvac_mode(HVACMode.OFF) + + async def async_turn_on(self) -> None: + """Turn on.""" + await self.async_set_hvac_mode(HVACMode.AUTO) diff --git a/homeassistant/components/ccm15/config_flow.py b/homeassistant/components/ccm15/config_flow.py new file mode 100644 index 00000000000..efde47b8d30 --- /dev/null +++ b/homeassistant/components/ccm15/config_flow.py @@ -0,0 +1,56 @@ +"""Config flow for Midea ccm15 AC Controller integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from ccm15 import CCM15Device +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import DEFAULT_TIMEOUT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=80): cv.port, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Midea ccm15 AC Controller.""" + + 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: + self._async_abort_entries_match(user_input) + ccm15 = CCM15Device( + user_input[CONF_HOST], user_input[CONF_PORT], DEFAULT_TIMEOUT + ) + try: + if not await ccm15.async_test_connection(): + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/ccm15/const.py b/homeassistant/components/ccm15/const.py new file mode 100644 index 00000000000..5e8d1b82bd8 --- /dev/null +++ b/homeassistant/components/ccm15/const.py @@ -0,0 +1,26 @@ +"""Constants for the Midea ccm15 AC Controller integration.""" + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + HVACMode, +) + +DOMAIN = "ccm15" +DEFAULT_TIMEOUT = 10 +DEFAULT_INTERVAL = 30 + +CONST_STATE_CMD_MAP = { + HVACMode.COOL: 0, + HVACMode.HEAT: 1, + HVACMode.DRY: 2, + HVACMode.FAN_ONLY: 3, + HVACMode.OFF: 4, + HVACMode.AUTO: 5, +} +CONST_CMD_STATE_MAP = {v: k for k, v in CONST_STATE_CMD_MAP.items()} +CONST_FAN_CMD_MAP = {FAN_AUTO: 0, FAN_LOW: 2, FAN_MEDIUM: 3, FAN_HIGH: 4, FAN_OFF: 5} +CONST_CMD_FAN_MAP = {v: k for k, v in CONST_FAN_CMD_MAP.items()} diff --git a/homeassistant/components/ccm15/coordinator.py b/homeassistant/components/ccm15/coordinator.py new file mode 100644 index 00000000000..9d8a0281706 --- /dev/null +++ b/homeassistant/components/ccm15/coordinator.py @@ -0,0 +1,76 @@ +"""Climate device for CCM15 coordinator.""" +import datetime +import logging + +from ccm15 import CCM15Device, CCM15DeviceState, CCM15SlaveDevice +import httpx + +from homeassistant.components.climate import HVACMode +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONST_FAN_CMD_MAP, + CONST_STATE_CMD_MAP, + DEFAULT_INTERVAL, + DEFAULT_TIMEOUT, +) + +_LOGGER = logging.getLogger(__name__) + + +class CCM15Coordinator(DataUpdateCoordinator[CCM15DeviceState]): + """Class to coordinate multiple CCM15Climate devices.""" + + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=host, + update_interval=datetime.timedelta(seconds=DEFAULT_INTERVAL), + ) + self._ccm15 = CCM15Device(host, port, DEFAULT_TIMEOUT) + self._host = host + + def get_host(self) -> str: + """Get the host.""" + return self._host + + async def _async_update_data(self) -> CCM15DeviceState: + """Fetch data from Rain Bird device.""" + try: + return await self._fetch_data() + except httpx.RequestError as err: # pragma: no cover + raise UpdateFailed("Error communicating with Device") from err + + async def _fetch_data(self) -> CCM15DeviceState: + """Get the current status of all AC devices.""" + return await self._ccm15.get_status_async() + + async def async_set_state(self, ac_index: int, state: str, value: int) -> None: + """Set new target states.""" + if await self._ccm15.async_set_state(ac_index, state, value): + await self.async_request_refresh() + + def get_ac_data(self, ac_index: int) -> CCM15SlaveDevice | None: + """Get ac data from the ac_index.""" + if ac_index < 0 or ac_index >= len(self.data.devices): + # Network latency may return an empty or incomplete array + return None + return self.data.devices[ac_index] + + async def async_set_hvac_mode(self, ac_index, hvac_mode: HVACMode) -> None: + """Set the hvac mode.""" + _LOGGER.debug("Set Hvac[%s]='%s'", ac_index, str(hvac_mode)) + await self.async_set_state(ac_index, "mode", CONST_STATE_CMD_MAP[hvac_mode]) + + async def async_set_fan_mode(self, ac_index, fan_mode: str) -> None: + """Set the fan mode.""" + _LOGGER.debug("Set Fan[%s]='%s'", ac_index, fan_mode) + await self.async_set_state(ac_index, "fan", CONST_FAN_CMD_MAP[fan_mode]) + + async def async_set_temperature(self, ac_index, temp) -> None: + """Set the target temperature mode.""" + _LOGGER.debug("Set Temp[%s]='%s'", ac_index, temp) + await self.async_set_state(ac_index, "temp", temp) diff --git a/homeassistant/components/ccm15/diagnostics.py b/homeassistant/components/ccm15/diagnostics.py new file mode 100644 index 00000000000..b4a3c80f319 --- /dev/null +++ b/homeassistant/components/ccm15/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for CCM15.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import CCM15Coordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: CCM15Coordinator = hass.data[DOMAIN][config_entry.entry_id] + + return { + str(device_id): { + "is_celsius": device.is_celsius, + "locked_cool_temperature": device.locked_cool_temperature, + "locked_heat_temperature": device.locked_heat_temperature, + "locked_ac_mode": device.locked_ac_mode, + "error_code": device.error_code, + "ac_mode": device.ac_mode, + "fan_mode": device.fan_mode, + "is_ac_mode_locked": device.is_ac_mode_locked, + "temperature_setpoint": device.temperature_setpoint, + "fan_locked": device.fan_locked, + "is_remote_locked": device.is_remote_locked, + "temperature": device.temperature, + } + for device_id, device in coordinator.data.devices.items() + } diff --git a/homeassistant/components/ccm15/manifest.json b/homeassistant/components/ccm15/manifest.json new file mode 100644 index 00000000000..2d985d6148a --- /dev/null +++ b/homeassistant/components/ccm15/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ccm15", + "name": "Midea ccm15 AC Controller", + "codeowners": ["@ocalvo"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ccm15", + "iot_class": "local_polling", + "requirements": ["py-ccm15==0.0.9"] +} diff --git a/homeassistant/components/ccm15/strings.json b/homeassistant/components/ccm15/strings.json new file mode 100644 index 00000000000..1ac7a25e6f8 --- /dev/null +++ b/homeassistant/components/ccm15/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index a075467a313..78cb92944cb 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -1,11 +1,10 @@ """Provides functionality to interact with climate devices.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import functools as ft import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, Literal, final import voluptuous as vol @@ -20,13 +19,18 @@ from homeassistant.const import ( STATE_ON, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp @@ -34,6 +38,20 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( # noqa: F401 + _DEPRECATED_HVAC_MODE_AUTO, + _DEPRECATED_HVAC_MODE_COOL, + _DEPRECATED_HVAC_MODE_DRY, + _DEPRECATED_HVAC_MODE_FAN_ONLY, + _DEPRECATED_HVAC_MODE_HEAT, + _DEPRECATED_HVAC_MODE_HEAT_COOL, + _DEPRECATED_HVAC_MODE_OFF, + _DEPRECATED_SUPPORT_AUX_HEAT, + _DEPRECATED_SUPPORT_FAN_MODE, + _DEPRECATED_SUPPORT_PRESET_MODE, + _DEPRECATED_SUPPORT_SWING_MODE, + _DEPRECATED_SUPPORT_TARGET_HUMIDITY, + _DEPRECATED_SUPPORT_TARGET_TEMPERATURE, + _DEPRECATED_SUPPORT_TARGET_TEMPERATURE_RANGE, ATTR_AUX_HEAT, ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, @@ -65,10 +83,6 @@ from .const import ( # noqa: F401 FAN_OFF, FAN_ON, FAN_TOP, - HVAC_MODE_COOL, - HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, - HVAC_MODE_OFF, HVAC_MODES, PRESET_ACTIVITY, PRESET_AWAY, @@ -85,13 +99,6 @@ from .const import ( # noqa: F401 SERVICE_SET_PRESET_MODE, SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, - SUPPORT_AUX_HEAT, - SUPPORT_FAN_MODE, - SUPPORT_PRESET_MODE, - SUPPORT_SWING_MODE, - SUPPORT_TARGET_HUMIDITY, - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -102,6 +109,11 @@ from .const import ( # noqa: F401 HVACMode, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + DEFAULT_MIN_TEMP = 7 DEFAULT_MAX_TEMP = 35 DEFAULT_MIN_HUMIDITY = 30 @@ -129,6 +141,12 @@ SET_TEMPERATURE_SCHEMA = vol.All( ), ) +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) + # mypy: disallow-any-generics @@ -149,7 +167,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_PRESET_MODE, {vol.Required(ATTR_PRESET_MODE): cv.string}, - "async_set_preset_mode", + "async_handle_set_preset_mode_service", [ClimateEntityFeature.PRESET_MODE], ) component.async_register_entity_service( @@ -176,13 +194,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SET_FAN_MODE, {vol.Required(ATTR_FAN_MODE): cv.string}, - "async_set_fan_mode", + "async_handle_set_fan_mode_service", [ClimateEntityFeature.FAN_MODE], ) component.async_register_entity_service( SERVICE_SET_SWING_MODE, {vol.Required(ATTR_SWING_MODE): cv.string}, - "async_set_swing_mode", + "async_handle_set_swing_mode_service", [ClimateEntityFeature.SWING_MODE], ) @@ -201,12 +219,38 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class ClimateEntityDescription(EntityDescription): +class ClimateEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes climate entities.""" -class ClimateEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "temperature_unit", + "current_humidity", + "target_humidity", + "hvac_mode", + "hvac_modes", + "hvac_action", + "current_temperature", + "target_temperature", + "target_temperature_step", + "target_temperature_high", + "target_temperature_low", + "preset_mode", + "preset_modes", + "is_aux_heat", + "fan_mode", + "fan_modes", + "swing_mode", + "swing_modes", + "supported_features", + "min_temp", + "max_temp", + "min_humidity", + "max_humidity", +} + + +class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for climate entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -273,7 +317,7 @@ class ClimateEntity(Entity): @property def capability_attributes(self) -> dict[str, Any] | None: """Return the capability attributes.""" - supported_features = self.supported_features + supported_features = self.supported_features_compat temperature_unit = self.temperature_unit precision = self.precision hass = self.hass @@ -287,17 +331,17 @@ class ClimateEntity(Entity): if target_temperature_step := self.target_temperature_step: data[ATTR_TARGET_TEMP_STEP] = target_temperature_step - if supported_features & ClimateEntityFeature.TARGET_HUMIDITY: + if ClimateEntityFeature.TARGET_HUMIDITY in supported_features: data[ATTR_MIN_HUMIDITY] = self.min_humidity data[ATTR_MAX_HUMIDITY] = self.max_humidity - if supported_features & ClimateEntityFeature.FAN_MODE: + if ClimateEntityFeature.FAN_MODE in supported_features: data[ATTR_FAN_MODES] = self.fan_modes - if supported_features & ClimateEntityFeature.PRESET_MODE: + if ClimateEntityFeature.PRESET_MODE in supported_features: data[ATTR_PRESET_MODES] = self.preset_modes - if supported_features & ClimateEntityFeature.SWING_MODE: + if ClimateEntityFeature.SWING_MODE in supported_features: data[ATTR_SWING_MODES] = self.swing_modes return data @@ -306,7 +350,7 @@ class ClimateEntity(Entity): @property def state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" - supported_features = self.supported_features + supported_features = self.supported_features_compat temperature_unit = self.temperature_unit precision = self.precision hass = self.hass @@ -317,7 +361,7 @@ class ClimateEntity(Entity): ), } - if supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: + if ClimateEntityFeature.TARGET_TEMPERATURE in supported_features: data[ATTR_TEMPERATURE] = show_temp( hass, self.target_temperature, @@ -325,7 +369,7 @@ class ClimateEntity(Entity): precision, ) - if supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: + if ClimateEntityFeature.TARGET_TEMPERATURE_RANGE in supported_features: data[ATTR_TARGET_TEMP_HIGH] = show_temp( hass, self.target_temperature_high, temperature_unit, precision ) @@ -336,72 +380,72 @@ class ClimateEntity(Entity): if (current_humidity := self.current_humidity) is not None: data[ATTR_CURRENT_HUMIDITY] = current_humidity - if supported_features & ClimateEntityFeature.TARGET_HUMIDITY: + if ClimateEntityFeature.TARGET_HUMIDITY in supported_features: data[ATTR_HUMIDITY] = self.target_humidity - if supported_features & ClimateEntityFeature.FAN_MODE: + if ClimateEntityFeature.FAN_MODE in supported_features: data[ATTR_FAN_MODE] = self.fan_mode if hvac_action := self.hvac_action: data[ATTR_HVAC_ACTION] = hvac_action - if supported_features & ClimateEntityFeature.PRESET_MODE: + if ClimateEntityFeature.PRESET_MODE in supported_features: data[ATTR_PRESET_MODE] = self.preset_mode - if supported_features & ClimateEntityFeature.SWING_MODE: + if ClimateEntityFeature.SWING_MODE in supported_features: data[ATTR_SWING_MODE] = self.swing_mode - if supported_features & ClimateEntityFeature.AUX_HEAT: + if ClimateEntityFeature.AUX_HEAT in supported_features: data[ATTR_AUX_HEAT] = STATE_ON if self.is_aux_heat else STATE_OFF return data - @property + @cached_property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" return self._attr_temperature_unit - @property + @cached_property def current_humidity(self) -> int | None: """Return the current humidity.""" return self._attr_current_humidity - @property + @cached_property def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" return self._attr_target_humidity - @property + @cached_property def hvac_mode(self) -> HVACMode | None: """Return hvac operation ie. heat, cool mode.""" return self._attr_hvac_mode - @property + @cached_property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" return self._attr_hvac_modes - @property + @cached_property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" return self._attr_hvac_action - @property + @cached_property def current_temperature(self) -> float | None: """Return the current temperature.""" return self._attr_current_temperature - @property + @cached_property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._attr_target_temperature - @property + @cached_property def target_temperature_step(self) -> float | None: """Return the supported step of target temperature.""" return self._attr_target_temperature_step - @property + @cached_property def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach. @@ -409,7 +453,7 @@ class ClimateEntity(Entity): """ return self._attr_target_temperature_high - @property + @cached_property def target_temperature_low(self) -> float | None: """Return the lowbound target temperature we try to reach. @@ -417,7 +461,7 @@ class ClimateEntity(Entity): """ return self._attr_target_temperature_low - @property + @cached_property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp. @@ -425,7 +469,7 @@ class ClimateEntity(Entity): """ return self._attr_preset_mode - @property + @cached_property def preset_modes(self) -> list[str] | None: """Return a list of available preset modes. @@ -433,7 +477,7 @@ class ClimateEntity(Entity): """ return self._attr_preset_modes - @property + @cached_property def is_aux_heat(self) -> bool | None: """Return true if aux heater. @@ -441,7 +485,7 @@ class ClimateEntity(Entity): """ return self._attr_is_aux_heat - @property + @cached_property def fan_mode(self) -> str | None: """Return the fan setting. @@ -449,7 +493,7 @@ class ClimateEntity(Entity): """ return self._attr_fan_mode - @property + @cached_property def fan_modes(self) -> list[str] | None: """Return the list of available fan modes. @@ -457,7 +501,7 @@ class ClimateEntity(Entity): """ return self._attr_fan_modes - @property + @cached_property def swing_mode(self) -> str | None: """Return the swing setting. @@ -465,7 +509,7 @@ class ClimateEntity(Entity): """ return self._attr_swing_mode - @property + @cached_property def swing_modes(self) -> list[str] | None: """Return the list of available swing modes. @@ -473,6 +517,35 @@ class ClimateEntity(Entity): """ return self._attr_swing_modes + @final + @callback + def _valid_mode_or_raise( + self, + mode_type: Literal["preset", "swing", "fan"], + mode: str, + modes: list[str] | None, + ) -> None: + """Raise ServiceValidationError on invalid modes.""" + if modes and mode in modes: + return + modes_str: str = ", ".join(modes) if modes else "" + if mode_type == "preset": + translation_key = "not_valid_preset_mode" + elif mode_type == "swing": + translation_key = "not_valid_swing_mode" + elif mode_type == "fan": + translation_key = "not_valid_fan_mode" + raise ServiceValidationError( + f"The {mode_type}_mode {mode} is not a valid {mode_type}_mode:" + f" {modes_str}", + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={ + "mode": mode, + "modes": modes_str, + }, + ) + def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" raise NotImplementedError() @@ -491,6 +564,12 @@ class ClimateEntity(Entity): """Set new target humidity.""" await self.hass.async_add_executor_job(self.set_humidity, humidity) + @final + async def async_handle_set_fan_mode_service(self, fan_mode: str) -> None: + """Validate and set new preset mode.""" + self._valid_mode_or_raise("fan", fan_mode, self.fan_modes) + await self.async_set_fan_mode(fan_mode) + def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" raise NotImplementedError() @@ -507,6 +586,12 @@ class ClimateEntity(Entity): """Set new target hvac mode.""" await self.hass.async_add_executor_job(self.set_hvac_mode, hvac_mode) + @final + async def async_handle_set_swing_mode_service(self, swing_mode: str) -> None: + """Validate and set new preset mode.""" + self._valid_mode_or_raise("swing", swing_mode, self.swing_modes) + await self.async_set_swing_mode(swing_mode) + def set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" raise NotImplementedError() @@ -515,6 +600,12 @@ class ClimateEntity(Entity): """Set new target swing operation.""" await self.hass.async_add_executor_job(self.set_swing_mode, swing_mode) + @final + async def async_handle_set_preset_mode_service(self, preset_mode: str) -> None: + """Validate and set new preset mode.""" + self._valid_mode_or_raise("preset", preset_mode, self.preset_modes) + await self.async_set_preset_mode(preset_mode) + def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" raise NotImplementedError() @@ -570,12 +661,25 @@ class ClimateEntity(Entity): if HVACMode.OFF in self.hvac_modes: await self.async_set_hvac_mode(HVACMode.OFF) - @property + @cached_property def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" return self._attr_supported_features @property + def supported_features_compat(self) -> ClimateEntityFeature: + """Return the supported features as ClimateEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = ClimateEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + + @cached_property def min_temp(self) -> float: """Return the minimum temperature.""" if not hasattr(self, "_attr_min_temp"): @@ -584,7 +688,7 @@ class ClimateEntity(Entity): ) return self._attr_min_temp - @property + @cached_property def max_temp(self) -> float: """Return the maximum temperature.""" if not hasattr(self, "_attr_max_temp"): @@ -593,12 +697,12 @@ class ClimateEntity(Entity): ) return self._attr_max_temp - @property + @cached_property def min_humidity(self) -> int: """Return the minimum humidity.""" return self._attr_min_humidity - @property + @cached_property def max_humidity(self) -> int: """Return the maximum humidity.""" return self._attr_max_humidity diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index 23c76c151d7..615dc7d48dd 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -1,6 +1,13 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum +from functools import partial + +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) class HVACMode(StrEnum): @@ -31,13 +38,13 @@ class HVACMode(StrEnum): # These HVAC_MODE_* constants are deprecated as of Home Assistant 2022.5. # Please use the HVACMode enum instead. -HVAC_MODE_OFF = "off" -HVAC_MODE_HEAT = "heat" -HVAC_MODE_COOL = "cool" -HVAC_MODE_HEAT_COOL = "heat_cool" -HVAC_MODE_AUTO = "auto" -HVAC_MODE_DRY = "dry" -HVAC_MODE_FAN_ONLY = "fan_only" +_DEPRECATED_HVAC_MODE_OFF = DeprecatedConstantEnum(HVACMode.OFF, "2025.1") +_DEPRECATED_HVAC_MODE_HEAT = DeprecatedConstantEnum(HVACMode.HEAT, "2025.1") +_DEPRECATED_HVAC_MODE_COOL = DeprecatedConstantEnum(HVACMode.COOL, "2025.1") +_DEPRECATED_HVAC_MODE_HEAT_COOL = DeprecatedConstantEnum(HVACMode.HEAT_COOL, "2025.1") +_DEPRECATED_HVAC_MODE_AUTO = DeprecatedConstantEnum(HVACMode.AUTO, "2025.1") +_DEPRECATED_HVAC_MODE_DRY = DeprecatedConstantEnum(HVACMode.DRY, "2025.1") +_DEPRECATED_HVAC_MODE_FAN_ONLY = DeprecatedConstantEnum(HVACMode.FAN_ONLY, "2025.1") HVAC_MODES = [cls.value for cls in HVACMode] # No preset is active @@ -99,12 +106,12 @@ class HVACAction(StrEnum): # These CURRENT_HVAC_* constants are deprecated as of Home Assistant 2022.5. # Please use the HVACAction enum instead. -CURRENT_HVAC_OFF = "off" -CURRENT_HVAC_HEAT = "heating" -CURRENT_HVAC_COOL = "cooling" -CURRENT_HVAC_DRY = "drying" -CURRENT_HVAC_IDLE = "idle" -CURRENT_HVAC_FAN = "fan" +_DEPRECATED_CURRENT_HVAC_OFF = DeprecatedConstantEnum(HVACAction.OFF, "2025.1") +_DEPRECATED_CURRENT_HVAC_HEAT = DeprecatedConstantEnum(HVACAction.HEATING, "2025.1") +_DEPRECATED_CURRENT_HVAC_COOL = DeprecatedConstantEnum(HVACAction.COOLING, "2025.1") +_DEPRECATED_CURRENT_HVAC_DRY = DeprecatedConstantEnum(HVACAction.DRYING, "2025.1") +_DEPRECATED_CURRENT_HVAC_IDLE = DeprecatedConstantEnum(HVACAction.IDLE, "2025.1") +_DEPRECATED_CURRENT_HVAC_FAN = DeprecatedConstantEnum(HVACAction.FAN, "2025.1") CURRENT_HVAC_ACTIONS = [cls.value for cls in HVACAction] @@ -159,10 +166,28 @@ class ClimateEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the ClimateEntityFeature enum instead. -SUPPORT_TARGET_TEMPERATURE = 1 -SUPPORT_TARGET_TEMPERATURE_RANGE = 2 -SUPPORT_TARGET_HUMIDITY = 4 -SUPPORT_FAN_MODE = 8 -SUPPORT_PRESET_MODE = 16 -SUPPORT_SWING_MODE = 32 -SUPPORT_AUX_HEAT = 64 +_DEPRECATED_SUPPORT_TARGET_TEMPERATURE = DeprecatedConstantEnum( + ClimateEntityFeature.TARGET_TEMPERATURE, "2025.1" +) +_DEPRECATED_SUPPORT_TARGET_TEMPERATURE_RANGE = DeprecatedConstantEnum( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, "2025.1" +) +_DEPRECATED_SUPPORT_TARGET_HUMIDITY = DeprecatedConstantEnum( + ClimateEntityFeature.TARGET_HUMIDITY, "2025.1" +) +_DEPRECATED_SUPPORT_FAN_MODE = DeprecatedConstantEnum( + ClimateEntityFeature.FAN_MODE, "2025.1" +) +_DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum( + ClimateEntityFeature.PRESET_MODE, "2025.1" +) +_DEPRECATED_SUPPORT_SWING_MODE = DeprecatedConstantEnum( + ClimateEntityFeature.SWING_MODE, "2025.1" +) +_DEPRECATED_SUPPORT_AUX_HEAT = DeprecatedConstantEnum( + ClimateEntityFeature.AUX_HEAT, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index 6714e0bf35a..a920884c252 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.py @@ -72,7 +72,7 @@ async def async_get_actions( } actions.append({**base_action, CONF_TYPE: "set_hvac_mode"}) - if supported_features & const.SUPPORT_PRESET_MODE: + if supported_features & const.ClimateEntityFeature.PRESET_MODE: actions.append({**base_action, CONF_TYPE: "set_preset_mode"}) return actions diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index 57b9654651b..78f358db32e 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -71,7 +71,7 @@ async def async_get_conditions( conditions.append({**base_condition, CONF_TYPE: "is_hvac_mode"}) - if supported_features & const.SUPPORT_PRESET_MODE: + if supported_features & const.ClimateEntityFeature.PRESET_MODE: conditions.append({**base_condition, CONF_TYPE: "is_preset_mode"}) return conditions diff --git a/homeassistant/components/climate/significant_change.py b/homeassistant/components/climate/significant_change.py new file mode 100644 index 00000000000..7198153f9af --- /dev/null +++ b/homeassistant/components/climate/significant_change.py @@ -0,0 +1,106 @@ +"""Helper to test significant Climate state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ( + ATTR_AUX_HEAT, + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_HUMIDITY, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +) + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_AUX_HEAT, + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_HUMIDITY, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + ha_unit = hass.config.units.temperature_unit + + for attr_name in changed_attrs: + if attr_name in [ + ATTR_AUX_HEAT, + ATTR_FAN_MODE, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ]: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + absolute_change: float | None = None + if attr_name in [ + ATTR_CURRENT_TEMPERATURE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, + ]: + if ha_unit == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + if attr_name in [ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY]: + absolute_change = 1.0 + + if absolute_change and check_absolute_change( + old_attr_value, new_attr_value, absolute_change + ): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 55ccef2bc76..ef87f287430 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -233,5 +233,16 @@ "heat": "Heat" } } + }, + "exceptions": { + "not_valid_preset_mode": { + "message": "Preset mode {mode} is not valid. Valid preset modes are: {modes}." + }, + "not_valid_swing_mode": { + "message": "Swing mode {mode} is not valid. Valid swing modes are: {modes}." + }, + "not_valid_fan_mode": { + "message": "Fan mode {mode} is not valid. Valid fan modes are: {modes}." + } } } diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 4dc242376d9..d7d57835e3a 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -10,6 +10,7 @@ from hass_nabucasa import Cloud import voluptuous as vol from homeassistant.components import alexa, google_assistant +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DESCRIPTION, CONF_MODE, @@ -51,6 +52,7 @@ from .const import ( CONF_SERVICEHANDLERS_SERVER, CONF_THINGTALK_SERVER, CONF_USER_POOL_ID, + DATA_PLATFORMS_SETUP, DOMAIN, MODE_DEV, MODE_PROD, @@ -61,6 +63,8 @@ from .subscription import async_subscription_info DEFAULT_MODE = MODE_PROD +PLATFORMS = [Platform.BINARY_SENSOR, Platform.STT] + SERVICE_REMOTE_CONNECT = "remote_connect" SERVICE_REMOTE_DISCONNECT = "remote_disconnect" @@ -262,6 +266,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_manage_legacy_subscription_issue(hass, subscription_info) loaded = False + stt_platform_loaded = asyncio.Event() + tts_platform_loaded = asyncio.Event() + hass.data[DATA_PLATFORMS_SETUP] = { + Platform.STT: stt_platform_loaded, + Platform.TTS: tts_platform_loaded, + } async def _on_start() -> None: """Discover platforms.""" @@ -272,15 +282,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return loaded = True - stt_platform_loaded = asyncio.Event() - tts_platform_loaded = asyncio.Event() - stt_info = {"platform_loaded": stt_platform_loaded} tts_info = {"platform_loaded": tts_platform_loaded} - await async_load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config) - await async_load_platform(hass, Platform.STT, DOMAIN, stt_info, config) await async_load_platform(hass, Platform.TTS, DOMAIN, tts_info, config) - await asyncio.gather(stt_platform_loaded.wait(), tts_platform_loaded.wait()) + await tts_platform_loaded.wait() + + # The config entry should be loaded after the legacy tts platform is loaded + # to make sure that the tts integration is setup before we try to migrate + # old assist pipelines in the cloud stt entity. + await hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) async def _on_connect() -> None: """Handle cloud connect.""" @@ -304,7 +314,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cloud.register_on_initialized(_on_initialized) await cloud.initialize() - await http_api.async_setup(hass) + http_api.async_setup(hass) account_link.async_setup(hass) @@ -340,3 +350,19 @@ def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None: await cloud.remote.disconnect() cloud.client.prefs.async_listen_updates(remote_prefs_updated) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + stt_platform_loaded: asyncio.Event = hass.data[DATA_PLATFORMS_SETUP][Platform.STT] + stt_platform_loaded.set() + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + return unload_ok diff --git a/homeassistant/components/cloud/assist_pipeline.py b/homeassistant/components/cloud/assist_pipeline.py new file mode 100644 index 00000000000..31e990cdb81 --- /dev/null +++ b/homeassistant/components/cloud/assist_pipeline.py @@ -0,0 +1,85 @@ +"""Handle Cloud assist pipelines.""" +import asyncio + +from homeassistant.components.assist_pipeline import ( + async_create_default_pipeline, + async_get_pipelines, + async_setup_pipeline_store, + async_update_pipeline, +) +from homeassistant.components.conversation import HOME_ASSISTANT_AGENT +from homeassistant.components.stt import DOMAIN as STT_DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +import homeassistant.helpers.entity_registry as er + +from .const import DATA_PLATFORMS_SETUP, DOMAIN, STT_ENTITY_UNIQUE_ID + + +async def async_create_cloud_pipeline(hass: HomeAssistant) -> str | None: + """Create a cloud assist pipeline.""" + # Wait for stt and tts platforms to set up before creating the pipeline. + platforms_setup: dict[str, asyncio.Event] = hass.data[DATA_PLATFORMS_SETUP] + await asyncio.gather(*(event.wait() for event in platforms_setup.values())) + # Make sure the pipeline store is loaded, needed because assist_pipeline + # is an after dependency of cloud + await async_setup_pipeline_store(hass) + + entity_registry = er.async_get(hass) + new_stt_engine_id = entity_registry.async_get_entity_id( + STT_DOMAIN, DOMAIN, STT_ENTITY_UNIQUE_ID + ) + if new_stt_engine_id is None: + # If there's no cloud stt entity, we can't create a cloud pipeline. + return None + + def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: + """Return the ID of a cloud-enabled assist pipeline or None. + + Check if a cloud pipeline already exists with either + legacy or current cloud engine ids. + """ + for pipeline in async_get_pipelines(hass): + if ( + pipeline.conversation_engine == HOME_ASSISTANT_AGENT + and pipeline.stt_engine in (DOMAIN, new_stt_engine_id) + and pipeline.tts_engine == DOMAIN + ): + return pipeline.id + return None + + if (cloud_assist_pipeline(hass)) is not None or ( + cloud_pipeline := await async_create_default_pipeline( + hass, + stt_engine_id=new_stt_engine_id, + tts_engine_id=DOMAIN, + pipeline_name="Home Assistant Cloud", + ) + ) is None: + return None + + return cloud_pipeline.id + + +async def async_migrate_cloud_pipeline_stt_engine( + hass: HomeAssistant, stt_engine_id: str +) -> None: + """Migrate the speech-to-text engine in the cloud assist pipeline.""" + # Migrate existing pipelines with cloud stt to use new cloud stt engine id. + # Added in 2024.01.0. Can be removed in 2025.01.0. + + # We need to make sure that tts is loaded before this migration. + # Assist pipeline will call default engine of tts when setting up the store. + # Wait for the tts platform loaded event here. + platforms_setup: dict[str, asyncio.Event] = hass.data[DATA_PLATFORMS_SETUP] + await platforms_setup[Platform.TTS].wait() + + # Make sure the pipeline store is loaded, needed because assist_pipeline + # is an after dependency of cloud + await async_setup_pipeline_store(hass) + + pipelines = async_get_pipelines(hass) + for pipeline in pipelines: + if pipeline.stt_engine != DOMAIN: + continue + await async_update_pipeline(hass, pipeline, stt_engine=stt_engine_id) diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py index e09122ac7bf..d56896dd7b1 100644 --- a/homeassistant/components/cloud/binary_sensor.py +++ b/homeassistant/components/cloud/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from collections.abc import Callable from typing import Any from hass_nabucasa import Cloud @@ -11,11 +10,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory 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 ConfigType, DiscoveryInfoType from .client import CloudClient from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN @@ -23,17 +22,13 @@ from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN WAIT_UNTIL_CHANGE = 3 -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the cloud binary sensors.""" - if discovery_info is None: - return - cloud = hass.data[DOMAIN] - + """Set up the Home Assistant Cloud binary sensors.""" + cloud: Cloud[CloudClient] = hass.data[DOMAIN] async_add_entities([CloudRemoteBinary(cloud)]) @@ -49,7 +44,6 @@ class CloudRemoteBinary(BinarySensorEntity): def __init__(self, cloud: Cloud[CloudClient]) -> None: """Initialize the binary sensor.""" self.cloud = cloud - self._unsub_dispatcher: Callable[[], None] | None = None @property def is_on(self) -> bool: @@ -69,12 +63,8 @@ class CloudRemoteBinary(BinarySensorEntity): await asyncio.sleep(WAIT_UNTIL_CHANGE) self.async_write_ha_state() - self._unsub_dispatcher = async_dispatcher_connect( - self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update + ) ) - - async def async_will_remove_from_hass(self) -> None: - """Register update dispatcher.""" - if self._unsub_dispatcher is not None: - self._unsub_dispatcher() - self._unsub_dispatcher = None diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 019936869a1..cef3c5f0d42 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -6,7 +6,7 @@ from datetime import datetime from http import HTTPStatus import logging from pathlib import Path -from typing import Any +from typing import Any, Literal import aiohttp from hass_nabucasa.client import CloudClient as Interface @@ -22,12 +22,18 @@ from homeassistant.core import Context, HassJob, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util.aiohttp import MockRequest, serialize_response from . import alexa_config, google_config from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN from .prefs import CloudPreferences +VALID_REPAIR_TRANSLATION_KEYS = { + "warn_bad_custom_domain_configuration", + "reset_bad_custom_domain_configuration", +} + class CloudClient(Interface): """Interface class for Home Assistant Cloud.""" @@ -302,3 +308,24 @@ class CloudClient(Interface): ) -> None: """Update local list of cloudhooks.""" await self._prefs.async_update(cloudhooks=data) + + async def async_create_repair_issue( + self, + identifier: str, + translation_key: str, + *, + placeholders: dict[str, str] | None = None, + severity: Literal["error", "warning"] = "warning", + ) -> None: + """Create a repair issue.""" + if translation_key not in VALID_REPAIR_TRANSLATION_KEYS: + raise ValueError(f"Invalid translation key {translation_key}") + async_create_issue( + hass=self._hass, + domain=DOMAIN, + issue_id=identifier, + translation_key=translation_key, + translation_placeholders=placeholders, + severity=IssueSeverity(severity), + is_fixable=False, + ) diff --git a/homeassistant/components/cloud/config_flow.py b/homeassistant/components/cloud/config_flow.py new file mode 100644 index 00000000000..a9554d97294 --- /dev/null +++ b/homeassistant/components/cloud/config_flow.py @@ -0,0 +1,23 @@ +"""Config flow for the Cloud 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 DOMAIN + + +class CloudConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for the Cloud integration.""" + + VERSION = 1 + + async def async_step_system( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the system step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + return self.async_create_entry(title="Home Assistant Cloud", data={}) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 6e20978ec8d..db964607923 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -1,5 +1,6 @@ """Constants for the cloud component.""" DOMAIN = "cloud" +DATA_PLATFORMS_SETUP = "cloud_platforms_setup" REQUEST_TIMEOUT = 10 PREF_ENABLE_ALEXA = "alexa_enabled" @@ -64,3 +65,5 @@ MODE_DEV = "development" MODE_PROD = "production" DISPATCHER_REMOTE_UPDATE = "cloud_remote_update" + +STT_ENTITY_UNIQUE_ID = "cloud-speech-to-text" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 634a5e20b33..849a1c99db9 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,4 +1,6 @@ """The HTTP api to control the cloud integration.""" +from __future__ import annotations + import asyncio from collections.abc import Awaitable, Callable, Coroutine, Mapping from contextlib import suppress @@ -16,7 +18,7 @@ from hass_nabucasa.const import STATE_DISCONNECTED from hass_nabucasa.voice import MAP_VOICE import voluptuous as vol -from homeassistant.components import assist_pipeline, conversation, websocket_api +from homeassistant.components import websocket_api from homeassistant.components.alexa import ( entities as alexa_entities, errors as alexa_errors, @@ -26,12 +28,13 @@ from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info from .alexa_config import entity_supported as entity_supported_by_alexa +from .assist_pipeline import async_create_cloud_pipeline from .client import CloudClient from .const import ( DOMAIN, @@ -63,7 +66,8 @@ _CLOUD_ERRORS: dict[type[Exception], tuple[HTTPStatus, str]] = { } -async def async_setup(hass: HomeAssistant) -> None: +@callback +def async_setup(hass: HomeAssistant) -> None: """Initialize the HTTP API.""" websocket_api.async_register_command(hass, websocket_cloud_status) websocket_api.async_register_command(hass, websocket_subscription) @@ -113,7 +117,9 @@ _P = ParamSpec("_P") def _handle_cloud_errors( - handler: Callable[Concatenate[_HassViewT, web.Request, _P], Awaitable[web.Response]] + handler: Callable[ + Concatenate[_HassViewT, web.Request, _P], Awaitable[web.Response] + ], ) -> Callable[ Concatenate[_HassViewT, web.Request, _P], Coroutine[Any, Any, web.Response] ]: @@ -210,31 +216,11 @@ class CloudLoginView(HomeAssistantView): ) async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle login request.""" - - def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: - """Return the ID of a cloud-enabled assist pipeline or None.""" - for pipeline in assist_pipeline.async_get_pipelines(hass): - if ( - pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT - and pipeline.stt_engine == DOMAIN - and pipeline.tts_engine == DOMAIN - ): - return pipeline.id - return None - - hass = request.app["hass"] - cloud = hass.data[DOMAIN] + hass: HomeAssistant = request.app["hass"] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] await cloud.login(data["email"], data["password"]) - # Make sure the pipeline store is loaded, needed because assist_pipeline - # is an after dependency of cloud - await assist_pipeline.async_setup_pipeline_store(hass) - new_cloud_pipeline_id: str | None = None - if (cloud_assist_pipeline(hass)) is None: - if cloud_pipeline := await assist_pipeline.async_create_default_pipeline( - hass, DOMAIN, DOMAIN - ): - new_cloud_pipeline_id = cloud_pipeline.id + new_cloud_pipeline_id = await async_create_cloud_pipeline(hass) return self.json({"success": True, "cloud_pipeline": new_cloud_pipeline_id}) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 6d5c954361b..f7337e1d771 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.74.0"] + "requirements": ["hass-nabucasa==0.75.1"] } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index 9c1f29cfcaf..56fb3c0f5c9 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -1,4 +1,10 @@ { + "config": { + "step": {}, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, "system_health": { "info": { "can_reach_cert_server": "Reach Certificate Server", @@ -30,6 +36,14 @@ "operation_took_too_long": "The operation took too long. Please try again later." } } + }, + "warn_bad_custom_domain_configuration": { + "title": "Detected wrong custom domain configuration", + "description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. Please check the DNS configuration of your domain and make sure it points to the correct CNAME." + }, + "reset_bad_custom_domain_configuration": { + "title": "Custom domain ignored", + "description": "The DNS configuration for your custom domain ({custom_domains}) is not correct. This domain has now been ignored and will not be used for Home Assistant Cloud. If you want to use this domain, please fix the DNS configuration and restart Home Assistant. If you do not need this anymore, you can remove it from the account page." } }, "services": { diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index 7b6da8b7403..b652a36fa8a 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -13,37 +13,38 @@ from homeassistant.components.stt import ( AudioCodecs, AudioFormats, AudioSampleRates, - Provider, SpeechMetadata, SpeechResult, SpeechResultState, + SpeechToTextEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .assist_pipeline import async_migrate_cloud_pipeline_stt_engine from .client import CloudClient -from .const import DOMAIN +from .const import DOMAIN, STT_ENTITY_UNIQUE_ID _LOGGER = logging.getLogger(__name__) -async def async_get_engine( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> CloudProvider: - """Set up Cloud speech component.""" + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Home Assistant Cloud speech platform via config entry.""" cloud: Cloud[CloudClient] = hass.data[DOMAIN] - - cloud_provider = CloudProvider(cloud) - if discovery_info is not None: - discovery_info["platform_loaded"].set() - return cloud_provider + async_add_entities([CloudProviderEntity(cloud)]) -class CloudProvider(Provider): +class CloudProviderEntity(SpeechToTextEntity): """NabuCasa speech API provider.""" + _attr_name = "Home Assistant Cloud" + _attr_unique_id = STT_ENTITY_UNIQUE_ID + def __init__(self, cloud: Cloud[CloudClient]) -> None: """Home Assistant NabuCasa Speech to text.""" self.cloud = cloud @@ -78,6 +79,10 @@ class CloudProvider(Provider): """Return a list of supported channels.""" return [AudioChannels.CHANNEL_MONO] + async def async_added_to_hass(self) -> None: + """Run when entity is about to be added to hass.""" + await async_migrate_cloud_pipeline_stt_engine(self.hass, self.entity_id) + async def async_process_audio_stream( self, metadata: SpeechMetadata, stream: AsyncIterable[bytes] ) -> SpeechResult: diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 234c1c01392..dfa1e25d7d8 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -10,7 +10,12 @@ 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.const import ( + CONF_API_KEY, + CONF_COUNTRY_CODE, + CONF_LATITUDE, + CONF_LONGITUDE, +) from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -20,7 +25,7 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CONF_COUNTRY_CODE, DOMAIN +from .const import DOMAIN from .helpers import fetch_latest_carbon_intensity from .util import get_extra_name diff --git a/homeassistant/components/co2signal/const.py b/homeassistant/components/co2signal/const.py index 1e0cbfe0f11..b025c655ce6 100644 --- a/homeassistant/components/co2signal/const.py +++ b/homeassistant/components/co2signal/const.py @@ -2,5 +2,4 @@ DOMAIN = "co2signal" -CONF_COUNTRY_CODE = "country_code" ATTRIBUTION = "Data provided by Electricity Maps" diff --git a/homeassistant/components/co2signal/helpers.py b/homeassistant/components/co2signal/helpers.py index 43579c162e2..937b72a357c 100644 --- a/homeassistant/components/co2signal/helpers.py +++ b/homeassistant/components/co2signal/helpers.py @@ -5,11 +5,9 @@ from typing import Any from aioelectricitymaps import ElectricityMaps from aioelectricitymaps.models import CarbonIntensityResponse -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from .const import CONF_COUNTRY_CODE - async def fetch_latest_carbon_intensity( hass: HomeAssistant, diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index d82af5b5034..f91232c1a28 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -1,7 +1,7 @@ { "domain": "co2signal", "name": "Electricity Maps", - "codeowners": ["@jpbede"], + "codeowners": ["@jpbede", "@VIKTORVAV99"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/co2signal", "integration_type": "service", diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 00051d8bec9..9f955e35ed8 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -22,7 +22,7 @@ from .const import ATTRIBUTION, DOMAIN from .coordinator import CO2SignalCoordinator -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class CO2SensorEntityDescription(SensorEntityDescription): """Provide a description of a CO2 sensor.""" diff --git a/homeassistant/components/co2signal/util.py b/homeassistant/components/co2signal/util.py index af0bec34904..68403b4803e 100644 --- a/homeassistant/components/co2signal/util.py +++ b/homeassistant/components/co2signal/util.py @@ -3,9 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE - -from .const import CONF_COUNTRY_CODE +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE def get_extra_name(config: Mapping) -> str | None: diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index b271644234d..c51081196c9 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -1,38 +1,67 @@ """Comelit integration.""" +from aiocomelit.const import BRIDGE + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, Platform +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE, Platform from homeassistant.core import HomeAssistant from .const import DEFAULT_PORT, DOMAIN -from .coordinator import ComelitSerialBridge +from .coordinator import ComelitBaseCoordinator, ComelitSerialBridge, ComelitVedoSystem -PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] +BRIDGE_PLATFORMS = [ + Platform.COVER, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] +VEDO_PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Comelit platform.""" - coordinator = ComelitSerialBridge( - hass, - entry.data[CONF_HOST], - entry.data.get(CONF_PORT, DEFAULT_PORT), - entry.data[CONF_PIN], - ) + + coordinator: ComelitBaseCoordinator + if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: + coordinator = ComelitSerialBridge( + hass, + entry.data[CONF_HOST], + entry.data.get(CONF_PORT, DEFAULT_PORT), + entry.data[CONF_PIN], + ) + platforms = BRIDGE_PLATFORMS + else: + coordinator = ComelitVedoSystem( + hass, + entry.data[CONF_HOST], + entry.data.get(CONF_PORT, DEFAULT_PORT), + entry.data[CONF_PIN], + ) + platforms = VEDO_PLATFORMS 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) + 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): - coordinator: ComelitSerialBridge = hass.data[DOMAIN][entry.entry_id] + + if entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: + platforms = BRIDGE_PLATFORMS + else: + platforms = VEDO_PLATFORMS + + coordinator: ComelitBaseCoordinator = hass.data[DOMAIN][entry.entry_id] + if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): await coordinator.api.logout() await coordinator.api.close() hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py new file mode 100644 index 00000000000..33107dd3e82 --- /dev/null +++ b/homeassistant/components/comelit/alarm_control_panel.py @@ -0,0 +1,153 @@ +"""Support for Comelit VEDO system.""" +from __future__ import annotations + +import logging + +from aiocomelit.api import ComelitVedoAreaObject +from aiocomelit.const import ALARM_AREAS, AlarmAreaState + +from homeassistant.components.alarm_control_panel import ( + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, + CodeFormat, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMING, + STATE_ALARM_DISARMED, + STATE_ALARM_DISARMING, + STATE_ALARM_TRIGGERED, +) +from homeassistant.core import HomeAssistant +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 ComelitVedoSystem + +_LOGGER = logging.getLogger(__name__) + +AWAY = "away" +DISABLE = "disable" +HOME = "home" +HOME_P1 = "home_p1" +HOME_P2 = "home_p2" +NIGHT = "night" + +ALARM_ACTIONS: dict[str, str] = { + DISABLE: "dis", # Disarm + HOME: "p1", # Arm P1 + NIGHT: "p12", # Arm P1+P2 + AWAY: "tot", # Arm P1+P2 + IR / volumetric +} + + +ALARM_AREA_ARMED_STATUS: dict[str, int] = { + HOME_P1: 1, + HOME_P2: 2, + NIGHT: 3, + AWAY: 4, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Comelit VEDO system alarm control panel devices.""" + + coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + ComelitAlarmEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[ALARM_AREAS].values() + ) + + +class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanelEntity): + """Representation of a Ness alarm panel.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_code_format = CodeFormat.NUMBER + _attr_code_arm_required = False + _attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_HOME + ) + + def __init__( + self, + coordinator: ComelitVedoSystem, + area: ComelitVedoAreaObject, + config_entry_entry_id: str, + ) -> None: + """Initialize the alarm panel.""" + self._api = coordinator.api + self._area_index = area.index + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{area.index}" + self._attr_device_info = coordinator.platform_device_info(area, "area") + if area.p2: + self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_NIGHT + + @property + def _area(self) -> ComelitVedoAreaObject: + """Return area object.""" + return self.coordinator.data[ALARM_AREAS][self._area_index] + + @property + def available(self) -> bool: + """Return True if alarm is available.""" + if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]: + return False + return super().available + + @property + def state(self) -> StateType: + """Return the state of the alarm.""" + + _LOGGER.debug( + "Area %s status is: %s. Armed is %s", + self._area.name, + self._area.human_status, + self._area.armed, + ) + if self._area.human_status == AlarmAreaState.ARMED: + if self._area.armed == ALARM_AREA_ARMED_STATUS[AWAY]: + return STATE_ALARM_ARMED_AWAY + if self._area.armed == ALARM_AREA_ARMED_STATUS[NIGHT]: + return STATE_ALARM_ARMED_NIGHT + return STATE_ALARM_ARMED_HOME + + return { + AlarmAreaState.DISARMED: STATE_ALARM_DISARMED, + AlarmAreaState.ENTRY_DELAY: STATE_ALARM_DISARMING, + AlarmAreaState.EXIT_DELAY: STATE_ALARM_ARMING, + AlarmAreaState.TRIGGERED: STATE_ALARM_TRIGGERED, + }.get(self._area.human_status) + + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + if code != str(self._api.device_pin): + return + await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[DISABLE]) + + async def async_alarm_arm_away(self, code: str | None = None) -> None: + """Send arm away command.""" + await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[AWAY]) + + async def async_alarm_arm_home(self, code: str | None = None) -> None: + """Send arm home command.""" + await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[HOME]) + + async def async_alarm_arm_night(self, code: str | None = None) -> None: + """Send arm night command.""" + await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[NIGHT]) diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index b95853edf9d..cbd79ac1e1a 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -4,16 +4,22 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from aiocomelit import ComeliteSerialBridgeApi, exceptions as aiocomelit_exceptions +from aiocomelit import ( + ComeliteSerialBridgeApi, + ComelitVedoApi, + exceptions as aiocomelit_exceptions, +) +from aiocomelit.api import ComelitCommonApi +from aiocomelit.const import BRIDGE import voluptuous as vol from homeassistant import core, exceptions from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from .const import _LOGGER, DEFAULT_PORT, DOMAIN +from .const import _LOGGER, DEFAULT_PORT, DEVICE_TYPE_LIST, DOMAIN DEFAULT_HOST = "192.168.1.252" DEFAULT_PIN = 111111 @@ -27,6 +33,7 @@ def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, + vol.Required(CONF_TYPE, default=BRIDGE): vol.In(DEVICE_TYPE_LIST), } ) @@ -39,7 +46,11 @@ async def validate_input( ) -> dict[str, str]: """Validate the user input allows us to connect.""" - api = ComeliteSerialBridgeApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) + api: ComelitCommonApi + if data.get(CONF_TYPE, BRIDGE) == BRIDGE: + api = ComeliteSerialBridgeApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) + else: + api = ComelitVedoApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) try: await api.login() diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py index 57b7f35bc17..ca10e0b0a74 100644 --- a/homeassistant/components/comelit/const.py +++ b/homeassistant/components/comelit/const.py @@ -1,7 +1,10 @@ """Comelit constants.""" import logging +from aiocomelit.const import BRIDGE, VEDO + _LOGGER = logging.getLogger(__package__) DOMAIN = "comelit" DEFAULT_PORT = 80 +DEVICE_TYPE_LIST = [BRIDGE, VEDO] diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 1573d5cb627..6559e2ffb87 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -1,9 +1,18 @@ """Support for Comelit.""" +from abc import abstractmethod from datetime import timedelta from typing import Any -from aiocomelit import ComeliteSerialBridgeApi, ComelitSerialBridgeObject, exceptions -from aiocomelit.const import BRIDGE +from aiocomelit import ( + ComeliteSerialBridgeApi, + ComelitSerialBridgeObject, + ComelitVedoApi, + ComelitVedoAreaObject, + ComelitVedoZoneObject, + exceptions, +) +from aiocomelit.api import ComelitCommonApi +from aiocomelit.const import BRIDGE, VEDO from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -14,19 +23,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import _LOGGER, DOMAIN -class ComelitSerialBridge(DataUpdateCoordinator): - """Queries Comelit Serial Bridge.""" +class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Base coordinator for Comelit Devices.""" + _hw_version: str config_entry: ConfigEntry + api: ComelitCommonApi - def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: + def __init__(self, hass: HomeAssistant, device: str, host: str) -> None: """Initialize the scanner.""" + self._device = device self._host = host - self._port = port - self._pin = pin - - self.api = ComeliteSerialBridgeApi(host, port, pin) super().__init__( hass=hass, @@ -38,43 +46,82 @@ class ComelitSerialBridge(DataUpdateCoordinator): device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, identifiers={(DOMAIN, self.config_entry.entry_id)}, - model=BRIDGE, - name=f"{BRIDGE} ({self.api.host})", - **self.basic_device_info, + model=device, + name=f"{device} ({self._host})", + manufacturer="Comelit", + hw_version=self._hw_version, ) - @property - def basic_device_info(self) -> dict: - """Set basic device info.""" - - return { - "manufacturer": "Comelit", - "hw_version": "20003101", - } - - def platform_device_info(self, device: ComelitSerialBridgeObject) -> dr.DeviceInfo: + def platform_device_info( + self, + object_class: ComelitVedoZoneObject + | ComelitVedoAreaObject + | ComelitSerialBridgeObject, + object_type: str, + ) -> dr.DeviceInfo: """Set platform device info.""" return dr.DeviceInfo( identifiers={ - (DOMAIN, f"{self.config_entry.entry_id}-{device.type}-{device.index}") + ( + DOMAIN, + f"{self.config_entry.entry_id}-{object_type}-{object_class.index}", + ) }, via_device=(DOMAIN, self.config_entry.entry_id), - name=device.name, - model=f"{BRIDGE} {device.type}", - **self.basic_device_info, + name=object_class.name, + model=f"{self._device} {object_type}", + manufacturer="Comelit", + hw_version=self._hw_version, ) async def _async_update_data(self) -> dict[str, Any]: """Update device data.""" - _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) - + _LOGGER.debug("Polling Comelit %s host: %s", self._device, self._host) try: await self.api.login() - return await self.api.get_all_devices() + return await self._async_update_system_data() except exceptions.CannotConnect as err: _LOGGER.warning("Connection error for %s", self._host) await self.api.close() raise UpdateFailed(f"Error fetching data: {repr(err)}") from err except exceptions.CannotAuthenticate: raise ConfigEntryAuthFailed + + return {} + + @abstractmethod + async def _async_update_system_data(self) -> dict[str, Any]: + """Class method for updating data.""" + + +class ComelitSerialBridge(ComelitBaseCoordinator): + """Queries Comelit Serial Bridge.""" + + _hw_version = "20003101" + api: ComeliteSerialBridgeApi + + def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: + """Initialize the scanner.""" + self.api = ComeliteSerialBridgeApi(host, port, pin) + super().__init__(hass, BRIDGE, host) + + async def _async_update_system_data(self) -> dict[str, Any]: + """Specific method for updating data.""" + return await self.api.get_all_devices() + + +class ComelitVedoSystem(ComelitBaseCoordinator): + """Queries Comelit VEDO system.""" + + _hw_version = "VEDO IP" + api: ComelitVedoApi + + def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: + """Initialize the scanner.""" + self.api = ComelitVedoApi(host, port, pin) + super().__init__(hass, VEDO, host) + + async def _async_update_system_data(self) -> dict[str, Any]: + """Specific method for updating data.""" + return await self.api.get_all_areas_and_zones() diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 72bbf56e08a..d35180c761b 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -54,7 +54,7 @@ class ComelitCoverEntity( # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device) + self._attr_device_info = coordinator.platform_device_info(device, device.type) # Device doesn't provide a status so we assume UNKNOWN at first startup self._last_action: int | None = None self._last_state: str | None = None diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 95906f7ec6e..7deb3d49624 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -50,7 +50,7 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device) + self._attr_device_info = coordinator.platform_device_info(device, device.type) async def _light_set_state(self, state: int) -> None: """Set desired light state.""" diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 89157b54255..8b50ccdf767 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.6.2"] + "requirements": ["aiocomelit==0.7.0"] } diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index 554433fa6ad..66b04e6ae98 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -3,8 +3,8 @@ from __future__ import annotations from typing import Final -from aiocomelit import ComelitSerialBridgeObject -from aiocomelit.const import OTHER +from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject +from aiocomelit.const import ALARM_ZONES, BRIDGE, OTHER, AlarmZoneState from homeassistant.components.sensor import ( SensorDeviceClass, @@ -12,16 +12,16 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfPower +from homeassistant.const import CONF_TYPE, UnitOfPower from homeassistant.core import HomeAssistant 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 ComelitSerialBridge +from .coordinator import ComelitSerialBridge, ComelitVedoSystem -SENSOR_TYPES: Final = ( +SENSOR_BRIDGE_TYPES: Final = ( SensorEntityDescription( key="power", native_unit_of_measurement=UnitOfPower.WATT, @@ -29,6 +29,17 @@ SENSOR_TYPES: Final = ( ), ) +SENSOR_VEDO_TYPES: Final = ( + SensorEntityDescription( + key="human_status", + translation_key="zone_status", + name=None, + device_class=SensorDeviceClass.ENUM, + icon="mdi:shield-check", + options=[zone_state.value for zone_state in AlarmZoneState], + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -37,23 +48,57 @@ async def async_setup_entry( ) -> None: """Set up Comelit sensors.""" + if config_entry.data.get(CONF_TYPE, BRIDGE) == BRIDGE: + await async_setup_bridge_entry(hass, config_entry, async_add_entities) + else: + await async_setup_vedo_entry(hass, config_entry, async_add_entities) + + +async def async_setup_bridge_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit Bridge sensors.""" + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] - entities: list[ComelitSensorEntity] = [] + entities: list[ComelitBridgeSensorEntity] = [] for device in coordinator.data[OTHER].values(): entities.extend( - ComelitSensorEntity(coordinator, device, config_entry.entry_id, sensor_desc) - for sensor_desc in SENSOR_TYPES + ComelitBridgeSensorEntity( + coordinator, device, config_entry.entry_id, sensor_desc + ) + for sensor_desc in SENSOR_BRIDGE_TYPES ) - async_add_entities(entities) -class ComelitSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): +async def async_setup_vedo_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit VEDO sensors.""" + + coordinator: ComelitVedoSystem = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[ComelitVedoSensorEntity] = [] + for device in coordinator.data[ALARM_ZONES].values(): + entities.extend( + ComelitVedoSensorEntity( + coordinator, device, config_entry.entry_id, sensor_desc + ) + for sensor_desc in SENSOR_VEDO_TYPES + ) + async_add_entities(entities) + + +class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): """Sensor device.""" _attr_has_entity_name = True - entity_description: SensorEntityDescription + _attr_name = None def __init__( self, @@ -69,7 +114,7 @@ class ComelitSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device) + self._attr_device_info = coordinator.platform_device_info(device, device.type) self.entity_description = description @@ -80,3 +125,45 @@ class ComelitSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): self.coordinator.data[OTHER][self._device.index], self.entity_description.key, ) + + +class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity): + """Sensor device.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ComelitVedoSystem, + zone: ComelitVedoZoneObject, + config_entry_entry_id: str, + description: SensorEntityDescription, + ) -> None: + """Init sensor entity.""" + self._api = coordinator.api + self._zone = zone + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{zone.index}" + self._attr_device_info = coordinator.platform_device_info(zone, "zone") + + self.entity_description = description + + @property + def _zone_object(self) -> ComelitVedoZoneObject: + """Zone object.""" + return self.coordinator.data[ALARM_ZONES][self._zone.index] + + @property + def available(self) -> bool: + """Sensor availability.""" + return self._zone_object.human_status != AlarmZoneState.UNAVAILABLE + + @property + def native_value(self) -> StateType: + """Sensor value.""" + if (status := self._zone_object.human_status) == AlarmZoneState.UNKNOWN: + return None + + return status.value diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 73c2c7d00c6..dac8bc4123d 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -31,5 +31,22 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "zone_status": { + "state": { + "alarm": "Alarm", + "armed": "Armed", + "open": "Open", + "excluded": "Excluded", + "faulty": "Faulty", + "inhibited": "Inhibited", + "isolated": "Isolated", + "rest": "Rest", + "sabotated": "Sabotated" + } + } + } } } diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 379b936c3bb..ce08c64fa78 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -56,7 +56,7 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): # Use config_entry.entry_id as base for unique_id # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.type}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device) + self._attr_device_info = coordinator.platform_device_info(device, device.type) if device.type == OTHER: self._attr_device_class = SwitchDeviceClass.OUTLET diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 3f00a9b59f0..f76ed5939f5 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -22,10 +22,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index 21e6eda255d..421643f5ced 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -79,14 +79,14 @@ ATTR_SUPPLY_TEMPERATURE = "supply_temperature" _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ComfoconnectRequiredKeysMixin: """Mixin for required keys.""" sensor_id: int -@dataclass +@dataclass(frozen=True) class ComfoconnectSensorEntityDescription( SensorEntityDescription, ComfoconnectRequiredKeysMixin ): diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index cb03499d8e4..5f0c7b171ae 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.5.1", "home-assistant-intents==2023.12.05"] + "requirements": ["hassil==1.5.1", "home-assistant-intents==2024.1.2"] } diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 354b972e2b7..3e438fb4ca1 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag, StrEnum import functools as ft import logging -from typing import Any, ParamSpec, TypeVar, final +from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, final import voluptuous as vol @@ -33,11 +32,21 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) DOMAIN = "cover" @@ -70,16 +79,32 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(CoverDeviceClass)) # DEVICE_CLASS* below are deprecated as of 2021.12 # use the CoverDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass] -DEVICE_CLASS_AWNING = CoverDeviceClass.AWNING.value -DEVICE_CLASS_BLIND = CoverDeviceClass.BLIND.value -DEVICE_CLASS_CURTAIN = CoverDeviceClass.CURTAIN.value -DEVICE_CLASS_DAMPER = CoverDeviceClass.DAMPER.value -DEVICE_CLASS_DOOR = CoverDeviceClass.DOOR.value -DEVICE_CLASS_GARAGE = CoverDeviceClass.GARAGE.value -DEVICE_CLASS_GATE = CoverDeviceClass.GATE.value -DEVICE_CLASS_SHADE = CoverDeviceClass.SHADE.value -DEVICE_CLASS_SHUTTER = CoverDeviceClass.SHUTTER.value -DEVICE_CLASS_WINDOW = CoverDeviceClass.WINDOW.value +_DEPRECATED_DEVICE_CLASS_AWNING = DeprecatedConstantEnum( + CoverDeviceClass.AWNING, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_BLIND = DeprecatedConstantEnum( + CoverDeviceClass.BLIND, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_CURTAIN = DeprecatedConstantEnum( + CoverDeviceClass.CURTAIN, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DAMPER = DeprecatedConstantEnum( + CoverDeviceClass.DAMPER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DOOR = DeprecatedConstantEnum(CoverDeviceClass.DOOR, "2025.1") +_DEPRECATED_DEVICE_CLASS_GARAGE = DeprecatedConstantEnum( + CoverDeviceClass.GARAGE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_GATE = DeprecatedConstantEnum(CoverDeviceClass.GATE, "2025.1") +_DEPRECATED_DEVICE_CLASS_SHADE = DeprecatedConstantEnum( + CoverDeviceClass.SHADE, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SHUTTER = DeprecatedConstantEnum( + CoverDeviceClass.SHUTTER, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_WINDOW = DeprecatedConstantEnum( + CoverDeviceClass.WINDOW, "2025.1" +) # mypy: disallow-any-generics @@ -99,14 +124,28 @@ class CoverEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the CoverEntityFeature enum instead. -SUPPORT_OPEN = 1 -SUPPORT_CLOSE = 2 -SUPPORT_SET_POSITION = 4 -SUPPORT_STOP = 8 -SUPPORT_OPEN_TILT = 16 -SUPPORT_CLOSE_TILT = 32 -SUPPORT_STOP_TILT = 64 -SUPPORT_SET_TILT_POSITION = 128 +_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(CoverEntityFeature.OPEN, "2025.1") +_DEPRECATED_SUPPORT_CLOSE = DeprecatedConstantEnum(CoverEntityFeature.CLOSE, "2025.1") +_DEPRECATED_SUPPORT_SET_POSITION = DeprecatedConstantEnum( + CoverEntityFeature.SET_POSITION, "2025.1" +) +_DEPRECATED_SUPPORT_STOP = DeprecatedConstantEnum(CoverEntityFeature.STOP, "2025.1") +_DEPRECATED_SUPPORT_OPEN_TILT = DeprecatedConstantEnum( + CoverEntityFeature.OPEN_TILT, "2025.1" +) +_DEPRECATED_SUPPORT_CLOSE_TILT = DeprecatedConstantEnum( + CoverEntityFeature.CLOSE_TILT, "2025.1" +) +_DEPRECATED_SUPPORT_STOP_TILT = DeprecatedConstantEnum( + CoverEntityFeature.STOP_TILT, "2025.1" +) +_DEPRECATED_SUPPORT_SET_TILT_POSITION = DeprecatedConstantEnum( + CoverEntityFeature.SET_TILT_POSITION, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) ATTR_CURRENT_POSITION = "current_position" ATTR_CURRENT_TILT_POSITION = "current_tilt_position" @@ -212,14 +251,23 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class CoverEntityDescription(EntityDescription): +class CoverEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes cover entities.""" device_class: CoverDeviceClass | None = None -class CoverEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "current_cover_position", + "current_cover_tilt_position", + "device_class", + "is_opening", + "is_closing", + "is_closed", +} + + +class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for cover entities.""" entity_description: CoverEntityDescription @@ -234,7 +282,7 @@ class CoverEntity(Entity): _cover_is_last_toggle_direction_open = True - @property + @cached_property def current_cover_position(self) -> int | None: """Return current position of cover. @@ -242,7 +290,7 @@ class CoverEntity(Entity): """ return self._attr_current_cover_position - @property + @cached_property def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt. @@ -250,7 +298,7 @@ class CoverEntity(Entity): """ return self._attr_current_cover_tilt_position - @property + @cached_property def device_class(self) -> CoverDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -292,8 +340,12 @@ class CoverEntity(Entity): @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" - if self._attr_supported_features is not None: - return self._attr_supported_features + if (features := self._attr_supported_features) is not None: + if type(features) is int: # noqa: E721 + new_features = CoverEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP @@ -312,17 +364,17 @@ class CoverEntity(Entity): return supported_features - @property + @cached_property def is_opening(self) -> bool | None: """Return if the cover is opening or not.""" return self._attr_is_opening - @property + @cached_property def is_closing(self) -> bool | None: """Return if the cover is closing or not.""" return self._attr_is_closing - @property + @cached_property def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" return self._attr_is_closed diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py index e34a623be93..2224e5bab1c 100644 --- a/homeassistant/components/cover/device_action.py +++ b/homeassistant/components/cover/device_action.py @@ -24,18 +24,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import ( - ATTR_POSITION, - ATTR_TILT_POSITION, - DOMAIN, - SUPPORT_CLOSE, - SUPPORT_CLOSE_TILT, - SUPPORT_OPEN, - SUPPORT_OPEN_TILT, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, - SUPPORT_STOP, -) +from . import ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN, CoverEntityFeature CMD_ACTION_TYPES = {"open", "close", "stop", "open_tilt", "close_tilt"} POSITION_ACTION_TYPES = {"set_position", "set_tilt_position"} @@ -88,20 +77,20 @@ async def async_get_actions( CONF_ENTITY_ID: entry.id, } - if supported_features & SUPPORT_SET_POSITION: + if supported_features & CoverEntityFeature.SET_POSITION: actions.append({**base_action, CONF_TYPE: "set_position"}) - if supported_features & SUPPORT_OPEN: + if supported_features & CoverEntityFeature.OPEN: actions.append({**base_action, CONF_TYPE: "open"}) - if supported_features & SUPPORT_CLOSE: + if supported_features & CoverEntityFeature.CLOSE: actions.append({**base_action, CONF_TYPE: "close"}) - if supported_features & SUPPORT_STOP: + if supported_features & CoverEntityFeature.STOP: actions.append({**base_action, CONF_TYPE: "stop"}) - if supported_features & SUPPORT_SET_TILT_POSITION: + if supported_features & CoverEntityFeature.SET_TILT_POSITION: actions.append({**base_action, CONF_TYPE: "set_tilt_position"}) - if supported_features & SUPPORT_OPEN_TILT: + if supported_features & CoverEntityFeature.OPEN_TILT: actions.append({**base_action, CONF_TYPE: "open_tilt"}) - if supported_features & SUPPORT_CLOSE_TILT: + if supported_features & CoverEntityFeature.CLOSE_TILT: actions.append({**base_action, CONF_TYPE: "close_tilt"}) return actions diff --git a/homeassistant/components/cover/device_condition.py b/homeassistant/components/cover/device_condition.py index 2aa0a1dd2fb..23ec7d75650 100644 --- a/homeassistant/components/cover/device_condition.py +++ b/homeassistant/components/cover/device_condition.py @@ -26,13 +26,7 @@ from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import ( - DOMAIN, - SUPPORT_CLOSE, - SUPPORT_OPEN, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, -) +from . import DOMAIN, CoverEntityFeature # mypy: disallow-any-generics @@ -78,7 +72,9 @@ async def async_get_conditions( continue supported_features = get_supported_features(hass, entry.entity_id) - supports_open_close = supported_features & (SUPPORT_OPEN | SUPPORT_CLOSE) + supports_open_close = supported_features & ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) # Add conditions for each entity that belongs to this integration base_condition = { @@ -92,9 +88,9 @@ async def async_get_conditions( conditions += [ {**base_condition, CONF_TYPE: cond} for cond in STATE_CONDITION_TYPES ] - if supported_features & SUPPORT_SET_POSITION: + if supported_features & CoverEntityFeature.SET_POSITION: conditions.append({**base_condition, CONF_TYPE: "is_position"}) - if supported_features & SUPPORT_SET_TILT_POSITION: + if supported_features & CoverEntityFeature.SET_TILT_POSITION: conditions.append({**base_condition, CONF_TYPE: "is_tilt_position"}) return conditions diff --git a/homeassistant/components/cover/device_trigger.py b/homeassistant/components/cover/device_trigger.py index 2fb456d726d..8225348619d 100644 --- a/homeassistant/components/cover/device_trigger.py +++ b/homeassistant/components/cover/device_trigger.py @@ -29,13 +29,7 @@ from homeassistant.helpers.entity import get_supported_features from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType -from . import ( - DOMAIN, - SUPPORT_CLOSE, - SUPPORT_OPEN, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, -) +from . import DOMAIN, CoverEntityFeature POSITION_TRIGGER_TYPES = {"position", "tilt_position"} STATE_TRIGGER_TYPES = {"opened", "closed", "opening", "closing"} @@ -80,7 +74,9 @@ async def async_get_triggers( continue supported_features = get_supported_features(hass, entry.entity_id) - supports_open_close = supported_features & (SUPPORT_OPEN | SUPPORT_CLOSE) + supports_open_close = supported_features & ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) # Add triggers for each entity that belongs to this integration base_trigger = { @@ -98,14 +94,14 @@ async def async_get_triggers( } for trigger in STATE_TRIGGER_TYPES ] - if supported_features & SUPPORT_SET_POSITION: + if supported_features & CoverEntityFeature.SET_POSITION: triggers.append( { **base_trigger, CONF_TYPE: "position", } ) - if supported_features & SUPPORT_SET_TILT_POSITION: + if supported_features & CoverEntityFeature.SET_TILT_POSITION: triggers.append( { **base_trigger, diff --git a/homeassistant/components/cover/significant_change.py b/homeassistant/components/cover/significant_change.py new file mode 100644 index 00000000000..ca822c5e9e1 --- /dev/null +++ b/homeassistant/components/cover/significant_change.py @@ -0,0 +1,56 @@ +"""Helper to test significant Cover state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 1.0): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index 1646e292ee9..9e7a181ba32 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -39,14 +39,14 @@ from .const import ( ) -@dataclass +@dataclass(frozen=True) class DaikinRequiredKeysMixin: """Mixin for required keys.""" value_func: Callable[[Appliance], float | None] -@dataclass +@dataclass(frozen=True) class DaikinSensorEntityDescription(SensorEntityDescription, DaikinRequiredKeysMixin): """Describes Daikin sensor entity.""" diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py index 51f3a492c47..00ec09043c9 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -1,10 +1,9 @@ """Component to allow setting date as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import date, timedelta import logging -from typing import final +from typing import TYPE_CHECKING, final import voluptuous as vol @@ -22,6 +21,12 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, SERVICE_SET_VALUE +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -62,12 +67,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class DateEntityDescription(EntityDescription): +class DateEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes date entities.""" -class DateEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = {"native_value"} + + +class DateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Date entity.""" entity_description: DateEntityDescription @@ -75,13 +82,13 @@ class DateEntity(Entity): _attr_native_value: date | None _attr_state: None = None - @property + @cached_property @final def device_class(self) -> None: """Return the device class for the entity.""" return None - @property + @cached_property @final def state_attributes(self) -> None: """Return the state attributes.""" @@ -95,7 +102,7 @@ class DateEntity(Entity): return None return self.native_value.isoformat() - @property + @cached_property def native_value(self) -> date | None: """Return the value reported by the date.""" return self._attr_native_value diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index e25f4535d0c..9a509aadc70 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -1,10 +1,9 @@ """Component to allow setting date/time as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import UTC, datetime, timedelta import logging -from typing import final +from typing import TYPE_CHECKING, final import voluptuous as vol @@ -22,6 +21,11 @@ from homeassistant.util import dt as dt_util from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -71,12 +75,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class DateTimeEntityDescription(EntityDescription): +class DateTimeEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes date/time entities.""" -class DateTimeEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "native_value", +} + + +class DateTimeEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Date/time entity.""" entity_description: DateTimeEntityDescription @@ -84,13 +92,13 @@ class DateTimeEntity(Entity): _attr_state: None = None _attr_native_value: datetime | None - @property + @cached_property @final def device_class(self) -> None: """Return entity device class.""" return None - @property + @cached_property @final def state_attributes(self) -> None: """Return the state attributes.""" @@ -110,7 +118,7 @@ class DateTimeEntity(Entity): return value.astimezone(UTC).isoformat(timespec="seconds") - @property + @cached_property def native_value(self) -> datetime | None: """Return the value reported by the datetime.""" return self._attr_native_value diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 84141eac964..c0a4e2585a3 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -65,7 +65,7 @@ T = TypeVar( ) -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class DeconzBinarySensorDescription(Generic[T], BinarySensorEntityDescription): """Class describing deCONZ binary sensor entities.""" diff --git a/homeassistant/components/deconz/button.py b/homeassistant/components/deconz/button.py index 81d839ea0f2..52105c10203 100644 --- a/homeassistant/components/deconz/button.py +++ b/homeassistant/components/deconz/button.py @@ -23,7 +23,7 @@ from .deconz_device import DeconzDevice, DeconzSceneMixin from .gateway import DeconzGateway, get_gateway_from_config_entry -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class DeconzButtonDescription(ButtonEntityDescription): """Class describing deCONZ button entities.""" diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index 7cc0da936cb..e98f5d726ac 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -31,7 +31,7 @@ from .util import serial_from_unique_id T = TypeVar("T", Presence, PydeconzSensorBase) -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class DeconzNumberDescription(Generic[T], NumberEntityDescription): """Class describing deCONZ number entities.""" diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index ecb9ac9b297..8366c811318 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -93,7 +93,7 @@ T = TypeVar( ) -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class DeconzSensorDescription(Generic[T], SensorEntityDescription): """Class describing deCONZ binary sensor entities.""" diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py index d060b69c3f6..4a56b72ec66 100644 --- a/homeassistant/components/decora/light.py +++ b/homeassistant/components/decora/light.py @@ -60,7 +60,7 @@ PLATFORM_SCHEMA = vol.Schema( def retry( - method: Callable[Concatenate[_DecoraLightT, _P], _R] + method: Callable[Concatenate[_DecoraLightT, _P], _R], ) -> Callable[Concatenate[_DecoraLightT, _P], _R | None]: """Retry bluetooth commands.""" diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py index 9242e3e2d5e..eeb947663bf 100644 --- a/homeassistant/components/deluge/sensor.py +++ b/homeassistant/components/deluge/sensor.py @@ -38,7 +38,7 @@ def get_state(data: dict[str, float], key: str) -> str | float: return round(kb_spd, 2 if kb_spd < 0.1 else 1) -@dataclass +@dataclass(frozen=True) class DelugeSensorEntityDescription(SensorEntityDescription): """Class to describe a Deluge sensor.""" diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py index 722693280a0..502129b5c9d 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -19,6 +19,7 @@ async def async_setup_entry( [ DemoCamera("Demo camera", "image/jpg"), DemoCamera("Demo camera png", "image/png"), + DemoCameraWithoutStream("Demo camera without stream", "image/jpg"), ] ) @@ -28,7 +29,7 @@ class DemoCamera(Camera): _attr_is_streaming = True _attr_motion_detection_enabled = False - _attr_supported_features = CameraEntityFeature.ON_OFF + _attr_supported_features = CameraEntityFeature.ON_OFF | CameraEntityFeature.STREAM def __init__(self, name: str, content_type: str) -> None: """Initialize demo camera component.""" @@ -68,3 +69,9 @@ class DemoCamera(Camera): self._attr_is_streaming = True self._attr_is_on = True self.async_write_ha_state() + + +class DemoCameraWithoutStream(DemoCamera): + """The representation of a Demo camera without stream.""" + + _attr_supported_features = CameraEntityFeature.ON_OFF diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index 1e585b12acd..0eaa7d5f41f 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -73,7 +73,7 @@ async def async_setup_entry( target_temperature=None, unit_of_measurement=UnitOfTemperature.CELSIUS, preset="home", - preset_modes=["home", "eco"], + preset_modes=["home", "eco", "away"], current_temperature=23, fan_mode="Auto Low", target_humidity=None, diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 35bd35a2245..b0b2e1a95f5 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -38,6 +38,8 @@ async def async_setup_entry( DemoMusicPlayer(), DemoMusicPlayer("Kitchen"), DemoTVShowPlayer(), + DemoBrowsePlayer("Browse"), + DemoGroupPlayer("Group"), ] ) @@ -90,6 +92,8 @@ NETFLIX_PLAYER_SUPPORT = ( | MediaPlayerEntityFeature.STOP ) +BROWSE_PLAYER_SUPPORT = MediaPlayerEntityFeature.BROWSE_MEDIA + class AbstractDemoPlayer(MediaPlayerEntity): """A demo media players.""" @@ -379,3 +383,19 @@ class DemoTVShowPlayer(AbstractDemoPlayer): """Set the input source.""" self._attr_source = source self.schedule_update_ha_state() + + +class DemoBrowsePlayer(AbstractDemoPlayer): + """A Demo media player that supports browse.""" + + _attr_supported_features = BROWSE_PLAYER_SUPPORT + + +class DemoGroupPlayer(AbstractDemoPlayer): + """A Demo media player that supports grouping.""" + + _attr_supported_features = ( + YOUTUBE_PLAYER_SUPPORT + | MediaPlayerEntityFeature.GROUPING + | MediaPlayerEntityFeature.TURN_OFF + ) diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index d7641c34316..68d05c19f67 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -358,7 +358,7 @@ def async_validate_entity_schema( def handle_device_errors( - func: Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], Awaitable[None]] + func: Callable[[HomeAssistant, ActiveConnection, dict[str, Any]], Awaitable[None]], ) -> Callable[ [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] ]: diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index a6a8e9d2d8c..b5ad4660cde 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,8 +1,14 @@ """Provide functionality to keep track of devices.""" from __future__ import annotations +from functools import partial + from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401 from homeassistant.core import HomeAssistant +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -13,6 +19,10 @@ from .config_entry import ( # noqa: F401 async_unload_entry, ) from .const import ( # noqa: F401 + _DEPRECATED_SOURCE_TYPE_BLUETOOTH, + _DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE, + _DEPRECATED_SOURCE_TYPE_GPS, + _DEPRECATED_SOURCE_TYPE_ROUTER, ATTR_ATTRIBUTES, ATTR_BATTERY, ATTR_DEV_ID, @@ -32,10 +42,6 @@ from .const import ( # noqa: F401 DOMAIN, ENTITY_ID_FORMAT, SCAN_INTERVAL, - SOURCE_TYPE_BLUETOOTH, - SOURCE_TYPE_BLUETOOTH_LE, - SOURCE_TYPE_GPS, - SOURCE_TYPE_ROUTER, SourceType, ) from .legacy import ( # noqa: F401 @@ -51,6 +57,12 @@ from .legacy import ( # noqa: F401 see, ) +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + @bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index 3a0b0afd7c9..10c16e09107 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -3,9 +3,16 @@ from __future__ import annotations from datetime import timedelta from enum import StrEnum +from functools import partial import logging from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + LOGGER: Final = logging.getLogger(__package__) DOMAIN: Final = "device_tracker" @@ -14,13 +21,6 @@ ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" PLATFORM_TYPE_LEGACY: Final = "legacy" PLATFORM_TYPE_ENTITY: Final = "entity_platform" -# SOURCE_TYPE_* below are deprecated as of 2022.9 -# use the SourceType enum instead. -SOURCE_TYPE_GPS: Final = "gps" -SOURCE_TYPE_ROUTER: Final = "router" -SOURCE_TYPE_BLUETOOTH: Final = "bluetooth" -SOURCE_TYPE_BLUETOOTH_LE: Final = "bluetooth_le" - class SourceType(StrEnum): """Source type for device trackers.""" @@ -31,6 +31,23 @@ class SourceType(StrEnum): BLUETOOTH_LE = "bluetooth_le" +# SOURCE_TYPE_* below are deprecated as of 2022.9 +# use the SourceType enum instead. +_DEPRECATED_SOURCE_TYPE_GPS: Final = DeprecatedConstantEnum(SourceType.GPS, "2025.1") +_DEPRECATED_SOURCE_TYPE_ROUTER: Final = DeprecatedConstantEnum( + SourceType.ROUTER, "2025.1" +) +_DEPRECATED_SOURCE_TYPE_BLUETOOTH: Final = DeprecatedConstantEnum( + SourceType.BLUETOOTH, "2025.1" +) +_DEPRECATED_SOURCE_TYPE_BLUETOOTH_LE: Final = DeprecatedConstantEnum( + SourceType.BLUETOOTH_LE, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + CONF_SCAN_INTERVAL: Final = "interval_seconds" SCAN_INTERVAL: Final = timedelta(seconds=12) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index c931d256689..a17972526cf 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -14,7 +14,11 @@ import voluptuous as vol from homeassistant import util from homeassistant.backports.functools import cached_property from homeassistant.components import zone -from homeassistant.config import async_log_schema_error, load_yaml_config_file +from homeassistant.config import ( + async_log_schema_error, + config_per_platform, + load_yaml_config_file, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, @@ -33,7 +37,6 @@ from homeassistant.const import ( from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( - config_per_platform, config_validation as cv, discovery, entity_registry as er, @@ -284,7 +287,7 @@ class DeviceTrackerPlatform: ) -> None: """Set up a legacy platform.""" assert self.type == PLATFORM_TYPE_LEGACY - full_name = f"{DOMAIN}.{self.name}" + full_name = f"{self.name}.{DOMAIN}" LOGGER.info("Setting up %s", full_name) with async_start_setup(hass, [full_name]): try: diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json index 71ca03f9638..eb85e827551 100644 --- a/homeassistant/components/devolo_home_control/manifest.json +++ b/homeassistant/components/devolo_home_control/manifest.json @@ -9,6 +9,6 @@ "iot_class": "local_push", "loggers": ["devolo_home_control_api"], "quality_scale": "gold", - "requirements": ["devolo-home-control-api==0.18.2"], + "requirements": ["devolo-home-control-api==0.18.3"], "zeroconf": ["_dvl-deviceapi._tcp.local."] } diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index ebe7e60af7b..35b79b57f1d 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -32,14 +32,14 @@ def _is_connected_to_router(entity: DevoloBinarySensorEntity) -> bool: ) -@dataclass +@dataclass(frozen=True) class DevoloBinarySensorRequiredKeysMixin: """Mixin for required keys.""" value_func: Callable[[DevoloBinarySensorEntity], bool] -@dataclass +@dataclass(frozen=True) class DevoloBinarySensorEntityDescription( BinarySensorEntityDescription, DevoloBinarySensorRequiredKeysMixin ): diff --git a/homeassistant/components/devolo_home_network/button.py b/homeassistant/components/devolo_home_network/button.py index 463356268a6..9b3dd75ef98 100644 --- a/homeassistant/components/devolo_home_network/button.py +++ b/homeassistant/components/devolo_home_network/button.py @@ -22,14 +22,14 @@ from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS from .entity import DevoloEntity -@dataclass +@dataclass(frozen=True) class DevoloButtonRequiredKeysMixin: """Mixin for required keys.""" press_func: Callable[[Device], Awaitable[bool]] -@dataclass +@dataclass(frozen=True) class DevoloButtonEntityDescription( ButtonEntityDescription, DevoloButtonRequiredKeysMixin ): diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index aaee8051cb5..4caa4f5b60b 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -25,6 +25,8 @@ IDENTIFY = "identify" IMAGE_GUEST_WIFI = "image_guest_wifi" NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks" PAIRING = "pairing" +PLC_RX_RATE = "plc_rx_rate" +PLC_TX_RATE = "plc_tx_rate" REGULAR_FIRMWARE = "regular_firmware" RESTART = "restart" START_WPS = "start_wps" diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index a0aa0466d90..d6ddf661494 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -9,7 +9,7 @@ from devolo_plc_api.device_api import ( NeighborAPInfo, WifiGuestAccessGet, ) -from devolo_plc_api.plcnet_api import LogicalNetwork +from devolo_plc_api.plcnet_api import DataRate, LogicalNetwork from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -25,6 +25,7 @@ _DataT = TypeVar( "_DataT", bound=( LogicalNetwork + | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo] | WifiGuestAccessGet diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py index 3670c42bc6b..72cf4f57c1d 100644 --- a/homeassistant/components/devolo_home_network/image.py +++ b/homeassistant/components/devolo_home_network/image.py @@ -21,14 +21,14 @@ from .const import DOMAIN, IMAGE_GUEST_WIFI, SWITCH_GUEST_WIFI from .entity import DevoloCoordinatorEntity -@dataclass +@dataclass(frozen=True) class DevoloImageRequiredKeysMixin: """Mixin for required keys.""" image_func: Callable[[WifiGuestAccessGet], bytes] -@dataclass +@dataclass(frozen=True) class DevoloImageEntityDescription( ImageEntityDescription, DevoloImageRequiredKeysMixin ): diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 7a6da1f41a5..66395e3a465 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -3,19 +3,21 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from enum import StrEnum from typing import Any, Generic, TypeVar from devolo_plc_api.device import Device from devolo_plc_api.device_api import ConnectedStationInfo, NeighborAPInfo -from devolo_plc_api.plcnet_api import LogicalNetwork +from devolo_plc_api.plcnet_api import REMOTE, DataRate, LogicalNetwork from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -25,25 +27,38 @@ from .const import ( CONNECTED_WIFI_CLIENTS, DOMAIN, NEIGHBORING_WIFI_NETWORKS, + PLC_RX_RATE, + PLC_TX_RATE, ) from .entity import DevoloCoordinatorEntity -_DataT = TypeVar( - "_DataT", - bound=LogicalNetwork | list[ConnectedStationInfo] | list[NeighborAPInfo], +_CoordinatorDataT = TypeVar( + "_CoordinatorDataT", + bound=LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo], +) +_ValueDataT = TypeVar( + "_ValueDataT", + bound=LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo], ) -@dataclass -class DevoloSensorRequiredKeysMixin(Generic[_DataT]): +class DataRateDirection(StrEnum): + """Direction of data transfer.""" + + RX = "rx_rate" + TX = "tx_rate" + + +@dataclass(frozen=True) +class DevoloSensorRequiredKeysMixin(Generic[_CoordinatorDataT]): """Mixin for required keys.""" - value_func: Callable[[_DataT], int] + value_func: Callable[[_CoordinatorDataT], float] -@dataclass +@dataclass(frozen=True) class DevoloSensorEntityDescription( - SensorEntityDescription, DevoloSensorRequiredKeysMixin[_DataT] + SensorEntityDescription, DevoloSensorRequiredKeysMixin[_CoordinatorDataT] ): """Describes devolo sensor entity.""" @@ -71,6 +86,24 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = { icon="mdi:wifi-marker", value_func=len, ), + PLC_RX_RATE: DevoloSensorEntityDescription[DataRate]( + key=PLC_RX_RATE, + entity_category=EntityCategory.DIAGNOSTIC, + name="PLC downlink PHY rate", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + value_func=lambda data: getattr(data, DataRateDirection.RX, 0), + suggested_display_precision=0, + ), + PLC_TX_RATE: DevoloSensorEntityDescription[DataRate]( + key=PLC_TX_RATE, + entity_category=EntityCategory.DIAGNOSTIC, + name="PLC uplink PHY rate", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, + value_func=lambda data: getattr(data, DataRateDirection.TX, 0), + suggested_display_precision=0, + ), } @@ -83,7 +116,7 @@ async def async_setup_entry( entry.entry_id ]["coordinators"] - entities: list[DevoloSensorEntity[Any]] = [] + entities: list[BaseDevoloSensorEntity[Any, Any]] = [] if device.plcnet: entities.append( DevoloSensorEntity( @@ -93,6 +126,29 @@ async def async_setup_entry( device, ) ) + network = await device.plcnet.async_get_network_overview() + peers = [ + peer.mac_address for peer in network.devices if peer.topology == REMOTE + ] + for peer in peers: + entities.append( + DevoloPlcDataRateSensorEntity( + entry, + coordinators[CONNECTED_PLC_DEVICES], + SENSOR_TYPES[PLC_TX_RATE], + device, + peer, + ) + ) + entities.append( + DevoloPlcDataRateSensorEntity( + entry, + coordinators[CONNECTED_PLC_DEVICES], + SENSOR_TYPES[PLC_RX_RATE], + device, + peer, + ) + ) if device.device and "wifi1" in device.device.features: entities.append( DevoloSensorEntity( @@ -113,23 +169,70 @@ async def async_setup_entry( async_add_entities(entities) -class DevoloSensorEntity(DevoloCoordinatorEntity[_DataT], SensorEntity): +class BaseDevoloSensorEntity( + Generic[_CoordinatorDataT, _ValueDataT], + DevoloCoordinatorEntity[_CoordinatorDataT], + SensorEntity, +): """Representation of a devolo sensor.""" - entity_description: DevoloSensorEntityDescription[_DataT] - def __init__( self, entry: ConfigEntry, - coordinator: DataUpdateCoordinator[_DataT], - description: DevoloSensorEntityDescription[_DataT], + coordinator: DataUpdateCoordinator[_CoordinatorDataT], + description: DevoloSensorEntityDescription[_ValueDataT], device: Device, ) -> None: """Initialize entity.""" self.entity_description = description super().__init__(entry, coordinator, device) + +class DevoloSensorEntity(BaseDevoloSensorEntity[_CoordinatorDataT, _CoordinatorDataT]): + """Representation of a generic devolo sensor.""" + + entity_description: DevoloSensorEntityDescription[_CoordinatorDataT] + @property - def native_value(self) -> int: + def native_value(self) -> float: """State of the sensor.""" return self.entity_description.value_func(self.coordinator.data) + + +class DevoloPlcDataRateSensorEntity(BaseDevoloSensorEntity[LogicalNetwork, DataRate]): + """Representation of a devolo PLC data rate sensor.""" + + entity_description: DevoloSensorEntityDescription[DataRate] + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator[LogicalNetwork], + description: DevoloSensorEntityDescription[DataRate], + device: Device, + peer: str, + ) -> None: + """Initialize entity.""" + super().__init__(entry, coordinator, description, device) + self._peer = peer + peer_device = next( + device + for device in self.coordinator.data.devices + if device.mac_address == peer + ) + + self._attr_unique_id = f"{self._attr_unique_id}_{peer}" + self._attr_name = f"{description.name} ({peer_device.user_device_name})" + self._attr_entity_registry_enabled_default = peer_device.attached_to_router + + @property + def native_value(self) -> float: + """State of the sensor.""" + return self.entity_description.value_func( + next( + data_rate + for data_rate in self.coordinator.data.data_rates + if data_rate.mac_address_from == self.device.mac + and data_rate.mac_address_to == self._peer + ) + ) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 55a7920ab3e..1362417c125 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -62,6 +62,12 @@ }, "neighboring_wifi_networks": { "name": "Neighboring Wifi networks" + }, + "plc_rx_rate": { + "name": "PLC downlink PHY rate" + }, + "plc_tx_rate": { + "name": "PLC uplink PHY rate" } }, "switch": { diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index e7bcee3f2ec..99c23f77d35 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -23,7 +23,7 @@ from .entity import DevoloCoordinatorEntity _DataT = TypeVar("_DataT", bound=WifiGuestAccessGet | bool) -@dataclass +@dataclass(frozen=True) class DevoloSwitchRequiredKeysMixin(Generic[_DataT]): """Mixin for required keys.""" @@ -32,7 +32,7 @@ class DevoloSwitchRequiredKeysMixin(Generic[_DataT]): turn_off_func: Callable[[Device], Awaitable[bool]] -@dataclass +@dataclass(frozen=True) class DevoloSwitchEntityDescription( SwitchEntityDescription, DevoloSwitchRequiredKeysMixin[_DataT] ): diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py index 1c95c4262b2..03f86381307 100644 --- a/homeassistant/components/devolo_home_network/update.py +++ b/homeassistant/components/devolo_home_network/update.py @@ -26,7 +26,7 @@ from .const import DOMAIN, REGULAR_FIRMWARE from .entity import DevoloCoordinatorEntity -@dataclass +@dataclass(frozen=True) class DevoloUpdateRequiredKeysMixin: """Mixin for required keys.""" @@ -34,7 +34,7 @@ class DevoloUpdateRequiredKeysMixin: update_func: Callable[[Device], Awaitable[bool]] -@dataclass +@dataclass(frozen=True) class DevoloUpdateEntityDescription( UpdateEntityDescription, DevoloUpdateRequiredKeysMixin ): diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index c3705dad3dd..b8a12a937e3 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -9,7 +9,6 @@ from dataclasses import dataclass from datetime import timedelta from fnmatch import translate from functools import lru_cache -from ipaddress import ip_address as make_ip_address import logging import os import re @@ -22,6 +21,7 @@ from aiodiscover.discovery import ( IP_ADDRESS as DISCOVERY_IP_ADDRESS, MAC_ADDRESS as DISCOVERY_MAC_ADDRESS, ) +from cached_ipaddress import cached_ip_addresses from scapy.config import conf from scapy.error import Scapy_Exception @@ -57,7 +57,6 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.loader import DHCPMatcher, async_get_dhcp -from homeassistant.util.async_ import run_callback_threadsafe from .const import DOMAIN @@ -145,20 +144,19 @@ class WatcherBase(ABC): def process_client(self, ip_address: str, hostname: str, mac_address: str) -> None: """Process a client.""" - return run_callback_threadsafe( - self.hass.loop, - self.async_process_client, - ip_address, - hostname, - mac_address, - ).result() + self.hass.loop.call_soon_threadsafe( + self.async_process_client, ip_address, hostname, mac_address + ) @callback def async_process_client( self, ip_address: str, hostname: str, mac_address: str ) -> None: """Process a client.""" - made_ip_address = make_ip_address(ip_address) + if (made_ip_address := cached_ip_addresses(ip_address)) is None: + # Ignore invalid addresses + _LOGGER.debug("Ignoring invalid IP Address: %s", ip_address) + return if ( made_ip_address.is_link_local @@ -489,7 +487,7 @@ class DHCPWatcher(WatcherBase): def _dhcp_options_as_dict( - dhcp_options: Iterable[tuple[str, int | bytes | None]] + dhcp_options: Iterable[tuple[str, int | bytes | None]], ) -> dict[str, str | int | bytes | None]: """Extract data from packet options as a dict.""" return {option[0]: option[1] for option in dhcp_options if len(option) >= 2} diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index 3d9a5578045..f190f0ab10e 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -7,5 +7,9 @@ "iot_class": "local_push", "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], "quality_scale": "internal", - "requirements": ["scapy==2.5.0", "aiodiscover==1.5.1"] + "requirements": [ + "scapy==2.5.0", + "aiodiscover==1.6.0", + "cached_ipaddress==0.3.0" + ] } diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 9648492c2e4..df16551fff2 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -35,7 +35,7 @@ def _get_and_scale(reading: Reading, key: str, scale: int) -> datetime | float | return None -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class DiscovergySensorEntityDescription(SensorEntityDescription): """Class to describe a Discovergy sensor entity.""" diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 0fa884319c4..e2a07a3e351 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.36.2", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.38.0", "getmac==0.9.4"], "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 cd2f1ae2f50..749f2c887eb 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -55,7 +55,7 @@ _P = ParamSpec("_P") def catch_request_errors( - func: Callable[Concatenate[_DlnaDmrEntityT, _P], Awaitable[_R]] + func: Callable[Concatenate[_DlnaDmrEntityT, _P], Awaitable[_R]], ) -> Callable[Concatenate[_DlnaDmrEntityT, _P], Coroutine[Any, Any, _R | None]]: """Catch UpnpError errors.""" diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 6352d98da3c..62ff2be7d5b 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -124,7 +124,7 @@ class ActionError(DlnaDmsDeviceError): def catch_request_errors( - func: Callable[[_DlnaDmsDeviceMethod, str], Coroutine[Any, Any, _R]] + func: Callable[[_DlnaDmsDeviceMethod, str], Coroutine[Any, Any, _R]], ) -> Callable[[_DlnaDmsDeviceMethod, str], Coroutine[Any, Any, _R]]: """Catch UpnpError errors.""" diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index b3fa91a2e70..6173c9a3843 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.36.2"], + "requirements": ["async-upnp-client==0.38.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/doorbird/button.py b/homeassistant/components/doorbird/button.py index 1c69429d3c7..1e1b4c55e18 100644 --- a/homeassistant/components/doorbird/button.py +++ b/homeassistant/components/doorbird/button.py @@ -17,14 +17,14 @@ from .models import DoorBirdData IR_RELAY = "__ir_light__" -@dataclass +@dataclass(frozen=True) class DoorbirdButtonEntityDescriptionMixin: """Mixin to describe a Doorbird Button entity.""" press_action: Callable[[DoorBird, str], None] -@dataclass +@dataclass(frozen=True) class DoorbirdButtonEntityDescription( ButtonEntityDescription, DoorbirdButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/dormakaba_dkey/binary_sensor.py b/homeassistant/components/dormakaba_dkey/binary_sensor.py index 6cfbdd50b34..2ec2b0a1c91 100644 --- a/homeassistant/components/dormakaba_dkey/binary_sensor.py +++ b/homeassistant/components/dormakaba_dkey/binary_sensor.py @@ -22,14 +22,14 @@ from .entity import DormakabaDkeyEntity from .models import DormakabaDkeyData -@dataclass +@dataclass(frozen=True) class DormakabaDkeyBinarySensorDescriptionMixin: """Class for keys required by Dormakaba dKey binary sensor entity.""" is_on: Callable[[Notifications], bool] -@dataclass +@dataclass(frozen=True) class DormakabaDkeyBinarySensorDescription( BinarySensorEntityDescription, DormakabaDkeyBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index f84261de44b..4de20bf86e8 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -30,14 +30,14 @@ SENSOR_NETWORK = "network" SENSOR_SMS_UNREAD = "sms" -@dataclass +@dataclass(frozen=True) class DovadoRequiredKeysMixin: """Mixin for required keys.""" identifier: str -@dataclass +@dataclass(frozen=True) class DovadoSensorEntityDescription(SensorEntityDescription, DovadoRequiredKeysMixin): """Describes Dovado sensor entity.""" diff --git a/homeassistant/components/dremel_3d_printer/binary_sensor.py b/homeassistant/components/dremel_3d_printer/binary_sensor.py index 3a92bfe5510..22c2a1a9557 100644 --- a/homeassistant/components/dremel_3d_printer/binary_sensor.py +++ b/homeassistant/components/dremel_3d_printer/binary_sensor.py @@ -19,14 +19,14 @@ from .const import DOMAIN from .entity import Dremel3DPrinterEntity -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterBinarySensorEntityMixin: """Mixin for Dremel 3D Printer binary sensor.""" value_fn: Callable[[Dremel3DPrinter], bool] -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterBinarySensorEntityDescription( BinarySensorEntityDescription, Dremel3DPrinterBinarySensorEntityMixin ): diff --git a/homeassistant/components/dremel_3d_printer/button.py b/homeassistant/components/dremel_3d_printer/button.py index 2d328b30cea..b2ea103f78b 100644 --- a/homeassistant/components/dremel_3d_printer/button.py +++ b/homeassistant/components/dremel_3d_printer/button.py @@ -16,14 +16,14 @@ from .const import DOMAIN from .entity import Dremel3DPrinterEntity -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterButtonEntityMixin: """Mixin for required keys.""" press_fn: Callable[[Dremel3DPrinter], None] -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterButtonEntityDescription( ButtonEntityDescription, Dremel3DPrinterButtonEntityMixin ): diff --git a/homeassistant/components/dremel_3d_printer/sensor.py b/homeassistant/components/dremel_3d_printer/sensor.py index 660e7a90487..b24b01d2308 100644 --- a/homeassistant/components/dremel_3d_printer/sensor.py +++ b/homeassistant/components/dremel_3d_printer/sensor.py @@ -31,14 +31,14 @@ from .const import ATTR_EXTRUDER, ATTR_PLATFORM, DOMAIN from .entity import Dremel3DPrinterEntity -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterSensorEntityMixin: """Mixin for Dremel 3D Printer sensor.""" value_fn: Callable[[Dremel3DPrinter, str], StateType | datetime] -@dataclass +@dataclass(frozen=True) class Dremel3DPrinterSensorEntityDescription( SensorEntityDescription, Dremel3DPrinterSensorEntityMixin ): diff --git a/homeassistant/components/drop_connect/__init__.py b/homeassistant/components/drop_connect/__init__.py new file mode 100644 index 00000000000..7bfab762f99 --- /dev/null +++ b/homeassistant/components/drop_connect/__init__.py @@ -0,0 +1,71 @@ +"""The drop_connect integration.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from homeassistant.components import mqtt +from homeassistant.components.mqtt import ReceiveMessage +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback + +from .const import CONF_DATA_TOPIC, CONF_DEVICE_TYPE, DOMAIN +from .coordinator import DROPDeviceDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up DROP from a config entry.""" + + # Make sure MQTT integration is enabled and the client is available. + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return False + + if TYPE_CHECKING: + assert config_entry.unique_id is not None + drop_data_coordinator = DROPDeviceDataUpdateCoordinator( + hass, config_entry.unique_id + ) + + @callback + def mqtt_callback(msg: ReceiveMessage) -> None: + """Pass MQTT payload to DROP API parser.""" + if drop_data_coordinator.drop_api.parse_drop_message( + msg.topic, msg.payload, msg.qos, msg.retain + ): + drop_data_coordinator.async_set_updated_data(None) + + config_entry.async_on_unload( + await mqtt.async_subscribe( + hass, config_entry.data[CONF_DATA_TOPIC], mqtt_callback + ) + ) + _LOGGER.debug( + "Entry %s (%s) subscribed to %s", + config_entry.unique_id, + config_entry.data[CONF_DEVICE_TYPE], + config_entry.data[CONF_DATA_TOPIC], + ) + + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = drop_data_coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + hass.data[DOMAIN].pop(config_entry.entry_id) + return unload_ok diff --git a/homeassistant/components/drop_connect/binary_sensor.py b/homeassistant/components/drop_connect/binary_sensor.py new file mode 100644 index 00000000000..1bce60f87b3 --- /dev/null +++ b/homeassistant/components/drop_connect/binary_sensor.py @@ -0,0 +1,138 @@ +"""Support for DROP binary sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_DEVICE_TYPE, + DEV_HUB, + DEV_LEAK_DETECTOR, + DEV_PROTECTION_VALVE, + DEV_PUMP_CONTROLLER, + DEV_RO_FILTER, + DEV_SALT_SENSOR, + DEV_SOFTENER, + DOMAIN, +) +from .coordinator import DROPDeviceDataUpdateCoordinator +from .entity import DROPEntity + +_LOGGER = logging.getLogger(__name__) + +LEAK_ICON = "mdi:pipe-leak" +NOTIFICATION_ICON = "mdi:bell-ring" +PUMP_ICON = "mdi:water-pump" +SALT_ICON = "mdi:shaker" +WATER_ICON = "mdi:water" + +# Binary sensor type constants +LEAK_DETECTED = "leak" +PENDING_NOTIFICATION = "pending_notification" +PUMP_STATUS = "pump" +RESERVE_IN_USE = "reserve_in_use" +SALT_LOW = "salt" + + +@dataclass(kw_only=True, frozen=True) +class DROPBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes DROP binary sensor entity.""" + + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], int | None] + + +BINARY_SENSORS: list[DROPBinarySensorEntityDescription] = [ + DROPBinarySensorEntityDescription( + key=LEAK_DETECTED, + translation_key=LEAK_DETECTED, + icon=LEAK_ICON, + device_class=BinarySensorDeviceClass.MOISTURE, + value_fn=lambda device: device.drop_api.leak_detected(), + ), + DROPBinarySensorEntityDescription( + key=PENDING_NOTIFICATION, + translation_key=PENDING_NOTIFICATION, + icon=NOTIFICATION_ICON, + value_fn=lambda device: device.drop_api.notification_pending(), + ), + DROPBinarySensorEntityDescription( + key=SALT_LOW, + translation_key=SALT_LOW, + icon=SALT_ICON, + value_fn=lambda device: device.drop_api.salt_low(), + ), + DROPBinarySensorEntityDescription( + key=RESERVE_IN_USE, + translation_key=RESERVE_IN_USE, + icon=WATER_ICON, + value_fn=lambda device: device.drop_api.reserve_in_use(), + ), + DROPBinarySensorEntityDescription( + key=PUMP_STATUS, + translation_key=PUMP_STATUS, + icon=PUMP_ICON, + value_fn=lambda device: device.drop_api.pump_status(), + ), +] + +# Defines which binary sensors are used by each device type +DEVICE_BINARY_SENSORS: dict[str, list[str]] = { + DEV_HUB: [LEAK_DETECTED, PENDING_NOTIFICATION], + DEV_LEAK_DETECTOR: [LEAK_DETECTED], + DEV_PROTECTION_VALVE: [LEAK_DETECTED], + DEV_PUMP_CONTROLLER: [LEAK_DETECTED, PUMP_STATUS], + DEV_RO_FILTER: [LEAK_DETECTED], + DEV_SALT_SENSOR: [SALT_LOW], + DEV_SOFTENER: [RESERVE_IN_USE], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DROP binary sensors from config entry.""" + _LOGGER.debug( + "Set up binary sensor for device type %s with entry_id is %s", + config_entry.data[CONF_DEVICE_TYPE], + config_entry.entry_id, + ) + + if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_BINARY_SENSORS: + async_add_entities( + DROPBinarySensor(hass.data[DOMAIN][config_entry.entry_id], sensor) + for sensor in BINARY_SENSORS + if sensor.key in DEVICE_BINARY_SENSORS[config_entry.data[CONF_DEVICE_TYPE]] + ) + + +class DROPBinarySensor(DROPEntity, BinarySensorEntity): + """Representation of a DROP binary sensor.""" + + entity_description: DROPBinarySensorEntityDescription + + def __init__( + self, + coordinator: DROPDeviceDataUpdateCoordinator, + entity_description: DROPBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(entity_description.key, coordinator) + self.entity_description = entity_description + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + return self.entity_description.value_fn(self.coordinator) == 1 diff --git a/homeassistant/components/drop_connect/config_flow.py b/homeassistant/components/drop_connect/config_flow.py new file mode 100644 index 00000000000..a2b93ad1da1 --- /dev/null +++ b/homeassistant/components/drop_connect/config_flow.py @@ -0,0 +1,98 @@ +"""Config flow for drop_connect integration.""" +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from dropmqttapi.discovery import DropDiscovery + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from .const import ( + CONF_COMMAND_TOPIC, + CONF_DATA_TOPIC, + CONF_DEVICE_DESC, + CONF_DEVICE_ID, + CONF_DEVICE_NAME, + CONF_DEVICE_OWNER_ID, + CONF_DEVICE_TYPE, + CONF_HUB_ID, + DISCOVERY_TOPIC, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle DROP config flow.""" + + VERSION = 1 + + _drop_discovery: DropDiscovery | None = None + + async def async_step_mqtt(self, discovery_info: MqttServiceInfo) -> FlowResult: + """Handle a flow initialized by MQTT discovery.""" + + # Abort if the topic does not match our discovery topic or the payload is empty. + if ( + discovery_info.subscribed_topic != DISCOVERY_TOPIC + or not discovery_info.payload + ): + return self.async_abort(reason="invalid_discovery_info") + + self._drop_discovery = DropDiscovery(DOMAIN) + if not ( + await self._drop_discovery.parse_discovery( + discovery_info.topic, discovery_info.payload + ) + ): + return self.async_abort(reason="invalid_discovery_info") + existing_entry = await self.async_set_unique_id( + f"{self._drop_discovery.hub_id}_{self._drop_discovery.device_id}" + ) + if existing_entry is not None: + # Note: returning "invalid_discovery_info" here instead of "already_configured" + # allows discovery of additional device types. + return self.async_abort(reason="invalid_discovery_info") + + self.context.update({"title_placeholders": {"name": self._drop_discovery.name}}) + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm the setup.""" + if TYPE_CHECKING: + assert self._drop_discovery is not None + if user_input is not None: + device_data = { + CONF_COMMAND_TOPIC: self._drop_discovery.command_topic, + CONF_DATA_TOPIC: self._drop_discovery.data_topic, + CONF_DEVICE_DESC: self._drop_discovery.device_desc, + CONF_DEVICE_ID: self._drop_discovery.device_id, + CONF_DEVICE_NAME: self._drop_discovery.name, + CONF_DEVICE_TYPE: self._drop_discovery.device_type, + CONF_HUB_ID: self._drop_discovery.hub_id, + CONF_DEVICE_OWNER_ID: self._drop_discovery.owner_id, + } + return self.async_create_entry( + title=self._drop_discovery.name, data=device_data + ) + + return self.async_show_form( + step_id="confirm", + description_placeholders={ + "device_name": self._drop_discovery.name, + "device_type": self._drop_discovery.device_desc, + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + return self.async_abort(reason="not_supported") diff --git a/homeassistant/components/drop_connect/const.py b/homeassistant/components/drop_connect/const.py new file mode 100644 index 00000000000..38a8a57ea72 --- /dev/null +++ b/homeassistant/components/drop_connect/const.py @@ -0,0 +1,25 @@ +"""Constants for the drop_connect integration.""" + +# Keys for values used in the config_entry data dictionary +CONF_COMMAND_TOPIC = "drop_command_topic" +CONF_DATA_TOPIC = "drop_data_topic" +CONF_DEVICE_DESC = "device_desc" +CONF_DEVICE_ID = "device_id" +CONF_DEVICE_TYPE = "device_type" +CONF_HUB_ID = "drop_hub_id" +CONF_DEVICE_NAME = "name" +CONF_DEVICE_OWNER_ID = "drop_device_owner_id" + +# Values for DROP device types +DEV_FILTER = "filt" +DEV_HUB = "hub" +DEV_LEAK_DETECTOR = "leak" +DEV_PROTECTION_VALVE = "pv" +DEV_PUMP_CONTROLLER = "pc" +DEV_RO_FILTER = "ro" +DEV_SALT_SENSOR = "salt" +DEV_SOFTENER = "soft" + +DISCOVERY_TOPIC = "drop_connect/discovery/#" + +DOMAIN = "drop_connect" diff --git a/homeassistant/components/drop_connect/coordinator.py b/homeassistant/components/drop_connect/coordinator.py new file mode 100644 index 00000000000..e4937ed5f65 --- /dev/null +++ b/homeassistant/components/drop_connect/coordinator.py @@ -0,0 +1,53 @@ +"""DROP device data update coordinator object.""" +from __future__ import annotations + +import logging + +from dropmqttapi.mqttapi import DropAPI + +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_COMMAND_TOPIC, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class DROPDeviceDataUpdateCoordinator(DataUpdateCoordinator[None]): + """DROP device object.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, unique_id: str) -> None: + """Initialize the device.""" + super().__init__(hass, _LOGGER, name=f"{DOMAIN}-{unique_id}") + self.drop_api = DropAPI() + + async def set_water(self, value: int) -> None: + """Change water supply state.""" + payload = self.drop_api.set_water_message(value) + await mqtt.async_publish( + self.hass, + self.config_entry.data[CONF_COMMAND_TOPIC], + payload, + ) + + async def set_bypass(self, value: int) -> None: + """Change water bypass state.""" + payload = self.drop_api.set_bypass_message(value) + await mqtt.async_publish( + self.hass, + self.config_entry.data[CONF_COMMAND_TOPIC], + payload, + ) + + async def set_protect_mode(self, value: str) -> None: + """Change protect mode state.""" + payload = self.drop_api.set_protect_mode_message(value) + await mqtt.async_publish( + self.hass, + self.config_entry.data[CONF_COMMAND_TOPIC], + payload, + ) diff --git a/homeassistant/components/drop_connect/entity.py b/homeassistant/components/drop_connect/entity.py new file mode 100644 index 00000000000..85c506b19a3 --- /dev/null +++ b/homeassistant/components/drop_connect/entity.py @@ -0,0 +1,53 @@ +"""Base entity class for DROP entities.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + CONF_DEVICE_DESC, + CONF_DEVICE_NAME, + CONF_DEVICE_OWNER_ID, + CONF_DEVICE_TYPE, + CONF_HUB_ID, + DEV_HUB, + DOMAIN, +) +from .coordinator import DROPDeviceDataUpdateCoordinator + + +class DROPEntity(CoordinatorEntity[DROPDeviceDataUpdateCoordinator]): + """Representation of a DROP device entity.""" + + _attr_has_entity_name = True + + def __init__( + self, entity_type: str, coordinator: DROPDeviceDataUpdateCoordinator + ) -> None: + """Init DROP entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + assert coordinator.config_entry.unique_id is not None + unique_id = coordinator.config_entry.unique_id + self._attr_unique_id = f"{unique_id}_{entity_type}" + entry_data = coordinator.config_entry.data + model: str = entry_data[CONF_DEVICE_DESC] + if entry_data[CONF_DEVICE_TYPE] == DEV_HUB: + model = f"Hub {entry_data[CONF_HUB_ID]}" + self._attr_device_info = DeviceInfo( + manufacturer="Chandler Systems, Inc.", + model=model, + name=entry_data[CONF_DEVICE_NAME], + identifiers={(DOMAIN, unique_id)}, + ) + if entry_data[CONF_DEVICE_TYPE] != DEV_HUB: + self._attr_device_info.update( + { + "via_device": ( + DOMAIN, + entry_data[CONF_DEVICE_OWNER_ID], + ) + } + ) diff --git a/homeassistant/components/drop_connect/manifest.json b/homeassistant/components/drop_connect/manifest.json new file mode 100644 index 00000000000..f65c1848aff --- /dev/null +++ b/homeassistant/components/drop_connect/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "drop_connect", + "name": "DROP", + "codeowners": ["@ChandlerSystems", "@pfrazer"], + "config_flow": true, + "dependencies": ["mqtt"], + "documentation": "https://www.home-assistant.io/integrations/drop_connect", + "iot_class": "local_push", + "mqtt": ["drop_connect/discovery/#"], + "requirements": ["dropmqttapi==1.0.1"] +} diff --git a/homeassistant/components/drop_connect/select.py b/homeassistant/components/drop_connect/select.py new file mode 100644 index 00000000000..e026cfcd59e --- /dev/null +++ b/homeassistant/components/drop_connect/select.py @@ -0,0 +1,96 @@ +"""Support for DROP selects.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import logging +from typing import Any + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_DEVICE_TYPE, DEV_HUB, DOMAIN +from .coordinator import DROPDeviceDataUpdateCoordinator +from .entity import DROPEntity + +_LOGGER = logging.getLogger(__name__) + +# Select type constants +PROTECT_MODE = "protect_mode" + +PROTECT_MODE_OPTIONS = ["away", "home", "schedule"] + +FLOOD_ICON = "mdi:home-flood" + + +@dataclass(kw_only=True, frozen=True) +class DROPSelectEntityDescription(SelectEntityDescription): + """Describes DROP select entity.""" + + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], int | None] + set_fn: Callable[[DROPDeviceDataUpdateCoordinator, str], Awaitable[Any]] + + +SELECTS: list[DROPSelectEntityDescription] = [ + DROPSelectEntityDescription( + key=PROTECT_MODE, + translation_key=PROTECT_MODE, + icon=FLOOD_ICON, + options=PROTECT_MODE_OPTIONS, + value_fn=lambda device: device.drop_api.protect_mode(), + set_fn=lambda device, value: device.set_protect_mode(value), + ) +] + +# Defines which selects are used by each device type +DEVICE_SELECTS: dict[str, list[str]] = { + DEV_HUB: [PROTECT_MODE], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DROP selects from config entry.""" + _LOGGER.debug( + "Set up select for device type %s with entry_id is %s", + config_entry.data[CONF_DEVICE_TYPE], + config_entry.entry_id, + ) + + if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SELECTS: + async_add_entities( + DROPSelect(hass.data[DOMAIN][config_entry.entry_id], select) + for select in SELECTS + if select.key in DEVICE_SELECTS[config_entry.data[CONF_DEVICE_TYPE]] + ) + + +class DROPSelect(DROPEntity, SelectEntity): + """Representation of a DROP select.""" + + entity_description: DROPSelectEntityDescription + + def __init__( + self, + coordinator: DROPDeviceDataUpdateCoordinator, + entity_description: DROPSelectEntityDescription, + ) -> None: + """Initialize the select.""" + super().__init__(entity_description.key, coordinator) + self.entity_description = entity_description + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + val = self.entity_description.value_fn(self.coordinator) + return str(val) if val else None + + async def async_select_option(self, option: str) -> None: + """Update the current selected option.""" + await self.entity_description.set_fn(self.coordinator, option) diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py new file mode 100644 index 00000000000..c5215df8395 --- /dev/null +++ b/homeassistant/components/drop_connect/sensor.py @@ -0,0 +1,285 @@ +"""Support for DROP sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + EntityCategory, + UnitOfPressure, + UnitOfTemperature, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_DEVICE_TYPE, + DEV_FILTER, + DEV_HUB, + DEV_LEAK_DETECTOR, + DEV_PROTECTION_VALVE, + DEV_PUMP_CONTROLLER, + DEV_RO_FILTER, + DEV_SOFTENER, + DOMAIN, +) +from .coordinator import DROPDeviceDataUpdateCoordinator +from .entity import DROPEntity + +_LOGGER = logging.getLogger(__name__) + +FLOW_ICON = "mdi:shower-head" +GAUGE_ICON = "mdi:gauge" +TDS_ICON = "mdi:water-opacity" + +# Sensor type constants +CURRENT_FLOW_RATE = "current_flow_rate" +PEAK_FLOW_RATE = "peak_flow_rate" +WATER_USED_TODAY = "water_used_today" +AVERAGE_WATER_USED = "average_water_used" +CAPACITY_REMAINING = "capacity_remaining" +CURRENT_SYSTEM_PRESSURE = "current_system_pressure" +HIGH_SYSTEM_PRESSURE = "high_system_pressure" +LOW_SYSTEM_PRESSURE = "low_system_pressure" +BATTERY = "battery" +TEMPERATURE = "temperature" +INLET_TDS = "inlet_tds" +OUTLET_TDS = "outlet_tds" +CARTRIDGE_1_LIFE = "cart1" +CARTRIDGE_2_LIFE = "cart2" +CARTRIDGE_3_LIFE = "cart3" + + +@dataclass(kw_only=True, frozen=True) +class DROPSensorEntityDescription(SensorEntityDescription): + """Describes DROP sensor entity.""" + + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], float | int | None] + + +SENSORS: list[DROPSensorEntityDescription] = [ + DROPSensorEntityDescription( + key=CURRENT_FLOW_RATE, + translation_key=CURRENT_FLOW_RATE, + icon="mdi:shower-head", + native_unit_of_measurement="gpm", + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.current_flow_rate(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=PEAK_FLOW_RATE, + translation_key=PEAK_FLOW_RATE, + icon="mdi:shower-head", + native_unit_of_measurement="gpm", + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.peak_flow_rate(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=WATER_USED_TODAY, + translation_key=WATER_USED_TODAY, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.GALLONS, + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.water_used_today(), + state_class=SensorStateClass.TOTAL, + ), + DROPSensorEntityDescription( + key=AVERAGE_WATER_USED, + translation_key=AVERAGE_WATER_USED, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.GALLONS, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.average_water_used(), + state_class=SensorStateClass.TOTAL, + ), + DROPSensorEntityDescription( + key=CAPACITY_REMAINING, + translation_key=CAPACITY_REMAINING, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.GALLONS, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.capacity_remaining(), + state_class=SensorStateClass.TOTAL, + ), + DROPSensorEntityDescription( + key=CURRENT_SYSTEM_PRESSURE, + translation_key=CURRENT_SYSTEM_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.current_system_pressure(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=HIGH_SYSTEM_PRESSURE, + translation_key=HIGH_SYSTEM_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.high_system_pressure(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=LOW_SYSTEM_PRESSURE, + translation_key=LOW_SYSTEM_PRESSURE, + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.PSI, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.low_system_pressure(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=BATTERY, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.battery(), + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DROPSensorEntityDescription( + key=TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + suggested_display_precision=1, + value_fn=lambda device: device.drop_api.temperature(), + state_class=SensorStateClass.MEASUREMENT, + ), + DROPSensorEntityDescription( + key=INLET_TDS, + translation_key=INLET_TDS, + icon=TDS_ICON, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.inlet_tds(), + ), + DROPSensorEntityDescription( + key=OUTLET_TDS, + translation_key=OUTLET_TDS, + icon=TDS_ICON, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.outlet_tds(), + ), + DROPSensorEntityDescription( + key=CARTRIDGE_1_LIFE, + translation_key=CARTRIDGE_1_LIFE, + icon=GAUGE_ICON, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.cart1(), + ), + DROPSensorEntityDescription( + key=CARTRIDGE_2_LIFE, + translation_key=CARTRIDGE_2_LIFE, + icon=GAUGE_ICON, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.cart2(), + ), + DROPSensorEntityDescription( + key=CARTRIDGE_3_LIFE, + translation_key=CARTRIDGE_3_LIFE, + icon=GAUGE_ICON, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda device: device.drop_api.cart3(), + ), +] + +# Defines which sensors are used by each device type +DEVICE_SENSORS: dict[str, list[str]] = { + DEV_HUB: [ + AVERAGE_WATER_USED, + BATTERY, + CURRENT_FLOW_RATE, + CURRENT_SYSTEM_PRESSURE, + HIGH_SYSTEM_PRESSURE, + LOW_SYSTEM_PRESSURE, + PEAK_FLOW_RATE, + WATER_USED_TODAY, + ], + DEV_SOFTENER: [ + BATTERY, + CAPACITY_REMAINING, + CURRENT_FLOW_RATE, + CURRENT_SYSTEM_PRESSURE, + ], + DEV_FILTER: [BATTERY, CURRENT_FLOW_RATE, CURRENT_SYSTEM_PRESSURE], + DEV_LEAK_DETECTOR: [BATTERY, TEMPERATURE], + DEV_PROTECTION_VALVE: [ + BATTERY, + CURRENT_FLOW_RATE, + CURRENT_SYSTEM_PRESSURE, + TEMPERATURE, + ], + DEV_PUMP_CONTROLLER: [CURRENT_FLOW_RATE, CURRENT_SYSTEM_PRESSURE, TEMPERATURE], + DEV_RO_FILTER: [ + CARTRIDGE_1_LIFE, + CARTRIDGE_2_LIFE, + CARTRIDGE_3_LIFE, + INLET_TDS, + OUTLET_TDS, + ], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DROP sensors from config entry.""" + _LOGGER.debug( + "Set up sensor for device type %s with entry_id is %s", + config_entry.data[CONF_DEVICE_TYPE], + config_entry.entry_id, + ) + + if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SENSORS: + async_add_entities( + DROPSensor(hass.data[DOMAIN][config_entry.entry_id], sensor) + for sensor in SENSORS + if sensor.key in DEVICE_SENSORS[config_entry.data[CONF_DEVICE_TYPE]] + ) + + +class DROPSensor(DROPEntity, SensorEntity): + """Representation of a DROP sensor.""" + + entity_description: DROPSensorEntityDescription + + def __init__( + self, + coordinator: DROPDeviceDataUpdateCoordinator, + entity_description: DROPSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(entity_description.key, coordinator) + self.entity_description = entity_description + + @property + def native_value(self) -> float | int | None: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/drop_connect/strings.json b/homeassistant/components/drop_connect/strings.json new file mode 100644 index 00000000000..761d134bd18 --- /dev/null +++ b/homeassistant/components/drop_connect/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "abort": { + "not_supported": "Configuration for DROP is through MQTT discovery. Use the DROP Connect app to connect your DROP Hub to your MQTT broker." + }, + "step": { + "confirm": { + "title": "Confirm association", + "description": "Do you want to configure the DROP {device_type} named {device_name}?'" + } + } + }, + "entity": { + "sensor": { + "current_flow_rate": { "name": "Water flow rate" }, + "peak_flow_rate": { "name": "Peak water flow rate today" }, + "water_used_today": { "name": "Total water used today" }, + "average_water_used": { "name": "Average daily water usage" }, + "capacity_remaining": { "name": "Capacity remaining" }, + "current_system_pressure": { "name": "Current water pressure" }, + "high_system_pressure": { "name": "High water pressure today" }, + "low_system_pressure": { "name": "Low water pressure today" }, + "inlet_tds": { "name": "Inlet TDS" }, + "outlet_tds": { "name": "Outlet TDS" }, + "cart1": { "name": "Cartridge 1 life remaining" }, + "cart2": { "name": "Cartridge 2 life remaining" }, + "cart3": { "name": "Cartridge 3 life remaining" } + }, + "binary_sensor": { + "leak": { "name": "Leak detected" }, + "pending_notification": { "name": "Notification unread" }, + "reserve_in_use": { "name": "Reserve capacity in use" }, + "salt": { "name": "Salt low" }, + "pump": { "name": "Pump status" } + }, + "select": { + "protect_mode": { + "name": "Protect mode", + "state": { + "away": "Away", + "home": "Home", + "schedule": "Schedule" + } + } + }, + "switch": { + "water": { "name": "Water supply" }, + "bypass": { "name": "Treatment bypass" } + } + } +} diff --git a/homeassistant/components/drop_connect/switch.py b/homeassistant/components/drop_connect/switch.py new file mode 100644 index 00000000000..b0ebe4b5a85 --- /dev/null +++ b/homeassistant/components/drop_connect/switch.py @@ -0,0 +1,124 @@ +"""Support for DROP switches.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import logging +from typing import Any + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CONF_DEVICE_TYPE, + DEV_FILTER, + DEV_HUB, + DEV_PROTECTION_VALVE, + DEV_SOFTENER, + DOMAIN, +) +from .coordinator import DROPDeviceDataUpdateCoordinator +from .entity import DROPEntity + +_LOGGER = logging.getLogger(__name__) + +ICON_VALVE_OPEN = "mdi:valve-open" +ICON_VALVE_CLOSED = "mdi:valve-closed" +ICON_VALVE_UNKNOWN = "mdi:valve" +ICON_VALVE = {False: ICON_VALVE_CLOSED, True: ICON_VALVE_OPEN, None: ICON_VALVE_UNKNOWN} + +SWITCH_VALUE: dict[int | None, bool] = {0: False, 1: True} + +# Switch type constants +WATER_SWITCH = "water" +BYPASS_SWITCH = "bypass" + + +@dataclass(kw_only=True, frozen=True) +class DROPSwitchEntityDescription(SwitchEntityDescription): + """Describes DROP switch entity.""" + + value_fn: Callable[[DROPDeviceDataUpdateCoordinator], int | None] + set_fn: Callable[[DROPDeviceDataUpdateCoordinator, int], Awaitable[Any]] + + +SWITCHES: list[DROPSwitchEntityDescription] = [ + DROPSwitchEntityDescription( + key=WATER_SWITCH, + translation_key=WATER_SWITCH, + icon=ICON_VALVE_UNKNOWN, + value_fn=lambda device: device.drop_api.water(), + set_fn=lambda device, value: device.set_water(value), + ), + DROPSwitchEntityDescription( + key=BYPASS_SWITCH, + translation_key=BYPASS_SWITCH, + icon=ICON_VALVE_UNKNOWN, + value_fn=lambda device: device.drop_api.bypass(), + set_fn=lambda device, value: device.set_bypass(value), + ), +] + +# Defines which switches are used by each device type +DEVICE_SWITCHES: dict[str, list[str]] = { + DEV_FILTER: [BYPASS_SWITCH], + DEV_HUB: [WATER_SWITCH, BYPASS_SWITCH], + DEV_PROTECTION_VALVE: [WATER_SWITCH], + DEV_SOFTENER: [BYPASS_SWITCH], +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the DROP switches from config entry.""" + _LOGGER.debug( + "Set up switch for device type %s with entry_id is %s", + config_entry.data[CONF_DEVICE_TYPE], + config_entry.entry_id, + ) + + if config_entry.data[CONF_DEVICE_TYPE] in DEVICE_SWITCHES: + async_add_entities( + DROPSwitch(hass.data[DOMAIN][config_entry.entry_id], switch) + for switch in SWITCHES + if switch.key in DEVICE_SWITCHES[config_entry.data[CONF_DEVICE_TYPE]] + ) + + +class DROPSwitch(DROPEntity, SwitchEntity): + """Representation of a DROP switch.""" + + entity_description: DROPSwitchEntityDescription + + def __init__( + self, + coordinator: DROPDeviceDataUpdateCoordinator, + entity_description: DROPSwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(entity_description.key, coordinator) + self.entity_description = entity_description + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return SWITCH_VALUE.get(self.entity_description.value_fn(self.coordinator)) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn switch on.""" + await self.entity_description.set_fn(self.coordinator, 1) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn switch off.""" + await self.entity_description.set_fn(self.coordinator, 0) + + @property + def icon(self) -> str: + """Return the icon to use for dynamic states.""" + return ICON_VALVE[self.is_on] diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 3b32d354766..376b4d100fc 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -19,13 +19,12 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_PROTOCOL, CONF_TYPE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from .const import ( CONF_DSMR_VERSION, - CONF_PROTOCOL, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, CONF_TIME_BETWEEN_UPDATE, @@ -116,7 +115,7 @@ class DSMRConnection: try: transport, protocol = await asyncio.create_task(reader_factory()) - except (serial.serialutil.SerialException, OSError): + except (serial.SerialException, OSError): LOGGER.exception("Error connecting to DSMR") return False diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 45332546195..9504929c5a9 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -11,8 +11,6 @@ LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.SENSOR] CONF_DSMR_VERSION = "dsmr_version" -CONF_PROTOCOL = "protocol" -CONF_PRECISION = "precision" CONF_TIME_BETWEEN_UPDATE = "time_between_update" CONF_SERIAL_ID = "serial_id" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index b128f9d3baa..3e26ee1ea62 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -28,6 +28,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, + CONF_PROTOCOL, EVENT_HOMEASSISTANT_STOP, EntityCategory, UnitOfEnergy, @@ -46,8 +47,6 @@ from homeassistant.util import Throttle from .const import ( CONF_DSMR_VERSION, - CONF_PRECISION, - CONF_PROTOCOL, CONF_SERIAL_ID, CONF_SERIAL_ID_GAS, CONF_TIME_BETWEEN_UPDATE, @@ -68,7 +67,7 @@ EVENT_FIRST_TELEGRAM = "dsmr_first_telegram_{}" UNIT_CONVERSION = {"m3": UnitOfVolume.CUBIC_METERS} -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class DSMRSensorEntityDescription(SensorEntityDescription): """Represents an DSMR Sensor.""" @@ -79,6 +78,13 @@ class DSMRSensorEntityDescription(SensorEntityDescription): SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( + DSMRSensorEntityDescription( + key="timestamp", + obis_reference=obis_references.P1_MESSAGE_TIMESTAMP, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), DSMRSensorEntityDescription( key="current_electricity_usage", translation_key="current_electricity_usage", @@ -648,7 +654,7 @@ async def async_setup_entry( # throttle reconnect attempts await asyncio.sleep(DEFAULT_RECONNECT_INTERVAL) - except (serial.serialutil.SerialException, OSError): + except (serial.SerialException, OSError): # Log any error while establishing connection and drop to retry # connection wait LOGGER.exception("Error connecting to DSMR") @@ -790,9 +796,7 @@ class DSMREntity(SensorEntity): return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION]) with suppress(TypeError): - value = round( - float(value), self._entry.data.get(CONF_PRECISION, DEFAULT_PRECISION) - ) + value = round(float(value), DEFAULT_PRECISION) # Make sure we do not return a zero value for an energy sensor if not value and self.state_class == SensorStateClass.TOTAL_INCREASING: diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index f12b2ad72bc..2b5b995eabd 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -38,7 +38,7 @@ def tariff_transform(value): return "high" -@dataclass +@dataclass(frozen=True) class DSMRReaderSensorEntityDescription(SensorEntityDescription): """Sensor entity description for DSMR Reader.""" diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py index 8e23e742c04..dc10e0a61d9 100644 --- a/homeassistant/components/duotecno/climate.py +++ b/homeassistant/components/duotecno/climate.py @@ -52,7 +52,7 @@ class DuotecnoClimate(DuotecnoEntity, ClimateEntity): _attr_translation_key = "duotecno" @property - def current_temperature(self) -> int | None: + def current_temperature(self) -> float | None: """Get the current temperature.""" return self._unit.get_cur_temp() diff --git a/homeassistant/components/duotecno/entity.py b/homeassistant/components/duotecno/entity.py index d38d52a0d26..8d905979bfe 100644 --- a/homeassistant/components/duotecno/entity.py +++ b/homeassistant/components/duotecno/entity.py @@ -47,7 +47,7 @@ _P = ParamSpec("_P") def api_call( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Catch command exceptions.""" diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index 2f221929178..9f6d082cae8 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"], "quality_scale": "silver", - "requirements": ["pyDuotecno==2023.11.1"] + "requirements": ["pyDuotecno==2024.1.1"] } diff --git a/homeassistant/components/dynalite/convert_config.py b/homeassistant/components/dynalite/convert_config.py index 4abc02c0565..25d18dd92e8 100644 --- a/homeassistant/components/dynalite/convert_config.py +++ b/homeassistant/components/dynalite/convert_config.py @@ -138,7 +138,7 @@ def convert_template(config: dict[str, Any]) -> dict[str, Any]: def convert_config( - config: dict[str, Any] | MappingProxyType[str, Any] + config: dict[str, Any] | MappingProxyType[str, Any], ) -> dict[str, Any]: """Convert a config dict by replacing component consts with library consts.""" my_map = { diff --git a/homeassistant/components/easyenergy/__init__.py b/homeassistant/components/easyenergy/__init__.py index 498a355f0ab..e941c78b1fb 100644 --- a/homeassistant/components/easyenergy/__init__.py +++ b/homeassistant/components/easyenergy/__init__.py @@ -5,11 +5,23 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import EasyEnergyDataUpdateCoordinator +from .services import async_setup_services PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the easyEnergy services.""" + + async_setup_services(hass) + + return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -25,6 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True diff --git a/homeassistant/components/easyenergy/manifest.json b/homeassistant/components/easyenergy/manifest.json index 6fa177c7221..6f57ea6ed5f 100644 --- a/homeassistant/components/easyenergy/manifest.json +++ b/homeassistant/components/easyenergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/easyenergy", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["easyenergy==1.0.0"] + "requirements": ["easyenergy==2.1.0"] } diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 28bcbbafcb8..7298c49660f 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -29,7 +29,7 @@ from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES from .coordinator import EasyEnergyData, EasyEnergyDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class EasyEnergySensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -37,7 +37,7 @@ class EasyEnergySensorEntityDescriptionMixin: service_type: str -@dataclass +@dataclass(frozen=True) class EasyEnergySensorEntityDescription( SensorEntityDescription, EasyEnergySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/easyenergy/services.py b/homeassistant/components/easyenergy/services.py new file mode 100644 index 00000000000..a68dfcb791c --- /dev/null +++ b/homeassistant/components/easyenergy/services.py @@ -0,0 +1,177 @@ +"""Services for easyEnergy integration.""" +from __future__ import annotations + +from datetime import date, datetime +from enum import Enum +from functools import partial +from typing import Final + +from easyenergy import Electricity, Gas, VatOption +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import selector +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import EasyEnergyDataUpdateCoordinator + +ATTR_CONFIG_ENTRY: Final = "config_entry" +ATTR_START: Final = "start" +ATTR_END: Final = "end" +ATTR_INCL_VAT: Final = "incl_vat" + +GAS_SERVICE_NAME: Final = "get_gas_prices" +ENERGY_USAGE_SERVICE_NAME: Final = "get_energy_usage_prices" +ENERGY_RETURN_SERVICE_NAME: Final = "get_energy_return_prices" +SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(ATTR_INCL_VAT): bool, + vol.Optional(ATTR_START): str, + vol.Optional(ATTR_END): str, + } +) + + +class PriceType(str, Enum): + """Type of price.""" + + ENERGY_USAGE = "energy_usage" + ENERGY_RETURN = "energy_return" + GAS = "gas" + + +def __get_date(date_input: str | None) -> date | datetime: + """Get date.""" + if not date_input: + return dt_util.now().date() + + if value := dt_util.parse_datetime(date_input): + return value + + raise ServiceValidationError( + "Invalid datetime provided.", + translation_domain=DOMAIN, + translation_key="invalid_date", + translation_placeholders={ + "date": date_input, + }, + ) + + +def __serialize_prices(prices: list[dict[str, float | datetime]]) -> ServiceResponse: + """Serialize prices to service response.""" + return { + "prices": [ + { + key: str(value) if isinstance(value, datetime) else value + for key, value in timestamp_price.items() + } + for timestamp_price in prices + ] + } + + +def __get_coordinator( + hass: HomeAssistant, call: ServiceCall +) -> EasyEnergyDataUpdateCoordinator: + """Get the coordinator from the entry.""" + entry_id: str = call.data[ATTR_CONFIG_ENTRY] + entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + + if not entry: + raise ServiceValidationError( + f"Invalid config entry: {entry_id}", + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={ + "config_entry": entry_id, + }, + ) + if entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + f"{entry.title} is not loaded", + translation_domain=DOMAIN, + translation_key="unloaded_config_entry", + translation_placeholders={ + "config_entry": entry.title, + }, + ) + + return hass.data[DOMAIN][entry_id] + + +async def __get_prices( + call: ServiceCall, + *, + hass: HomeAssistant, + price_type: PriceType, +) -> ServiceResponse: + """Get prices from easyEnergy.""" + coordinator = __get_coordinator(hass, call) + + start = __get_date(call.data.get(ATTR_START)) + end = __get_date(call.data.get(ATTR_END)) + + vat = VatOption.INCLUDE + if call.data.get(ATTR_INCL_VAT) is False: + vat = VatOption.EXCLUDE + + data: Electricity | Gas + + if price_type == PriceType.GAS: + data = await coordinator.easyenergy.gas_prices( + start_date=start, + end_date=end, + vat=vat, + ) + return __serialize_prices(data.timestamp_prices) + data = await coordinator.easyenergy.energy_prices( + start_date=start, + end_date=end, + vat=vat, + ) + + if price_type == PriceType.ENERGY_USAGE: + return __serialize_prices(data.timestamp_usage_prices) + return __serialize_prices(data.timestamp_return_prices) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for easyEnergy integration.""" + + hass.services.async_register( + DOMAIN, + GAS_SERVICE_NAME, + partial(__get_prices, hass=hass, price_type=PriceType.GAS), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + ENERGY_USAGE_SERVICE_NAME, + partial(__get_prices, hass=hass, price_type=PriceType.ENERGY_USAGE), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + ENERGY_RETURN_SERVICE_NAME, + partial(__get_prices, hass=hass, price_type=PriceType.ENERGY_RETURN), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/easyenergy/services.yaml b/homeassistant/components/easyenergy/services.yaml new file mode 100644 index 00000000000..63187256f00 --- /dev/null +++ b/homeassistant/components/easyenergy/services.yaml @@ -0,0 +1,61 @@ +get_gas_prices: + fields: + config_entry: + required: true + selector: + config_entry: + integration: easyenergy + incl_vat: + required: true + default: true + selector: + boolean: + start: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: +get_energy_usage_prices: + fields: + config_entry: + required: true + selector: + config_entry: + integration: easyenergy + incl_vat: + required: true + default: true + selector: + boolean: + start: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: +get_energy_return_prices: + fields: + config_entry: + required: true + selector: + config_entry: + integration: easyenergy + start: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2024-01-01 00:00:00" + selector: + datetime: diff --git a/homeassistant/components/easyenergy/strings.json b/homeassistant/components/easyenergy/strings.json index 93fb264b01d..c42ef9df5ac 100644 --- a/homeassistant/components/easyenergy/strings.json +++ b/homeassistant/components/easyenergy/strings.json @@ -9,6 +9,17 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "exceptions": { + "invalid_date": { + "message": "Invalid date provided. Got {date}" + }, + "invalid_config_entry": { + "message": "Invalid config entry provided. Got {config_entry}" + }, + "unloaded_config_entry": { + "message": "Invalid config entry provided. {config_entry} is not loaded." + } + }, "entity": { "sensor": { "current_hour_price": { @@ -42,5 +53,69 @@ "name": "Hours priced equal or higher than current - today" } } + }, + "services": { + "get_gas_prices": { + "name": "Get gas prices", + "description": "Request gas prices from easyEnergy.", + "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, + "incl_vat": { + "name": "VAT Included", + "description": "Include or exclude VAT in the prices, default is true." + }, + "start": { + "name": "Start", + "description": "Specifies the date and time from which to retrieve prices. Defaults to today if omitted." + }, + "end": { + "name": "End", + "description": "Specifies the date and time until which to retrieve prices. Defaults to today if omitted." + } + } + }, + "get_energy_usage_prices": { + "name": "Get energy usage prices", + "description": "Request usage energy prices from easyEnergy.", + "fields": { + "config_entry": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::description%]" + }, + "incl_vat": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::incl_vat::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::incl_vat::description%]" + }, + "start": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::start::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::start::description%]" + }, + "end": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::end::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::end::description%]" + } + } + }, + "get_energy_return_prices": { + "name": "Get energy return prices", + "description": "Request return energy prices from easyEnergy.", + "fields": { + "config_entry": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::config_entry::description%]" + }, + "start": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::start::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::start::description%]" + }, + "end": { + "name": "[%key:component::easyenergy::services::get_gas_prices::fields::end::name%]", + "description": "[%key:component::easyenergy::services::get_gas_prices::fields::end::description%]" + } + } + } } } diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py index 67c975010ab..345ca7b705f 100644 --- a/homeassistant/components/ecobee/number.py +++ b/homeassistant/components/ecobee/number.py @@ -18,7 +18,7 @@ from .entity import EcobeeBaseEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class EcobeeNumberEntityDescriptionBase: """Required values when describing Ecobee number entities.""" @@ -26,7 +26,7 @@ class EcobeeNumberEntityDescriptionBase: set_fn: Callable[[EcobeeData, int, int], Awaitable] -@dataclass +@dataclass(frozen=True) class EcobeeNumberEntityDescription( NumberEntityDescription, EcobeeNumberEntityDescriptionBase ): diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 4d07ec9447e..7f0e7b808a8 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -25,14 +25,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER -@dataclass +@dataclass(frozen=True) class EcobeeSensorEntityDescriptionMixin: """Represent the required ecobee entity description attributes.""" runtime_key: str | None -@dataclass +@dataclass(frozen=True) class EcobeeSensorEntityDescription( SensorEntityDescription, EcobeeSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/ecoforest/number.py b/homeassistant/components/ecoforest/number.py index 90ea0bd4dff..79d62b6a2d2 100644 --- a/homeassistant/components/ecoforest/number.py +++ b/homeassistant/components/ecoforest/number.py @@ -16,14 +16,14 @@ from .coordinator import EcoforestCoordinator from .entity import EcoforestEntity -@dataclass +@dataclass(frozen=True) class EcoforestRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[Device], float | None] -@dataclass +@dataclass(frozen=True) class EcoforestNumberEntityDescription( NumberEntityDescription, EcoforestRequiredKeysMixin ): diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py index e595ddb65f7..6f903bee2ba 100644 --- a/homeassistant/components/ecoforest/sensor.py +++ b/homeassistant/components/ecoforest/sensor.py @@ -33,14 +33,14 @@ STATUS_TYPE = [s.value for s in State] ALARM_TYPE = [a.value for a in Alarm] + ["none"] -@dataclass +@dataclass(frozen=True) class EcoforestRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[Device], StateType] -@dataclass +@dataclass(frozen=True) class EcoforestSensorEntityDescription( SensorEntityDescription, EcoforestRequiredKeysMixin ): diff --git a/homeassistant/components/ecoforest/switch.py b/homeassistant/components/ecoforest/switch.py index 32341ff5d61..1e70068cde8 100644 --- a/homeassistant/components/ecoforest/switch.py +++ b/homeassistant/components/ecoforest/switch.py @@ -17,7 +17,7 @@ from .coordinator import EcoforestCoordinator from .entity import EcoforestEntity -@dataclass +@dataclass(frozen=True) class EcoforestSwitchRequiredKeysMixin: """Mixin for required keys.""" @@ -25,7 +25,7 @@ class EcoforestSwitchRequiredKeysMixin: switch_fn: Callable[[EcoforestApi, bool], Awaitable[Device]] -@dataclass +@dataclass(frozen=True) class EcoforestSwitchEntityDescription( SwitchEntityDescription, EcoforestSwitchRequiredKeysMixin ): diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 6fc6eed40f6..809f1c531da 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -1,6 +1,7 @@ """Support for Efergy sensors.""" from __future__ import annotations +import dataclasses from re import sub from typing import cast @@ -121,7 +122,10 @@ async def async_setup_entry( ) ) else: - description.entity_registry_enabled_default = len(api.sids) > 1 + description = dataclasses.replace( + description, + entity_registry_enabled_default=len(api.sids) > 1, + ) for sid in api.sids: sensors.append( EfergySensor( diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 8017bbf006e..51d02781554 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -28,14 +28,14 @@ ATTR_EK_HOP_START = "hop_sensor_start" ATTR_EK_HOP_END = "hop_sensor_end" -@dataclass +@dataclass(frozen=True) class ElectricKiwiHOPRequiredKeysMixin: """Mixin for required HOP keys.""" value_func: Callable[[Hop], datetime] -@dataclass +@dataclass(frozen=True) class ElectricKiwiHOPSensorEntityDescription( SensorEntityDescription, ElectricKiwiHOPRequiredKeysMixin, diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index 7a69db24012..9747496c126 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -23,7 +23,7 @@ from .coordinator import ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ElgatoButtonEntityDescription(ButtonEntityDescription): """Class describing Elgato button entities.""" diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py index 27dedee25c9..b683b80f5fa 100644 --- a/homeassistant/components/elgato/sensor.py +++ b/homeassistant/components/elgato/sensor.py @@ -26,7 +26,7 @@ from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ElgatoSensorEntityDescription(SensorEntityDescription): """Class describing Elgato sensor entities.""" diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py index e9ab506c3a4..d1f370547a4 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -19,7 +19,7 @@ from .coordinator import ElgatoData, ElgatoDataUpdateCoordinator from .entity import ElgatoEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ElgatoSwitchEntityDescription(SwitchEntityDescription): """Class describing Elgato switch entities.""" diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index b78157588e8..b633e1ae620 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -17,6 +17,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_CONNECTIONS, + CONF_ENABLED, CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE, @@ -46,7 +47,6 @@ from .const import ( CONF_AREA, CONF_AUTO_CONFIGURE, CONF_COUNTER, - CONF_ENABLED, CONF_KEYPAD, CONF_OUTPUT, CONF_PLC, diff --git a/homeassistant/components/elkm1/const.py b/homeassistant/components/elkm1/const.py index a2bb5744c11..9e952c7ee0b 100644 --- a/homeassistant/components/elkm1/const.py +++ b/homeassistant/components/elkm1/const.py @@ -14,7 +14,6 @@ LOGIN_TIMEOUT = 20 CONF_AUTO_CONFIGURE = "auto_configure" CONF_AREA = "area" CONF_COUNTER = "counter" -CONF_ENABLED = "enabled" CONF_KEYPAD = "keypad" CONF_OUTPUT = "output" CONF_PLC = "plc" diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index ad6b0541cd6..0730eced60c 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Iterable from functools import lru_cache import hashlib from http import HTTPStatus @@ -41,6 +42,7 @@ from homeassistant.components.light import ( ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_XY_COLOR, + ColorMode, LightEntityFeature, ) from homeassistant.components.media_player import ( @@ -57,6 +59,7 @@ from homeassistant.const import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_SET, + STATE_CLOSED, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -73,6 +76,7 @@ from homeassistant.util.network import is_local from .config import Config _LOGGER = logging.getLogger(__name__) +_OFF_STATES: dict[str, str] = {cover.DOMAIN: STATE_CLOSED} # How long to wait for a state change to happen STATE_CHANGE_WAIT_TIMEOUT = 5.0 @@ -113,12 +117,19 @@ UNAUTHORIZED_USER = [ {"error": {"address": "/", "description": "unauthorized user", "type": "1"}} ] -DIMMABLE_SUPPORT_FEATURES = ( - CoverEntityFeature.SET_POSITION - | FanEntityFeature.SET_SPEED - | MediaPlayerEntityFeature.VOLUME_SET - | ClimateEntityFeature.TARGET_TEMPERATURE -) +DIMMABLE_SUPPORTED_FEATURES_BY_DOMAIN = { + cover.DOMAIN: CoverEntityFeature.SET_POSITION, + fan.DOMAIN: FanEntityFeature.SET_SPEED, + media_player.DOMAIN: MediaPlayerEntityFeature.VOLUME_SET, + climate.DOMAIN: ClimateEntityFeature.TARGET_TEMPERATURE, +} + +ENTITY_FEATURES_BY_DOMAIN = { + cover.DOMAIN: CoverEntityFeature, + fan.DOMAIN: FanEntityFeature, + media_player.DOMAIN: MediaPlayerEntityFeature, + climate.DOMAIN: ClimateEntityFeature, +} @lru_cache(maxsize=32) @@ -394,7 +405,7 @@ class HueOneLightChangeView(HomeAssistantView): return self.json_message("Bad request", HTTPStatus.BAD_REQUEST) parsed[STATE_ON] = request_json[HUE_API_STATE_ON] else: - parsed[STATE_ON] = entity.state != STATE_OFF + parsed[STATE_ON] = _hass_to_hue_state(entity) for key, attr in ( (HUE_API_STATE_BRI, STATE_BRIGHTNESS), @@ -585,7 +596,7 @@ class HueOneLightChangeView(HomeAssistantView): ) if service is not None: - state_will_change = parsed[STATE_ON] != (entity.state != STATE_OFF) + state_will_change = parsed[STATE_ON] != _hass_to_hue_state(entity) hass.async_create_task( hass.services.async_call(domain, service, data, blocking=True) @@ -643,7 +654,7 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: cached_state = entry_state elif time.time() - entry_time < STATE_CACHED_TIMEOUT and entry_state[ STATE_ON - ] == (entity.state != STATE_OFF): + ] == _hass_to_hue_state(entity): # We only want to use the cache if the actual state of the entity # is in sync so that it can be detected as an error by Alexa. cached_state = entry_state @@ -676,7 +687,7 @@ def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: @lru_cache(maxsize=512) def _build_entity_state_dict(entity: State) -> dict[str, Any]: """Build a state dict for an entity.""" - is_on = entity.state != STATE_OFF + is_on = _hass_to_hue_state(entity) data: dict[str, Any] = { STATE_ON: is_on, STATE_BRIGHTNESS: None, @@ -754,7 +765,6 @@ def _entity_unique_id(entity_id: str) -> str: def state_to_json(config: Config, state: State) -> dict[str, Any]: """Convert an entity to its Hue bridge JSON representation.""" - entity_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) color_modes = state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) unique_id = _entity_unique_id(state.entity_id) state_dict = get_entity_state_dict(config, state) @@ -771,9 +781,9 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: "manufacturername": "Home Assistant", "swversion": "123", } - - color_supported = light.color_supported(color_modes) - color_temp_supported = light.color_temp_supported(color_modes) + is_light = state.domain == light.DOMAIN + color_supported = is_light and light.color_supported(color_modes) + color_temp_supported = is_light and light.color_temp_supported(color_modes) if color_supported and color_temp_supported: # Extended Color light (Zigbee Device ID: 0x0210) # Same as Color light, but which supports additional setting of color temperature @@ -818,9 +828,7 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS], } ) - elif entity_features & DIMMABLE_SUPPORT_FEATURES or light.brightness_supported( - color_modes - ): + elif state_supports_hue_brightness(state, color_modes): # Dimmable light (Zigbee Device ID: 0x0100) # Supports groups, scenes, on/off and dimming retval["type"] = "Dimmable light" @@ -843,6 +851,21 @@ def state_to_json(config: Config, state: State) -> dict[str, Any]: return retval +def state_supports_hue_brightness( + state: State, color_modes: Iterable[ColorMode] +) -> bool: + """Return True if the state supports brightness.""" + domain = state.domain + if domain == light.DOMAIN: + return light.brightness_supported(color_modes) + if not (required_feature := DIMMABLE_SUPPORTED_FEATURES_BY_DOMAIN.get(domain)): + return False + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + enum = ENTITY_FEATURES_BY_DOMAIN[domain] + features = enum(features) if type(features) is int else features # noqa: E721 + return required_feature in features + + def create_hue_success_response( entity_number: str, attr: str, value: str ) -> dict[str, Any]: @@ -891,6 +914,11 @@ def hass_to_hue_brightness(value: int) -> int: return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX)) +def _hass_to_hue_state(entity: State) -> bool: + """Convert hass entity states to simple True/False on/off state for Hue.""" + return entity.state != _OFF_STATES.get(entity.domain, STATE_OFF) + + async def wait_for_state_change_or_timeout( hass: core.HomeAssistant, entity_id: str, timeout: float ) -> None: diff --git a/homeassistant/components/energyzero/__init__.py b/homeassistant/components/energyzero/__init__.py index 096e312efc0..8878a99e562 100644 --- a/homeassistant/components/energyzero/__init__.py +++ b/homeassistant/components/energyzero/__init__.py @@ -5,11 +5,23 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import EnergyZeroDataUpdateCoordinator +from .services import async_setup_services PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up EnergyZero services.""" + + async_setup_services(hass) + + return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -25,6 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True diff --git a/homeassistant/components/energyzero/manifest.json b/homeassistant/components/energyzero/manifest.json index 9ef99173ffb..025f929a4f6 100644 --- a/homeassistant/components/energyzero/manifest.json +++ b/homeassistant/components/energyzero/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/energyzero", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["energyzero==1.0.0"] + "requirements": ["energyzero==2.1.0"] } diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py index 2468e5e68bf..59c44c1aad8 100644 --- a/homeassistant/components/energyzero/sensor.py +++ b/homeassistant/components/energyzero/sensor.py @@ -29,7 +29,7 @@ from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES from .coordinator import EnergyZeroData, EnergyZeroDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class EnergyZeroSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -37,7 +37,7 @@ class EnergyZeroSensorEntityDescriptionMixin: service_type: str -@dataclass +@dataclass(frozen=True) class EnergyZeroSensorEntityDescription( SensorEntityDescription, EnergyZeroSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py new file mode 100644 index 00000000000..d8e548c22f8 --- /dev/null +++ b/homeassistant/components/energyzero/services.py @@ -0,0 +1,166 @@ +"""The EnergyZero services.""" +from __future__ import annotations + +from datetime import date, datetime +from enum import Enum +from functools import partial +from typing import Final + +from energyzero import Electricity, Gas, VatOption +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import selector +from homeassistant.util import dt as dt_util + +from .const import DOMAIN +from .coordinator import EnergyZeroDataUpdateCoordinator + +ATTR_CONFIG_ENTRY: Final = "config_entry" +ATTR_START: Final = "start" +ATTR_END: Final = "end" +ATTR_INCL_VAT: Final = "incl_vat" + +GAS_SERVICE_NAME: Final = "get_gas_prices" +ENERGY_SERVICE_NAME: Final = "get_energy_prices" +SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(ATTR_INCL_VAT): bool, + vol.Optional(ATTR_START): str, + vol.Optional(ATTR_END): str, + } +) + + +class PriceType(Enum): + """Type of price.""" + + ENERGY = "energy" + GAS = "gas" + + +def __get_date(date_input: str | None) -> date | datetime: + """Get date.""" + if not date_input: + return dt_util.now().date() + + if value := dt_util.parse_datetime(date_input): + return value + + raise ServiceValidationError( + "Invalid datetime provided.", + translation_domain=DOMAIN, + translation_key="invalid_date", + translation_placeholders={ + "date": date_input, + }, + ) + + +def __serialize_prices(prices: Electricity | Gas) -> ServiceResponse: + """Serialize prices.""" + return { + "prices": [ + { + key: str(value) if isinstance(value, datetime) else value + for key, value in timestamp_price.items() + } + for timestamp_price in prices.timestamp_prices + ] + } + + +def __get_coordinator( + hass: HomeAssistant, call: ServiceCall +) -> EnergyZeroDataUpdateCoordinator: + """Get the coordinator from the entry.""" + entry_id: str = call.data[ATTR_CONFIG_ENTRY] + entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + + if not entry: + raise ServiceValidationError( + f"Invalid config entry: {entry_id}", + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={ + "config_entry": entry_id, + }, + ) + if entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + f"{entry.title} is not loaded", + translation_domain=DOMAIN, + translation_key="unloaded_config_entry", + translation_placeholders={ + "config_entry": entry.title, + }, + ) + + return hass.data[DOMAIN][entry_id] + + +async def __get_prices( + call: ServiceCall, + *, + hass: HomeAssistant, + price_type: PriceType, +) -> ServiceResponse: + coordinator = __get_coordinator(hass, call) + + start = __get_date(call.data.get(ATTR_START)) + end = __get_date(call.data.get(ATTR_END)) + + vat = VatOption.INCLUDE + + if call.data.get(ATTR_INCL_VAT) is False: + vat = VatOption.EXCLUDE + + data: Electricity | Gas + + if price_type == PriceType.GAS: + data = await coordinator.energyzero.gas_prices( + start_date=start, + end_date=end, + vat=vat, + ) + else: + data = await coordinator.energyzero.energy_prices( + start_date=start, + end_date=end, + vat=vat, + ) + + return __serialize_prices(data) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up EnergyZero services.""" + + hass.services.async_register( + DOMAIN, + GAS_SERVICE_NAME, + partial(__get_prices, hass=hass, price_type=PriceType.GAS), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + hass.services.async_register( + DOMAIN, + ENERGY_SERVICE_NAME, + partial(__get_prices, hass=hass, price_type=PriceType.ENERGY), + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/energyzero/services.yaml b/homeassistant/components/energyzero/services.yaml new file mode 100644 index 00000000000..dc8df9aa6d0 --- /dev/null +++ b/homeassistant/components/energyzero/services.yaml @@ -0,0 +1,44 @@ +get_gas_prices: + fields: + config_entry: + required: true + selector: + config_entry: + integration: energyzero + incl_vat: + required: true + default: true + selector: + boolean: + start: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: +get_energy_prices: + fields: + config_entry: + required: true + selector: + config_entry: + integration: energyzero + incl_vat: + required: true + default: true + selector: + boolean: + start: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index a27ce236c28..9858838aff7 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -9,6 +9,17 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "exceptions": { + "invalid_date": { + "message": "Invalid date provided. Got {date}" + }, + "invalid_config_entry": { + "message": "Invalid config entry provided. Got {config_entry}" + }, + "unloaded_config_entry": { + "message": "Invalid config entry provided. {config_entry} is not loaded." + } + }, "entity": { "sensor": { "current_hour_price": { @@ -39,5 +50,51 @@ "name": "Hours priced equal or lower than current - today" } } + }, + "services": { + "get_gas_prices": { + "name": "Get gas prices", + "description": "Request gas prices from EnergyZero.", + "fields": { + "config_entry": { + "name": "Config Entry", + "description": "The config entry to use for this service." + }, + "incl_vat": { + "name": "Including VAT", + "description": "Include VAT in the prices." + }, + "start": { + "name": "Start", + "description": "Specifies the date and time from which to retrieve prices. Defaults to today if omitted." + }, + "end": { + "name": "End", + "description": "Specifies the date and time until which to retrieve prices. Defaults to today if omitted." + } + } + }, + "get_energy_prices": { + "name": "Get energy prices", + "description": "Request energy prices from EnergyZero.", + "fields": { + "config_entry": { + "name": "[%key:component::energyzero::services::get_gas_prices::fields::config_entry::name%]", + "description": "[%key:component::energyzero::services::get_gas_prices::fields::config_entry::description%]" + }, + "incl_vat": { + "name": "[%key:component::energyzero::services::get_gas_prices::fields::incl_vat::name%]", + "description": "[%key:component::energyzero::services::get_gas_prices::fields::incl_vat::description%]" + }, + "start": { + "name": "[%key:component::energyzero::services::get_gas_prices::fields::start::name%]", + "description": "[%key:component::energyzero::services::get_gas_prices::fields::start::description%]" + }, + "end": { + "name": "[%key:component::energyzero::services::get_gas_prices::fields::end::name%]", + "description": "[%key:component::energyzero::services::get_gas_prices::fields::end::description%]" + } + } + } } } diff --git a/homeassistant/components/enigma2/const.py b/homeassistant/components/enigma2/const.py new file mode 100644 index 00000000000..0511a794172 --- /dev/null +++ b/homeassistant/components/enigma2/const.py @@ -0,0 +1,17 @@ +"""Constants for the Enigma2 platform.""" +DOMAIN = "enigma2" + +CONF_USE_CHANNEL_ICON = "use_channel_icon" +CONF_DEEP_STANDBY = "deep_standby" +CONF_SOURCE_BOUQUET = "source_bouquet" +CONF_MAC_ADDRESS = "mac_address" + +DEFAULT_NAME = "Enigma2 Media Player" +DEFAULT_PORT = 80 +DEFAULT_SSL = False +DEFAULT_USE_CHANNEL_ICON = False +DEFAULT_USERNAME = "root" +DEFAULT_PASSWORD = "dreambox" +DEFAULT_DEEP_STANDBY = False +DEFAULT_SOURCE_BOUQUET = "" +DEFAULT_MAC_ADDRESS = "" diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json index 932cbda66ec..7909db3b7c7 100644 --- a/homeassistant/components/enigma2/manifest.json +++ b/homeassistant/components/enigma2/manifest.json @@ -1,9 +1,9 @@ { "domain": "enigma2", "name": "Enigma2 (OpenWebif)", - "codeowners": ["@fbradyirl"], + "codeowners": ["@autinerd"], "documentation": "https://www.home-assistant.io/integrations/enigma2", "iot_class": "local_polling", "loggers": ["openwebif"], - "requirements": ["openwebifpy==3.2.7"] + "requirements": ["openwebifpy==4.0.2"] } diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index a479590f464..432823d781b 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -1,7 +1,8 @@ """Support for Enigma2 media players.""" from __future__ import annotations -from openwebif.api import CreateDevice +from openwebif.api import OpenWebIfDevice +from openwebif.enums import RemoteControlCodes import voluptuous as vol from homeassistant.components.media_player import ( @@ -24,26 +25,27 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import ( + CONF_DEEP_STANDBY, + CONF_MAC_ADDRESS, + CONF_SOURCE_BOUQUET, + CONF_USE_CHANNEL_ICON, + DEFAULT_DEEP_STANDBY, + DEFAULT_MAC_ADDRESS, + DEFAULT_NAME, + DEFAULT_PASSWORD, + DEFAULT_PORT, + DEFAULT_SOURCE_BOUQUET, + DEFAULT_SSL, + DEFAULT_USE_CHANNEL_ICON, + DEFAULT_USERNAME, +) + ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" ATTR_MEDIA_DESCRIPTION = "media_description" ATTR_MEDIA_END_TIME = "media_end_time" ATTR_MEDIA_START_TIME = "media_start_time" -CONF_USE_CHANNEL_ICON = "use_channel_icon" -CONF_DEEP_STANDBY = "deep_standby" -CONF_MAC_ADDRESS = "mac_address" -CONF_SOURCE_BOUQUET = "source_bouquet" - -DEFAULT_NAME = "Enigma2 Media Player" -DEFAULT_PORT = 80 -DEFAULT_SSL = False -DEFAULT_USE_CHANNEL_ICON = False -DEFAULT_USERNAME = "root" -DEFAULT_PASSWORD = "dreambox" -DEFAULT_DEEP_STANDBY = False -DEFAULT_MAC_ADDRESS = "" -DEFAULT_SOURCE_BOUQUET = "" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -62,10 +64,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_devices: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up of an enigma2 media player.""" @@ -84,24 +86,26 @@ def setup_platform( config[CONF_DEEP_STANDBY] = DEFAULT_DEEP_STANDBY config[CONF_SOURCE_BOUQUET] = DEFAULT_SOURCE_BOUQUET - device = CreateDevice( + device = OpenWebIfDevice( host=config[CONF_HOST], port=config.get(CONF_PORT), username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), is_https=config[CONF_SSL], - prefer_picon=config.get(CONF_USE_CHANNEL_ICON), - mac_address=config.get(CONF_MAC_ADDRESS), turn_off_to_deep=config.get(CONF_DEEP_STANDBY), source_bouquet=config.get(CONF_SOURCE_BOUQUET), ) - add_devices([Enigma2Device(config[CONF_NAME], device)], True) + async_add_entities( + [Enigma2Device(config[CONF_NAME], device, await device.get_about())] + ) class Enigma2Device(MediaPlayerEntity): """Representation of an Enigma2 box.""" + _attr_has_entity_name = True + _attr_media_content_type = MediaType.TVSHOW _attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_SET @@ -116,146 +120,96 @@ class Enigma2Device(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, name, device): + def __init__(self, name: str, device: OpenWebIfDevice, about: dict) -> None: """Initialize the Enigma2 device.""" - self._name = name - self.e2_box = device + self._device: OpenWebIfDevice = device + self._device.mac_address = about["info"]["ifaces"][0]["mac"] - @property - def name(self): - """Return the name of the device.""" - return self._name + self._attr_name = name + self._attr_unique_id = device.mac_address - @property - def unique_id(self): - """Return the unique ID for this entity.""" - return self.e2_box.mac_address - - @property - def state(self) -> MediaPlayerState: - """Return the state of the device.""" - if self.e2_box.is_recording_playback: - return MediaPlayerState.PLAYING - return MediaPlayerState.OFF if self.e2_box.in_standby else MediaPlayerState.ON - - @property - def available(self) -> bool: - """Return True if the device is available.""" - return not self.e2_box.is_offline - - def turn_off(self) -> None: + async def async_turn_off(self) -> None: """Turn off media player.""" - self.e2_box.turn_off() + await self._device.turn_off() - def turn_on(self) -> None: + async def async_turn_on(self) -> None: """Turn the media player on.""" - self.e2_box.turn_on() + await self._device.turn_on() - @property - def media_title(self): - """Title of current playing media.""" - return self.e2_box.current_service_channel_name - - @property - def media_series_title(self): - """Return the title of current episode of TV show.""" - return self.e2_box.current_programme_name - - @property - def media_channel(self): - """Channel of current playing media.""" - return self.e2_box.current_service_channel_name - - @property - def media_content_id(self): - """Service Ref of current playing media.""" - return self.e2_box.current_service_ref - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self.e2_box.muted - - @property - def media_image_url(self): - """Picon url for the channel.""" - return self.e2_box.picon_url - - def set_volume_level(self, volume: float) -> None: + async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - self.e2_box.set_volume(int(volume * 100)) + await self._device.set_volume(int(volume * 100)) - def volume_up(self) -> None: + async def async_volume_up(self) -> None: """Volume up the media player.""" - self.e2_box.set_volume(int(self.e2_box.volume * 100) + 5) + if self._attr_volume_level is None: + return + await self._device.set_volume(int(self._attr_volume_level * 100) + 5) - def volume_down(self) -> None: + async def async_volume_down(self) -> None: """Volume down media player.""" - self.e2_box.set_volume(int(self.e2_box.volume * 100) - 5) + if self._attr_volume_level is None: + return + await self._device.set_volume(int(self._attr_volume_level * 100) - 5) - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self.e2_box.volume - - def media_stop(self) -> None: + async def async_media_stop(self) -> None: """Send stop command.""" - self.e2_box.set_stop() + await self._device.send_remote_control_action(RemoteControlCodes.STOP) - def media_play(self) -> None: + async def async_media_play(self) -> None: """Play media.""" - self.e2_box.toggle_play_pause() + await self._device.send_remote_control_action(RemoteControlCodes.PLAY) - def media_pause(self) -> None: + async def async_media_pause(self) -> None: """Pause the media player.""" - self.e2_box.toggle_play_pause() + await self._device.send_remote_control_action(RemoteControlCodes.PAUSE) - def media_next_track(self) -> None: + async def async_media_next_track(self) -> None: """Send next track command.""" - self.e2_box.set_channel_up() + await self._device.send_remote_control_action(RemoteControlCodes.CHANNEL_UP) - def media_previous_track(self) -> None: + async def async_media_previous_track(self) -> None: """Send next track command.""" - self.e2_box.set_channel_down() + self._device.send_remote_control_action(RemoteControlCodes.CHANNEL_DOWN) - def mute_volume(self, mute: bool) -> None: + async def async_mute_volume(self, mute: bool) -> None: """Mute or unmute.""" - self.e2_box.mute_volume() + await self._device.toggle_mute() - @property - def source(self): - """Return the current input source.""" - return self.e2_box.current_service_channel_name - - @property - def source_list(self): - """List of available input sources.""" - return self.e2_box.source_list - - def select_source(self, source: str) -> None: + async def async_select_source(self, source: str) -> None: """Select input source.""" - self.e2_box.select_source(self.e2_box.sources[source]) + await self._device.zap(self._device.sources[source]) - def update(self) -> None: + async def async_update(self) -> None: """Update state of the media_player.""" - self.e2_box.update() + await self._device.update() + self._attr_available = not self._device.is_offline - @property - def extra_state_attributes(self): - """Return device specific state attributes. + if not self._device.status.in_standby: + self._attr_extra_state_attributes = { + ATTR_MEDIA_CURRENTLY_RECORDING: self._device.status.is_recording, + ATTR_MEDIA_DESCRIPTION: self._device.status.currservice.fulldescription, + ATTR_MEDIA_START_TIME: self._device.status.currservice.begin, + ATTR_MEDIA_END_TIME: self._device.status.currservice.end, + } + else: + self._attr_extra_state_attributes = {} - isRecording: Is the box currently recording. - currservice_fulldescription: Full program description. - currservice_begin: is in the format '21:00'. - currservice_end: is in the format '21:00'. - """ - if self.e2_box.in_standby: - return {} - return { - ATTR_MEDIA_CURRENTLY_RECORDING: self.e2_box.status_info["isRecording"], - ATTR_MEDIA_DESCRIPTION: self.e2_box.status_info[ - "currservice_fulldescription" - ], - ATTR_MEDIA_START_TIME: self.e2_box.status_info["currservice_begin"], - ATTR_MEDIA_END_TIME: self.e2_box.status_info["currservice_end"], - } + self._attr_media_title = self._device.status.currservice.station + self._attr_media_series_title = self._device.status.currservice.name + self._attr_media_channel = self._device.status.currservice.station + self._attr_is_volume_muted = self._device.status.muted + self._attr_media_content_id = self._device.status.currservice.serviceref + self._attr_media_image_url = self._device.picon_url + self._attr_source = self._device.status.currservice.station + self._attr_source_list = self._device.source_list + + if self._device.status.in_standby: + self._attr_state = MediaPlayerState.OFF + else: + self._attr_state = MediaPlayerState.ON + + if (volume_level := self._device.status.volume) is not None: + self._attr_volume_level = volume_level / 100 + else: + self._attr_volume_level = None diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index f63fd7239d0..83c801d598e 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -44,14 +44,14 @@ SENSOR_TYPE_TEMPERATURE = "temperature" SENSOR_TYPE_WINDOWHANDLE = "windowhandle" -@dataclass +@dataclass(frozen=True) class EnOceanSensorEntityDescriptionMixin: """Mixin for required keys.""" unique_id: Callable[[list[int]], str | None] -@dataclass +@dataclass(frozen=True) class EnOceanSensorEntityDescription( SensorEntityDescription, EnOceanSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index 7060943deb8..5eb2e621e47 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -22,14 +22,14 @@ from .coordinator import EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEncharge], bool] -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeBinarySensorEntityDescription( BinarySensorEntityDescription, EnvoyEnchargeRequiredKeysMixin ): @@ -53,14 +53,14 @@ ENCHARGE_SENSORS = ( ) -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEnpower], bool] -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerBinarySensorEntityDescription( BinarySensorEntityDescription, EnvoyEnpowerRequiredKeysMixin ): diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index 1d589cfb176..7b8a3e03270 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -39,6 +39,7 @@ async def async_get_config_entry_diagnostics( return async_redact_data( { "entry": entry.as_dict(), + "envoy_firmware": coordinator.envoy.firmware, "data": coordinator.data, }, TO_REDACT, diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index 918e4002e7a..bf54c91f45b 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -25,21 +25,21 @@ from .coordinator import EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity -@dataclass +@dataclass(frozen=True) class EnvoyRelayRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyDryContactSettings], float] -@dataclass +@dataclass(frozen=True) class EnvoyRelayNumberEntityDescription( NumberEntityDescription, EnvoyRelayRequiredKeysMixin ): """Describes an Envoy Dry Contact Relay number entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsRequiredKeysMixin: """Mixin for required keys.""" @@ -47,7 +47,7 @@ class EnvoyStorageSettingsRequiredKeysMixin: update_fn: Callable[[Envoy, float], Awaitable[dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsNumberEntityDescription( NumberEntityDescription, EnvoyStorageSettingsRequiredKeysMixin ): diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 331d2a999ad..5d2edf91d9a 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -21,7 +21,7 @@ from .coordinator import EnphaseUpdateCoordinator from .entity import EnvoyBaseEntity -@dataclass +@dataclass(frozen=True) class EnvoyRelayRequiredKeysMixin: """Mixin for required keys.""" @@ -31,14 +31,14 @@ class EnvoyRelayRequiredKeysMixin: ] -@dataclass +@dataclass(frozen=True) class EnvoyRelaySelectEntityDescription( SelectEntityDescription, EnvoyRelayRequiredKeysMixin ): """Describes an Envoy Dry Contact Relay select entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsRequiredKeysMixin: """Mixin for required keys.""" @@ -46,7 +46,7 @@ class EnvoyStorageSettingsRequiredKeysMixin: update_fn: Callable[[Envoy, str], Awaitable[dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsSelectEntityDescription( SelectEntityDescription, EnvoyStorageSettingsRequiredKeysMixin ): diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 33b9e3a64df..1dfd72dcaf3 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -47,14 +47,14 @@ INVERTERS_KEY = "inverters" LAST_REPORTED_KEY = "last_reported" -@dataclass +@dataclass(frozen=True) class EnvoyInverterRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyInverter], datetime.datetime | float] -@dataclass +@dataclass(frozen=True) class EnvoyInverterSensorEntityDescription( SensorEntityDescription, EnvoyInverterRequiredKeysMixin ): @@ -80,14 +80,14 @@ INVERTER_SENSORS = ( ) -@dataclass +@dataclass(frozen=True) class EnvoyProductionRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoySystemProduction], int] -@dataclass +@dataclass(frozen=True) class EnvoyProductionSensorEntityDescription( SensorEntityDescription, EnvoyProductionRequiredKeysMixin ): @@ -137,14 +137,14 @@ PRODUCTION_SENSORS = ( ) -@dataclass +@dataclass(frozen=True) class EnvoyConsumptionRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoySystemConsumption], int] -@dataclass +@dataclass(frozen=True) class EnvoyConsumptionSensorEntityDescription( SensorEntityDescription, EnvoyConsumptionRequiredKeysMixin ): @@ -194,28 +194,28 @@ CONSUMPTION_SENSORS = ( ) -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEncharge], datetime.datetime | int | float] -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeSensorEntityDescription( SensorEntityDescription, EnvoyEnchargeRequiredKeysMixin ): """Describes an Envoy Encharge sensor entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyEnchargePowerRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEnchargePower], int | float] -@dataclass +@dataclass(frozen=True) class EnvoyEnchargePowerSensorEntityDescription( SensorEntityDescription, EnvoyEnchargePowerRequiredKeysMixin ): @@ -259,14 +259,14 @@ ENCHARGE_POWER_SENSORS = ( ) -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEnpower], datetime.datetime | int | float] -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerSensorEntityDescription( SensorEntityDescription, EnvoyEnpowerRequiredKeysMixin ): @@ -289,14 +289,14 @@ ENPOWER_SENSORS = ( ) -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeAggregateRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[EnvoyEnchargeAggregate], int] -@dataclass +@dataclass(frozen=True) class EnvoyEnchargeAggregateSensorEntityDescription( SensorEntityDescription, EnvoyEnchargeAggregateRequiredKeysMixin ): diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index 22746fd9479..76c73914db6 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -24,7 +24,7 @@ from .entity import EnvoyBaseEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerRequiredKeysMixin: """Mixin for required keys.""" @@ -33,14 +33,14 @@ class EnvoyEnpowerRequiredKeysMixin: turn_off_fn: Callable[[Envoy], Coroutine[Any, Any, dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyEnpowerSwitchEntityDescription( SwitchEntityDescription, EnvoyEnpowerRequiredKeysMixin ): """Describes an Envoy Enpower switch entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyDryContactRequiredKeysMixin: """Mixin for required keys.""" @@ -49,14 +49,14 @@ class EnvoyDryContactRequiredKeysMixin: turn_off_fn: Callable[[Envoy, str], Coroutine[Any, Any, dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyDryContactSwitchEntityDescription( SwitchEntityDescription, EnvoyDryContactRequiredKeysMixin ): """Describes an Envoy Enpower dry contact switch entity.""" -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsRequiredKeysMixin: """Mixin for required keys.""" @@ -65,7 +65,7 @@ class EnvoyStorageSettingsRequiredKeysMixin: turn_off_fn: Callable[[Envoy], Awaitable[dict[str, Any]]] -@dataclass +@dataclass(frozen=True) class EnvoyStorageSettingsSwitchEntityDescription( SwitchEntityDescription, EnvoyStorageSettingsRequiredKeysMixin ): diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 64a4b7dad20..14fb3e8e54c 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -6,13 +6,13 @@ import xml.etree.ElementTree as et from env_canada import ECAirQuality, ECRadar, ECWeather, ec_exc from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_LANGUAGE, CONF_STATION, DOMAIN +from .const import CONF_STATION, DOMAIN DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5) DEFAULT_WEATHER_UPDATE_INTERVAL = timedelta(minutes=5) diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py index 07b6eac0da0..f4b9ee792c3 100644 --- a/homeassistant/components/environment_canada/config_flow.py +++ b/homeassistant/components/environment_canada/config_flow.py @@ -7,10 +7,10 @@ from env_canada import ECWeather, ec_exc import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.helpers import config_validation as cv -from .const import CONF_LANGUAGE, CONF_STATION, CONF_TITLE, DOMAIN +from .const import CONF_STATION, CONF_TITLE, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/environment_canada/const.py b/homeassistant/components/environment_canada/const.py index 16f7dc1cf99..f1f6db2e0df 100644 --- a/homeassistant/components/environment_canada/const.py +++ b/homeassistant/components/environment_canada/const.py @@ -2,7 +2,6 @@ ATTR_OBSERVATION_TIME = "observation_time" ATTR_STATION = "station" -CONF_LANGUAGE = "language" CONF_STATION = "station" CONF_TITLE = "title" DOMAIN = "environment_canada" diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 987a779d2e8..9ec4971f573 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -33,14 +33,14 @@ from .const import ATTR_STATION, DOMAIN ATTR_TIME = "alert time" -@dataclass +@dataclass(frozen=True) class ECSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[Any], Any] -@dataclass +@dataclass(frozen=True) class ECSensorEntityDescription( SensorEntityDescription, ECSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/esphome/bluetooth.py b/homeassistant/components/esphome/bluetooth.py new file mode 100644 index 00000000000..24524233a70 --- /dev/null +++ b/homeassistant/components/esphome/bluetooth.py @@ -0,0 +1,44 @@ +"""Bluetooth support for esphome.""" +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING + +from aioesphomeapi import APIClient, DeviceInfo +from bleak_esphome import connect_scanner +from bleak_esphome.backend.cache import ESPHomeBluetoothCache + +from homeassistant.components.bluetooth import async_register_scanner +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback + +from .entry_data import RuntimeEntryData + + +@hass_callback +def _async_unload(unload_callbacks: list[CALLBACK_TYPE]) -> None: + """Cancel all the callbacks on unload.""" + for callback in unload_callbacks: + callback() + + +async def async_connect_scanner( + hass: HomeAssistant, + entry_data: RuntimeEntryData, + cli: APIClient, + device_info: DeviceInfo, + cache: ESPHomeBluetoothCache, +) -> CALLBACK_TYPE: + """Connect scanner.""" + client_data = await connect_scanner(cli, device_info, cache, entry_data.available) + entry_data.bluetooth_device = client_data.bluetooth_device + client_data.disconnect_callbacks = entry_data.disconnect_callbacks + scanner = client_data.scanner + if TYPE_CHECKING: + assert scanner is not None + return partial( + _async_unload, + [ + async_register_scanner(hass, scanner), + scanner.async_setup(), + ], + ) diff --git a/homeassistant/components/esphome/bluetooth/__init__.py b/homeassistant/components/esphome/bluetooth/__init__.py deleted file mode 100644 index 6936afac714..00000000000 --- a/homeassistant/components/esphome/bluetooth/__init__.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Bluetooth support for esphome.""" -from __future__ import annotations - -import asyncio -from collections.abc import Coroutine -from functools import partial -import logging -from typing import Any - -from aioesphomeapi import APIClient, BluetoothProxyFeature - -from homeassistant.components.bluetooth import ( - HaBluetoothConnector, - async_get_advertisement_callback, - async_register_scanner, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback - -from ..entry_data import RuntimeEntryData -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( - 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 - - -@hass_callback -def _async_unload(unload_callbacks: list[CALLBACK_TYPE]) -> None: - """Cancel all the callbacks on unload.""" - for callback in unload_callbacks: - callback() - - -async def async_connect_scanner( - hass: HomeAssistant, - 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) - 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, - source, - 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, client_data=client_data), # type: ignore[arg-type] - source=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 - coros: list[Coroutine[Any, Any, CALLBACK_TYPE]] = [] - # These calls all return a callback that can be used to unsubscribe - # but we never unsubscribe so we don't care about the return value - - 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 - coros.append( - cli.subscribe_bluetooth_connections_free( - bluetooth_device.async_update_ble_connection_limits - ) - ) - - if feature_flags & BluetoothProxyFeature.RAW_ADVERTISEMENTS: - coros.append( - cli.subscribe_bluetooth_le_raw_advertisements( - scanner.async_on_raw_advertisements - ) - ) - else: - coros.append( - cli.subscribe_bluetooth_le_advertisements(scanner.async_on_advertisement) - ) - - await asyncio.gather(*coros) - return partial( - _async_unload, - [ - async_register_scanner(hass, scanner, connectable), - scanner.async_setup(), - ], - ) diff --git a/homeassistant/components/esphome/bluetooth/cache.py b/homeassistant/components/esphome/bluetooth/cache.py deleted file mode 100644 index 3ec29121382..00000000000 --- a/homeassistant/components/esphome/bluetooth/cache.py +++ /dev/null @@ -1,50 +0,0 @@ -"""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/characteristic.py b/homeassistant/components/esphome/bluetooth/characteristic.py deleted file mode 100644 index 0db73dd3d5f..00000000000 --- a/homeassistant/components/esphome/bluetooth/characteristic.py +++ /dev/null @@ -1,95 +0,0 @@ -"""BleakGATTCharacteristicESPHome.""" -from __future__ import annotations - -import contextlib -from uuid import UUID - -from aioesphomeapi.model import BluetoothGATTCharacteristic -from bleak.backends.characteristic import BleakGATTCharacteristic -from bleak.backends.descriptor import BleakGATTDescriptor - -PROPERTY_MASKS = { - 2**n: prop - for n, prop in enumerate( - ( - "broadcast", - "read", - "write-without-response", - "write", - "notify", - "indicate", - "authenticated-signed-writes", - "extended-properties", - "reliable-writes", - "writable-auxiliaries", - ) - ) -} - - -class BleakGATTCharacteristicESPHome(BleakGATTCharacteristic): - """GATT Characteristic implementation for the ESPHome backend.""" - - obj: BluetoothGATTCharacteristic - - def __init__( - self, - obj: BluetoothGATTCharacteristic, - max_write_without_response_size: int, - service_uuid: str, - service_handle: int, - ) -> None: - """Init a BleakGATTCharacteristicESPHome.""" - super().__init__(obj, max_write_without_response_size) - self.__descriptors: list[BleakGATTDescriptor] = [] - self.__service_uuid: str = service_uuid - self.__service_handle: int = service_handle - char_props = self.obj.properties - self.__props: list[str] = [ - prop for mask, prop in PROPERTY_MASKS.items() if char_props & mask - ] - - @property - def service_uuid(self) -> str: - """Uuid of the Service containing this characteristic.""" - return self.__service_uuid - - @property - def service_handle(self) -> int: - """Integer handle of the Service containing this characteristic.""" - return self.__service_handle - - @property - def handle(self) -> int: - """Integer handle for this characteristic.""" - return self.obj.handle - - @property - def uuid(self) -> str: - """Uuid of this characteristic.""" - return self.obj.uuid - - @property - def properties(self) -> list[str]: - """Properties of this characteristic.""" - return self.__props - - @property - def descriptors(self) -> list[BleakGATTDescriptor]: - """List of descriptors for this service.""" - return self.__descriptors - - def get_descriptor(self, specifier: int | str | UUID) -> BleakGATTDescriptor | None: - """Get a descriptor by handle (int) or UUID (str or uuid.UUID).""" - with contextlib.suppress(StopIteration): - if isinstance(specifier, int): - return next(filter(lambda x: x.handle == specifier, self.descriptors)) - return next(filter(lambda x: x.uuid == str(specifier), self.descriptors)) - return None - - def add_descriptor(self, descriptor: BleakGATTDescriptor) -> None: - """Add a :py:class:`~BleakGATTDescriptor` to the characteristic. - - Should not be used by end user, but rather by `bleak` itself. - """ - self.__descriptors.append(descriptor) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py deleted file mode 100644 index 96f1bce686a..00000000000 --- a/homeassistant/components/esphome/bluetooth/client.py +++ /dev/null @@ -1,714 +0,0 @@ -"""Bluetooth client for esphome.""" -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 -import sys -from typing import Any, TypeVar, cast -import uuid - -if sys.version_info < (3, 12): - from typing_extensions import Buffer -else: - from collections.abc import Buffer - -from aioesphomeapi import ( - ESP_CONNECTION_ERROR_DESCRIPTION, - ESPHOME_GATT_ERRORS, - APIClient, - APIVersion, - BLEConnectionError, - BluetoothConnectionDroppedError, - BluetoothProxyFeature, - DeviceInfo, -) -from aioesphomeapi.core import ( - APIConnectionError, - BluetoothGATTAPIError, - TimeoutAPIError, -) -from bleak.backends.characteristic import BleakGATTCharacteristic -from bleak.backends.client import BaseBleakClient, NotifyCallback -from bleak.backends.device import BLEDevice -from bleak.backends.service import BleakGATTServiceCollection -from bleak.exc import BleakError - -from homeassistant.core import CALLBACK_TYPE - -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 -GATT_HEADER_SIZE = 3 -DISCONNECT_TIMEOUT = 5.0 -CONNECT_FREE_SLOT_TIMEOUT = 2.0 -GATT_READ_TIMEOUT = 30.0 - -# CCCD (Characteristic Client Config Descriptor) -CCCD_UUID = "00002902-0000-1000-8000-00805f9b34fb" -CCCD_NOTIFY_BYTES = b"\x01\x00" -CCCD_INDICATE_BYTES = b"\x02\x00" - -DEFAULT_MAX_WRITE_WITHOUT_RESPONSE = DEFAULT_MTU - GATT_HEADER_SIZE -_LOGGER = logging.getLogger(__name__) - -_WrapFuncType = TypeVar("_WrapFuncType", bound=Callable[..., Any]) - - -def mac_to_int(address: str) -> int: - """Convert a mac address to an integer.""" - return int(address.replace(":", ""), 16) - - -def api_error_as_bleak_error(func: _WrapFuncType) -> _WrapFuncType: - """Define a wrapper throw esphome api errors as BleakErrors.""" - - async def _async_wrap_bluetooth_operation( - self: ESPHomeClient, *args: Any, **kwargs: Any - ) -> Any: - # pylint: disable=protected-access - try: - return await func(self, *args, **kwargs) - except TimeoutAPIError as err: - raise asyncio.TimeoutError(str(err)) from err - except BluetoothConnectionDroppedError as ex: - _LOGGER.debug( - "%s: BLE device disconnected during %s operation", - self._description, - func.__name__, - ) - self._async_ble_device_disconnected() - raise BleakError(str(ex)) from ex - except BluetoothGATTAPIError as ex: - # If the device disconnects in the middle of an operation - # be sure to mark it as disconnected so any library using - # the proxy knows to reconnect. - # - # Because callbacks are delivered asynchronously it's possible - # that we find out about the disconnection during the operation - # before the callback is delivered. - - if ex.error.error == -1: - _LOGGER.debug( - "%s: BLE device disconnected during %s operation", - self._description, - func.__name__, - ) - self._async_ble_device_disconnected() - raise BleakError(str(ex)) from ex - except APIConnectionError as err: - raise BleakError(str(err)) from err - - 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: set[Callable[[], None]] = field(default_factory=set) - - -class ESPHomeClient(BaseBleakClient): - """ESPHome Bleak Client.""" - - def __init__( - self, - address_or_ble_device: BLEDevice | str, - *args: Any, - 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._loop = asyncio.get_running_loop() - ble_device = address_or_ble_device - self._ble_device = ble_device - self._address_as_int = mac_to_int(ble_device.address) - assert ble_device.details is not None - self._source = ble_device.details["source"] - 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._device_info = client_data.device_info - self._feature_flags = device_info.bluetooth_proxy_feature_flags_compat( - client_data.api_version - ) - self._address_type = ble_device.details["address_type"] - self._source_name = f"{client_data.title} [{self._source}]" - self._description = ( - f"{self._source_name}: {ble_device.name} - {ble_device.address}" - ) - scanner = client_data.scanner - assert scanner is not None - self._scanner = scanner - - def __str__(self) -> str: - """Return the string representation of the client.""" - return f"ESPHomeClient ({self._description})" - - def _async_disconnected_cleanup(self) -> None: - """Clean up on disconnect.""" - self.services = BleakGATTServiceCollection() # type: ignore[no-untyped-call] - self._is_connected = False - for _, notify_abort in self._notify_cancels.values(): - notify_abort() - self._notify_cancels.clear() - self._disconnect_callbacks.discard(self._async_esp_disconnected) - if self._cancel_connection_state: - self._cancel_connection_state() - self._cancel_connection_state = None - - def _async_ble_device_disconnected(self) -> None: - """Handle the BLE device disconnecting from the ESP.""" - was_connected = self._is_connected - self._async_disconnected_cleanup() - if was_connected: - _LOGGER.debug("%s: BLE device disconnected", self._description) - self._async_call_bleak_disconnected_callback() - - def _async_esp_disconnected(self) -> None: - """Handle the esp32 client disconnecting from us.""" - _LOGGER.debug("%s: ESP device disconnected", self._description) - # Calling _async_ble_device_disconnected calls - # _async_disconnected_cleanup which will also remove - # the disconnect callbacks - self._async_ble_device_disconnected() - - def _async_call_bleak_disconnected_callback(self) -> None: - """Call the disconnected callback to inform the bleak consumer.""" - if self._disconnected_callback: - 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: Connection state changed to connected=%s mtu=%s error=%s", - self._description, - 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: connected, registering for disconnected callbacks", - self._description, - ) - self._disconnect_callbacks.add(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 - ) -> bool: - """Connect to a specified Peripheral. - - **kwargs: - timeout (float): Timeout for required - ``BleakScanner.find_device_by_address`` call. Defaults to 10.0. - - Returns: - Boolean representing connection status. - """ - await self._wait_for_free_connection_slot(CONNECT_FREE_SLOT_TIMEOUT) - cache = self._cache - - 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 cache.get_gatt_services_cache(self._address_as_int) - and self._mtu - ) - connected_future: asyncio.Future[bool] = self._loop.create_future() - - timeout = kwargs.get("timeout", self._timeout) - with self._scanner.connecting(): - try: - self._cancel_connection_state = ( - await self._client.bluetooth_device_connect( - self._address_as_int, - partial(self._on_bluetooth_connection_state, connected_future), - timeout=timeout, - has_cache=has_cache, - feature_flags=self._feature_flags, - address_type=self._address_type, - ) - ) - except asyncio.CancelledError: - if connected_future.done(): - with contextlib.suppress(BleakError): - # If we are cancelled while connecting, - # we need to make sure we await the future - # to avoid a warning about an un-retrieved - # exception. - await connected_future - raise - except Exception as ex: - if connected_future.done(): - with contextlib.suppress(BleakError): - # If the connect call throws an exception, - # we need to make sure we await the future - # to avoid a warning about an un-retrieved - # exception since we prefer to raise the - # exception from the connect call as it - # will be more descriptive. - await connected_future - 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 - ) - 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() - raise - except Exception: - await self._disconnect() - raise - - 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: - await self._client.bluetooth_device_disconnect(self._address_as_int) - self._async_ble_device_disconnected() - await self._wait_for_free_connection_slot(DISCONNECT_TIMEOUT) - return True - - async def _wait_for_free_connection_slot(self, timeout: float) -> None: - """Wait for a free connection slot.""" - bluetooth_device = self._bluetooth_device - if bluetooth_device.ble_connections_free: - return - _LOGGER.debug( - "%s: Out of connection slots, waiting for a free one", - self._description, - ) - async with asyncio.timeout(timeout): - await bluetooth_device.wait_for_ble_connections_free() - - @property - def is_connected(self) -> bool: - """Is Connected.""" - return self._is_connected - - @property - def mtu_size(self) -> int: - """Get ATT MTU size for active connection.""" - return self._mtu or DEFAULT_MTU - - @api_error_as_bleak_error - async def pair(self, *args: Any, **kwargs: Any) -> bool: - """Attempt to pair.""" - if not self._feature_flags & BluetoothProxyFeature.PAIRING: - raise NotImplementedError( - "Pairing is not available in this version ESPHome; " - f"Upgrade the ESPHome version on the {self._device_info.name} device." - ) - self._raise_if_not_connected() - response = await self._client.bluetooth_device_pair(self._address_as_int) - if response.paired: - return True - _LOGGER.error( - "%s: Pairing failed due to error: %s", self._description, response.error - ) - return False - - @api_error_as_bleak_error - async def unpair(self) -> bool: - """Attempt to unpair.""" - if not self._feature_flags & BluetoothProxyFeature.PAIRING: - raise NotImplementedError( - "Unpairing is not available in this version ESPHome; " - f"Upgrade the ESPHome version on the {self._device_info.name} device." - ) - self._raise_if_not_connected() - response = await self._client.bluetooth_device_unpair(self._address_as_int) - if response.success: - return True - _LOGGER.error( - "%s: Unpairing failed due to error: %s", self._description, response.error - ) - return False - - @api_error_as_bleak_error - async def get_services( - self, dangerous_use_bleak_cache: bool = False, **kwargs: Any - ) -> BleakGATTServiceCollection: - """Get all services registered for this GATT server. - - Returns: - 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 - ) - - 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 - """ - self._raise_if_not_connected() - address_as_int = self._address_as_int - 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 := cache.get_gatt_services_cache(address_as_int)): - _LOGGER.debug("%s: Cached services hit", self._description) - self.services = cached_services - return self.services - _LOGGER.debug("%s: Cached services miss", self._description) - esphome_services = await self._client.bluetooth_gatt_get_services( - address_as_int - ) - _LOGGER.debug("%s: Got services: %s", self._description, esphome_services) - max_write_without_response = self.mtu_size - GATT_HEADER_SIZE - services = BleakGATTServiceCollection() # type: ignore[no-untyped-call] - for service in esphome_services.services: - services.add_service(BleakGATTServiceESPHome(service)) - for characteristic in service.characteristics: - services.add_characteristic( - BleakGATTCharacteristicESPHome( - characteristic, - max_write_without_response, - service.uuid, - service.handle, - ) - ) - for descriptor in characteristic.descriptors: - services.add_descriptor( - BleakGATTDescriptorESPHome( - descriptor, - characteristic.uuid, - characteristic.handle, - ) - ) - - if not esphome_services.services: - # If we got no services, we must have disconnected - # or something went wrong on the ESP32's BLE stack. - raise BleakError("Failed to get services from remote esp") - - self.services = services - _LOGGER.debug("%s: Cached services saved", self._description) - cache.set_gatt_services_cache(address_as_int, services) - return services - - def _resolve_characteristic( - self, char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID - ) -> BleakGATTCharacteristic: - """Resolve a characteristic specifier to a BleakGATTCharacteristic object.""" - if (services := self.services) is None: - raise BleakError(f"{self._description}: Services have not been resolved") - if not isinstance(char_specifier, BleakGATTCharacteristic): - characteristic = services.get_characteristic(char_specifier) - else: - characteristic = char_specifier - if not characteristic: - raise BleakError( - f"{self._description}: Characteristic {char_specifier} was not found!" - ) - return characteristic - - @api_error_as_bleak_error - async def clear_cache(self) -> bool: - """Clear the GATT cache.""" - 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; " - "Upgrade the ESPHome version on the device %s; Only memory cache will be cleared", - self._device_info.name, - ) - return True - self._raise_if_not_connected() - response = await self._client.bluetooth_device_clear_cache(self._address_as_int) - if response.success: - return True - _LOGGER.error( - "%s: Clear cache failed due to error: %s", - self._description, - response.error, - ) - return False - - @api_error_as_bleak_error - async def read_gatt_char( - self, - char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID, - **kwargs: Any, - ) -> bytearray: - """Perform read operation on the specified GATT characteristic. - - Args: - char_specifier (BleakGATTCharacteristic, int, str or UUID): - The characteristic to read from, specified by either integer - handle, UUID or directly by the BleakGATTCharacteristic - object representing it. - **kwargs: Unused - - Returns: - (bytearray) The read data. - """ - self._raise_if_not_connected() - characteristic = self._resolve_characteristic(char_specifier) - return await self._client.bluetooth_gatt_read( - self._address_as_int, characteristic.handle, GATT_READ_TIMEOUT - ) - - @api_error_as_bleak_error - async def read_gatt_descriptor(self, handle: int, **kwargs: Any) -> bytearray: - """Perform read operation on the specified GATT descriptor. - - Args: - handle (int): The handle of the descriptor to read from. - **kwargs: Unused - - Returns: - (bytearray) The read data. - """ - self._raise_if_not_connected() - return await self._client.bluetooth_gatt_read_descriptor( - self._address_as_int, handle, GATT_READ_TIMEOUT - ) - - @api_error_as_bleak_error - async def write_gatt_char( - self, - characteristic: BleakGATTCharacteristic | int | str | uuid.UUID, - data: Buffer, - response: bool = False, - ) -> None: - """Perform a write operation of the specified GATT characteristic. - - Args: - characteristic (BleakGATTCharacteristic, int, str or UUID): - The characteristic to write to, specified by either integer - handle, UUID or directly by the BleakGATTCharacteristic object - representing it. - data (bytes or bytearray): The data to send. - response (bool): If write-with-response operation should be done. - Defaults to `False`. - """ - self._raise_if_not_connected() - characteristic = self._resolve_characteristic(characteristic) - await self._client.bluetooth_gatt_write( - self._address_as_int, characteristic.handle, bytes(data), response - ) - - @api_error_as_bleak_error - async def write_gatt_descriptor(self, handle: int, data: Buffer) -> None: - """Perform a write operation on the specified GATT descriptor. - - Args: - handle (int): The handle of the descriptor to read from. - data (bytes or bytearray): The data to send. - """ - self._raise_if_not_connected() - await self._client.bluetooth_gatt_write_descriptor( - self._address_as_int, handle, bytes(data) - ) - - @api_error_as_bleak_error - async def start_notify( - self, - characteristic: BleakGATTCharacteristic, - callback: NotifyCallback, - **kwargs: Any, - ) -> None: - """Activate notifications/indications on a characteristic. - - Callbacks must accept two inputs. The first will be a integer handle of the - characteristic generating the data and the second will be a ``bytearray`` - containing the data sent from the connected server. - - .. code-block:: python - def callback(sender: int, data: bytearray): - print(f"{sender}: {data}") - client.start_notify(char_uuid, callback) - - Args: - characteristic (BleakGATTCharacteristic): - The characteristic to activate notifications/indications on a - characteristic, specified by either integer handle, UUID or - directly by the BleakGATTCharacteristic object representing it. - callback (function): The function to be called on notification. - kwargs: Unused. - """ - self._raise_if_not_connected() - ble_handle = characteristic.handle - if ble_handle in self._notify_cancels: - raise BleakError( - f"{self._description}: Notifications are already enabled on " - f"service:{characteristic.service_uuid} " - f"characteristic:{characteristic.uuid} " - f"handle:{ble_handle}" - ) - if ( - "notify" not in characteristic.properties - and "indicate" not in characteristic.properties - ): - raise BleakError( - f"{self._description}: Characteristic {characteristic.uuid} " - "does not have notify or indicate property set." - ) - - self._notify_cancels[ - ble_handle - ] = await self._client.bluetooth_gatt_start_notify( - self._address_as_int, - ble_handle, - lambda handle, data: callback(data), - ) - - if not self._feature_flags & BluetoothProxyFeature.REMOTE_CACHING: - return - - # For connection v3 we are responsible for enabling notifications - # on the cccd (characteristic client config descriptor) handle since - # the esp32 will not have resolved the characteristic descriptors to - # save memory since doing so can exhaust the memory and cause a soft - # reset - cccd_descriptor = characteristic.get_descriptor(CCCD_UUID) - if not cccd_descriptor: - raise BleakError( - f"{self._description}: Characteristic {characteristic.uuid} " - "does not have a characteristic client config descriptor." - ) - - _LOGGER.debug( - "%s: Writing to CCD descriptor %s for notifications with properties=%s", - self._description, - cccd_descriptor.handle, - characteristic.properties, - ) - supports_notify = "notify" in characteristic.properties - await self._client.bluetooth_gatt_write_descriptor( - self._address_as_int, - cccd_descriptor.handle, - CCCD_NOTIFY_BYTES if supports_notify else CCCD_INDICATE_BYTES, - wait_for_response=False, - ) - - @api_error_as_bleak_error - async def stop_notify( - self, - char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID, - ) -> None: - """Deactivate notification/indication on a specified characteristic. - - Args: - char_specifier (BleakGATTCharacteristic, int, str or UUID): - The characteristic to deactivate notification/indication on, - specified by either integer handle, UUID or directly by the - BleakGATTCharacteristic object representing it. - """ - self._raise_if_not_connected() - characteristic = self._resolve_characteristic(char_specifier) - # Do not raise KeyError if notifications are not enabled on this characteristic - # to be consistent with the behavior of the BlueZ backend - if notify_cancel := self._notify_cancels.pop(characteristic.handle, None): - notify_stop, _ = notify_cancel - await notify_stop() - - def _raise_if_not_connected(self) -> None: - """Raise a BleakError if not connected.""" - if not self._is_connected: - raise BleakError(f"{self._description} is not connected") - - def __del__(self) -> None: - """Destructor to make sure the connection state is unsubscribed.""" - if self._cancel_connection_state: - _LOGGER.warning( - ( - "%s: ESPHomeClient bleak client was not properly" - " disconnected before destruction" - ), - self._description, - ) - if not self._loop.is_closed(): - self._loop.call_soon_threadsafe(self._async_disconnected_cleanup) diff --git a/homeassistant/components/esphome/bluetooth/descriptor.py b/homeassistant/components/esphome/bluetooth/descriptor.py deleted file mode 100644 index 0ba11639740..00000000000 --- a/homeassistant/components/esphome/bluetooth/descriptor.py +++ /dev/null @@ -1,42 +0,0 @@ -"""BleakGATTDescriptorESPHome.""" -from __future__ import annotations - -from aioesphomeapi.model import BluetoothGATTDescriptor -from bleak.backends.descriptor import BleakGATTDescriptor - - -class BleakGATTDescriptorESPHome(BleakGATTDescriptor): - """GATT Descriptor implementation for ESPHome backend.""" - - obj: BluetoothGATTDescriptor - - def __init__( - self, - obj: BluetoothGATTDescriptor, - characteristic_uuid: str, - characteristic_handle: int, - ) -> None: - """Init a BleakGATTDescriptorESPHome.""" - super().__init__(obj) - self.__characteristic_uuid: str = characteristic_uuid - self.__characteristic_handle: int = characteristic_handle - - @property - def characteristic_handle(self) -> int: - """Handle for the characteristic that this descriptor belongs to.""" - return self.__characteristic_handle - - @property - def characteristic_uuid(self) -> str: - """UUID for the characteristic that this descriptor belongs to.""" - return self.__characteristic_uuid - - @property - def uuid(self) -> str: - """UUID for this descriptor.""" - return self.obj.uuid - - @property - def handle(self) -> int: - """Integer handle for this descriptor.""" - return self.obj.handle diff --git a/homeassistant/components/esphome/bluetooth/device.py b/homeassistant/components/esphome/bluetooth/device.py deleted file mode 100644 index c76562a2145..00000000000 --- a/homeassistant/components/esphome/bluetooth/device.py +++ /dev/null @@ -1,55 +0,0 @@ -"""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 - ) - loop: asyncio.AbstractEventLoop = field(default_factory=asyncio.get_running_loop) - - @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] = self.loop.create_future() - self._ble_connection_free_futures.append(fut) - return await fut diff --git a/homeassistant/components/esphome/bluetooth/scanner.py b/homeassistant/components/esphome/bluetooth/scanner.py deleted file mode 100644 index a54e7af59a6..00000000000 --- a/homeassistant/components/esphome/bluetooth/scanner.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Bluetooth scanner for esphome.""" -from __future__ import annotations - -from aioesphomeapi import BluetoothLEAdvertisement, BluetoothLERawAdvertisement -from bluetooth_data_tools import ( - int_to_bluetooth_address, - parse_advertisement_data_tuple, -) - -from homeassistant.components.bluetooth import MONOTONIC_TIME, BaseHaRemoteScanner -from homeassistant.core import callback - - -class ESPHomeScanner(BaseHaRemoteScanner): - """Scanner for esphome.""" - - __slots__ = () - - @callback - def async_on_advertisement(self, adv: BluetoothLEAdvertisement) -> None: - """Call the registered callback.""" - # The mac address is a uint64, but we need a string - self._async_on_advertisement( - int_to_bluetooth_address(adv.address), - adv.rssi, - adv.name, - adv.service_uuids, - adv.service_data, - adv.manufacturer_data, - None, - {"address_type": adv.address_type}, - MONOTONIC_TIME(), - ) - - @callback - def async_on_raw_advertisements( - self, advertisements: list[BluetoothLERawAdvertisement] - ) -> None: - """Call the registered callback.""" - now = MONOTONIC_TIME() - for adv in advertisements: - self._async_on_advertisement( - int_to_bluetooth_address(adv.address), - adv.rssi, - *parse_advertisement_data_tuple((adv.data,)), - {"address_type": adv.address_type}, - now, - ) diff --git a/homeassistant/components/esphome/bluetooth/service.py b/homeassistant/components/esphome/bluetooth/service.py deleted file mode 100644 index 5df7d2bf603..00000000000 --- a/homeassistant/components/esphome/bluetooth/service.py +++ /dev/null @@ -1,40 +0,0 @@ -"""BleakGATTServiceESPHome.""" -from __future__ import annotations - -from aioesphomeapi.model import BluetoothGATTService -from bleak.backends.characteristic import BleakGATTCharacteristic -from bleak.backends.service import BleakGATTService - - -class BleakGATTServiceESPHome(BleakGATTService): - """GATT Characteristic implementation for the ESPHome backend.""" - - obj: BluetoothGATTService - - def __init__(self, obj: BluetoothGATTService) -> None: - """Init a BleakGATTServiceESPHome.""" - super().__init__(obj) # type: ignore[no-untyped-call] - self.__characteristics: list[BleakGATTCharacteristic] = [] - self.__handle: int = self.obj.handle - - @property - def handle(self) -> int: - """Integer handle of this service.""" - return self.__handle - - @property - def uuid(self) -> str: - """UUID for this service.""" - return self.obj.uuid - - @property - def characteristics(self) -> list[BleakGATTCharacteristic]: - """List of characteristics for this service.""" - return self.__characteristics - - def add_characteristic(self, characteristic: BleakGATTCharacteristic) -> None: - """Add a :py:class:`~BleakGATTCharacteristicESPHome` to the service. - - Should not be used by end user, but rather by `bleak` itself. - """ - self.__characteristics.append(characteristic) diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py index a984d057c0c..f270196db50 100644 --- a/homeassistant/components/esphome/diagnostics.py +++ b/homeassistant/components/esphome/diagnostics.py @@ -32,12 +32,13 @@ async def async_get_config_entry_diagnostics( if ( config_entry.unique_id - and (scanner := async_scanner_by_source(hass, config_entry.unique_id)) + and (scanner := async_scanner_by_source(hass, config_entry.unique_id.upper())) and (bluetooth_device := entry_data.bluetooth_device) ): diag["bluetooth"] = { "connections_free": bluetooth_device.ble_connections_free, "connections_limit": bluetooth_device.ble_connections_limit, + "available": bluetooth_device.available, "scanner": await scanner.async_diagnostics(), } diff --git a/homeassistant/components/esphome/domain_data.py b/homeassistant/components/esphome/domain_data.py index bf7c5d9c969..6dae91c4c24 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -4,11 +4,12 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Self, cast +from bleak_esphome.backend.cache import ESPHomeBluetoothCache + 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 diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index dc5a4ff0968..1def6d37e02 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -92,7 +92,7 @@ async def platform_async_setup_entry( def esphome_state_property( - func: Callable[[_EntityT], _R] + func: Callable[[_EntityT], _R], ) -> Callable[[_EntityT], _R | None]: """Wrap a state property of an esphome entity. diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index d69a30a8c1a..d9e5b199748 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -35,6 +35,7 @@ from aioesphomeapi import ( build_unique_id, ) from aioesphomeapi.model import ButtonInfo +from bleak_esphome.backend.device import ESPHomeBluetoothDevice from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -43,7 +44,6 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store -from .bluetooth.device import ESPHomeBluetoothDevice from .const import DOMAIN from .dashboard import async_get_dashboard @@ -420,6 +420,8 @@ class RuntimeEntryData: Safe to call multiple times. """ self.available = False + if self.bluetooth_device: + self.bluetooth_device.available = False # Make a copy since calling the disconnect callbacks # may also try to discard/remove themselves. for disconnect_cb in self.disconnect_callbacks.copy(): @@ -428,3 +430,21 @@ class RuntimeEntryData: # to it and make sure all the callbacks can be GC'd. self.disconnect_callbacks.clear() self.disconnect_callbacks = set() + + @callback + def async_on_connect( + self, device_info: DeviceInfo, api_version: APIVersion + ) -> None: + """Call when the entry has been connected.""" + self.available = True + if self.bluetooth_device: + self.bluetooth_device.available = True + + self.device_info = device_info + self.api_version = api_version + # 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. + self.expected_disconnect = True diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index 9942498e12d..08135e1a702 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -105,6 +105,10 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction) ) + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + await self._client.fan_command(key=self._key, preset_mode=preset_mode) + @property @esphome_state_property def is_on(self) -> bool | None: @@ -144,6 +148,17 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """Return the current fan direction.""" return _FAN_DIRECTIONS.from_esphome(self._state.direction) + @property + @esphome_state_property + def preset_mode(self) -> str | None: + """Return the current fan preset mode.""" + return self._state.preset_mode + + @property + def preset_modes(self) -> list[str] | None: + """Return the supported fan preset modes.""" + return self._static_info.supported_preset_modes + @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: """Set attrs from static info.""" @@ -156,4 +171,6 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): flags |= FanEntityFeature.SET_SPEED if static_info.supports_direction: flags |= FanEntityFeature.DIRECTION + if static_info.supported_preset_modes: + flags |= FanEntityFeature.PRESET_MODE self._attr_supported_features = flags diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 79e8a0a06fa..b4ae1a1d0ad 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -447,16 +447,10 @@ class ESPHomeManager: 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 + api_version = cli.api_version + assert api_version is not None, "API version must be set" + entry_data.async_on_connect(device_info, api_version) + if device_info.name: reconnect_logic.name = device_info.name @@ -472,10 +466,10 @@ class ESPHomeManager: setup_coros_with_disconnect_callbacks: list[ Coroutine[Any, Any, CALLBACK_TYPE] ] = [] - if device_info.bluetooth_proxy_feature_flags_compat(cli.api_version): + if device_info.bluetooth_proxy_feature_flags_compat(api_version): setup_coros_with_disconnect_callbacks.append( async_connect_scanner( - hass, entry, cli, entry_data, self.domain_data.bluetooth_cache + hass, entry_data, cli, device_info, self.domain_data.bluetooth_cache ) ) @@ -507,7 +501,7 @@ class ESPHomeManager: entry_data.disconnect_callbacks.add(cancel_callback) hass.async_create_task(entry_data.async_save_to_store()) - _async_check_firmware_version(hass, device_info, entry_data.api_version) + _async_check_firmware_version(hass, device_info, api_version) _async_check_using_api_password(hass, device_info, bool(self.password)) async def on_disconnect(self, expected_disconnect: bool) -> None: diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 7eca285681d..4a1301ccf29 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -13,11 +13,11 @@ "documentation": "https://www.home-assistant.io/integrations/esphome", "integration_type": "device", "iot_class": "local_push", - "loggers": ["aioesphomeapi", "noiseprotocol"], + "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "requirements": [ - "aioesphomeapi==19.2.1", - "bluetooth-data-tools==1.15.0", - "esphome-dashboard-api==1.2.3" + "aioesphomeapi==21.0.1", + "esphome-dashboard-api==1.2.3", + "bleak-esphome==0.4.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/eufylife_ble/__init__.py b/homeassistant/components/eufylife_ble/__init__.py index 49370c2efcf..f407e86a289 100644 --- a/homeassistant/components/eufylife_ble/__init__.py +++ b/homeassistant/components/eufylife_ble/__init__.py @@ -6,10 +6,10 @@ from eufylife_ble_client import EufyLifeBLEDevice from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import CONF_MODEL, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback -from .const import CONF_MODEL, DOMAIN +from .const import DOMAIN from .models import EufyLifeData PLATFORMS: list[Platform] = [Platform.SENSOR] diff --git a/homeassistant/components/eufylife_ble/config_flow.py b/homeassistant/components/eufylife_ble/config_flow.py index 9e1ff4af7a8..e3a1a301f25 100644 --- a/homeassistant/components/eufylife_ble/config_flow.py +++ b/homeassistant/components/eufylife_ble/config_flow.py @@ -11,10 +11,10 @@ from homeassistant.components.bluetooth import ( async_discovered_service_info, ) from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_ADDRESS +from homeassistant.const import CONF_ADDRESS, CONF_MODEL from homeassistant.data_entry_flow import FlowResult -from .const import CONF_MODEL, DOMAIN +from .const import DOMAIN class EufyLifeConfigFlow(ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/eufylife_ble/const.py b/homeassistant/components/eufylife_ble/const.py index dac0afc9109..e6beb34aaff 100644 --- a/homeassistant/components/eufylife_ble/const.py +++ b/homeassistant/components/eufylife_ble/const.py @@ -1,5 +1,3 @@ """Constants for the EufyLife integration.""" DOMAIN = "eufylife_ble" - -CONF_MODEL = "model" diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index d9608670972..b05c3a6f3a5 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -5,7 +5,7 @@ from dataclasses import asdict, dataclass from datetime import datetime, timedelta from enum import StrEnum import logging -from typing import Any, Self, final +from typing import TYPE_CHECKING, Any, Self, final from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -21,6 +21,12 @@ from homeassistant.util import dt as dt_util from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -71,8 +77,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class EventEntityDescription(EntityDescription): +class EventEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes event entities.""" device_class: EventDeviceClass | None = None @@ -102,7 +107,13 @@ class EventExtraStoredData(ExtraStoredData): return None -class EventEntity(RestoreEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "event_types", +} + + +class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of an Event entity.""" _entity_component_unrecorded_attributes = frozenset({ATTR_EVENT_TYPES}) @@ -116,7 +127,7 @@ class EventEntity(RestoreEntity): __last_event_type: str | None = None __last_event_attributes: dict[str, Any] | None = None - @property + @cached_property def device_class(self) -> EventDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -125,7 +136,7 @@ class EventEntity(RestoreEntity): return self.entity_description.device_class return None - @property + @cached_property def event_types(self) -> list[str]: """Return a list of possible events.""" if hasattr(self, "_attr_event_types"): diff --git a/homeassistant/components/evil_genius_labs/util.py b/homeassistant/components/evil_genius_labs/util.py index b0e01c1f329..eb2caf59d9d 100644 --- a/homeassistant/components/evil_genius_labs/util.py +++ b/homeassistant/components/evil_genius_labs/util.py @@ -13,7 +13,7 @@ _P = ParamSpec("_P") def update_when_done( - func: Callable[Concatenate[_EvilGeniusEntityT, _P], Awaitable[_R]] + func: Callable[Concatenate[_EvilGeniusEntityT, _P], Awaitable[_R]], ) -> Callable[Concatenate[_EvilGeniusEntityT, _P], Coroutine[Any, Any, _R]]: """Decorate function to trigger update when function is done.""" diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index f4ceaf2c48c..06712a83b6a 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -4,15 +4,32 @@ Such systems include evohome, Round Thermostat, and others. """ from __future__ import annotations -from datetime import datetime as dt, timedelta +from collections.abc import Awaitable +from datetime import datetime, timedelta from http import HTTPStatus import logging import re from typing import Any -import evohomeasync -import evohomeasync2 -import voluptuous as vol +import evohomeasync as ev1 +from evohomeasync.schema import SZ_ID, SZ_SESSION_ID, SZ_TEMP +import evohomeasync2 as evo +from evohomeasync2.schema.const import ( + SZ_ALLOWED_SYSTEM_MODES, + SZ_AUTO_WITH_RESET, + SZ_CAN_BE_TEMPORARY, + SZ_HEAT_SETPOINT, + SZ_LOCATION_INFO, + SZ_SETPOINT_STATUS, + SZ_STATE_STATUS, + SZ_SYSTEM_MODE, + SZ_SYSTEM_MODE_STATUS, + SZ_TIME_UNTIL, + SZ_TIME_ZONE, + SZ_TIMING_MODE, + SZ_UNTIL, +) +import voluptuous as vol # type: ignore[import-untyped] from homeassistant.const import ( ATTR_ENTITY_ID, @@ -96,15 +113,15 @@ SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( # system mode schemas are built dynamically, below -def _dt_local_to_aware(dt_naive: dt) -> dt: - dt_aware = dt_util.now() + (dt_naive - dt.now()) +def _dt_local_to_aware(dt_naive: datetime) -> datetime: + dt_aware = dt_util.now() + (dt_naive - datetime.now()) if dt_aware.microsecond >= 500000: dt_aware += timedelta(seconds=1) return dt_aware.replace(microsecond=0) -def _dt_aware_to_naive(dt_aware: dt) -> dt: - dt_naive = dt.now() + (dt_aware - dt_util.now()) +def _dt_aware_to_naive(dt_aware: datetime) -> datetime: + dt_naive = datetime.now() + (dt_aware - dt_util.now()) if dt_naive.microsecond >= 500000: dt_naive += timedelta(seconds=1) return dt_naive.replace(microsecond=0) @@ -141,12 +158,12 @@ def convert_dict(dictionary: dict[str, Any]) -> dict[str, Any]: } -def _handle_exception(err) -> None: +def _handle_exception(err: evo.RequestFailed) -> None: """Return False if the exception can't be ignored.""" try: raise err - except evohomeasync2.AuthenticationFailed: + except evo.AuthenticationFailed: _LOGGER.error( ( "Failed to authenticate with the vendor's server. Check your username" @@ -157,7 +174,7 @@ def _handle_exception(err) -> None: err, ) - except evohomeasync2.RequestFailed: + except evo.RequestFailed: if err.status is None: _LOGGER.warning( ( @@ -190,14 +207,14 @@ def _handle_exception(err) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Create a (EMEA/EU-based) Honeywell TCC system.""" - async def load_auth_tokens(store) -> tuple[dict, dict | None]: + async def load_auth_tokens(store: Store) -> tuple[dict, dict | None]: app_storage = await store.async_load() tokens = dict(app_storage or {}) if tokens.pop(CONF_USERNAME, None) != config[DOMAIN][CONF_USERNAME]: # any tokens won't be valid, and store might be corrupt await store.async_save({}) - return ({}, None) + return ({}, {}) # evohomeasync2 requires naive/local datetimes as strings if tokens.get(ACCESS_TOKEN_EXPIRES) is not None and ( @@ -205,13 +222,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ): tokens[ACCESS_TOKEN_EXPIRES] = _dt_aware_to_naive(expires) - user_data = tokens.pop(USER_DATA, None) + user_data = tokens.pop(USER_DATA, {}) return (tokens, user_data) store = Store[dict[str, Any]](hass, STORAGE_VER, STORAGE_KEY) tokens, user_data = await load_auth_tokens(store) - client_v2 = evohomeasync2.EvohomeClient( + client_v2 = evo.EvohomeClient( config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD], **tokens, @@ -220,7 +237,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: await client_v2.login() - except evohomeasync2.AuthenticationFailed as err: + except evo.AuthenticationFailed as err: _handle_exception(err) return False finally: @@ -243,17 +260,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if _LOGGER.isEnabledFor(logging.DEBUG): _config: dict[str, Any] = { - "locationInfo": {"timeZone": None}, + SZ_LOCATION_INFO: {SZ_TIME_ZONE: None}, GWS: [{TCS: None}], } - _config["locationInfo"]["timeZone"] = loc_config["locationInfo"]["timeZone"] + _config[SZ_LOCATION_INFO][SZ_TIME_ZONE] = loc_config[SZ_LOCATION_INFO][ + SZ_TIME_ZONE + ] _config[GWS][0][TCS] = loc_config[GWS][0][TCS] _LOGGER.debug("Config = %s", _config) - client_v1 = evohomeasync.EvohomeClient( + client_v1 = ev1.EvohomeClient( client_v2.username, client_v2.password, - user_data=user_data, + session_id=user_data.get(SZ_SESSION_ID) if user_data else None, # STORAGE_VER 1 session=async_get_clientsession(hass), ) @@ -283,7 +302,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @callback -def setup_service_functions(hass: HomeAssistant, broker): +def setup_service_functions(hass: HomeAssistant, broker: EvoBroker) -> None: """Set up the service handlers for the system/zone operating modes. Not all Honeywell TCC-compatible systems support all operating modes. In addition, @@ -333,25 +352,25 @@ def setup_service_functions(hass: HomeAssistant, broker): hass.services.async_register(DOMAIN, SVC_REFRESH_SYSTEM, force_refresh) # Enumerate which operating modes are supported by this system - modes = broker.config["allowedSystemModes"] + modes = broker.config[SZ_ALLOWED_SYSTEM_MODES] # Not all systems support "AutoWithReset": register this handler only if required - if [m["systemMode"] for m in modes if m["systemMode"] == "AutoWithReset"]: + if [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_SYSTEM_MODE] == SZ_AUTO_WITH_RESET]: hass.services.async_register(DOMAIN, SVC_RESET_SYSTEM, set_system_mode) system_mode_schemas = [] - modes = [m for m in modes if m["systemMode"] != "AutoWithReset"] + modes = [m for m in modes if m[SZ_SYSTEM_MODE] != SZ_AUTO_WITH_RESET] # Permanent-only modes will use this schema - perm_modes = [m["systemMode"] for m in modes if not m["canBeTemporary"]] + perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]] if perm_modes: # any of: "Auto", "HeatingOff": permanent only schema = vol.Schema({vol.Required(ATTR_SYSTEM_MODE): vol.In(perm_modes)}) system_mode_schemas.append(schema) - modes = [m for m in modes if m["canBeTemporary"]] + modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]] # These modes are set for a number of hours (or indefinitely): use this schema - temp_modes = [m["systemMode"] for m in modes if m["timingMode"] == "Duration"] + temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Duration"] if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours schema = vol.Schema( { @@ -365,7 +384,7 @@ def setup_service_functions(hass: HomeAssistant, broker): system_mode_schemas.append(schema) # These modes are set for a number of days (or indefinitely): use this schema - temp_modes = [m["systemMode"] for m in modes if m["timingMode"] == "Period"] + temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == "Period"] if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days schema = vol.Schema( { @@ -383,7 +402,7 @@ def setup_service_functions(hass: HomeAssistant, broker): DOMAIN, SVC_SET_SYSTEM_MODE, set_system_mode, - schema=vol.Any(*system_mode_schemas), + schema=vol.Schema(vol.Any(*system_mode_schemas)), ) # The zone modes are consistent across all systems and use the same schema @@ -407,8 +426,8 @@ class EvoBroker: def __init__( self, hass: HomeAssistant, - client: evohomeasync2.EvohomeClient, - client_v1: evohomeasync.EvohomeClient | None, + client: evo.EvohomeClient, + client_v1: ev1.EvohomeClient | None, store: Store[dict[str, Any]], params: ConfigType, ) -> None: @@ -420,12 +439,12 @@ class EvoBroker: self.params = params loc_idx = params[CONF_LOCATION_IDX] + self._location: evo.Location = client.locations[loc_idx] + self.config = client.installation_info[loc_idx][GWS][0][TCS][0] - self.tcs = client.locations[loc_idx]._gateways[0]._control_systems[0] - self.tcs_utc_offset = timedelta( - minutes=client.locations[loc_idx].timeZone[UTC_OFFSET] - ) - self.temps: dict[str, Any] | None = {} + self.tcs: evo.ControlSystem = self._location._gateways[0]._control_systems[0] + self.tcs_utc_offset = timedelta(minutes=self._location.timeZone[UTC_OFFSET]) + self.temps: dict[str, float | None] = {} async def save_auth_tokens(self) -> None: """Save access tokens and session IDs to the store for later use.""" @@ -441,45 +460,48 @@ class EvoBroker: ACCESS_TOKEN_EXPIRES: access_token_expires.isoformat(), } - if self.client_v1 and self.client_v1.user_data: - user_id = self.client_v1.user_data["userInfo"]["userID"] # type: ignore[index] + if self.client_v1: app_storage[USER_DATA] = { # type: ignore[assignment] - "userInfo": {"userID": user_id}, - "sessionId": self.client_v1.user_data["sessionId"], - } + SZ_SESSION_ID: self.client_v1.broker.session_id, + } # this is the schema for STORAGE_VER == 1 else: - app_storage[USER_DATA] = None + app_storage[USER_DATA] = {} # type: ignore[assignment] await self._store.async_save(app_storage) - async def call_client_api(self, api_function, update_state=True) -> Any: + async def call_client_api( + self, + api_function: Awaitable[dict[str, Any] | None], + update_state: bool = True, + ) -> dict[str, Any] | None: """Call a client API and update the broker state if required.""" try: result = await api_function - except evohomeasync2.EvohomeError as err: + except evo.RequestFailed as err: _handle_exception(err) - return + return None if update_state: # wait a moment for system to quiesce before updating state async_call_later(self.hass, 1, self._update_v2_api_state) return result - async def _update_v1_api_temps(self, *args, **kwargs) -> None: + async def _update_v1_api_temps(self) -> None: """Get the latest high-precision temperatures of the default Location.""" - assert self.client_v1 + assert self.client_v1 is not None # mypy check - def get_session_id(client_v1) -> str | None: + def get_session_id(client_v1: ev1.EvohomeClient) -> str | None: user_data = client_v1.user_data if client_v1 else None - return user_data.get("sessionId") if user_data else None + return user_data.get(SZ_SESSION_ID) if user_data else None # type: ignore[return-value] session_id = get_session_id(self.client_v1) + self.temps = {} # these are now stale, will fall back to v2 temps try: - temps = list(await self.client_v1.temperatures(force_refresh=True)) + temps = await self.client_v1.get_temperatures() - except evohomeasync.InvalidSchema as exc: + except ev1.InvalidSchema as err: _LOGGER.warning( ( "Unable to obtain high-precision temperatures. " @@ -487,11 +509,11 @@ class EvoBroker: "so the high-precision feature will be disabled until next restart." "Message is: %s" ), - exc, + err, ) - self.temps = self.client_v1 = None + self.client_v1 = None - except evohomeasync.EvohomeError as exc: + except ev1.RequestFailed as err: _LOGGER.warning( ( "Unable to obtain the latest high-precision temperatures. " @@ -499,48 +521,44 @@ class EvoBroker: "Proceeding without high-precision temperatures for now. " "Message is: %s" ), - exc, + err, ) - self.temps = None # these are now stale, will fall back to v2 temps else: - if ( - str(self.client_v1.location_id) - != self.client.locations[self.params[CONF_LOCATION_IDX]].locationId - ): + if str(self.client_v1.location_id) != self._location.locationId: _LOGGER.warning( "The v2 API's configured location doesn't match " "the v1 API's default location (there is more than one location), " "so the high-precision feature will be disabled until next restart" ) - self.temps = self.client_v1 = None + self.client_v1 = None else: - self.temps = {str(i["id"]): i["temp"] for i in temps} + self.temps = {str(i[SZ_ID]): i[SZ_TEMP] for i in temps} finally: - if session_id != get_session_id(self.client_v1): + if self.client_v1 and session_id != self.client_v1.broker.session_id: await self.save_auth_tokens() _LOGGER.debug("Temperatures = %s", self.temps) - async def _update_v2_api_state(self, *args, **kwargs) -> None: + async def _update_v2_api_state(self, *args: Any) -> None: """Get the latest modes, temperatures, setpoints of a Location.""" - access_token = self.client.access_token - loc_idx = self.params[CONF_LOCATION_IDX] + access_token = self.client.access_token # maybe receive a new token? + try: - status = await self.client.locations[loc_idx].refresh_status() - except evohomeasync2.EvohomeError as err: + status = await self._location.refresh_status() + except evo.RequestFailed as err: _handle_exception(err) else: async_dispatcher_send(self.hass, DOMAIN) _LOGGER.debug("Status = %s", status) + finally: + if access_token != self.client.access_token: + await self.save_auth_tokens() - if access_token != self.client.access_token: - await self.save_auth_tokens() - - async def async_update(self, *args, **kwargs) -> None: + async def async_update(self, *args: Any) -> None: """Get the latest state data of an entire Honeywell TCC Location. This includes state data for a Controller and all its child devices, such as the @@ -562,7 +580,11 @@ class EvoDevice(Entity): _attr_should_poll = False - def __init__(self, evo_broker, evo_device) -> None: + def __init__( + self, + evo_broker: EvoBroker, + evo_device: evo.ControlSystem | evo.HotWater | evo.Zone, + ) -> None: """Initialize the evohome entity.""" self._evo_device = evo_device self._evo_broker = evo_broker @@ -594,12 +616,12 @@ class EvoDevice(Entity): def extra_state_attributes(self) -> dict[str, Any]: """Return the evohome-specific state attributes.""" status = self._device_state_attrs - if "systemModeStatus" in status: - convert_until(status["systemModeStatus"], "timeUntil") - if "setpointStatus" in status: - convert_until(status["setpointStatus"], "until") - if "stateStatus" in status: - convert_until(status["stateStatus"], "until") + if SZ_SYSTEM_MODE_STATUS in status: + convert_until(status[SZ_SYSTEM_MODE_STATUS], SZ_TIME_UNTIL) + if SZ_SETPOINT_STATUS in status: + convert_until(status[SZ_SETPOINT_STATUS], SZ_UNTIL) + if SZ_STATE_STATUS in status: + convert_until(status[SZ_STATE_STATUS], SZ_UNTIL) return {"status": convert_dict(status)} @@ -614,27 +636,26 @@ class EvoChild(EvoDevice): This includes (up to 12) Heating Zones and (optionally) a DHW controller. """ - def __init__(self, evo_broker, evo_device) -> None: + _evo_id: str # mypy hint + + def __init__( + self, evo_broker: EvoBroker, evo_device: evo.HotWater | evo.Zone + ) -> None: """Initialize a evohome Controller (hub).""" super().__init__(evo_broker, evo_device) + self._schedule: dict[str, Any] = {} self._setpoints: dict[str, Any] = {} @property def current_temperature(self) -> float | None: """Return the current temperature of a Zone.""" - if self._evo_device.TYPE == "domesticHotWater": - dev_id = self._evo_device.dhwId - else: - dev_id = self._evo_device.zoneId - if self._evo_broker.temps and self._evo_broker.temps[dev_id] is not None: - return self._evo_broker.temps[dev_id] + assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check - if self._evo_device.temperatureStatus["isAvailable"]: - return self._evo_device.temperatureStatus["temperature"] - - return None + if self._evo_broker.temps.get(self._evo_id) is not None: + return self._evo_broker.temps[self._evo_id] + return self._evo_device.temperature @property def setpoints(self) -> dict[str, Any]: @@ -643,7 +664,7 @@ class EvoChild(EvoDevice): Only Zones & DHW controllers (but not the TCS) can have schedules. """ - def _dt_evo_to_aware(dt_naive: dt, utc_offset: timedelta) -> dt: + def _dt_evo_to_aware(dt_naive: datetime, utc_offset: timedelta) -> datetime: dt_aware = dt_naive.replace(tzinfo=dt_util.UTC) - utc_offset return dt_util.as_local(dt_aware) @@ -679,14 +700,14 @@ class EvoChild(EvoDevice): switchpoint_time_of_day = dt_util.parse_datetime( f"{sp_date}T{switchpoint['TimeOfDay']}" ) - assert switchpoint_time_of_day + assert switchpoint_time_of_day is not None # mypy check dt_aware = _dt_evo_to_aware( switchpoint_time_of_day, self._evo_broker.tcs_utc_offset ) self._setpoints[f"{key}_sp_from"] = dt_aware.isoformat() try: - self._setpoints[f"{key}_sp_temp"] = switchpoint["heatSetpoint"] + self._setpoints[f"{key}_sp_temp"] = switchpoint[SZ_HEAT_SETPOINT] except KeyError: self._setpoints[f"{key}_sp_state"] = switchpoint["DhwState"] @@ -701,7 +722,10 @@ class EvoChild(EvoDevice): async def _update_schedule(self) -> None: """Get the latest schedule, if any.""" - self._schedule = await self._evo_broker.call_client_api( + + assert isinstance(self._evo_device, evo.HotWater | evo.Zone) # mypy check + + self._schedule = await self._evo_broker.call_client_api( # type: ignore[assignment] self._evo_device.get_schedule(), update_state=False ) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index fb608262a7d..1e092d7fc34 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -1,9 +1,24 @@ """Support for Climate devices of (EMEA/EU-based) Honeywell TCC systems.""" from __future__ import annotations -from datetime import datetime as dt +from datetime import datetime, timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any + +import evohomeasync2 as evo +from evohomeasync2.schema.const import ( + SZ_ACTIVE_FAULTS, + SZ_ALLOWED_SYSTEM_MODES, + SZ_SETPOINT_STATUS, + SZ_SYSTEM_ID, + SZ_SYSTEM_MODE, + SZ_SYSTEM_MODE_STATUS, + SZ_TEMPERATURE_STATUS, + SZ_UNTIL, + SZ_ZONE_ID, + ZoneModelType, + ZoneType, +) from homeassistant.components.climate import ( PRESET_AWAY, @@ -47,6 +62,10 @@ from .const import ( EVO_TEMPOVER, ) +if TYPE_CHECKING: + from . import EvoBroker + + _LOGGER = logging.getLogger(__name__) PRESET_RESET = "Reset" # reset all child zones to EVO_FOLLOW @@ -71,8 +90,13 @@ EVO_PRESET_TO_HA = { } HA_PRESET_TO_EVO = {v: k for k, v in EVO_PRESET_TO_HA.items()} -STATE_ATTRS_TCS = ["systemId", "activeFaults", "systemModeStatus"] -STATE_ATTRS_ZONES = ["zoneId", "activeFaults", "setpointStatus", "temperatureStatus"] +STATE_ATTRS_TCS = [SZ_SYSTEM_ID, SZ_ACTIVE_FAULTS, SZ_SYSTEM_MODE_STATUS] +STATE_ATTRS_ZONES = [ + SZ_ZONE_ID, + SZ_ACTIVE_FAULTS, + SZ_SETPOINT_STATUS, + SZ_TEMPERATURE_STATUS, +] async def async_setup_platform( @@ -85,7 +109,7 @@ async def async_setup_platform( if discovery_info is None: return - broker = hass.data[DOMAIN]["broker"] + broker: EvoBroker = hass.data[DOMAIN]["broker"] _LOGGER.debug( "Found the Location/Controller (%s), id=%s, name=%s (location_idx=%s)", @@ -98,7 +122,10 @@ async def async_setup_platform( entities: list[EvoClimateEntity] = [EvoController(broker, broker.tcs)] for zone in broker.tcs.zones.values(): - if zone.modelType == "HeatingZone" or zone.zoneType == "Thermostat": + if ( + zone.modelType == ZoneModelType.HEATING_ZONE + or zone.zoneType == ZoneType.THERMOSTAT + ): _LOGGER.debug( "Adding: %s (%s), id=%s, name=%s", zone.zoneType, @@ -141,9 +168,13 @@ class EvoZone(EvoChild, EvoClimateEntity): _attr_preset_modes = list(HA_PRESET_TO_EVO) - def __init__(self, evo_broker, evo_device) -> None: + _evo_device: evo.Zone # mypy hint + + def __init__(self, evo_broker: EvoBroker, evo_device: evo.Zone) -> None: """Initialize a Honeywell TCC Zone.""" + super().__init__(evo_broker, evo_device) + self._evo_id = evo_device.zoneId if evo_device.modelType.startswith("VisionProWifi"): # this system does not have a distinct ID for the zone @@ -174,7 +205,7 @@ class EvoZone(EvoChild, EvoClimateEntity): temperature = max(min(data[ATTR_ZONE_TEMP], self.max_temp), self.min_temp) if ATTR_DURATION_UNTIL in data: - duration = data[ATTR_DURATION_UNTIL] + duration: timedelta = data[ATTR_DURATION_UNTIL] if duration.total_seconds() == 0: await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) @@ -189,24 +220,29 @@ class EvoZone(EvoChild, EvoClimateEntity): ) @property - def hvac_mode(self) -> HVACMode: + def hvac_mode(self) -> HVACMode | None: """Return the current operating mode of a Zone.""" - if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): + if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): return HVACMode.AUTO - is_off = self.target_temperature <= self.min_temp - return HVACMode.OFF if is_off else HVACMode.HEAT + if self.target_temperature is None: + return None + if self.target_temperature <= self.min_temp: + return HVACMode.OFF + return HVACMode.HEAT @property - def target_temperature(self) -> float: + def target_temperature(self) -> float | None: """Return the target temperature of a Zone.""" - return self._evo_device.setpointStatus["targetHeatTemperature"] + return self._evo_device.target_heat_temperature @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - if self._evo_tcs.systemModeStatus["mode"] in (EVO_AWAY, EVO_HEATOFF): - return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) - return EVO_PRESET_TO_HA.get(self._evo_device.setpointStatus["setpointMode"]) + if self._evo_tcs.system_mode in (EVO_AWAY, EVO_HEATOFF): + return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) + if self._evo_device.mode is None: + return None + return EVO_PRESET_TO_HA.get(self._evo_device.mode) @property def min_temp(self) -> float: @@ -214,7 +250,7 @@ class EvoZone(EvoChild, EvoClimateEntity): The default is 5, but is user-configurable within 5-35 (in Celsius). """ - return self._evo_device.setpointCapabilities["minHeatSetpoint"] + return self._evo_device.min_heat_setpoint @property def max_temp(self) -> float: @@ -222,18 +258,23 @@ class EvoZone(EvoChild, EvoClimateEntity): The default is 35, but is user-configurable within 5-35 (in Celsius). """ - return self._evo_device.setpointCapabilities["maxHeatSetpoint"] + return self._evo_device.max_heat_setpoint async def async_set_temperature(self, **kwargs: Any) -> None: """Set a new target temperature.""" + + assert self._evo_device.setpointStatus is not None # mypy check + temperature = kwargs["temperature"] if (until := kwargs.get("until")) is None: - if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: + if self._evo_device.mode == EVO_FOLLOW: await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) - elif self._evo_device.setpointStatus["setpointMode"] == EVO_TEMPOVER: - until = dt_util.parse_datetime(self._evo_device.setpointStatus["until"]) + elif self._evo_device.mode == EVO_TEMPOVER: + until = dt_util.parse_datetime( + self._evo_device.setpointStatus[SZ_UNTIL] + ) until = dt_util.as_utc(until) if until else None await self._evo_broker.call_client_api( @@ -272,14 +313,15 @@ class EvoZone(EvoChild, EvoClimateEntity): await self._evo_broker.call_client_api(self._evo_device.reset_mode()) return - temperature = self._evo_device.setpointStatus["targetHeatTemperature"] - if evo_preset_mode == EVO_TEMPOVER: await self._update_schedule() until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) else: # EVO_PERMOVER until = None + temperature = self._evo_device.target_heat_temperature + assert temperature is not None # mypy check + until = dt_util.as_utc(until) if until else None await self._evo_broker.call_client_api( self._evo_device.set_temperature(temperature, until=until) @@ -306,14 +348,18 @@ class EvoController(EvoClimateEntity): _attr_icon = "mdi:thermostat" _attr_precision = PRECISION_TENTHS - def __init__(self, evo_broker, evo_device) -> None: + _evo_device: evo.ControlSystem # mypy hint + + def __init__(self, evo_broker: EvoBroker, evo_device: evo.ControlSystem) -> None: """Initialize a Honeywell TCC Controller/Location.""" + super().__init__(evo_broker, evo_device) + self._evo_id = evo_device.systemId self._attr_unique_id = evo_device.systemId self._attr_name = evo_device.location.name - modes = [m["systemMode"] for m in evo_broker.config["allowedSystemModes"]] + modes = [m[SZ_SYSTEM_MODE] for m in evo_broker.config[SZ_ALLOWED_SYSTEM_MODES]] self._attr_preset_modes = [ TCS_PRESET_TO_HA[m] for m in modes if m in list(TCS_PRESET_TO_HA) ] @@ -342,17 +388,17 @@ class EvoController(EvoClimateEntity): await self._set_tcs_mode(mode, until=until) - async def _set_tcs_mode(self, mode: str, until: dt | None = None) -> None: + async def _set_tcs_mode(self, mode: str, until: datetime | None = None) -> None: """Set a Controller to any of its native EVO_* operating modes.""" until = dt_util.as_utc(until) if until else None await self._evo_broker.call_client_api( - self._evo_tcs.set_mode(mode, until=until) + self._evo_tcs.set_mode(mode, until=until) # type: ignore[arg-type] ) @property def hvac_mode(self) -> HVACMode: """Return the current operating mode of a Controller.""" - tcs_mode = self._evo_tcs.systemModeStatus["mode"] + tcs_mode = self._evo_tcs.system_mode return HVACMode.OFF if tcs_mode == EVO_HEATOFF else HVACMode.HEAT @property @@ -362,16 +408,18 @@ class EvoController(EvoClimateEntity): Controllers do not have a current temp, but one is expected by HA. """ temps = [ - z.temperatureStatus["temperature"] + z.temperature for z in self._evo_tcs.zones.values() - if z.temperatureStatus["isAvailable"] + if z.temperature is not None ] return round(sum(temps) / len(temps), 1) if temps else None @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" - return TCS_PRESET_TO_HA.get(self._evo_tcs.systemModeStatus["mode"]) + if not self._evo_tcs.system_mode: + return None + return TCS_PRESET_TO_HA.get(self._evo_tcs.system_mode) async def async_set_temperature(self, **kwargs: Any) -> None: """Raise exception as Controllers don't have a target temperature.""" @@ -393,7 +441,7 @@ class EvoController(EvoClimateEntity): attrs = self._device_state_attrs for attr in STATE_ATTRS_TCS: - if attr == "activeFaults": + if attr == SZ_ACTIVE_FAULTS: attrs["activeSystemFaults"] = getattr(self._evo_tcs, attr) else: attrs[attr] = getattr(self._evo_tcs, attr) diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json index 58efb2c25b2..062bba1cfdc 100644 --- a/homeassistant/components/evohome/manifest.json +++ b/homeassistant/components/evohome/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/evohome", "iot_class": "cloud_polling", "loggers": ["evohomeasync", "evohomeasync2"], - "requirements": ["evohome-async==0.4.6"] + "requirements": ["evohome-async==0.4.15"] } diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index 5d49e9b46ec..77a7b1c2ced 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -2,6 +2,17 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING, Any + +import evohomeasync2 as evo +from evohomeasync2.schema.const import ( + SZ_ACTIVE_FAULTS, + SZ_DHW_ID, + SZ_OFF, + SZ_ON, + SZ_STATE_STATUS, + SZ_TEMPERATURE_STATUS, +) from homeassistant.components.water_heater import ( WaterHeaterEntity, @@ -22,14 +33,18 @@ import homeassistant.util.dt as dt_util from . import EvoChild from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER +if TYPE_CHECKING: + from . import EvoBroker + + _LOGGER = logging.getLogger(__name__) STATE_AUTO = "auto" -HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: "On", STATE_OFF: "Off"} +HA_STATE_TO_EVO = {STATE_AUTO: "", STATE_ON: SZ_ON, STATE_OFF: SZ_OFF} EVO_STATE_TO_HA = {v: k for k, v in HA_STATE_TO_EVO.items() if k != ""} -STATE_ATTRS_DHW = ["dhwId", "activeFaults", "stateStatus", "temperatureStatus"] +STATE_ATTRS_DHW = [SZ_DHW_ID, SZ_ACTIVE_FAULTS, SZ_STATE_STATUS, SZ_TEMPERATURE_STATUS] async def async_setup_platform( @@ -42,7 +57,9 @@ async def async_setup_platform( if discovery_info is None: return - broker = hass.data[DOMAIN]["broker"] + broker: EvoBroker = hass.data[DOMAIN]["broker"] + + assert broker.tcs.hotwater is not None # mypy check _LOGGER.debug( "Adding: DhwController (%s), id=%s", @@ -63,9 +80,13 @@ class EvoDHW(EvoChild, WaterHeaterEntity): _attr_operation_list = list(HA_STATE_TO_EVO) _attr_temperature_unit = UnitOfTemperature.CELSIUS - def __init__(self, evo_broker, evo_device) -> None: + _evo_device: evo.HotWater # mypy hint + + def __init__(self, evo_broker: EvoBroker, evo_device: evo.HotWater) -> None: """Initialize an evohome DHW controller.""" + super().__init__(evo_broker, evo_device) + self._evo_id = evo_device.dhwId self._attr_unique_id = evo_device.dhwId @@ -77,17 +98,21 @@ class EvoDHW(EvoChild, WaterHeaterEntity): ) @property - def current_operation(self) -> str: + def current_operation(self) -> str | None: """Return the current operating mode (Auto, On, or Off).""" - if self._evo_device.stateStatus["mode"] == EVO_FOLLOW: + if self._evo_device.mode == EVO_FOLLOW: return STATE_AUTO - return EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] + if (device_state := self._evo_device.state) is None: + return None + return EVO_STATE_TO_HA[device_state] @property - def is_away_mode_on(self): + def is_away_mode_on(self) -> bool | None: """Return True if away mode is on.""" - is_off = EVO_STATE_TO_HA[self._evo_device.stateStatus["state"]] == STATE_OFF - is_permanent = self._evo_device.stateStatus["mode"] == EVO_PERMOVER + if self._evo_device.state is None: + return None + is_off = EVO_STATE_TO_HA[self._evo_device.state] == STATE_OFF + is_permanent = self._evo_device.mode == EVO_PERMOVER return is_off and is_permanent async def async_set_operation_mode(self, operation_mode: str) -> None: @@ -119,11 +144,11 @@ class EvoDHW(EvoChild, WaterHeaterEntity): """Turn away mode off.""" await self._evo_broker.call_client_api(self._evo_device.reset_mode()) - async def async_turn_on(self): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on.""" await self._evo_broker.call_client_api(self._evo_device.set_on()) - async def async_turn_off(self): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off.""" await self._evo_broker.call_client_api(self._evo_device.set_off()) diff --git a/homeassistant/components/ezviz/alarm_control_panel.py b/homeassistant/components/ezviz/alarm_control_panel.py index 4dd16b23480..1cdda152685 100644 --- a/homeassistant/components/ezviz/alarm_control_panel.py +++ b/homeassistant/components/ezviz/alarm_control_panel.py @@ -33,14 +33,14 @@ SCAN_INTERVAL = timedelta(seconds=60) PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class EzvizAlarmControlPanelEntityDescriptionMixin: """Mixin values for EZVIZ Alarm control panel entities.""" ezviz_alarm_states: list -@dataclass +@dataclass(frozen=True) class EzvizAlarmControlPanelEntityDescription( AlarmControlPanelEntityDescription, EzvizAlarmControlPanelEntityDescriptionMixin ): diff --git a/homeassistant/components/ezviz/button.py b/homeassistant/components/ezviz/button.py index 2199f82a476..abc44419075 100644 --- a/homeassistant/components/ezviz/button.py +++ b/homeassistant/components/ezviz/button.py @@ -22,7 +22,7 @@ from .entity import EzvizEntity PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class EzvizButtonEntityDescriptionMixin: """Mixin values for EZVIZ button entities.""" @@ -30,7 +30,7 @@ class EzvizButtonEntityDescriptionMixin: supported_ext: str -@dataclass +@dataclass(frozen=True) class EzvizButtonEntityDescription( ButtonEntityDescription, EzvizButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py index ea7a4812b32..c922173aa87 100644 --- a/homeassistant/components/ezviz/number.py +++ b/homeassistant/components/ezviz/number.py @@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class EzvizNumberEntityDescriptionMixin: """Mixin values for EZVIZ Number entities.""" @@ -38,7 +38,7 @@ class EzvizNumberEntityDescriptionMixin: supported_ext_value: list -@dataclass +@dataclass(frozen=True) class EzvizNumberEntityDescription( NumberEntityDescription, EzvizNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py index 369a429dbe6..8110cf61a5c 100644 --- a/homeassistant/components/ezviz/select.py +++ b/homeassistant/components/ezviz/select.py @@ -20,14 +20,14 @@ from .entity import EzvizEntity PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class EzvizSelectEntityDescriptionMixin: """Mixin values for EZVIZ Select entities.""" supported_switch: int -@dataclass +@dataclass(frozen=True) class EzvizSelectEntityDescription( SelectEntityDescription, EzvizSelectEntityDescriptionMixin ): diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index 4089b0ae393..f6d19afae0c 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -22,14 +22,14 @@ from .coordinator import EzvizDataUpdateCoordinator from .entity import EzvizEntity -@dataclass +@dataclass(frozen=True) class EzvizSwitchEntityDescriptionMixin: """Mixin values for EZVIZ Switch entities.""" supported_ext: str | None -@dataclass +@dataclass(frozen=True) class EzvizSwitchEntityDescription( SwitchEntityDescription, EzvizSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py index 5cbb206f223..20bebcf08c8 100644 --- a/homeassistant/components/faa_delays/binary_sensor.py +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -1,44 +1,88 @@ """Platform for FAA Delays sensor component.""" from __future__ import annotations +from collections.abc import Callable, Mapping +from dataclasses import dataclass from typing import Any +from faadelays import Airport + from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import FAADataUpdateCoordinator from .const import DOMAIN -FAA_BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( - BinarySensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class FaaDelaysBinarySensorEntityDescription(BinarySensorEntityDescription): + """Mixin for required keys.""" + + is_on_fn: Callable[[Airport], bool | None] + extra_state_attributes_fn: Callable[[Airport], Mapping[str, Any]] + + +FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = ( + FaaDelaysBinarySensorEntityDescription( key="GROUND_DELAY", - name="Ground Delay", + translation_key="ground_delay", icon="mdi:airport", + is_on_fn=lambda airport: airport.ground_delay.status, + extra_state_attributes_fn=lambda airport: { + "average": airport.ground_delay.average, + "reason": airport.ground_delay.reason, + }, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="GROUND_STOP", - name="Ground Stop", + translation_key="ground_stop", icon="mdi:airport", + is_on_fn=lambda airport: airport.ground_stop.status, + extra_state_attributes_fn=lambda airport: { + "endtime": airport.ground_stop.endtime, + "reason": airport.ground_stop.reason, + }, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="DEPART_DELAY", - name="Departure Delay", + translation_key="depart_delay", icon="mdi:airplane-takeoff", + is_on_fn=lambda airport: airport.depart_delay.status, + extra_state_attributes_fn=lambda airport: { + "minimum": airport.depart_delay.minimum, + "maximum": airport.depart_delay.maximum, + "trend": airport.depart_delay.trend, + "reason": airport.depart_delay.reason, + }, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="ARRIVE_DELAY", - name="Arrival Delay", + translation_key="arrive_delay", icon="mdi:airplane-landing", + is_on_fn=lambda airport: airport.arrive_delay.status, + extra_state_attributes_fn=lambda airport: { + "minimum": airport.arrive_delay.minimum, + "maximum": airport.arrive_delay.maximum, + "trend": airport.arrive_delay.trend, + "reason": airport.arrive_delay.reason, + }, ), - BinarySensorEntityDescription( + FaaDelaysBinarySensorEntityDescription( key="CLOSURE", - name="Closure", + translation_key="closure", icon="mdi:airplane:off", + is_on_fn=lambda airport: airport.closure.status, + extra_state_attributes_fn=lambda airport: { + "begin": airport.closure.start, + "end": airport.closure.end, + }, ), ) @@ -57,60 +101,38 @@ async def async_setup_entry( async_add_entities(entities) -class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): +class FAABinarySensor(CoordinatorEntity[FAADataUpdateCoordinator], BinarySensorEntity): """Define a binary sensor for FAA Delays.""" + _attr_has_entity_name = True + + entity_description: FaaDelaysBinarySensorEntityDescription + def __init__( - self, coordinator, entry_id, description: BinarySensorEntityDescription + self, + coordinator: FAADataUpdateCoordinator, + entry_id: str, + description: FaaDelaysBinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = description - - self.coordinator = coordinator - self._entry_id = entry_id - self._attrs: dict[str, Any] = {} _id = coordinator.data.code self._attr_name = f"{_id} {description.name}" self._attr_unique_id = f"{_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, _id)}, + name=_id, + manufacturer="Federal Aviation Administration", + entry_type=DeviceEntryType.SERVICE, + ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return the status of the sensor.""" - sensor_type = self.entity_description.key - if sensor_type == "GROUND_DELAY": - return self.coordinator.data.ground_delay.status - if sensor_type == "GROUND_STOP": - return self.coordinator.data.ground_stop.status - if sensor_type == "DEPART_DELAY": - return self.coordinator.data.depart_delay.status - if sensor_type == "ARRIVE_DELAY": - return self.coordinator.data.arrive_delay.status - if sensor_type == "CLOSURE": - return self.coordinator.data.closure.status - return None + return self.entity_description.is_on_fn(self.coordinator.data) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return attributes for sensor.""" - sensor_type = self.entity_description.key - if sensor_type == "GROUND_DELAY": - self._attrs["average"] = self.coordinator.data.ground_delay.average - self._attrs["reason"] = self.coordinator.data.ground_delay.reason - elif sensor_type == "GROUND_STOP": - self._attrs["endtime"] = self.coordinator.data.ground_stop.endtime - self._attrs["reason"] = self.coordinator.data.ground_stop.reason - elif sensor_type == "DEPART_DELAY": - self._attrs["minimum"] = self.coordinator.data.depart_delay.minimum - self._attrs["maximum"] = self.coordinator.data.depart_delay.maximum - self._attrs["trend"] = self.coordinator.data.depart_delay.trend - self._attrs["reason"] = self.coordinator.data.depart_delay.reason - elif sensor_type == "ARRIVE_DELAY": - self._attrs["minimum"] = self.coordinator.data.arrive_delay.minimum - self._attrs["maximum"] = self.coordinator.data.arrive_delay.maximum - self._attrs["trend"] = self.coordinator.data.arrive_delay.trend - self._attrs["reason"] = self.coordinator.data.arrive_delay.reason - elif sensor_type == "CLOSURE": - self._attrs["begin"] = self.coordinator.data.closure.start - self._attrs["end"] = self.coordinator.data.closure.end - return self._attrs + return self.entity_description.extra_state_attributes_fn(self.coordinator.data) diff --git a/homeassistant/components/faa_delays/config_flow.py b/homeassistant/components/faa_delays/config_flow.py index b2f7f69dd49..2f91ce9f797 100644 --- a/homeassistant/components/faa_delays/config_flow.py +++ b/homeassistant/components/faa_delays/config_flow.py @@ -1,5 +1,6 @@ """Config flow for FAA Delays integration.""" import logging +from typing import Any from aiohttp import ClientConnectionError import faadelays @@ -7,6 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_ID +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DOMAIN @@ -21,7 +23,9 @@ 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: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors = {} if user_input is not None: diff --git a/homeassistant/components/faa_delays/coordinator.py b/homeassistant/components/faa_delays/coordinator.py index f2aefdada66..2f110cf7730 100644 --- a/homeassistant/components/faa_delays/coordinator.py +++ b/homeassistant/components/faa_delays/coordinator.py @@ -6,6 +6,7 @@ import logging from aiohttp import ClientConnectionError from faadelays import Airport +from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -14,19 +15,18 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class FAADataUpdateCoordinator(DataUpdateCoordinator): +class FAADataUpdateCoordinator(DataUpdateCoordinator[Airport]): """Class to manage fetching FAA API data from a single endpoint.""" - def __init__(self, hass, code): + def __init__(self, hass: HomeAssistant, code: str) -> None: """Initialize the coordinator.""" super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1) ) self.session = aiohttp_client.async_get_clientsession(hass) self.data = Airport(code, self.session) - self.code = code - async def _async_update_data(self): + async def _async_update_data(self) -> Airport: try: async with asyncio.timeout(10): await self.data.update() diff --git a/homeassistant/components/faa_delays/strings.json b/homeassistant/components/faa_delays/strings.json index 92a9dafb4da..145c9e3ab34 100644 --- a/homeassistant/components/faa_delays/strings.json +++ b/homeassistant/components/faa_delays/strings.json @@ -17,5 +17,76 @@ "abort": { "already_configured": "This airport is already configured." } + }, + "entity": { + "binary_sensor": { + "ground_delay": { + "name": "Ground delay", + "state_attributes": { + "average": { + "name": "Average" + }, + "reason": { + "name": "Reason" + } + } + }, + "ground_stop": { + "name": "Ground stop", + "state_attributes": { + "endtime": { + "name": "End time" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "depart_delay": { + "name": "Departure delay", + "state_attributes": { + "minimum": { + "name": "Minimum" + }, + "maximum": { + "name": "Maximum" + }, + "trend": { + "name": "Trend" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "arrive_delay": { + "name": "Arrival delay", + "state_attributes": { + "minimum": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::minimum::name%]" + }, + "maximum": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::maximum::name%]" + }, + "trend": { + "name": "[%key:component::faa_delays::entity::binary_sensor::depart_delay::state_attributes::trend::name%]" + }, + "reason": { + "name": "[%key:component::faa_delays::entity::binary_sensor::ground_delay::state_attributes::reason::name%]" + } + } + }, + "closure": { + "name": "Closure", + "state_attributes": { + "begin": { + "name": "Begin" + }, + "end": { + "name": "End" + } + } + } + } } } diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 21ffca35962..dedaedfe600 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -1,13 +1,12 @@ """Provides functionality to interact with fans.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag import functools as ft import logging import math -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -25,6 +24,11 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -34,6 +38,12 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) DOMAIN = "fan" @@ -53,10 +63,22 @@ class FanEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the FanEntityFeature enum instead. -SUPPORT_SET_SPEED = 1 -SUPPORT_OSCILLATE = 2 -SUPPORT_DIRECTION = 4 -SUPPORT_PRESET_MODE = 8 +_DEPRECATED_SUPPORT_SET_SPEED = DeprecatedConstantEnum( + FanEntityFeature.SET_SPEED, "2025.1" +) +_DEPRECATED_SUPPORT_OSCILLATE = DeprecatedConstantEnum( + FanEntityFeature.OSCILLATE, "2025.1" +) +_DEPRECATED_SUPPORT_DIRECTION = DeprecatedConstantEnum( + FanEntityFeature.DIRECTION, "2025.1" +) +_DEPRECATED_SUPPORT_PRESET_MODE = DeprecatedConstantEnum( + FanEntityFeature.PRESET_MODE, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) SERVICE_INCREASE_SPEED = "increase_speed" SERVICE_DECREASE_SPEED = "decrease_speed" @@ -187,12 +209,22 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class FanEntityDescription(ToggleEntityDescription): +class FanEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes fan entities.""" -class FanEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "percentage", + "speed_count", + "current_direction", + "oscillating", + "supported_features", + "preset_mode", + "preset_modes", +} + + +class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for fan entities.""" _entity_component_unrecorded_attributes = frozenset({ATTR_PRESET_MODES}) @@ -335,14 +367,14 @@ class FanEntity(ToggleEntity): self.percentage is not None and self.percentage > 0 ) or self.preset_mode is not None - @property + @cached_property def percentage(self) -> int | None: """Return the current speed as a percentage.""" if hasattr(self, "_attr_percentage"): return self._attr_percentage return 0 - @property + @cached_property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" if hasattr(self, "_attr_speed_count"): @@ -354,12 +386,12 @@ class FanEntity(ToggleEntity): """Return the step size for percentage.""" return 100 / self.speed_count - @property + @cached_property def current_direction(self) -> str | None: """Return the current direction of the fan.""" return self._attr_current_direction - @property + @cached_property def oscillating(self) -> bool | None: """Return whether or not the fan is currently oscillating.""" return self._attr_oscillating @@ -368,10 +400,11 @@ class FanEntity(ToggleEntity): def capability_attributes(self) -> dict[str, list[str] | None]: """Return capability attributes.""" attrs = {} + supported_features = self.supported_features_compat if ( - self.supported_features & FanEntityFeature.SET_SPEED - or self.supported_features & FanEntityFeature.PRESET_MODE + FanEntityFeature.SET_SPEED in supported_features + or FanEntityFeature.PRESET_MODE in supported_features ): attrs[ATTR_PRESET_MODES] = self.preset_modes @@ -382,32 +415,44 @@ class FanEntity(ToggleEntity): def state_attributes(self) -> dict[str, float | str | None]: """Return optional state attributes.""" data: dict[str, float | str | None] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat - if supported_features & FanEntityFeature.DIRECTION: + if FanEntityFeature.DIRECTION in supported_features: data[ATTR_DIRECTION] = self.current_direction - if supported_features & FanEntityFeature.OSCILLATE: + if FanEntityFeature.OSCILLATE in supported_features: data[ATTR_OSCILLATING] = self.oscillating - if supported_features & FanEntityFeature.SET_SPEED: + has_set_speed = FanEntityFeature.SET_SPEED in supported_features + + if has_set_speed: data[ATTR_PERCENTAGE] = self.percentage data[ATTR_PERCENTAGE_STEP] = self.percentage_step - if ( - supported_features & FanEntityFeature.PRESET_MODE - or supported_features & FanEntityFeature.SET_SPEED - ): + if has_set_speed or FanEntityFeature.PRESET_MODE in supported_features: data[ATTR_PRESET_MODE] = self.preset_mode return data - @property + @cached_property def supported_features(self) -> FanEntityFeature: """Flag supported features.""" return self._attr_supported_features @property + def supported_features_compat(self) -> FanEntityFeature: + """Return the supported features as FanEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = FanEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + + @cached_property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., auto, smart, interval, favorite. @@ -417,7 +462,7 @@ class FanEntity(ToggleEntity): return self._attr_preset_mode return None - @property + @cached_property def preset_modes(self) -> list[str] | None: """Return a list of available preset modes. diff --git a/homeassistant/components/fan/significant_change.py b/homeassistant/components/fan/significant_change.py new file mode 100644 index 00000000000..b8038b93f79 --- /dev/null +++ b/homeassistant/components/fan/significant_change.py @@ -0,0 +1,61 @@ +"""Helper to test significant Fan state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, ATTR_PRESET_MODE + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name != ATTR_PERCENTAGE: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 1.0): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index 56f9ba4fd5f..165d81edd0b 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -1,22 +1,19 @@ """Support for testing internet speed via Fast.com.""" from __future__ import annotations -from datetime import datetime, timedelta import logging -from typing import Any -from fastdotcom import fast_com import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STARTED +from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType -from .const import CONF_MANUAL, DATA_UPDATED, DEFAULT_INTERVAL, DOMAIN, PLATFORMS +from .const import CONF_MANUAL, DEFAULT_INTERVAL, DOMAIN, PLATFORMS +from .coordinator import FastdotcomDataUpdateCoordindator _LOGGER = logging.getLogger(__name__) @@ -49,20 +46,35 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up the Fast.com component.""" - data = hass.data[DOMAIN] = SpeedtestData(hass) + """Set up Fast.com from a config entry.""" + coordinator = FastdotcomDataUpdateCoordindator(hass) - entry.async_on_unload( - async_track_time_interval(hass, data.update, timedelta(hours=DEFAULT_INTERVAL)) - ) - # Run an initial update to get a starting state - await data.update() + async def _request_refresh(event: Event) -> None: + """Request a refresh.""" + await coordinator.async_request_refresh() - async def update(service_call: ServiceCall | None = None) -> None: - """Service call to manually update the data.""" - await data.update() + async def _request_refresh_service(call: ServiceCall) -> None: + """Request a refresh via the service.""" + ir.async_create_issue( + hass, + DOMAIN, + "service_deprecation", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation", + ) + await coordinator.async_request_refresh() - hass.services.async_register(DOMAIN, "speedtest", update) + if hass.state == CoreState.running: + await coordinator.async_config_entry_first_refresh() + else: + # Don't start the speedtest when HA is starting up + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _request_refresh) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.services.async_register(DOMAIN, "speedtest", _request_refresh_service) await hass.config_entries.async_forward_entry_setups( entry, @@ -74,23 +86,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Fast.com config entry.""" + hass.services.async_remove(DOMAIN, "speedtest") if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data.pop(DOMAIN) + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class SpeedtestData: - """Get the latest data from Fast.com.""" - - def __init__(self, hass: HomeAssistant) -> None: - """Initialize the data object.""" - self.data: dict[str, Any] | None = None - self._hass = hass - - async def update(self, now: datetime | None = None) -> None: - """Get the latest data from fast.com.""" - _LOGGER.debug("Executing Fast.com speedtest") - fast_com_data = await self._hass.async_add_executor_job(fast_com) - self.data = {"download": fast_com_data} - _LOGGER.debug("Fast.com speedtest finished, with mbit/s: %s", fast_com_data) - dispatcher_send(self._hass, DATA_UPDATED) diff --git a/homeassistant/components/fastdotcom/coordinator.py b/homeassistant/components/fastdotcom/coordinator.py new file mode 100644 index 00000000000..692a85d2eda --- /dev/null +++ b/homeassistant/components/fastdotcom/coordinator.py @@ -0,0 +1,31 @@ +"""DataUpdateCoordinator for the Fast.com integration.""" +from __future__ import annotations + +from datetime import timedelta + +from fastdotcom import fast_com + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_INTERVAL, DOMAIN, LOGGER + + +class FastdotcomDataUpdateCoordindator(DataUpdateCoordinator[float]): + """Class to manage fetching Fast.com data API.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the coordinator for Fast.com.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(hours=DEFAULT_INTERVAL), + ) + + async def _async_update_data(self) -> float: + """Run an executor job to retrieve Fast.com data.""" + try: + return await self.hass.async_add_executor_job(fast_com) + except Exception as exc: + raise UpdateFailed(f"Error communicating with Fast.com: {exc}") from exc diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 939ab4a40e5..2ca0b2d9168 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -1,8 +1,6 @@ """Support for Fast.com internet speed testing sensor.""" from __future__ import annotations -from typing import Any - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -10,12 +8,13 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfDataRate -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_UPDATED, DOMAIN +from .const import DOMAIN +from .coordinator import FastdotcomDataUpdateCoordindator async def async_setup_entry( @@ -24,45 +23,38 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fast.com sensor.""" - async_add_entities([SpeedtestSensor(entry.entry_id, hass.data[DOMAIN])]) + coordinator: FastdotcomDataUpdateCoordindator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SpeedtestSensor(entry.entry_id, coordinator)]) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class SpeedtestSensor(RestoreEntity, SensorEntity): +class SpeedtestSensor( + CoordinatorEntity[FastdotcomDataUpdateCoordindator], SensorEntity +): """Implementation of a Fast.com sensor.""" - _attr_name = "Fast.com Download" + _attr_translation_key = "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 + _attr_has_entity_name = True - def __init__(self, entry_id: str, speedtest_data: dict[str, Any]) -> None: + def __init__( + self, entry_id: str, coordinator: FastdotcomDataUpdateCoordindator + ) -> None: """Initialize the sensor.""" - self._speedtest_data = speedtest_data + super().__init__(coordinator) self._attr_unique_id = entry_id - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - - self.async_on_remove( - async_dispatcher_connect( - self.hass, DATA_UPDATED, self._schedule_immediate_update - ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + configuration_url="https://www.fast.com", ) - if not (state := await self.async_get_last_state()): - return - self._attr_native_value = state.state - - def update(self) -> None: - """Get the latest data and update the states.""" - if (data := self._speedtest_data.data) is None: # type: ignore[attr-defined] - return - self._attr_native_value = data["download"] - - @callback - def _schedule_immediate_update(self) -> None: - self.async_schedule_update_ha_state(True) + @property + def native_value( + self, + ) -> float: + """Return the state of the sensor.""" + return self.coordinator.data diff --git a/homeassistant/components/fastdotcom/strings.json b/homeassistant/components/fastdotcom/strings.json index d647250b423..61a1f686747 100644 --- a/homeassistant/components/fastdotcom/strings.json +++ b/homeassistant/components/fastdotcom/strings.json @@ -9,10 +9,30 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, + "entity": { + "sensor": { + "download": { + "name": "Download" + } + } + }, "services": { "speedtest": { "name": "Speed test", "description": "Immediately executes a speed test with Fast.com." } + }, + "issues": { + "service_deprecation": { + "title": "Fast.com speedtest service is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::fastdotcom::issues::service_deprecation::title%]", + "description": "Use `homeassistant.update_entity` instead to update the data.\n\nPlease replace this service and adjust your automations and scripts and select **submit** to fix this issue." + } + } + } + } } } diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index eef84996d56..04511a1a986 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -197,35 +197,40 @@ class FeedManager: ) entry.update({"feed_url": self._url}) self._hass.bus.fire(self._event_type, entry) + _LOGGER.debug("New event fired for entry %s", entry.get("link")) def _publish_new_entries(self) -> None: """Publish new entries to the event bus.""" assert self._feed is not None - new_entries = False + new_entry_count = 0 self._last_entry_timestamp = self._storage.get_timestamp(self._feed_id) if self._last_entry_timestamp: self._firstrun = False else: # Set last entry timestamp as epoch time if not available self._last_entry_timestamp = dt_util.utc_from_timestamp(0).timetuple() + # locally cache self._last_entry_timestamp so that entries published at identical times can be processed + last_entry_timestamp = self._last_entry_timestamp for entry in self._feed.entries: if ( self._firstrun or ( "published_parsed" in entry - and entry.published_parsed > self._last_entry_timestamp + and entry.published_parsed > last_entry_timestamp ) or ( "updated_parsed" in entry - and entry.updated_parsed > self._last_entry_timestamp + and entry.updated_parsed > last_entry_timestamp ) ): self._update_and_fire_entry(entry) - new_entries = True + new_entry_count += 1 else: - _LOGGER.debug("Entry %s already processed", entry) - if not new_entries: + _LOGGER.debug("Already processed entry %s", entry.get("link")) + if new_entry_count == 0: self._log_no_entries() + else: + _LOGGER.debug("%d entries published in feed %s", new_entry_count, self._url) self._firstrun = False diff --git a/homeassistant/components/feedreader/manifest.json b/homeassistant/components/feedreader/manifest.json index 922bf5551ee..fe52dc4d4c2 100644 --- a/homeassistant/components/feedreader/manifest.json +++ b/homeassistant/components/feedreader/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/feedreader", "iot_class": "cloud_polling", "loggers": ["feedparser", "sgmllib3k"], - "requirements": ["feedparser==6.0.10"] + "requirements": ["feedparser==6.0.11"] } diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index fafe1fcf2bf..c969adfe637 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -168,8 +168,8 @@ class FinTsClient: if not account_information: return False - if 1 <= account_information["type"] <= 9: - return True + if account_type := account_information.get("type"): + return 1 <= account_type <= 9 if ( account_information["iban"] in self.account_config @@ -188,8 +188,8 @@ class FinTsClient: if not account_information: return False - if 30 <= account_information["type"] <= 39: - return True + if account_type := account_information.get("type"): + return 30 <= account_type <= 39 if ( account_information["iban"] in self.holdings_config diff --git a/homeassistant/components/firmata/board.py b/homeassistant/components/firmata/board.py index c309676c8d6..233388d5013 100644 --- a/homeassistant/components/firmata/board.py +++ b/homeassistant/components/firmata/board.py @@ -65,12 +65,12 @@ class FirmataBoard: except RuntimeError as err: _LOGGER.error("Error connecting to PyMata board %s: %s", self.name, err) return False - except serial.serialutil.SerialTimeoutException as err: + except serial.SerialTimeoutException as err: _LOGGER.error( "Timeout writing to serial port for PyMata board %s: %s", self.name, err ) return False - except serial.serialutil.SerialException as err: + except serial.SerialException as err: _LOGGER.error( "Error connecting to serial port for PyMata board %s: %s", self.name, diff --git a/homeassistant/components/firmata/config_flow.py b/homeassistant/components/firmata/config_flow.py index 8aa4cfb836c..f5b7cb5af40 100644 --- a/homeassistant/components/firmata/config_flow.py +++ b/homeassistant/components/firmata/config_flow.py @@ -41,12 +41,12 @@ class FirmataFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except RuntimeError as err: _LOGGER.error("Error connecting to PyMata board %s: %s", name, err) return self.async_abort(reason="cannot_connect") - except serial.serialutil.SerialTimeoutException as err: + except serial.SerialTimeoutException as err: _LOGGER.error( "Timeout writing to serial port for PyMata board %s: %s", name, err ) return self.async_abort(reason="cannot_connect") - except serial.serialutil.SerialException as err: + except serial.SerialException as err: _LOGGER.error( "Error connecting to serial port for PyMata board %s: %s", name, err ) diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py index caa47351f45..bbd7af09183 100644 --- a/homeassistant/components/fitbit/application_credentials.py +++ b/homeassistant/components/fitbit/application_credentials.py @@ -69,6 +69,8 @@ class FitbitOAuth2Implementation(AuthImplementation): ) if err.status == HTTPStatus.UNAUTHORIZED: raise FitbitAuthException(f"Unauthorized error: {err}") from err + if err.status == HTTPStatus.BAD_REQUEST: + raise FitbitAuthException(f"Bad Request error: {err}") from err raise FitbitApiException(f"Server error response: {err}") from err except aiohttp.ClientError as err: raise FitbitApiException(f"Client connection error: {err}") from err diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index e2cfb3e3992..eb7d3b02b4d 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -146,7 +146,7 @@ def _int_value_or_none(field: str) -> Callable[[dict[str, Any]], int | None]: return convert -@dataclass +@dataclass(frozen=True) class FitbitSensorEntityDescription(SensorEntityDescription): """Describes Fitbit sensor entity.""" diff --git a/homeassistant/components/fivem/binary_sensor.py b/homeassistant/components/fivem/binary_sensor.py index 153732d2ce5..ee46067f443 100644 --- a/homeassistant/components/fivem/binary_sensor.py +++ b/homeassistant/components/fivem/binary_sensor.py @@ -14,7 +14,7 @@ from .const import DOMAIN, NAME_STATUS from .entity import FiveMEntity, FiveMEntityDescription -@dataclass +@dataclass(frozen=True) class FiveMBinarySensorEntityDescription( BinarySensorEntityDescription, FiveMEntityDescription ): diff --git a/homeassistant/components/fivem/entity.py b/homeassistant/components/fivem/entity.py index c11378ff049..69204b559ae 100644 --- a/homeassistant/components/fivem/entity.py +++ b/homeassistant/components/fivem/entity.py @@ -16,7 +16,7 @@ from .coordinator import FiveMDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class FiveMEntityDescription(EntityDescription): """Describes FiveM entity.""" diff --git a/homeassistant/components/fivem/sensor.py b/homeassistant/components/fivem/sensor.py index 1c4e4b77c45..967a1392fe5 100644 --- a/homeassistant/components/fivem/sensor.py +++ b/homeassistant/components/fivem/sensor.py @@ -24,7 +24,7 @@ from .const import ( from .entity import FiveMEntity, FiveMEntityDescription -@dataclass +@dataclass(frozen=True) class FiveMSensorEntityDescription(SensorEntityDescription, FiveMEntityDescription): """Describes FiveM sensor entity.""" diff --git a/homeassistant/components/fjaraskupan/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py index 41cdc0dbffe..03302d490a6 100644 --- a/homeassistant/components/fjaraskupan/binary_sensor.py +++ b/homeassistant/components/fjaraskupan/binary_sensor.py @@ -22,7 +22,7 @@ from . import async_setup_entry_platform from .coordinator import FjaraskupanCoordinator -@dataclass +@dataclass(frozen=True) class EntityDescription(BinarySensorEntityDescription): """Entity description.""" diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py new file mode 100644 index 00000000000..c9a0b332d93 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/__init__.py @@ -0,0 +1,43 @@ +"""The Flexit Nordic (BACnet) integration.""" +from __future__ import annotations + +import asyncio.exceptions + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Flexit Nordic (BACnet) from a config entry.""" + + device = FlexitBACnet(entry.data[CONF_IP_ADDRESS], entry.data[CONF_DEVICE_ID]) + + try: + await device.update() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise ConfigEntryNotReady( + f"Timeout while connecting to {entry.data['address']}" + ) from exc + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device + + 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/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py new file mode 100644 index 00000000000..c15cb59a6f3 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/climate.py @@ -0,0 +1,148 @@ +"""The Flexit Nordic (BACnet) integration.""" +import asyncio.exceptions +from typing import Any + +from flexit_bacnet import ( + VENTILATION_MODE_AWAY, + VENTILATION_MODE_HOME, + VENTILATION_MODE_STOP, + FlexitBACnet, +) +from flexit_bacnet.bacnet import DecodingError + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_BOOST, + PRESET_HOME, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + DOMAIN, + PRESET_TO_VENTILATION_MODE_MAP, + VENTILATION_TO_PRESET_MODE_MAP, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_devices: AddEntitiesCallback, +) -> None: + """Set up the Flexit Nordic unit.""" + device = hass.data[DOMAIN][config_entry.entry_id] + + async_add_devices([FlexitClimateEntity(device)]) + + +class FlexitClimateEntity(ClimateEntity): + """Flexit air handling unit.""" + + _attr_name = None + + _attr_has_entity_name = True + + _attr_hvac_modes = [ + HVACMode.OFF, + HVACMode.FAN_ONLY, + ] + + _attr_preset_modes = [ + PRESET_AWAY, + PRESET_HOME, + PRESET_BOOST, + ] + + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + + _attr_target_temperature_step = PRECISION_HALVES + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + def __init__(self, device: FlexitBACnet) -> None: + """Initialize the unit.""" + self._device = device + self._attr_unique_id = device.serial_number + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, device.serial_number), + }, + name=device.device_name, + manufacturer="Flexit", + model="Nordic", + serial_number=device.serial_number, + ) + + async def async_update(self) -> None: + """Refresh unit state.""" + await self._device.update() + + @property + def current_temperature(self) -> float: + """Return the current temperature.""" + return self._device.room_temperature + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + if self._device.ventilation_mode == VENTILATION_MODE_AWAY: + return self._device.air_temp_setpoint_away + + return self._device.air_temp_setpoint_home + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + + try: + if self._device.ventilation_mode == VENTILATION_MODE_AWAY: + await self._device.set_air_temp_setpoint_away(temperature) + else: + await self._device.set_air_temp_setpoint_home(temperature) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + + @property + def preset_mode(self) -> str: + """Return the current preset mode, e.g., home, away, temp. + + Requires ClimateEntityFeature.PRESET_MODE. + """ + return VENTILATION_TO_PRESET_MODE_MAP[self._device.ventilation_mode] + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + ventilation_mode = PRESET_TO_VENTILATION_MODE_MAP[preset_mode] + + try: + await self._device.set_ventilation_mode(ventilation_mode) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc + + @property + def hvac_mode(self) -> HVACMode: + """Return hvac operation ie. heat, cool mode.""" + if self._device.ventilation_mode == VENTILATION_MODE_STOP: + return HVACMode.OFF + + return HVACMode.FAN_ONLY + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + try: + if hvac_mode == HVACMode.OFF: + await self._device.set_ventilation_mode(VENTILATION_MODE_STOP) + else: + await self._device.set_ventilation_mode(VENTILATION_MODE_HOME) + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc: + raise HomeAssistantError from exc diff --git a/homeassistant/components/flexit_bacnet/config_flow.py b/homeassistant/components/flexit_bacnet/config_flow.py new file mode 100644 index 00000000000..2c87dfc5b97 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for Flexit Nordic (BACnet) integration.""" +from __future__ import annotations + +import asyncio.exceptions +import logging +from typing import Any + +from flexit_bacnet import FlexitBACnet +from flexit_bacnet.bacnet import DecodingError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_DEVICE_ID = 2 + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + vol.Required(CONF_DEVICE_ID, default=DEFAULT_DEVICE_ID): int, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Flexit Nordic (BACnet).""" + + 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: + device = FlexitBACnet( + user_input[CONF_IP_ADDRESS], user_input[CONF_DEVICE_ID] + ) + try: + await device.update() + except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError): + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(device.serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=device.device_name, data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/flexit_bacnet/const.py b/homeassistant/components/flexit_bacnet/const.py new file mode 100644 index 00000000000..269a88c4cec --- /dev/null +++ b/homeassistant/components/flexit_bacnet/const.py @@ -0,0 +1,30 @@ +"""Constants for the Flexit Nordic (BACnet) integration.""" +from flexit_bacnet import ( + VENTILATION_MODE_AWAY, + VENTILATION_MODE_HIGH, + VENTILATION_MODE_HOME, + VENTILATION_MODE_STOP, +) + +from homeassistant.components.climate import ( + PRESET_AWAY, + PRESET_BOOST, + PRESET_HOME, + PRESET_NONE, +) + +DOMAIN = "flexit_bacnet" + +VENTILATION_TO_PRESET_MODE_MAP = { + VENTILATION_MODE_STOP: PRESET_NONE, + VENTILATION_MODE_AWAY: PRESET_AWAY, + VENTILATION_MODE_HOME: PRESET_HOME, + VENTILATION_MODE_HIGH: PRESET_BOOST, +} + +PRESET_TO_VENTILATION_MODE_MAP = { + PRESET_NONE: VENTILATION_MODE_STOP, + PRESET_AWAY: VENTILATION_MODE_AWAY, + PRESET_HOME: VENTILATION_MODE_HOME, + PRESET_BOOST: VENTILATION_MODE_HIGH, +} diff --git a/homeassistant/components/flexit_bacnet/manifest.json b/homeassistant/components/flexit_bacnet/manifest.json new file mode 100644 index 00000000000..d230e4ebb7a --- /dev/null +++ b/homeassistant/components/flexit_bacnet/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "flexit_bacnet", + "name": "Flexit Nordic (BACnet)", + "codeowners": ["@lellky", "@piotrbulinski"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/flexit_bacnet", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["flexit_bacnet==2.1.0"] +} diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json new file mode 100644 index 00000000000..fd2725c6403 --- /dev/null +++ b/homeassistant/components/flexit_bacnet/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]", + "device_id": "[%key:common::config_flow::data::device%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py index 2305cd9f23e..fd6fcc5f4b9 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -39,14 +39,14 @@ BINARY_SENSOR_DESCRIPTION_CONNECTED = BinarySensorEntityDescription( ) -@dataclass +@dataclass(frozen=True) class FlumeBinarySensorRequiredKeysMixin: """Mixin for required keys.""" event_rule: str -@dataclass +@dataclass(frozen=True) class FlumeBinarySensorEntityDescription( BinarySensorEntityDescription, FlumeBinarySensorRequiredKeysMixin ): diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index 11e045bec70..9094006c791 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations import contextlib -from typing import Any, Final, cast +from typing import Any, cast from flux_led.const import ( ATTR_ID, @@ -17,7 +17,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_DEVICE, CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import device_registry as dr @@ -47,8 +47,6 @@ from .discovery import ( ) from .util import format_as_flux_mac, mac_matches_by_one -CONF_DEVICE: Final = "device" - class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Magic Home Integration.""" diff --git a/homeassistant/components/flux_led/const.py b/homeassistant/components/flux_led/const.py index db545aa1e68..8b42f5f2e0d 100644 --- a/homeassistant/components/flux_led/const.py +++ b/homeassistant/components/flux_led/const.py @@ -65,7 +65,6 @@ TRANSITION_STROBE: Final = "strobe" CONF_COLORS: Final = "colors" CONF_SPEED_PCT: Final = "speed_pct" CONF_TRANSITION: Final = "transition" -CONF_EFFECT: Final = "effect" EFFECT_SPEED_SUPPORT_MODES: Final = {ColorMode.RGB, ColorMode.RGBW, ColorMode.RGBWW} diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index d880d517f1a..1232cb41031 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -22,6 +22,7 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) +from homeassistant.const import CONF_EFFECT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv @@ -37,7 +38,6 @@ from .const import ( CONF_CUSTOM_EFFECT_COLORS, CONF_CUSTOM_EFFECT_SPEED_PCT, CONF_CUSTOM_EFFECT_TRANSITION, - CONF_EFFECT, CONF_SPEED_PCT, CONF_TRANSITION, DEFAULT_EFFECT_SPEED, diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 7a2723ce591..68a3fe81867 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -27,7 +27,7 @@ from .const import DOMAIN from .coordinator import ForecastSolarDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class ForecastSolarSensorEntityDescription(SensorEntityDescription): """Describes a Forecast.Solar Sensor.""" diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index ef88d0f671a..057ef4dbe8c 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -15,15 +15,28 @@ from homeassistant.helpers.entity_registry import async_migrate_entries from .config_flow import DEFAULT_RTSP_PORT from .const import CONF_RTSP_PORT, DOMAIN, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET +from .coordinator import FoscamCoordinator PLATFORMS = [Platform.CAMERA] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up foscam from a config entry.""" - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data + session = FoscamCamera( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + verbose=False, + ) + coordinator = FoscamCoordinator(hass, session) + + 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 diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index 384aea4c5fa..c07ddfd9bfb 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -3,16 +3,16 @@ from __future__ import annotations import asyncio -from libpyfoscam import FoscamCamera import voluptuous as vol from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( CONF_RTSP_PORT, @@ -22,6 +22,7 @@ from .const import ( SERVICE_PTZ, SERVICE_PTZ_PRESET, ) +from .coordinator import FoscamCoordinator DIR_UP = "up" DIR_DOWN = "down" @@ -88,28 +89,27 @@ async def async_setup_entry( "async_perform_ptz_preset", ) - camera = FoscamCamera( - config_entry.data[CONF_HOST], - config_entry.data[CONF_PORT], - config_entry.data[CONF_USERNAME], - config_entry.data[CONF_PASSWORD], - verbose=False, - ) + coordinator: FoscamCoordinator = hass.data[DOMAIN][config_entry.entry_id] - async_add_entities([HassFoscamCamera(camera, config_entry)]) + async_add_entities([HassFoscamCamera(coordinator, config_entry)]) -class HassFoscamCamera(Camera): +class HassFoscamCamera(CoordinatorEntity[FoscamCoordinator], Camera): """An implementation of a Foscam IP camera.""" _attr_has_entity_name = True _attr_name = None - def __init__(self, camera: FoscamCamera, config_entry: ConfigEntry) -> None: + def __init__( + self, + coordinator: FoscamCoordinator, + config_entry: ConfigEntry, + ) -> None: """Initialize a Foscam camera.""" - super().__init__() + super().__init__(coordinator) + Camera.__init__(self) - self._foscam_session = camera + self._foscam_session = coordinator.session self._username = config_entry.data[CONF_USERNAME] self._password = config_entry.data[CONF_PASSWORD] self._stream = config_entry.data[CONF_STREAM] @@ -125,6 +125,9 @@ class HassFoscamCamera(Camera): async def async_added_to_hass(self) -> None: """Handle entity addition to hass.""" # Get motion detection status + + await super().async_added_to_hass() + ret, response = await self.hass.async_add_executor_job( self._foscam_session.get_motion_detect_config ) diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py new file mode 100644 index 00000000000..063d5235c04 --- /dev/null +++ b/homeassistant/components/foscam/coordinator.py @@ -0,0 +1,47 @@ +"""The foscam coordinator object.""" + +import asyncio +from datetime import timedelta +from typing import Any + +from libpyfoscam import FoscamCamera + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, LOGGER + + +class FoscamCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Foscam coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + session: FoscamCamera, + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.session = session + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from API endpoint.""" + + async with asyncio.timeout(30): + data = {} + ret, dev_info = await self.hass.async_add_executor_job( + self.session.get_dev_info + ) + if ret == 0: + data["dev_info"] = dev_info + + all_info = await self.hass.async_add_executor_job( + self.session.get_product_all_info + ) + data["product_info"] = all_info[1] + return data diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json index fc7cbb72e3c..da4e9f53af4 100644 --- a/homeassistant/components/foscam/manifest.json +++ b/homeassistant/components/foscam/manifest.json @@ -1,7 +1,7 @@ { "domain": "foscam", "name": "Foscam", - "codeowners": ["@skgsergio"], + "codeowners": ["@skgsergio", "@krmarien"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/foscam", "iot_class": "local_polling", diff --git a/homeassistant/components/freebox/button.py b/homeassistant/components/freebox/button.py index e3a206b43a8..d1268fb91d2 100644 --- a/homeassistant/components/freebox/button.py +++ b/homeassistant/components/freebox/button.py @@ -18,14 +18,14 @@ from .const import DOMAIN from .router import FreeboxRouter -@dataclass +@dataclass(frozen=True) class FreeboxButtonRequiredKeysMixin: """Mixin for required keys.""" async_press: Callable[[FreeboxRouter], Awaitable] -@dataclass +@dataclass(frozen=True) class FreeboxButtonEntityDescription( ButtonEntityDescription, FreeboxButtonRequiredKeysMixin ): diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 6d371a82c95..00e9f406ed4 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -26,7 +26,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class FritzBinarySensorEntityDescription( BinarySensorEntityDescription, FritzEntityDescription ): diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index a4504996820..5b4a3f5a20c 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -23,14 +23,14 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class FritzButtonDescriptionMixin: """Mixin to describe a Button entity.""" press_action: Callable -@dataclass +@dataclass(frozen=True) class FritzButtonDescription(ButtonEntityDescription, FritzButtonDescriptionMixin): """Class to describe a Button entity.""" diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 2abba137fbf..63f9f593ea8 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -1092,14 +1092,14 @@ class FritzBoxBaseEntity: ) -@dataclass +@dataclass(frozen=True) class FritzRequireKeysMixin: """Fritz entity description mix in.""" value_fn: Callable[[FritzStatus, Any], Any] | None -@dataclass +@dataclass(frozen=True) class FritzEntityDescription(EntityDescription, FritzRequireKeysMixin): """Fritz entity base description.""" diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index d6b78c1cfc0..53a299cd576 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -142,7 +142,7 @@ def _retrieve_link_attenuation_received_state( return status.attenuation[1] / 10 # type: ignore[no-any-return] -@dataclass +@dataclass(frozen=True) class FritzSensorEntityDescription(SensorEntityDescription, FritzEntityDescription): """Describes Fritz sensor entity.""" diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 80cbe1f4c5c..fafd9c37ab8 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -21,7 +21,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescription): """Describes Fritz update entity.""" diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index e36056d2fab..c6676bb1bbf 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -22,14 +22,14 @@ from .common import get_coordinator from .model import FritzEntityDescriptionMixinBase -@dataclass +@dataclass(frozen=True) class FritzEntityDescriptionMixinBinarySensor(FritzEntityDescriptionMixinBase): """BinarySensor description mixin for Fritz!Smarthome entities.""" is_on: Callable[[FritzhomeDevice], bool | None] -@dataclass +@dataclass(frozen=True) class FritzBinarySensorEntityDescription( BinarySensorEntityDescription, FritzEntityDescriptionMixinBinarySensor ): diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index 3c3275e0ff0..74c5bd42927 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -18,7 +18,7 @@ class ClimateExtraAttributes(TypedDict, total=False): window_open: bool -@dataclass +@dataclass(frozen=True) class FritzEntityDescriptionMixinBase: """Bases description mixin for Fritz!Smarthome entities.""" diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 140ecaef331..fd55369d915 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -35,14 +35,14 @@ from .common import get_coordinator from .model import FritzEntityDescriptionMixinBase -@dataclass +@dataclass(frozen=True) class FritzEntityDescriptionMixinSensor(FritzEntityDescriptionMixinBase): """Sensor description mixin for Fritz!Smarthome entities.""" native_value: Callable[[FritzhomeDevice], StateType | datetime] -@dataclass +@dataclass(frozen=True) class FritzSensorEntityDescription( SensorEntityDescription, FritzEntityDescriptionMixinSensor ): diff --git a/homeassistant/components/fronius/__init__.py b/homeassistant/components/fronius/__init__.py index c05f18107a0..d0e13aa7914 100644 --- a/homeassistant/components/fronius/__init__.py +++ b/homeassistant/components/fronius/__init__.py @@ -65,6 +65,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + return True + + class FroniusSolarNet: """The FroniusSolarNet class routes received values to sensor entities.""" diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index f058a25a044..93c13c8e579 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -104,7 +104,7 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class FroniusSensorEntityDescription(SensorEntityDescription): """Describes Fronius sensor entity.""" diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2a7ef1396d5..52f3932237b 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==20231208.2"] + "requirements": ["home-assistant-frontend==20240103.3"] } diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index 82f169dc6c9..91646dcb745 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -1,7 +1,7 @@ """API for persistent storage for the frontend.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from functools import wraps from typing import Any @@ -50,12 +50,19 @@ async def async_user_store( return store, data[user_id] -def with_store(orig_func: Callable) -> Callable: +def with_store( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], Store, dict[str, Any]], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate function to provide data.""" @wraps(orig_func) async def with_store_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" user_id = connection.user.id diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py index 62f2623d05e..f1e0ad48d30 100644 --- a/homeassistant/components/frontier_silicon/__init__.py +++ b/homeassistant/components/frontier_silicon/__init__.py @@ -6,11 +6,11 @@ import logging from afsapi import AFSAPI, ConnectionError as FSConnectionError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_PIN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_PIN, CONF_WEBFSAPI_URL, DOMAIN +from .const import CONF_WEBFSAPI_URL, DOMAIN PLATFORMS = [Platform.MEDIA_PLAYER] diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 2274b1cdb44..470be7d9b26 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -16,11 +16,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import ( - CONF_PIN, CONF_WEBFSAPI_URL, DEFAULT_PIN, DEFAULT_PORT, diff --git a/homeassistant/components/frontier_silicon/const.py b/homeassistant/components/frontier_silicon/const.py index 34201fe8f4a..94f4e09a35a 100644 --- a/homeassistant/components/frontier_silicon/const.py +++ b/homeassistant/components/frontier_silicon/const.py @@ -2,7 +2,6 @@ DOMAIN = "frontier_silicon" CONF_WEBFSAPI_URL = "webfsapi_url" -CONF_PIN = "pin" SSDP_ST = "urn:schemas-frontier-silicon-com:undok:fsapi:1" SSDP_ATTR_SPEAKER_NAME = "SPEAKER-NAME" diff --git a/homeassistant/components/fujitsu_anywair/__init__.py b/homeassistant/components/fujitsu_anywair/__init__.py new file mode 100644 index 00000000000..5845e00f8b0 --- /dev/null +++ b/homeassistant/components/fujitsu_anywair/__init__.py @@ -0,0 +1 @@ +"""Fujitsu anywAIR virtual integration for Home Assistant.""" diff --git a/homeassistant/components/fujitsu_anywair/manifest.json b/homeassistant/components/fujitsu_anywair/manifest.json new file mode 100644 index 00000000000..463f0724919 --- /dev/null +++ b/homeassistant/components/fujitsu_anywair/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "fujitsu_anywair", + "name": "Fujitsu anywAIR", + "integration_type": "virtual", + "supported_by": "advantage_air" +} diff --git a/homeassistant/components/fully_kiosk/button.py b/homeassistant/components/fully_kiosk/button.py index 9f4d60e9574..0a6233937ae 100644 --- a/homeassistant/components/fully_kiosk/button.py +++ b/homeassistant/components/fully_kiosk/button.py @@ -22,14 +22,14 @@ from .coordinator import FullyKioskDataUpdateCoordinator from .entity import FullyKioskEntity -@dataclass +@dataclass(frozen=True) class FullyButtonEntityDescriptionMixin: """Mixin to describe a Fully Kiosk Browser button entity.""" press_action: Callable[[FullyKiosk], Any] -@dataclass +@dataclass(frozen=True) class FullyButtonEntityDescription( ButtonEntityDescription, FullyButtonEntityDescriptionMixin ): @@ -54,16 +54,19 @@ BUTTONS: tuple[FullyButtonEntityDescription, ...] = ( FullyButtonEntityDescription( key="toForeground", translation_key="to_foreground", + entity_category=EntityCategory.CONFIG, press_action=lambda fully: fully.toForeground(), ), FullyButtonEntityDescription( key="toBackground", translation_key="to_background", + entity_category=EntityCategory.CONFIG, press_action=lambda fully: fully.toBackground(), ), FullyButtonEntityDescription( key="loadStartUrl", translation_key="load_start_url", + entity_category=EntityCategory.CONFIG, press_action=lambda fully: fully.loadStartUrl(), ), ) diff --git a/homeassistant/components/fully_kiosk/number.py b/homeassistant/components/fully_kiosk/number.py index 298a58e2a11..4203a64074d 100644 --- a/homeassistant/components/fully_kiosk/number.py +++ b/homeassistant/components/fully_kiosk/number.py @@ -46,6 +46,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = ( native_max_value=255, native_step=1, native_min_value=0, + entity_category=EntityCategory.CONFIG, ), ) diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py index dd775e7d55a..8e9029fda73 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -40,7 +40,7 @@ def truncate_url(value: StateType) -> tuple[StateType, dict[str, Any]]: return (url, extra_state_attributes) -@dataclass +@dataclass(frozen=True) class FullySensorEntityDescription(SensorEntityDescription): """Fully Kiosk Browser sensor description.""" diff --git a/homeassistant/components/fully_kiosk/switch.py b/homeassistant/components/fully_kiosk/switch.py index c1d5d4e5c75..d5480b784c4 100644 --- a/homeassistant/components/fully_kiosk/switch.py +++ b/homeassistant/components/fully_kiosk/switch.py @@ -18,7 +18,7 @@ from .coordinator import FullyKioskDataUpdateCoordinator from .entity import FullyKioskEntity -@dataclass +@dataclass(frozen=True) class FullySwitchEntityDescriptionMixin: """Fully Kiosk Browser switch entity description mixin.""" @@ -29,7 +29,7 @@ class FullySwitchEntityDescriptionMixin: mqtt_off_event: str | None -@dataclass +@dataclass(frozen=True) class FullySwitchEntityDescription( SwitchEntityDescription, FullySwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py index b66cb8cd00d..bf905bc551d 100644 --- a/homeassistant/components/gardena_bluetooth/binary_sensor.py +++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py @@ -20,7 +20,7 @@ from .const import DOMAIN from .coordinator import Coordinator, GardenaBluetoothDescriptorEntity -@dataclass +@dataclass(frozen=True) class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescription): """Description of entity.""" diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py index 1ed738a9690..cbdbda5f367 100644 --- a/homeassistant/components/gardena_bluetooth/button.py +++ b/homeassistant/components/gardena_bluetooth/button.py @@ -16,7 +16,7 @@ from .const import DOMAIN from .coordinator import Coordinator, GardenaBluetoothDescriptorEntity -@dataclass +@dataclass(frozen=True) class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription): """Description of entity.""" diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index bcbb25d55a2..6598aeaafd8 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -13,5 +13,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", - "requirements": ["gardena-bluetooth==1.4.0"] + "requirements": ["gardena-bluetooth==1.4.1"] } diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index f0ba5dbd2fe..ef19a921041 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -29,7 +29,7 @@ from .coordinator import ( ) -@dataclass +@dataclass(frozen=True) class GardenaBluetoothNumberEntityDescription(NumberEntityDescription): """Description of entity.""" diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py index 495a1fcb1eb..ca2b1acdd8c 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -27,7 +27,7 @@ from .coordinator import ( ) -@dataclass +@dataclass(frozen=True) class GardenaBluetoothSensorEntityDescription(SensorEntityDescription): """Description of entity.""" diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 5d5589c54d6..8a0a0113ced 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -7,6 +7,7 @@ import logging from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -44,12 +45,14 @@ class GdacsSensor(SensorEntity): _attr_should_poll = False _attr_icon = DEFAULT_ICON _attr_native_unit_of_measurement = DEFAULT_UNIT_OF_MEASUREMENT + _attr_has_entity_name = True + _attr_name = None def __init__(self, config_entry: ConfigEntry, manager) -> None: """Initialize entity.""" + assert config_entry.unique_id self._config_entry_id = config_entry.entry_id self._attr_unique_id = config_entry.unique_id - self._attr_name = f"GDACS ({config_entry.title})" self._manager = manager self._status = None self._last_update = None @@ -60,6 +63,11 @@ class GdacsSensor(SensorEntity): self._updated = None self._removed = None self._remove_signal_status: Callable[[], None] | None = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.unique_id)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="GDACS", + ) async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 9ffd873efd6..f4c02a2ab9f 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -1,7 +1,9 @@ """Support for IP Cameras.""" from __future__ import annotations +import asyncio from collections.abc import Mapping +from datetime import datetime, timedelta import logging from typing import Any @@ -129,6 +131,8 @@ class GenericCamera(Camera): """A generic implementation of an IP camera.""" _last_image: bytes | None + _last_update: datetime + _update_lock: asyncio.Lock def __init__( self, @@ -172,6 +176,8 @@ class GenericCamera(Camera): self._last_url = None self._last_image = None + self._last_update = datetime.min + self._update_lock = asyncio.Lock() self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, identifier)}, @@ -198,22 +204,39 @@ class GenericCamera(Camera): if url == self._last_url and self._limit_refetch: return self._last_image - try: - async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) - response = await async_client.get( - url, auth=self._auth, follow_redirects=True, timeout=GET_IMAGE_TIMEOUT - ) - response.raise_for_status() - self._last_image = response.content - except httpx.TimeoutException: - _LOGGER.error("Timeout getting camera image from %s", self._name) - return self._last_image - except (httpx.RequestError, httpx.HTTPStatusError) as err: - _LOGGER.error("Error getting new camera image from %s: %s", self._name, err) - return self._last_image + async with self._update_lock: + if ( + self._last_image is not None + and url == self._last_url + and self._last_update + timedelta(0, self._attr_frame_interval) + > datetime.now() + ): + return self._last_image - self._last_url = url - return self._last_image + try: + update_time = datetime.now() + async_client = get_async_client(self.hass, verify_ssl=self.verify_ssl) + response = await async_client.get( + url, + auth=self._auth, + follow_redirects=True, + timeout=GET_IMAGE_TIMEOUT, + ) + response.raise_for_status() + self._last_image = response.content + self._last_update = update_time + + except httpx.TimeoutException: + _LOGGER.error("Timeout getting camera image from %s", self._name) + return self._last_image + except (httpx.RequestError, httpx.HTTPStatusError) as err: + _LOGGER.error( + "Error getting new camera image from %s: %s", self._name, err + ) + return self._last_image + + self._last_url = url + return self._last_image @property def name(self): diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py index af64443ca28..c5e91d32b20 100644 --- a/homeassistant/components/geo_location/__init__.py +++ b/homeassistant/components/geo_location/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE @@ -16,6 +16,12 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) ATTR_DISTANCE = "distance" @@ -51,7 +57,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -class GeolocationEvent(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "source", + "distance", + "latitude", + "longitude", +} + + +class GeolocationEvent(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for an external event with an associated geolocation.""" # Entity Properties @@ -68,22 +82,22 @@ class GeolocationEvent(Entity): return round(self.distance, 1) return None - @property + @cached_property def source(self) -> str: """Return source value of this external event.""" return self._attr_source - @property + @cached_property def distance(self) -> float | None: """Return distance value of this external event.""" return self._attr_distance - @property + @cached_property def latitude(self) -> float | None: """Return latitude value of this external event.""" return self._attr_latitude - @property + @cached_property def longitude(self) -> float | None: """Return longitude value of this external event.""" return self._attr_longitude diff --git a/homeassistant/components/geocaching/sensor.py b/homeassistant/components/geocaching/sensor.py index 541d2e0b89d..dd324492d73 100644 --- a/homeassistant/components/geocaching/sensor.py +++ b/homeassistant/components/geocaching/sensor.py @@ -18,14 +18,14 @@ from .const import DOMAIN from .coordinator import GeocachingDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class GeocachingRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[GeocachingStatus], str | int | None] -@dataclass +@dataclass(frozen=True) class GeocachingSensorEntityDescription( SensorEntityDescription, GeocachingRequiredKeysMixin ): diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index f5bbdb06198..99c1775beef 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -42,17 +42,11 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -@dataclass -class GiosSensorRequiredKeysMixin: - """Class for GIOS entity required keys.""" - - value: Callable[[GiosSensors], StateType] - - -@dataclass -class GiosSensorEntityDescription(SensorEntityDescription, GiosSensorRequiredKeysMixin): +@dataclass(frozen=True, kw_only=True) +class GiosSensorEntityDescription(SensorEntityDescription): """Class describing GIOS sensor entities.""" + value: Callable[[GiosSensors], StateType] subkey: str | None = None diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index 9afbf80297c..c90caf0fc89 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -2,7 +2,8 @@ from __future__ import annotations import asyncio -from typing import Any +from contextlib import suppress +from typing import TYPE_CHECKING, Any from aiogithubapi import ( GitHubAPI, @@ -15,22 +16,16 @@ from aiogithubapi.const import OAUTH_USER_LOGIN import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import FlowResult, UnknownFlow from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, ) import homeassistant.helpers.config_validation as cv -from .const import ( - CLIENT_ID, - CONF_ACCESS_TOKEN, - CONF_REPOSITORIES, - DEFAULT_REPOSITORIES, - DOMAIN, - LOGGER, -) +from .const import CLIENT_ID, CONF_REPOSITORIES, DEFAULT_REPOSITORIES, DOMAIN, LOGGER async def get_repositories(hass: HomeAssistant, access_token: str) -> list[str]: @@ -124,19 +119,27 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle device steps.""" async def _wait_for_login() -> None: - # mypy is not aware that we can't get here without having these set already - assert self._device is not None - assert self._login_device is not None + if TYPE_CHECKING: + # mypy is not aware that we can't get here without having these set already + assert self._device is not None + assert self._login_device is not None try: response = await self._device.activation( device_code=self._login_device.device_code ) self._login = response.data + finally: - self.hass.async_create_task( - self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) - ) + + async def _progress(): + # If the user closes the dialog the flow will no longer exist and it will raise UnknownFlow + with suppress(UnknownFlow): + await self.hass.config_entries.flow.async_configure( + flow_id=self.flow_id + ) + + self.hass.async_create_task(_progress()) if not self._device: self._device = GitHubDeviceAPI( @@ -145,31 +148,33 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): **{"client_name": SERVER_SOFTWARE}, ) - try: - response = await self._device.register() - self._login_device = response.data - except GitHubException as exception: - LOGGER.exception(exception) - return self.async_abort(reason="could_not_register") + try: + response = await self._device.register() + self._login_device = response.data + except GitHubException as exception: + LOGGER.exception(exception) + return self.async_abort(reason="could_not_register") - if not self.login_task: + if self.login_task is None: self.login_task = self.hass.async_create_task(_wait_for_login()) - return self.async_show_progress( - step_id="device", - progress_action="wait_for_device", - description_placeholders={ - "url": OAUTH_USER_LOGIN, - "code": self._login_device.user_code, - }, - ) - try: - await self.login_task - except GitHubException as exception: - LOGGER.exception(exception) - return self.async_show_progress_done(next_step_id="could_not_register") + if self.login_task.done(): + if self.login_task.exception(): + return self.async_show_progress_done(next_step_id="could_not_register") + return self.async_show_progress_done(next_step_id="repositories") - return self.async_show_progress_done(next_step_id="repositories") + if TYPE_CHECKING: + # mypy is not aware that we can't get here without having this set already + assert self._login_device is not None + + return self.async_show_progress( + step_id="device", + progress_action="wait_for_device", + description_placeholders={ + "url": OAUTH_USER_LOGIN, + "code": self._login_device.user_code, + }, + ) async def async_step_repositories( self, @@ -177,8 +182,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle repositories step.""" - # mypy is not aware that we can't get here without having this set already - assert self._login is not None + if TYPE_CHECKING: + # mypy is not aware that we can't get here without having this set already + assert self._login is not None if not user_input: repositories = await get_repositories(self.hass, self._login.access_token) @@ -214,6 +220,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) + @callback + def async_remove(self) -> None: + """Handle remove handler callback.""" + if self.login_task and not self.login_task.done(): + # Clean up login task if it's still running + self.login_task.cancel() + class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for GitHub.""" diff --git a/homeassistant/components/github/const.py b/homeassistant/components/github/const.py index a186f4684b3..d01656ee8ae 100644 --- a/homeassistant/components/github/const.py +++ b/homeassistant/components/github/const.py @@ -13,7 +13,6 @@ CLIENT_ID = "1440cafcc86e3ea5d6a2" DEFAULT_REPOSITORIES = ["home-assistant/core", "esphome/esphome"] FALLBACK_UPDATE_INTERVAL = timedelta(hours=1, minutes=30) -CONF_ACCESS_TOKEN = "access_token" CONF_REPOSITORIES = "repositories" diff --git a/homeassistant/components/github/diagnostics.py b/homeassistant/components/github/diagnostics.py index c2546d636b8..15626497344 100644 --- a/homeassistant/components/github/diagnostics.py +++ b/homeassistant/components/github/diagnostics.py @@ -6,13 +6,14 @@ from typing import Any from aiogithubapi import GitHubAPI, GitHubException from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import ( SERVER_SOFTWARE, async_get_clientsession, ) -from .const import CONF_ACCESS_TOKEN, DOMAIN +from .const import DOMAIN from .coordinator import GitHubDataUpdateCoordinator diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index d497700f5db..cec0e6b763f 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -22,14 +22,14 @@ from .const import DOMAIN from .coordinator import GitHubDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class BaseEntityDescriptionMixin: """Mixin for required GitHub base description keys.""" value_fn: Callable[[dict[str, Any]], StateType] -@dataclass +@dataclass(frozen=True) class BaseEntityDescription(SensorEntityDescription): """Describes GitHub sensor entity default overrides.""" @@ -38,7 +38,7 @@ class BaseEntityDescription(SensorEntityDescription): avabl_fn: Callable[[dict[str, Any]], bool] = lambda data: True -@dataclass +@dataclass(frozen=True) class GitHubSensorEntityDescription(BaseEntityDescription, BaseEntityDescriptionMixin): """Describes GitHub issue sensor entity.""" diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json index d90f7b8274c..d022995b786 100644 --- a/homeassistant/components/glances/manifest.json +++ b/homeassistant/components/glances/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/glances", "iot_class": "local_polling", "loggers": ["glances_api"], - "requirements": ["glances-api==0.4.3"] + "requirements": ["glances-api==0.5.0"] } diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 78aa5ffbf0a..a3578bf6f66 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -30,7 +30,7 @@ from . import GlancesDataUpdateCoordinator from .const import CPU_ICON, DOMAIN -@dataclass +@dataclass(frozen=True) class GlancesSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -38,7 +38,7 @@ class GlancesSensorEntityDescriptionMixin: name_suffix: str -@dataclass +@dataclass(frozen=True) class GlancesSensorEntityDescription( SensorEntityDescription, GlancesSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/goodwe/button.py b/homeassistant/components/goodwe/button.py index 12cad42547d..0aebdb8c073 100644 --- a/homeassistant/components/goodwe/button.py +++ b/homeassistant/components/goodwe/button.py @@ -18,14 +18,14 @@ from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class GoodweButtonEntityDescriptionRequired: """Required attributes of GoodweButtonEntityDescription.""" action: Callable[[Inverter], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class GoodweButtonEntityDescription( ButtonEntityDescription, GoodweButtonEntityDescriptionRequired ): diff --git a/homeassistant/components/goodwe/diagnostics.py b/homeassistant/components/goodwe/diagnostics.py new file mode 100644 index 00000000000..285036c0254 --- /dev/null +++ b/homeassistant/components/goodwe/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for Goodwe.""" +from __future__ import annotations + +from typing import Any + +from goodwe import Inverter + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, KEY_INVERTER + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + inverter: Inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER] + + diagnostics_data = { + "config_entry": config_entry.as_dict(), + "inverter": { + "model_name": inverter.model_name, + "rated_power": inverter.rated_power, + "firmware": inverter.firmware, + "arm_firmware": inverter.arm_firmware, + "dsp1_version": inverter.dsp1_version, + "dsp2_version": inverter.dsp2_version, + "dsp_svn_version": inverter.dsp_svn_version, + "arm_version": inverter.arm_version, + "arm_svn_version": inverter.arm_svn_version, + }, + } + + return diagnostics_data diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index a3e4190f309..d92f6ab8fd0 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -23,7 +23,7 @@ from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class GoodweNumberEntityDescriptionBase: """Required values when describing Goodwe number entities.""" @@ -32,7 +32,7 @@ class GoodweNumberEntityDescriptionBase: filter: Callable[[Inverter], bool] -@dataclass +@dataclass(frozen=True) class GoodweNumberEntityDescription( NumberEntityDescription, GoodweNumberEntityDescriptionBase ): diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 0065d70dda9..a43ff971a9a 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -75,7 +75,7 @@ _ICONS: dict[SensorKind, str] = { } -@dataclass +@dataclass(frozen=True) class GoodweSensorEntityDescription(SensorEntityDescription): """Class describing Goodwe sensor entities.""" diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 060f7ce50e5..431433e2bba 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -22,6 +22,8 @@ from homeassistant.components import ( sensor, switch, vacuum, + valve, + water_heater, ) DOMAIN = "google_assistant" @@ -64,6 +66,8 @@ DEFAULT_EXPOSED_DOMAINS = [ "sensor", "switch", "vacuum", + "valve", + "water_heater", ] # https://developers.google.com/assistant/smarthome/guides @@ -93,6 +97,8 @@ TYPE_THERMOSTAT = f"{PREFIX_TYPES}THERMOSTAT" TYPE_TV = f"{PREFIX_TYPES}TV" TYPE_WINDOW = f"{PREFIX_TYPES}WINDOW" TYPE_VACUUM = f"{PREFIX_TYPES}VACUUM" +TYPE_VALVE = f"{PREFIX_TYPES}VALVE" +TYPE_WATERHEATER = f"{PREFIX_TYPES}WATERHEATER" SERVICE_REQUEST_SYNC = "request_sync" HOMEGRAPH_URL = "https://homegraph.googleapis.com/" @@ -147,6 +153,8 @@ DOMAIN_TO_GOOGLE_TYPES = { sensor.DOMAIN: TYPE_SENSOR, switch.DOMAIN: TYPE_SWITCH, vacuum.DOMAIN: TYPE_VACUUM, + valve.DOMAIN: TYPE_VALVE, + water_heater.DOMAIN: TYPE_WATERHEATER, } DEVICE_CLASS_TO_GOOGLE_TYPES = { diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 33f0d7a3329..9b8a95f0b4a 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -29,6 +29,8 @@ from homeassistant.components import ( sensor, switch, vacuum, + valve, + water_heater, ) from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.components.camera import CameraEntityFeature @@ -40,6 +42,8 @@ from homeassistant.components.light import LightEntityFeature from homeassistant.components.lock import STATE_JAMMED, STATE_UNLOCKING from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.valve import ValveEntityFeature +from homeassistant.components.water_heater import WaterHeaterEntityFeature from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_BATTERY_LEVEL, @@ -139,6 +143,7 @@ COMMAND_PAUSEUNPAUSE = f"{PREFIX_COMMANDS}PauseUnpause" COMMAND_BRIGHTNESS_ABSOLUTE = f"{PREFIX_COMMANDS}BrightnessAbsolute" COMMAND_COLOR_ABSOLUTE = f"{PREFIX_COMMANDS}ColorAbsolute" COMMAND_ACTIVATE_SCENE = f"{PREFIX_COMMANDS}ActivateScene" +COMMAND_SET_TEMPERATURE = f"{PREFIX_COMMANDS}SetTemperature" COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = ( f"{PREFIX_COMMANDS}ThermostatTemperatureSetpoint" ) @@ -177,6 +182,65 @@ TRAITS: list[type[_Trait]] = [] FAN_SPEED_MAX_SPEED_COUNT = 5 +COVER_VALVE_STATES = { + cover.DOMAIN: { + "closed": cover.STATE_CLOSED, + "closing": cover.STATE_CLOSING, + "open": cover.STATE_OPEN, + "opening": cover.STATE_OPENING, + }, + valve.DOMAIN: { + "closed": valve.STATE_CLOSED, + "closing": valve.STATE_CLOSING, + "open": valve.STATE_OPEN, + "opening": valve.STATE_OPENING, + }, +} + +SERVICE_STOP_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_STOP_COVER, + valve.DOMAIN: valve.SERVICE_STOP_VALVE, +} +SERVICE_OPEN_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_OPEN_COVER, + valve.DOMAIN: valve.SERVICE_OPEN_VALVE, +} +SERVICE_CLOSE_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_CLOSE_COVER, + valve.DOMAIN: valve.SERVICE_CLOSE_VALVE, +} +SERVICE_TOGGLE_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_TOGGLE, + valve.DOMAIN: valve.SERVICE_TOGGLE, +} +SERVICE_SET_POSITION_COVER_VALVE = { + cover.DOMAIN: cover.SERVICE_SET_COVER_POSITION, + valve.DOMAIN: valve.SERVICE_SET_VALVE_POSITION, +} + +COVER_VALVE_CURRENT_POSITION = { + cover.DOMAIN: cover.ATTR_CURRENT_POSITION, + valve.DOMAIN: valve.ATTR_CURRENT_POSITION, +} + +COVER_VALVE_POSITION = { + cover.DOMAIN: cover.ATTR_POSITION, + valve.DOMAIN: valve.ATTR_POSITION, +} + +COVER_VALVE_SET_POSITION_FEATURE = { + cover.DOMAIN: CoverEntityFeature.SET_POSITION, + valve.DOMAIN: ValveEntityFeature.SET_POSITION, +} +COVER_VALVE_STOP_FEATURE = { + cover.DOMAIN: CoverEntityFeature.STOP, + valve.DOMAIN: ValveEntityFeature.STOP, +} + +COVER_VALVE_DOMAINS = {cover.DOMAIN, valve.DOMAIN} + +FRIENDLY_DOMAIN = {cover.DOMAIN: "Cover", valve.DOMAIN: "Valve"} + _TraitT = TypeVar("_TraitT", bound="_Trait") @@ -417,6 +481,9 @@ class OnOffTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" + if domain == water_heater.DOMAIN and features & WaterHeaterEntityFeature.ON_OFF: + return True + return domain in ( group.DOMAIN, input_boolean.DOMAIN, @@ -787,7 +854,10 @@ class StartStopTrait(_Trait): if domain == vacuum.DOMAIN: return True - if domain == cover.DOMAIN and features & CoverEntityFeature.STOP: + if ( + domain in COVER_VALVE_DOMAINS + and features & COVER_VALVE_STOP_FEATURE[domain] + ): return True return False @@ -801,7 +871,7 @@ class StartStopTrait(_Trait): & VacuumEntityFeature.PAUSE != 0 } - if domain == cover.DOMAIN: + if domain in COVER_VALVE_DOMAINS: return {} def query_attributes(self): @@ -815,16 +885,22 @@ class StartStopTrait(_Trait): "isPaused": state == vacuum.STATE_PAUSED, } - if domain == cover.DOMAIN: - return {"isRunning": state in (cover.STATE_CLOSING, cover.STATE_OPENING)} + if domain in COVER_VALVE_DOMAINS: + return { + "isRunning": state + in ( + COVER_VALVE_STATES[domain]["closing"], + COVER_VALVE_STATES[domain]["opening"], + ) + } async def execute(self, command, data, params, challenge): """Execute a StartStop command.""" domain = self.state.domain if domain == vacuum.DOMAIN: return await self._execute_vacuum(command, data, params, challenge) - if domain == cover.DOMAIN: - return await self._execute_cover(command, data, params, challenge) + if domain in COVER_VALVE_DOMAINS: + return await self._execute_cover_or_valve(command, data, params, challenge) async def _execute_vacuum(self, command, data, params, challenge): """Execute a StartStop command.""" @@ -863,28 +939,34 @@ class StartStopTrait(_Trait): context=data.context, ) - async def _execute_cover(self, command, data, params, challenge): + async def _execute_cover_or_valve(self, command, data, params, challenge): """Execute a StartStop command.""" + domain = self.state.domain if command == COMMAND_STARTSTOP: if params["start"] is False: if self.state.state in ( - cover.STATE_CLOSING, - cover.STATE_OPENING, + COVER_VALVE_STATES[domain]["closing"], + COVER_VALVE_STATES[domain]["opening"], ) or self.state.attributes.get(ATTR_ASSUMED_STATE): await self.hass.services.async_call( - self.state.domain, - cover.SERVICE_STOP_COVER, + domain, + SERVICE_STOP_COVER_VALVE[domain], {ATTR_ENTITY_ID: self.state.entity_id}, blocking=not self.config.should_report_state, context=data.context, ) else: raise SmartHomeError( - ERR_ALREADY_STOPPED, "Cover is already stopped" + ERR_ALREADY_STOPPED, + f"{FRIENDLY_DOMAIN[domain]} is already stopped", ) else: - raise SmartHomeError( - ERR_NOT_SUPPORTED, "Starting a cover is not supported" + await self.hass.services.async_call( + domain, + SERVICE_TOGGLE_COVER_VALVE[domain], + {ATTR_ENTITY_ID: self.state.entity_id}, + blocking=not self.config.should_report_state, + context=data.context, ) else: raise SmartHomeError( @@ -894,38 +976,97 @@ class StartStopTrait(_Trait): @register_trait class TemperatureControlTrait(_Trait): - """Trait for devices (other than thermostats) that support controlling temperature. Workaround for Temperature sensors. + """Trait for devices (other than thermostats) that support controlling temperature. + + Control the target temperature of water heaters. + Offers a workaround for Temperature sensors by setting queryOnlyTemperatureControl + in the response. https://developers.google.com/assistant/smarthome/traits/temperaturecontrol """ name = TRAIT_TEMPERATURE_CONTROL + commands = [ + COMMAND_SET_TEMPERATURE, + ] + @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" return ( + domain == water_heater.DOMAIN + and features & WaterHeaterEntityFeature.TARGET_TEMPERATURE + ) or ( domain == sensor.DOMAIN and device_class == sensor.SensorDeviceClass.TEMPERATURE ) def sync_attributes(self): """Return temperature attributes for a sync request.""" - return { - "temperatureUnitForUX": _google_temp_unit( - self.hass.config.units.temperature_unit - ), - "queryOnlyTemperatureControl": True, - "temperatureRange": { + response = {} + domain = self.state.domain + attrs = self.state.attributes + unit = self.hass.config.units.temperature_unit + response["temperatureUnitForUX"] = _google_temp_unit(unit) + + if domain == water_heater.DOMAIN: + min_temp = round( + TemperatureConverter.convert( + float(attrs[water_heater.ATTR_MIN_TEMP]), + unit, + UnitOfTemperature.CELSIUS, + ) + ) + max_temp = round( + TemperatureConverter.convert( + float(attrs[water_heater.ATTR_MAX_TEMP]), + unit, + UnitOfTemperature.CELSIUS, + ) + ) + response["temperatureRange"] = { + "minThresholdCelsius": min_temp, + "maxThresholdCelsius": max_temp, + } + else: + response["queryOnlyTemperatureControl"] = True + response["temperatureRange"] = { "minThresholdCelsius": -100, "maxThresholdCelsius": 100, - }, - } + } + + return response def query_attributes(self): """Return temperature states.""" response = {} + domain = self.state.domain unit = self.hass.config.units.temperature_unit + if domain == water_heater.DOMAIN: + target_temp = self.state.attributes[water_heater.ATTR_TEMPERATURE] + current_temp = self.state.attributes[water_heater.ATTR_CURRENT_TEMPERATURE] + if target_temp not in (STATE_UNKNOWN, STATE_UNAVAILABLE): + response["temperatureSetpointCelsius"] = round( + TemperatureConverter.convert( + float(target_temp), + unit, + UnitOfTemperature.CELSIUS, + ), + 1, + ) + if current_temp is not None: + response["temperatureAmbientCelsius"] = round( + TemperatureConverter.convert( + float(current_temp), + unit, + UnitOfTemperature.CELSIUS, + ), + 1, + ) + return response + + # domain == sensor.DOMAIN current_temp = self.state.state if current_temp not in (STATE_UNKNOWN, STATE_UNAVAILABLE): temp = round( @@ -940,8 +1081,35 @@ class TemperatureControlTrait(_Trait): return response async def execute(self, command, data, params, challenge): - """Unsupported.""" - raise SmartHomeError(ERR_NOT_SUPPORTED, "Execute is not supported by sensor") + """Execute a temperature point or mode command.""" + # All sent in temperatures are always in Celsius + domain = self.state.domain + unit = self.hass.config.units.temperature_unit + + if domain == water_heater.DOMAIN and command == COMMAND_SET_TEMPERATURE: + min_temp = self.state.attributes[water_heater.ATTR_MIN_TEMP] + max_temp = self.state.attributes[water_heater.ATTR_MAX_TEMP] + temp = TemperatureConverter.convert( + params["temperature"], UnitOfTemperature.CELSIUS, unit + ) + if unit == UnitOfTemperature.FAHRENHEIT: + temp = round(temp) + if temp < min_temp or temp > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + f"Temperature should be between {min_temp} and {max_temp}", + ) + + await self.hass.services.async_call( + water_heater.DOMAIN, + water_heater.SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: self.state.entity_id, ATTR_TEMPERATURE: temp}, + blocking=not self.config.should_report_state, + context=data.context, + ) + return + + raise SmartHomeError(ERR_NOT_SUPPORTED, f"Execute is not supported by {domain}") @register_trait @@ -1696,6 +1864,12 @@ class ModesTrait(_Trait): if domain == light.DOMAIN and features & LightEntityFeature.EFFECT: return True + if ( + domain == water_heater.DOMAIN + and features & WaterHeaterEntityFeature.OPERATION_MODE + ): + return True + if domain != media_player.DOMAIN: return False @@ -1736,6 +1910,7 @@ class ModesTrait(_Trait): (select.DOMAIN, select.ATTR_OPTIONS, "option"), (humidifier.DOMAIN, humidifier.ATTR_AVAILABLE_MODES, "mode"), (light.DOMAIN, light.ATTR_EFFECT_LIST, "effect"), + (water_heater.DOMAIN, water_heater.ATTR_OPERATION_LIST, "operation mode"), ): if self.state.domain != domain: continue @@ -1769,6 +1944,11 @@ class ModesTrait(_Trait): elif self.state.domain == humidifier.DOMAIN: if ATTR_MODE in attrs: mode_settings["mode"] = attrs.get(ATTR_MODE) + elif self.state.domain == water_heater.DOMAIN: + if water_heater.ATTR_OPERATION_MODE in attrs: + mode_settings["operation mode"] = attrs.get( + water_heater.ATTR_OPERATION_MODE + ) elif self.state.domain == light.DOMAIN and ( effect := attrs.get(light.ATTR_EFFECT) ): @@ -1840,6 +2020,20 @@ class ModesTrait(_Trait): ) return + if self.state.domain == water_heater.DOMAIN: + requested_mode = settings["operation mode"] + await self.hass.services.async_call( + water_heater.DOMAIN, + water_heater.SERVICE_SET_OPERATION_MODE, + { + water_heater.ATTR_OPERATION_MODE: requested_mode, + ATTR_ENTITY_ID: self.state.entity_id, + }, + blocking=not self.config.should_report_state, + context=data.context, + ) + return + if self.state.domain == light.DOMAIN: requested_effect = settings["effect"] await self.hass.services.async_call( @@ -1963,7 +2157,7 @@ class OpenCloseTrait(_Trait): @staticmethod def supported(domain, features, device_class, _): """Test if state is supported.""" - if domain == cover.DOMAIN: + if domain in COVER_VALVE_DOMAINS: return True return domain == binary_sensor.DOMAIN and device_class in ( @@ -1998,6 +2192,17 @@ class OpenCloseTrait(_Trait): and features & CoverEntityFeature.CLOSE == 0 ): response["queryOnlyOpenClose"] = True + elif ( + self.state.domain == valve.DOMAIN + and features & ValveEntityFeature.SET_POSITION == 0 + ): + response["discreteOnlyOpenClose"] = True + + if ( + features & ValveEntityFeature.OPEN == 0 + and features & ValveEntityFeature.CLOSE == 0 + ): + response["queryOnlyOpenClose"] = True if self.state.attributes.get(ATTR_ASSUMED_STATE): response["commandOnlyOpenClose"] = True @@ -2016,17 +2221,17 @@ class OpenCloseTrait(_Trait): if self.state.attributes.get(ATTR_ASSUMED_STATE): return response - if domain == cover.DOMAIN: + if domain in COVER_VALVE_DOMAINS: if self.state.state == STATE_UNKNOWN: raise SmartHomeError( ERR_NOT_SUPPORTED, "Querying state is not supported" ) - position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION) + position = self.state.attributes.get(COVER_VALVE_CURRENT_POSITION[domain]) if position is not None: response["openPercent"] = position - elif self.state.state != cover.STATE_CLOSED: + elif self.state.state != COVER_VALVE_STATES[domain]["closed"]: response["openPercent"] = 100 else: response["openPercent"] = 0 @@ -2044,11 +2249,13 @@ class OpenCloseTrait(_Trait): domain = self.state.domain features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if domain == cover.DOMAIN: + if domain in COVER_VALVE_DOMAINS: svc_params = {ATTR_ENTITY_ID: self.state.entity_id} should_verify = False if command == COMMAND_OPENCLOSE_RELATIVE: - position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION) + position = self.state.attributes.get( + COVER_VALVE_CURRENT_POSITION[domain] + ) if position is None: raise SmartHomeError( ERR_NOT_SUPPORTED, @@ -2059,16 +2266,16 @@ class OpenCloseTrait(_Trait): position = params["openPercent"] if position == 0: - service = cover.SERVICE_CLOSE_COVER + service = SERVICE_CLOSE_COVER_VALVE[domain] should_verify = False elif position == 100: - service = cover.SERVICE_OPEN_COVER + service = SERVICE_OPEN_COVER_VALVE[domain] should_verify = True - elif features & CoverEntityFeature.SET_POSITION: - service = cover.SERVICE_SET_COVER_POSITION + elif features & COVER_VALVE_SET_POSITION_FEATURE[domain]: + service = SERVICE_SET_POSITION_COVER_VALVE[domain] if position > 0: should_verify = True - svc_params[cover.ATTR_POSITION] = position + svc_params[COVER_VALVE_POSITION[domain]] = position else: raise SmartHomeError( ERR_NOT_SUPPORTED, "No support for partial open close" @@ -2082,7 +2289,7 @@ class OpenCloseTrait(_Trait): _verify_pin_challenge(data, self.state, challenge) await self.hass.services.async_call( - cover.DOMAIN, + domain, service, svc_params, blocking=not self.config.should_report_state, diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 94639177a42..fea023c604e 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -129,7 +129,7 @@ class OptionsFlow(config_entries.OptionsFlow): def google_generative_ai_config_option_schema( - options: MappingProxyType[str, Any] + options: MappingProxyType[str, Any], ) -> dict: """Return a schema for Google Generative AI completion options.""" if not options: diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json index 65d9e0b3894..5bafa9c43de 100644 --- a/homeassistant/components/google_generative_ai_conversation/manifest.json +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["google-generativeai==0.1.0"] + "requirements": ["google-generativeai==0.3.1"] } diff --git a/homeassistant/components/google_tasks/api.py b/homeassistant/components/google_tasks/api.py index 5dd7156702f..2658fdedc59 100644 --- a/homeassistant/components/google_tasks/api.py +++ b/homeassistant/components/google_tasks/api.py @@ -126,6 +126,21 @@ class AsyncConfigEntryAuth: ) await self._execute(batch) + async def move( + self, + task_list_id: str, + task_id: str, + previous: str | None, + ) -> None: + """Move a task resource to a specific position within the task list.""" + service = await self._get_service() + cmd: HttpRequest = service.tasks().move( + tasklist=task_list_id, + task=task_id, + previous=previous, + ) + await self._execute(cmd) + async def _execute(self, request: HttpRequest | BatchHttpRequest) -> Any: try: result = await self._hass.async_add_executor_job(request.execute) diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index 130c0d2cc01..e83b0d39a30 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -29,18 +29,20 @@ TODO_STATUS_MAP = { TODO_STATUS_MAP_INV = {v: k for k, v in TODO_STATUS_MAP.items()} -def _convert_todo_item(item: TodoItem) -> dict[str, str]: +def _convert_todo_item(item: TodoItem) -> dict[str, str | None]: """Convert TodoItem dataclass items to dictionary of attributes the tasks API.""" - result: dict[str, str] = {} - if item.summary is not None: - result["title"] = item.summary + result: dict[str, str | None] = {} + result["title"] = item.summary if item.status is not None: result["status"] = TODO_STATUS_MAP_INV[item.status] + else: + result["status"] = TodoItemStatus.NEEDS_ACTION if (due := item.due) is not None: # due API field is a timestamp string, but with only date resolution result["due"] = dt_util.start_of_local_day(due).isoformat() - if (description := item.description) is not None: - result["notes"] = description + else: + result["due"] = None + result["notes"] = item.description return result @@ -91,6 +93,7 @@ class GoogleTaskTodoListEntity( TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.MOVE_TODO_ITEM | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM | TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM ) @@ -138,6 +141,13 @@ class GoogleTaskTodoListEntity( await self.coordinator.api.delete(self._task_list_id, uids) await self.coordinator.async_refresh() + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: + """Re-order a To-do item.""" + await self.coordinator.api.move(self._task_list_id, uid, previous=previous_uid) + await self.coordinator.async_refresh() + def _order_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]: """Order the task items response. diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index 83e144f6bbd..ec8187d91af 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY, CONF_MODE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv @@ -23,7 +23,6 @@ from .const import ( CONF_AVOID, CONF_DEPARTURE_TIME, CONF_DESTINATION, - CONF_LANGUAGE, CONF_ORIGIN, CONF_TIME, CONF_TIME_TYPE, diff --git a/homeassistant/components/google_travel_time/const.py b/homeassistant/components/google_travel_time/const.py index 0535e295b93..041858d948f 100644 --- a/homeassistant/components/google_travel_time/const.py +++ b/homeassistant/components/google_travel_time/const.py @@ -7,7 +7,6 @@ CONF_DESTINATION = "destination" CONF_OPTIONS = "options" CONF_ORIGIN = "origin" CONF_TRAVEL_MODE = "travel_mode" -CONF_LANGUAGE = "language" CONF_AVOID = "avoid" CONF_UNITS = "units" CONF_ARRIVAL_TIME = "arrival_time" diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 6bf552b824b..f90cc028fdf 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -42,7 +42,7 @@ ENDPOINT = "/api/v1/status" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) -@dataclass +@dataclass(frozen=True) class GoogleWifiRequiredKeysMixin: """Mixin for required keys.""" @@ -50,7 +50,7 @@ class GoogleWifiRequiredKeysMixin: sensor_key: str -@dataclass +@dataclass(frozen=True) class GoogleWifiSensorEntityDescription( SensorEntityDescription, GoogleWifiRequiredKeysMixin ): diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index ba162173724..8d50cdf2aed 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -38,10 +38,8 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .bridge import DeviceDataUpdateCoordinator from .const import ( @@ -52,6 +50,7 @@ from .const import ( FAN_MEDIUM_LOW, TARGET_TEMPERATURE_STEP, ) +from .entity import GreeEntity _LOGGER = logging.getLogger(__name__) @@ -105,7 +104,7 @@ async def async_setup_entry( ) -class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateEntity): +class GreeClimateEntity(GreeEntity, ClimateEntity): """Representation of a Gree HVAC device.""" _attr_precision = PRECISION_WHOLE @@ -120,19 +119,12 @@ class GreeClimateEntity(CoordinatorEntity[DeviceDataUpdateCoordinator], ClimateE _attr_preset_modes = PRESET_MODES _attr_fan_modes = [*FAN_MODES_REVERSE] _attr_swing_modes = SWING_MODES + _attr_name = None def __init__(self, coordinator: DeviceDataUpdateCoordinator) -> None: """Initialize the Gree device.""" super().__init__(coordinator) - self._attr_name = coordinator.device.device_info.name - mac = coordinator.device.device_info.mac - self._attr_unique_id = mac - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, mac)}, - identifiers={(DOMAIN, mac)}, - manufacturer="Gree", - name=self._attr_name, - ) + self._attr_unique_id = coordinator.device.device_info.mac units = self.coordinator.device.temperature_units if units == TemperatureUnits.C: self._attr_temperature_unit = UnitOfTemperature.CELSIUS diff --git a/homeassistant/components/gree/entity.py b/homeassistant/components/gree/entity.py index fd1b80ef90d..c965ad45721 100644 --- a/homeassistant/components/gree/entity.py +++ b/homeassistant/components/gree/entity.py @@ -9,13 +9,15 @@ from .const import DOMAIN class GreeEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]): """Generic Gree entity (base class).""" - def __init__(self, coordinator: DeviceDataUpdateCoordinator, desc: str) -> None: + _attr_has_entity_name = True + + def __init__( + self, coordinator: DeviceDataUpdateCoordinator, desc: str | None = None + ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._desc = desc name = coordinator.device.device_info.name mac = coordinator.device.device_info.mac - self._attr_name = f"{name} {desc}" self._attr_unique_id = f"{mac}_{desc}" self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, mac)}, diff --git a/homeassistant/components/gree/strings.json b/homeassistant/components/gree/strings.json index ad8f0f41ae7..45911433b92 100644 --- a/homeassistant/components/gree/strings.json +++ b/homeassistant/components/gree/strings.json @@ -9,5 +9,24 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } + }, + "entity": { + "switch": { + "light": { + "name": "Panel light" + }, + "quiet": { + "name": "Quiet" + }, + "fresh_air": { + "name": "Fresh air" + }, + "xfan": { + "name": "XFan" + }, + "health_mode": { + "name": "Health mode" + } + } } } diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 7916df18abc..07e88223306 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -21,23 +21,14 @@ from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN from .entity import GreeEntity -@dataclass -class GreeRequiredKeysMixin: - """Mixin for required keys.""" +@dataclass(kw_only=True, frozen=True) +class GreeSwitchEntityDescription(SwitchEntityDescription): + """Describes a Gree switch entity.""" get_value_fn: Callable[[Device], bool] set_value_fn: Callable[[Device, bool], None] -@dataclass -class GreeSwitchEntityDescription(SwitchEntityDescription, GreeRequiredKeysMixin): - """Describes Gree switch entity.""" - - # GreeSwitch does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - - def _set_light(device: Device, value: bool) -> None: """Typed helper to set device light property.""" device.light = value @@ -66,33 +57,33 @@ def _set_anion(device: Device, value: bool) -> None: GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = ( GreeSwitchEntityDescription( icon="mdi:lightbulb", - name="Panel Light", - key="light", + key="Panel Light", + translation_key="light", get_value_fn=lambda d: d.light, set_value_fn=_set_light, ), GreeSwitchEntityDescription( - name="Quiet", - key="quiet", + key="Quiet", + translation_key="quiet", get_value_fn=lambda d: d.quiet, set_value_fn=_set_quiet, ), GreeSwitchEntityDescription( - name="Fresh Air", - key="fresh_air", + key="Fresh Air", + translation_key="fresh_air", get_value_fn=lambda d: d.fresh_air, set_value_fn=_set_fresh_air, ), GreeSwitchEntityDescription( - name="XFan", - key="xfan", + key="XFan", + translation_key="xfan", get_value_fn=lambda d: d.xfan, set_value_fn=_set_xfan, ), GreeSwitchEntityDescription( icon="mdi:pine-tree", - name="Health mode", - key="anion", + key="Health mode", + translation_key="health_mode", get_value_fn=lambda d: d.anion, set_value_fn=_set_anion, entity_registry_enabled_default=False, @@ -134,7 +125,7 @@ class GreeSwitch(GreeEntity, SwitchEntity): """Initialize the Gree device.""" self.entity_description = description - super().__init__(coordinator, description.name) + super().__init__(coordinator, description.key) @property def is_on(self) -> bool: diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index ae246041db9..a2a61b3016a 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -509,7 +509,8 @@ class GroupEntity(Entity): self.async_update_supported_features( event.data["entity_id"], event.data["new_state"] ) - preview_callback(*self._async_generate_attributes()) + calculated_state = self._async_calculate_state() + preview_callback(calculated_state.state, calculated_state.attributes) async_state_changed_listener(None) return async_track_state_change_event( diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index bc238519cfa..b85fbf32a0d 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -236,7 +236,8 @@ class MediaPlayerGroup(MediaPlayerEntity): ) -> None: """Handle child updates.""" self.async_update_group_state() - preview_callback(*self._async_generate_attributes()) + calculated_state = self._async_calculate_state() + preview_callback(calculated_state.state, calculated_state.attributes) async_state_changed_listener(None) return async_track_state_change_event( diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 10030ab647f..c35c96d38aa 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -154,7 +154,7 @@ def async_create_preview_sensor( def calc_min( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate min value.""" val: float | None = None @@ -170,7 +170,7 @@ def calc_min( def calc_max( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate max value.""" val: float | None = None @@ -186,7 +186,7 @@ def calc_max( def calc_mean( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate mean value.""" result = (sensor_value for _, sensor_value, _ in sensor_values) @@ -196,7 +196,7 @@ def calc_mean( def calc_median( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate median value.""" result = (sensor_value for _, sensor_value, _ in sensor_values) @@ -206,7 +206,7 @@ def calc_median( def calc_last( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float | None]: """Calculate last value.""" last_updated: datetime | None = None @@ -223,7 +223,7 @@ def calc_last( def calc_range( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float]: """Calculate range value.""" max_result = max((sensor_value for _, sensor_value, _ in sensor_values)) @@ -234,7 +234,7 @@ def calc_range( def calc_sum( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float]: """Calculate a sum of values.""" result = 0.0 @@ -245,7 +245,7 @@ def calc_sum( def calc_product( - sensor_values: list[tuple[str, float, State]] + sensor_values: list[tuple[str, float, State]], ) -> tuple[dict[str, str | None], float]: """Calculate a product of values.""" result = 1.0 diff --git a/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py b/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py index cd286e228b4..cfeb98a382e 100644 --- a/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py +++ b/homeassistant/components/growatt_server/sensor_types/sensor_entity_description.py @@ -6,14 +6,14 @@ from dataclasses import dataclass from homeassistant.components.sensor import SensorEntityDescription -@dataclass +@dataclass(frozen=True) class GrowattRequiredKeysMixin: """Mixin for required keys.""" api_key: str -@dataclass +@dataclass(frozen=True) class GrowattSensorEntityDescription(SensorEntityDescription, GrowattRequiredKeysMixin): """Describes Growatt sensor entity.""" diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index d7a9fe4e836..4a394692dd8 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from typing import cast +from typing import Any, cast from aioguardian import Client from aioguardian.errors import GuardianError @@ -170,7 +170,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @callback - def call_with_data(func: Callable) -> Callable: + def call_with_data( + func: Callable[[ServiceCall, GuardianData], Coroutine[Any, Any, None]], + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Hydrate a service call with the appropriate GuardianData object.""" async def wrapper(call: ServiceCall) -> None: @@ -408,14 +410,14 @@ class PairedSensorEntity(GuardianEntity): self._attr_unique_id = f"{paired_sensor_uid}_{description.key}" -@dataclass +@dataclass(frozen=True) class ValveControllerEntityDescriptionMixin: """Define an entity description mixin for valve controller entities.""" api_category: str -@dataclass +@dataclass(frozen=True) class ValveControllerEntityDescription( EntityDescription, ValveControllerEntityDescriptionMixin ): diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 7114d33f93a..179158ab512 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -39,7 +39,7 @@ SENSOR_KIND_LEAK_DETECTED = "leak_detected" SENSOR_KIND_MOVED = "moved" -@dataclass +@dataclass(frozen=True) class ValveControllerBinarySensorDescription( BinarySensorEntityDescription, ValveControllerEntityDescription ): diff --git a/homeassistant/components/guardian/button.py b/homeassistant/components/guardian/button.py index c6363c9bcec..7a931f35019 100644 --- a/homeassistant/components/guardian/button.py +++ b/homeassistant/components/guardian/button.py @@ -23,14 +23,14 @@ from . import GuardianData, ValveControllerEntity, ValveControllerEntityDescript from .const import API_SYSTEM_DIAGNOSTICS, DOMAIN -@dataclass +@dataclass(frozen=True) class GuardianButtonEntityDescriptionMixin: """Define an mixin for button entities.""" push_action: Callable[[Client], Awaitable] -@dataclass +@dataclass(frozen=True) class ValveControllerButtonDescription( ButtonEntityDescription, ValveControllerEntityDescription, diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index c5fc77cc8f9..68833234b15 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -39,7 +39,7 @@ SENSOR_KIND_TEMPERATURE = "temperature" SENSOR_KIND_UPTIME = "uptime" -@dataclass +@dataclass(frozen=True) class ValveControllerSensorDescription( SensorEntityDescription, ValveControllerEntityDescription ): diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 4e2be5ae179..98179c1922f 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -29,7 +29,7 @@ SWITCH_KIND_ONBOARD_AP = "onboard_ap" SWITCH_KIND_VALVE = "valve" -@dataclass +@dataclass(frozen=True) class SwitchDescriptionMixin: """Define an entity description mixin for Guardian switches.""" @@ -37,7 +37,7 @@ class SwitchDescriptionMixin: on_action: Callable[[Client], Awaitable] -@dataclass +@dataclass(frozen=True) class ValveControllerSwitchDescription( SwitchEntityDescription, ValveControllerEntityDescription, SwitchDescriptionMixin ): diff --git a/homeassistant/components/hassio/addon_manager.py b/homeassistant/components/hassio/addon_manager.py index 22265f49912..7f9299fa2b1 100644 --- a/homeassistant/components/hassio/addon_manager.py +++ b/homeassistant/components/hassio/addon_manager.py @@ -43,7 +43,7 @@ def api_error( """Handle HassioAPIError and raise a specific AddonError.""" def handle_hassio_api_error( - func: _FuncType[_AddonManagerT, _P, _R] + func: _FuncType[_AddonManagerT, _P, _R], ) -> _ReturnFuncType[_AddonManagerT, _P, _R]: """Handle a HassioAPIError.""" diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index e2cd1bae270..f57cfa472c4 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -17,7 +17,7 @@ from .const import ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS from .entity import HassioAddonEntity -@dataclass +@dataclass(frozen=True) class HassioBinarySensorEntityDescription(BinarySensorEntityDescription): """Hassio binary sensor entity description.""" diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 751e9005809..0c0fe55b686 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -67,18 +67,20 @@ class HassIOIngress(HomeAssistantView): self._websession = websession @lru_cache - def _create_url(self, token: str, path: str) -> str: + def _create_url(self, token: str, path: str) -> URL: """Create URL to service.""" base_path = f"/ingress/{token}/" url = f"http://{self._host}{base_path}{quote(path)}" try: - if not URL(url).path.startswith(base_path): - raise HTTPBadRequest() + target_url = URL(url) except ValueError as err: raise HTTPBadRequest() from err - return url + if not target_url.path.startswith(base_path): + raise HTTPBadRequest() + + return target_url async def _handle( self, request: web.Request, token: str, path: str @@ -128,7 +130,7 @@ class HassIOIngress(HomeAssistantView): # Support GET query if request.query_string: - url = f"{url}?{request.query_string}" + url = url.with_query(request.query_string) # Start proxy async with self._websession.ws_connect( diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 8337405641c..fcfe23dda6e 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -1,6 +1,7 @@ """Repairs implementation for supervisor integration.""" +from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from types import MethodType from typing import Any @@ -116,7 +117,12 @@ class SupervisorIssueRepairFlow(RepairsFlow): return self.async_create_entry(data={}) @staticmethod - def _async_step(suggestion: Suggestion) -> Callable: + def _async_step( + suggestion: Suggestion, + ) -> Callable[ + [SupervisorIssueRepairFlow, dict[str, str] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler for a suggestion.""" async def _async_step( diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index ba060caa43a..1a386d3b271 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -131,7 +131,7 @@ async def async_remove_config_entry_device( def refresh_system( - func: Callable[Concatenate[_HiveEntityT, _P], Awaitable[Any]] + func: Callable[Concatenate[_HiveEntityT, _P], Awaitable[Any]], ) -> Callable[Concatenate[_HiveEntityT, _P], Coroutine[Any, Any, None]]: """Force update all entities after state change.""" diff --git a/homeassistant/components/holiday/__init__.py b/homeassistant/components/holiday/__init__.py new file mode 100644 index 00000000000..224b1b01294 --- /dev/null +++ b/homeassistant/components/holiday/__init__.py @@ -0,0 +1,20 @@ +"""The Holiday integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Holiday from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py new file mode 100644 index 00000000000..bb9a332cb73 --- /dev/null +++ b/homeassistant/components/holiday/calendar.py @@ -0,0 +1,134 @@ +"""Holiday Calendar.""" +from __future__ import annotations + +from datetime import datetime + +from holidays import HolidayBase, country_holidays + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util + +from .const import CONF_PROVINCE, DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Holiday Calendar config entry.""" + country: str = config_entry.data[CONF_COUNTRY] + province: str | None = config_entry.data.get(CONF_PROVINCE) + language = hass.config.language + + obj_holidays = country_holidays( + country, + subdiv=province, + years={dt_util.now().year, dt_util.now().year + 1}, + language=language, + ) + if language == "en": + for lang in obj_holidays.supported_languages: + if lang.startswith("en"): + obj_holidays = country_holidays( + country, + subdiv=province, + years={dt_util.now().year, dt_util.now().year + 1}, + language=lang, + ) + language = lang + break + + async_add_entities( + [ + HolidayCalendarEntity( + config_entry.title, + country, + province, + language, + obj_holidays, + config_entry.entry_id, + ) + ], + True, + ) + + +class HolidayCalendarEntity(CalendarEntity): + """Representation of a Holiday Calendar element.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + name: str, + country: str, + province: str | None, + language: str, + obj_holidays: HolidayBase, + unique_id: str, + ) -> None: + """Initialize HolidayCalendarEntity.""" + self._country = country + self._province = province + self._location = name + self._language = language + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + entry_type=DeviceEntryType.SERVICE, + name=name, + ) + self._obj_holidays = obj_holidays + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + next_holiday = None + for holiday_date, holiday_name in sorted( + self._obj_holidays.items(), key=lambda x: x[0] + ): + if holiday_date >= dt_util.now().date(): + next_holiday = (holiday_date, holiday_name) + break + + if next_holiday is None: + return None + + return CalendarEvent( + summary=next_holiday[1], + start=next_holiday[0], + end=next_holiday[0], + location=self._location, + ) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + obj_holidays = country_holidays( + self._country, + subdiv=self._province, + years=list({start_date.year, end_date.year}), + language=self._language, + ) + + event_list: list[CalendarEvent] = [] + + for holiday_date, holiday_name in obj_holidays.items(): + if start_date.date() <= holiday_date <= end_date.date(): + event = CalendarEvent( + summary=holiday_name, + start=holiday_date, + end=holiday_date, + location=self._location, + ) + event_list.append(event) + + return event_list diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py new file mode 100644 index 00000000000..33268de92b6 --- /dev/null +++ b/homeassistant/components/holiday/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for Holiday integration.""" +from __future__ import annotations + +from typing import Any + +from babel import Locale, UnknownLocaleError +from holidays import list_supported_countries +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_COUNTRY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + CountrySelector, + CountrySelectorConfig, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_PROVINCE, DOMAIN + +SUPPORTED_COUNTRIES = list_supported_countries(include_aliases=False) + + +class HolidayConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Holiday.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} + + 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.data = user_input + + selected_country = user_input[CONF_COUNTRY] + + if SUPPORTED_COUNTRIES[selected_country]: + return await self.async_step_province() + + self._async_abort_entries_match({CONF_COUNTRY: user_input[CONF_COUNTRY]}) + + try: + locale = Locale(self.hass.config.language.replace("-", "_")) + except UnknownLocaleError: + # Default to (US) English if language not recognized by babel + # Mainly an issue with English flavors such as "en-GB" + locale = Locale("en") + title = locale.territories[selected_country] + return self.async_create_entry(title=title, data=user_input) + + user_schema = vol.Schema( + { + vol.Optional( + CONF_COUNTRY, default=self.hass.config.country + ): CountrySelector( + CountrySelectorConfig( + countries=list(SUPPORTED_COUNTRIES), + ) + ), + } + ) + + return self.async_show_form(step_id="user", data_schema=user_schema) + + async def async_step_province( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the province step.""" + if user_input is not None: + combined_input: dict[str, Any] = {**self.data, **user_input} + + country = combined_input[CONF_COUNTRY] + province = combined_input.get(CONF_PROVINCE) + + self._async_abort_entries_match( + { + CONF_COUNTRY: country, + CONF_PROVINCE: province, + } + ) + + try: + locale = Locale(self.hass.config.language.replace("-", "_")) + except UnknownLocaleError: + # Default to (US) English if language not recognized by babel + # Mainly an issue with English flavors such as "en-GB" + locale = Locale("en") + province_str = f", {province}" if province else "" + name = f"{locale.territories[country]}{province_str}" + + return self.async_create_entry(title=name, data=combined_input) + + province_schema = vol.Schema( + { + vol.Optional(CONF_PROVINCE): SelectSelector( + SelectSelectorConfig( + options=SUPPORTED_COUNTRIES[self.data[CONF_COUNTRY]], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + + return self.async_show_form(step_id="province", data_schema=province_schema) diff --git a/homeassistant/components/holiday/const.py b/homeassistant/components/holiday/const.py new file mode 100644 index 00000000000..5d2a567a488 --- /dev/null +++ b/homeassistant/components/holiday/const.py @@ -0,0 +1,6 @@ +"""Constants for the Holiday integration.""" +from typing import Final + +DOMAIN: Final = "holiday" + +CONF_PROVINCE: Final = "province" diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json new file mode 100644 index 00000000000..7417cc5cd51 --- /dev/null +++ b/homeassistant/components/holiday/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "holiday", + "name": "Holiday", + "codeowners": ["@jrieger"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/holiday", + "iot_class": "local_polling", + "requirements": ["holidays==0.39", "babel==2.13.1"] +} diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json new file mode 100644 index 00000000000..4762a48c659 --- /dev/null +++ b/homeassistant/components/holiday/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Already configured. Only a single configuration for country/province combination possible." + }, + "step": { + "user": { + "data": { + "country": "Country" + } + }, + "province": { + "data": { + "province": "Province" + } + } + } + } +} diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 3308083f22f..9abfefc996f 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -30,11 +30,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import ( - config_per_platform, - config_validation as cv, - entity_platform, -) +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback, EntityPlatform from homeassistant.helpers.service import ( async_extract_entity_ids, @@ -208,7 +204,7 @@ async def async_setup_platform( await platform.async_reset() # Extract only the config for the Home Assistant platform, ignore the rest. - for p_type, p_config in config_per_platform(conf, SCENE_DOMAIN): + for p_type, p_config in conf_util.config_per_platform(conf, SCENE_DOMAIN): if p_type != HA_DOMAIN: continue diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 6981bdfe685..862ac12cefb 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -12,6 +12,14 @@ "title": "The configured currency is no longer in use", "description": "The currency {currency} is no longer in use, please reconfigure the currency configuration." }, + "legacy_templates_false": { + "title": "`legacy_templates` config key is being removed", + "description": "Nothing will change with your templates.\n\nRemove the `legacy_templates` key from the `homeassistant` configuration in your configuration.yaml file and restart Home Assistant to fix this issue." + }, + "legacy_templates_true": { + "title": "The support for legacy templates is being removed", + "description": "Please do the following steps:\n- Adopt your configuration to support template rendering to native python types.\n- Remove the `legacy_templates` key from the `homeassistant` configuration in your configuration.yaml file.\n- Restart Home Assistant to fix this issue." + }, "python_version": { "title": "Support for Python {current_python_version} is being removed", "description": "Support for running Home Assistant in the current used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." diff --git a/homeassistant/components/homeassistant/triggers/homeassistant.py b/homeassistant/components/homeassistant/triggers/homeassistant.py index 51686e54c55..84aafb44808 100644 --- a/homeassistant/components/homeassistant/triggers/homeassistant.py +++ b/homeassistant/components/homeassistant/triggers/homeassistant.py @@ -1,8 +1,8 @@ """Offer Home Assistant core automation rules.""" import voluptuous as vol -from homeassistant.const import CONF_EVENT, CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.const import CONF_EVENT, CONF_PLATFORM +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType @@ -30,24 +30,17 @@ async def async_attach_trigger( job = HassJob(action, f"homeassistant trigger {trigger_info}") if event == EVENT_SHUTDOWN: - - @callback - def hass_shutdown(event): - """Execute when Home Assistant is shutting down.""" - hass.async_run_hass_job( - job, - { - "trigger": { - **trigger_data, - "platform": "homeassistant", - "event": event, - "description": "Home Assistant stopping", - } - }, - event.context, - ) - - return hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hass_shutdown) + return hass.async_add_shutdown_job( + job, + { + "trigger": { + **trigger_data, + "platform": "homeassistant", + "event": event, + "description": "Home Assistant stopping", + } + }, + ) # Automation are enabled while hass is starting up, fire right away # Check state because a config reload shouldn't trigger it. diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 0920530524d..cd90c4acf60 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -152,7 +152,7 @@ _HOMEKIT_CONFIG_UPDATE_TIME = ( def _has_all_unique_names_and_ports( - bridges: list[dict[str, Any]] + bridges: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Validate that each homekit bridge configured has a unique name.""" names = [bridge[CONF_NAME] for bridge in bridges] diff --git a/homeassistant/components/homekit_controller/button.py b/homeassistant/components/homekit_controller/button.py index ff61c632be9..1c16b2c6483 100644 --- a/homeassistant/components/homekit_controller/button.py +++ b/homeassistant/components/homekit_controller/button.py @@ -28,7 +28,7 @@ from .entity import CharacteristicEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class HomeKitButtonEntityDescription(ButtonEntityDescription): """Describes Homekit button.""" diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 088747d39ff..08444555aca 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -257,6 +257,11 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) updated_ip_port = { "AccessoryIP": discovery_info.host, + "AccessoryIPs": [ + str(ip_addr) + for ip_addr in discovery_info.ip_addresses + if not ip_addr.is_link_local and not ip_addr.is_unspecified + ], "AccessoryPort": discovery_info.port, } # If the device is already paired and known to us we should monitor c# diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 91fd199e17c..edb81c14a72 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==3.0.9"], + "requirements": ["aiohomekit==3.1.1"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/select.py b/homeassistant/components/homekit_controller/select.py index 09bb57923c6..e6eae1c51ca 100644 --- a/homeassistant/components/homekit_controller/select.py +++ b/homeassistant/components/homekit_controller/select.py @@ -19,14 +19,14 @@ from .connection import HKDevice from .entity import CharacteristicEntity -@dataclass +@dataclass(frozen=True) class HomeKitSelectEntityDescriptionRequired: """Required fields for HomeKitSelectEntityDescription.""" choices: dict[str, IntEnum] -@dataclass +@dataclass(frozen=True) class HomeKitSelectEntityDescription( SelectEntityDescription, HomeKitSelectEntityDescriptionRequired ): diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 2d30de24650..eb5b99e126d 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -46,7 +46,7 @@ from .entity import CharacteristicEntity, HomeKitEntity from .utils import folded_name -@dataclass +@dataclass(frozen=True) class HomeKitSensorEntityDescription(SensorEntityDescription): """Describes Homekit sensor.""" diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index 15a7aca4a5d..2ae19152b93 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -30,7 +30,7 @@ ATTR_IS_CONFIGURED = "is_configured" ATTR_REMAINING_DURATION = "remaining_duration" -@dataclass +@dataclass(frozen=True) class DeclarativeSwitchEntityDescription(SwitchEntityDescription): """Describes Homekit button.""" diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index b24b49da965..bf425fe5c41 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -12,13 +12,12 @@ from voluptuous import Required, Schema from homeassistant.components import onboarding, zeroconf from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.const import CONF_IP_ADDRESS, CONF_PATH from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError from .const import ( CONF_API_ENABLED, - CONF_PATH, CONF_PRODUCT_NAME, CONF_PRODUCT_TYPE, CONF_SERIAL, diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index d4692ee8bf0..f1a1bee2568 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -17,8 +17,6 @@ LOGGER = logging.getLogger(__package__) # Platform config. CONF_API_ENABLED = "api_enabled" CONF_DATA = "data" -CONF_DEVICE = "device" -CONF_PATH = "path" CONF_PRODUCT_NAME = "product_name" CONF_PRODUCT_TYPE = "product_type" CONF_SERIAL = "serial" diff --git a/homeassistant/components/homewizard/helpers.py b/homeassistant/components/homewizard/helpers.py index 4f12a4f9726..4c3ae76a327 100644 --- a/homeassistant/components/homewizard/helpers.py +++ b/homeassistant/components/homewizard/helpers.py @@ -16,7 +16,7 @@ _P = ParamSpec("_P") def homewizard_exception_handler( - func: Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, Any]] + func: Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_HomeWizardEntityT, _P], Coroutine[Any, Any, None]]: """Decorate HomeWizard Energy calls to handle HomeWizardEnergy exceptions. diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index 58e0b02a06c..ced870d7072 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -6,6 +6,7 @@ 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.color import brightness_to_value, value_to_brightness from .const import DOMAIN from .coordinator import HWEnergyDeviceUpdateCoordinator @@ -45,7 +46,9 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): @homewizard_exception_handler async def async_set_native_value(self, value: float) -> None: """Set a new value.""" - await self.coordinator.api.state_set(brightness=int(value * (255 / 100))) + await self.coordinator.api.state_set( + brightness=value_to_brightness((0, 100), value) + ) await self.coordinator.async_refresh() @property @@ -61,4 +64,4 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): or (brightness := self.coordinator.data.state.brightness) is None ): return None - return round(brightness * (100 / 255)) + return brightness_to_value((0, 100), brightness) diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py index d980e66e0e4..12655dbbc39 100644 --- a/homeassistant/components/homewizard/sensor.py +++ b/homeassistant/components/homewizard/sensor.py @@ -35,7 +35,7 @@ from .entity import HomeWizardEntity PARALLEL_UPDATES = 1 -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class HomeWizardSensorEntityDescription(SensorEntityDescription): """Class describing HomeWizard sensor entities.""" diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py index 3f854aad320..fea4d7018bf 100644 --- a/homeassistant/components/homewizard/switch.py +++ b/homeassistant/components/homewizard/switch.py @@ -23,7 +23,7 @@ from .entity import HomeWizardEntity from .helpers import homewizard_exception_handler -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class HomeWizardSwitchEntityDescription(SwitchEntityDescription): """Class describing HomeWizard switch entities.""" diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index f76c78d52d2..2f06dd1cfbe 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -118,23 +118,17 @@ def remove_stale_devices( device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - all_device_ids: set = set() - for device in devices.values(): - all_device_ids.add(device.deviceid) + all_device_ids = {device.deviceid for device in devices.values()} for device_entry in device_entries: device_id: str | None = None - remove = True for identifier in device_entry.identifiers: - if identifier[0] != DOMAIN: - remove = False - continue + if identifier[0] == DOMAIN: + device_id = identifier[1] + break - device_id = identifier[1] - break - - if remove and (device_id is None or device_id not in all_device_ids): + if device_id is None or device_id not in all_device_ids: # If device_id is None an invalid device entry was found for this config entry. # If the device_id is not in existing device ids it's a stale device entry. # Remove config entry from this device entry in either case. diff --git a/homeassistant/components/honeywell/sensor.py b/homeassistant/components/honeywell/sensor.py index 9542648b996..0841b7df1cc 100644 --- a/homeassistant/components/honeywell/sensor.py +++ b/homeassistant/components/honeywell/sensor.py @@ -36,7 +36,7 @@ def _get_temperature_sensor_unit(device: Device) -> str: return UnitOfTemperature.FAHRENHEIT -@dataclass +@dataclass(frozen=True) class HoneywellSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -44,7 +44,7 @@ class HoneywellSensorEntityDescriptionMixin: unit_fn: Callable[[Device], Any] -@dataclass +@dataclass(frozen=True) class HoneywellSensorEntityDescription( SensorEntityDescription, HoneywellSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 449f00fb335..6bb0c154540 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -520,7 +520,9 @@ class HomeAssistantHTTP: # pylint: disable-next=protected-access self.app._router.freeze = lambda: None # type: ignore[method-assign] - self.runner = web.AppRunner(self.app, handler_cancellation=True) + self.runner = web.AppRunner( + self.app, handler_cancellation=True, shutdown_timeout=10 + ) await self.runner.setup() self.site = HomeAssistantTCPSite( diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index c56dd6c343b..62569495ba7 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -84,7 +84,7 @@ async def ban_middleware( def log_invalid_auth( - func: Callable[Concatenate[_HassViewT, Request, _P], Awaitable[Response]] + func: Callable[Concatenate[_HassViewT, Request, _P], Awaitable[Response]], ) -> Callable[Concatenate[_HassViewT, Request, _P], Coroutine[Any, Any, Response]]: """Decorate function to handle invalid auth or failed login attempts.""" diff --git a/homeassistant/components/http/decorators.py b/homeassistant/components/http/decorators.py index ce5b1b18c06..4d8ac5c2df5 100644 --- a/homeassistant/components/http/decorators.py +++ b/homeassistant/components/http/decorators.py @@ -45,7 +45,7 @@ def require_admin( """Home Assistant API decorator to require user to be an admin.""" def decorator_require_admin( - func: _FuncType[_HomeAssistantViewT, _P] + func: _FuncType[_HomeAssistantViewT, _P], ) -> _FuncType[_HomeAssistantViewT, _P]: """Wrap the provided with_admin function.""" diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index c68ecd79d5f..399cbf70ad7 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -9,6 +9,6 @@ "requirements": [ "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.1.1" + "aiohttp-zlib-ng==0.1.3" ] } diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 1ab4ef5bd6f..7fe359d6486 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -1,7 +1,7 @@ """Static file handling for HTTP component.""" from __future__ import annotations -from collections.abc import Mapping, MutableMapping +from collections.abc import Mapping import mimetypes from pathlib import Path from typing import Final @@ -10,7 +10,7 @@ from aiohttp import hdrs from aiohttp.web import FileResponse, Request, StreamResponse from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound from aiohttp.web_urldispatcher import StaticResource -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU from homeassistant.core import HomeAssistant @@ -19,9 +19,7 @@ from .const import KEY_HASS CACHE_TIME: Final = 31 * 86400 # = 1 month CACHE_HEADER = f"public, max-age={CACHE_TIME}" CACHE_HEADERS: Mapping[str, str] = {hdrs.CACHE_CONTROL: CACHE_HEADER} -PATH_CACHE: MutableMapping[ - tuple[str, Path, bool], tuple[Path | None, str | None] -] = LRU(512) +PATH_CACHE: LRU[tuple[str, Path, bool], tuple[Path | None, str | None]] = LRU(512) def _get_file_path(rel_url: str, directory: Path, follow_symlinks: bool) -> Path | None: diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index 6d03b874a64..5c0931b97ca 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -29,7 +29,6 @@ class HomeAssistantTCPSite(web.BaseSite): host: None | str | list[str], port: int, *, - shutdown_timeout: float = 10.0, ssl_context: SSLContext | None = None, backlog: int = 128, reuse_address: bool | None = None, @@ -38,7 +37,6 @@ class HomeAssistantTCPSite(web.BaseSite): """Initialize HomeAssistantTCPSite.""" super().__init__( runner, - shutdown_timeout=shutdown_timeout, ssl_context=ssl_context, backlog=backlog, ) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index d8c939e5c3a..42a1e066ac7 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -135,6 +135,7 @@ PLATFORMS = [ Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH, + Platform.SELECT, ] @@ -615,17 +616,18 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -@dataclass class HuaweiLteBaseEntity(Entity): """Huawei LTE entity base class.""" - router: Router - - _available: bool = field(default=True, init=False) - _unsub_handlers: list[Callable] = field(default_factory=list, init=False) - _attr_has_entity_name: bool = field(default=True, init=False) + _available = True + _attr_has_entity_name = True _attr_should_poll = False + def __init__(self, router: Router) -> None: + """Initialize.""" + self.router = router + self._unsub_handlers: list[Callable] = [] + @property def _device_unique_id(self) -> str: """Return unique ID for entity within a router.""" diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index bf63422ae3a..7f709b02dc2 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -1,7 +1,6 @@ """Support for Huawei LTE binary sensors.""" from __future__ import annotations -from dataclasses import dataclass, field import logging from typing import Any @@ -48,15 +47,14 @@ async def async_setup_entry( async_add_entities(entities, True) -@dataclass class HuaweiLteBaseBinarySensor(HuaweiLteBaseEntityWithDevice, BinarySensorEntity): """Huawei LTE binary sensor device base class.""" _attr_entity_registry_enabled_default = False - key: str = field(init=False) - item: str = field(init=False) - _raw_state: str | None = field(default=None, init=False) + key: str + item: str + _raw_state: str | None = None @property def _device_unique_id(self) -> str: @@ -100,17 +98,14 @@ CONNECTION_STATE_ATTRIBUTES = { } -@dataclass class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE mobile connection binary sensor.""" - _attr_translation_key: str = field(default="mobile_connection", init=False) + _attr_translation_key = "mobile_connection" _attr_entity_registry_enabled_default = True - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_MONITORING_STATUS - self.item = "ConnectionStatus" + key = KEY_MONITORING_STATUS + item = "ConnectionStatus" @property def is_on(self) -> bool: @@ -165,52 +160,40 @@ class HuaweiLteBaseWifiStatusBinarySensor(HuaweiLteBaseBinarySensor): return "mdi:wifi" if self.is_on else "mdi:wifi-off" -@dataclass class HuaweiLteWifiStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE WiFi status binary sensor.""" - _attr_translation_key: str = field(default="wifi_status", init=False) + _attr_translation_key: str = "wifi_status" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_MONITORING_STATUS - self.item = "WifiStatus" + key = KEY_MONITORING_STATUS + item = "WifiStatus" -@dataclass class HuaweiLteWifi24ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE 2.4GHz WiFi status binary sensor.""" - _attr_translation_key: str = field(default="24ghz_wifi_status", init=False) + _attr_translation_key: str = "24ghz_wifi_status" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_WLAN_WIFI_FEATURE_SWITCH - self.item = "wifi24g_switch_enable" + key = KEY_WLAN_WIFI_FEATURE_SWITCH + item = "wifi24g_switch_enable" -@dataclass class HuaweiLteWifi5ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE 5GHz WiFi status binary sensor.""" - _attr_translation_key: str = field(default="5ghz_wifi_status", init=False) + _attr_translation_key: str = "5ghz_wifi_status" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_WLAN_WIFI_FEATURE_SWITCH - self.item = "wifi5g_enabled" + key = KEY_WLAN_WIFI_FEATURE_SWITCH + item = "wifi5g_enabled" -@dataclass class HuaweiLteSmsStorageFullBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE SMS storage full binary sensor.""" - _attr_translation_key: str = field(default="sms_storage_full", init=False) + _attr_translation_key: str = "sms_storage_full" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_MONITORING_CHECK_NOTIFICATIONS - self.item = "SmsStorageFull" + key = KEY_MONITORING_CHECK_NOTIFICATIONS + item = "SmsStorageFull" @property def is_on(self) -> bool: diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 665c96e4888..fd1b9850054 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -1,7 +1,6 @@ """Support for device tracking of Huawei LTE routers.""" from __future__ import annotations -from dataclasses import dataclass, field import logging import re from typing import Any, cast @@ -173,16 +172,18 @@ def _better_snakecase(text: str) -> str: return cast(str, snakecase(text)) -@dataclass class HuaweiLteScannerEntity(HuaweiLteBaseEntity, ScannerEntity): """Huawei LTE router scanner entity.""" - _mac_address: str + _ip_address: str | None = None + _is_connected: bool = False + _hostname: str | None = None - _ip_address: str | None = field(default=None, init=False) - _is_connected: bool = field(default=False, init=False) - _hostname: str | None = field(default=None, init=False) - _extra_state_attributes: dict[str, Any] = field(default_factory=dict, init=False) + def __init__(self, router: Router, mac_address: str) -> None: + """Initialize.""" + super().__init__(router) + self._extra_state_attributes: dict[str, Any] = {} + self._mac_address = mac_address @property def name(self) -> str: diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index 4474188ea22..3b72e2216a6 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -1,7 +1,6 @@ """Support for Huawei LTE router notifications.""" from __future__ import annotations -from dataclasses import dataclass import logging import time from typing import Any @@ -34,12 +33,13 @@ async def async_get_service( return HuaweiLteSmsNotificationService(router, default_targets) -@dataclass class HuaweiLteSmsNotificationService(BaseNotificationService): """Huawei LTE router SMS notification service.""" - router: Router - default_targets: list[str] + def __init__(self, router: Router, default_targets: list[str]) -> None: + """Initialize.""" + self.router = router + self.default_targets = default_targets def send_message(self, message: str = "", **kwargs: Any) -> None: """Send message to target numbers.""" diff --git a/homeassistant/components/huawei_lte/select.py b/homeassistant/components/huawei_lte/select.py new file mode 100644 index 00000000000..f211da3c2e8 --- /dev/null +++ b/homeassistant/components/huawei_lte/select.py @@ -0,0 +1,139 @@ +"""Support for Huawei LTE selects.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from functools import partial +import logging + +from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SelectEntity, + SelectEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import UNDEFINED + +from . import HuaweiLteBaseEntityWithDevice, Router +from .const import DOMAIN, KEY_NET_NET_MODE + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class HuaweiSelectEntityMixin: + """Mixin for Huawei LTE select entities, to ensure required fields are set.""" + + setter_fn: Callable[[str], None] + + +@dataclass(frozen=True) +class HuaweiSelectEntityDescription(SelectEntityDescription, HuaweiSelectEntityMixin): + """Class describing Huawei LTE select entities.""" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up from config entry.""" + router = hass.data[DOMAIN].routers[config_entry.entry_id] + selects: list[Entity] = [] + + desc = HuaweiSelectEntityDescription( + key=KEY_NET_NET_MODE, + entity_category=EntityCategory.CONFIG, + icon="mdi:transmission-tower", + name="Preferred network mode", + translation_key="preferred_network_mode", + options=[ + NetworkModeEnum.MODE_AUTO.value, + NetworkModeEnum.MODE_4G_3G_AUTO.value, + NetworkModeEnum.MODE_4G_2G_AUTO.value, + NetworkModeEnum.MODE_4G_ONLY.value, + NetworkModeEnum.MODE_3G_2G_AUTO.value, + NetworkModeEnum.MODE_3G_ONLY.value, + NetworkModeEnum.MODE_2G_ONLY.value, + ], + setter_fn=partial( + router.client.net.set_net_mode, + LTEBandEnum.ALL, + NetworkBandEnum.ALL, + ), + ) + selects.append( + HuaweiLteSelectEntity( + router, + entity_description=desc, + key=desc.key, + item="NetworkMode", + ) + ) + + async_add_entities(selects, True) + + +class HuaweiLteSelectEntity(HuaweiLteBaseEntityWithDevice, SelectEntity): + """Huawei LTE select entity.""" + + entity_description: HuaweiSelectEntityDescription + _raw_state: str | None = None + + def __init__( + self, + router: Router, + entity_description: HuaweiSelectEntityDescription, + key: str, + item: str, + ) -> None: + """Initialize.""" + super().__init__(router) + self.entity_description = entity_description + self.key = key + self.item = item + + name = None + if self.entity_description.name != UNDEFINED: + name = self.entity_description.name + self._attr_name = name or self.item + + def select_option(self, option: str) -> None: + """Change the selected option.""" + self.entity_description.setter_fn(option) + + @property + def current_option(self) -> str | None: + """Return current option.""" + return self._raw_state + + @property + def _device_unique_id(self) -> str: + return f"{self.key}.{self.item}" + + async def async_added_to_hass(self) -> None: + """Subscribe to needed data on add.""" + await super().async_added_to_hass() + self.router.subscriptions[self.key].append(f"{SELECT_DOMAIN}/{self.item}") + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from needed data on remove.""" + await super().async_will_remove_from_hass() + self.router.subscriptions[self.key].remove(f"{SELECT_DOMAIN}/{self.item}") + + async def async_update(self) -> None: + """Update state.""" + try: + value = self.router.data[self.key][self.item] + except KeyError: + _LOGGER.debug("%s[%s] not in data", self.key, self.item) + self._available = False + return + self._available = True + self._raw_state = str(value) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index ca3734bb305..d7fb5565969 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from bisect import bisect from collections.abc import Callable, Sequence -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime, timedelta import logging import re @@ -29,7 +29,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import HuaweiLteBaseEntityWithDevice +from . import HuaweiLteBaseEntityWithDevice, Router from .const import ( DOMAIN, KEY_DEVICE_INFORMATION, @@ -111,7 +111,7 @@ class HuaweiSensorGroup: exclude: re.Pattern[str] | None = None -@dataclass +@dataclass(frozen=True) class HuaweiSensorEntityDescription(SensorEntityDescription): """Class describing Huawei LTE sensor entities.""" @@ -688,17 +688,26 @@ async def async_setup_entry( async_add_entities(sensors, True) -@dataclass class HuaweiLteSensor(HuaweiLteBaseEntityWithDevice, SensorEntity): """Huawei LTE sensor entity.""" - key: str - item: str entity_description: HuaweiSensorEntityDescription + _state: StateType = None + _unit: str | None = None + _last_reset: datetime | None = None - _state: StateType = field(default=None, init=False) - _unit: str | None = field(default=None, init=False) - _last_reset: datetime | None = field(default=None, init=False) + def __init__( + self, + router: Router, + key: str, + item: str, + entity_description: HuaweiSensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(router) + self.key = key + self.item = item + self.entity_description = entity_description async def async_added_to_hass(self) -> None: """Subscribe to needed data on add.""" diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 754f192e57e..225146799a3 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -286,6 +286,20 @@ "name": "SMS messages (SIM)" } }, + "select": { + "preferred_network_mode": { + "name": "Preferred network mode", + "state": { + "00": "4G/3G/2G auto", + "0302": "4G/3G auto", + "0301": "4G/2G auto", + "03": "4G only", + "0201": "3G/2G auto", + "02": "3G only", + "01": "2G only" + } + } + }, "switch": { "mobile_data": { "name": "Mobile data" diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py index eb9370a946f..651099be42d 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -1,7 +1,6 @@ """Support for Huawei LTE switches.""" from __future__ import annotations -from dataclasses import dataclass, field import logging from typing import Any @@ -43,17 +42,14 @@ async def async_setup_entry( async_add_entities(switches, True) -@dataclass class HuaweiLteBaseSwitch(HuaweiLteBaseEntityWithDevice, SwitchEntity): """Huawei LTE switch device base class.""" - key: str = field(init=False) - item: str = field(init=False) + key: str + item: str - _attr_device_class: SwitchDeviceClass = field( - default=SwitchDeviceClass.SWITCH, init=False - ) - _raw_state: str | None = field(default=None, init=False) + _attr_device_class: SwitchDeviceClass = SwitchDeviceClass.SWITCH + _raw_state: str | None = None def _turn(self, state: bool) -> None: raise NotImplementedError @@ -88,16 +84,13 @@ class HuaweiLteBaseSwitch(HuaweiLteBaseEntityWithDevice, SwitchEntity): self._raw_state = str(value) -@dataclass class HuaweiLteMobileDataSwitch(HuaweiLteBaseSwitch): """Huawei LTE mobile data switch device.""" - _attr_translation_key: str = field(default="mobile_data", init=False) + _attr_translation_key: str = "mobile_data" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_DIALUP_MOBILE_DATASWITCH - self.item = "dataswitch" + key = KEY_DIALUP_MOBILE_DATASWITCH + item = "dataswitch" @property def _device_unique_id(self) -> str: @@ -120,16 +113,13 @@ class HuaweiLteMobileDataSwitch(HuaweiLteBaseSwitch): return "mdi:signal" if self.is_on else "mdi:signal-off" -@dataclass class HuaweiLteWifiGuestNetworkSwitch(HuaweiLteBaseSwitch): """Huawei LTE WiFi guest network switch device.""" - _attr_translation_key: str = field(default="wifi_guest_network", init=False) + _attr_translation_key: str = "wifi_guest_network" - def __post_init__(self) -> None: - """Initialize identifiers.""" - self.key = KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH - self.item = "WifiEnable" + key = KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH + item = "WifiEnable" @property def _device_unique_id(self) -> str: diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 04bd63e5b1f..c5ceebec3f8 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -13,11 +13,11 @@ from aiohue.errors import AiohueException, BridgeBusy from homeassistant import core from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_HOST, Platform +from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_HOST, Platform from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import aiohttp_client -from .const import CONF_API_VERSION, DOMAIN +from .const import DOMAIN from .v1.sensor_base import SensorManager from .v2.device import async_setup_devices from .v2.hue_event import async_setup_hue_events diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 0957329abb0..7262dea39ef 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import ( @@ -26,7 +26,6 @@ from homeassistant.helpers import ( from .const import ( CONF_ALLOW_HUE_GROUPS, CONF_ALLOW_UNREACHABLE, - CONF_API_VERSION, CONF_IGNORE_AVAILABILITY, DEFAULT_ALLOW_HUE_GROUPS, DEFAULT_ALLOW_UNREACHABLE, diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py index 38c2587bc1a..5033aaa427a 100644 --- a/homeassistant/components/hue/const.py +++ b/homeassistant/components/hue/const.py @@ -7,7 +7,6 @@ from aiohue.v2.models.relative_rotary import ( DOMAIN = "hue" -CONF_API_VERSION = "api_version" CONF_IGNORE_AVAILABILITY = "ignore_availability" CONF_SUBTYPE = "subtype" diff --git a/homeassistant/components/hue/migration.py b/homeassistant/components/hue/migration.py index 035da145cc0..f4bf6366d61 100644 --- a/homeassistant/components/hue/migration.py +++ b/homeassistant/components/hue/migration.py @@ -11,7 +11,7 @@ from homeassistant import core from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_USERNAME +from homeassistant.const import CONF_API_KEY, CONF_API_VERSION, CONF_HOST, CONF_USERNAME from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import ( async_entries_for_config_entry as devices_for_config_entries, @@ -23,7 +23,7 @@ from homeassistant.helpers.entity_registry import ( async_get as async_get_entity_registry, ) -from .const import CONF_API_VERSION, DOMAIN +from .const import DOMAIN LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index b82b2b34a4b..82cf51d3b26 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -48,7 +48,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class HuisbaasjeSensorEntityDescription(SensorEntityDescription): """Class describing Airly sensor entities.""" diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 47745c53394..75d4f0fd225 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -1,11 +1,11 @@ """Provides functionality to interact with humidifier devices.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import StrEnum +from functools import partial import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -23,12 +23,19 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from .const import ( # noqa: F401 + _DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER, + _DEPRECATED_DEVICE_CLASS_HUMIDIFIER, + _DEPRECATED_SUPPORT_MODES, ATTR_ACTION, ATTR_AVAILABLE_MODES, ATTR_CURRENT_HUMIDITY, @@ -37,19 +44,22 @@ from .const import ( # noqa: F401 ATTR_MIN_HUMIDITY, DEFAULT_MAX_HUMIDITY, DEFAULT_MIN_HUMIDITY, - DEVICE_CLASS_DEHUMIDIFIER, - DEVICE_CLASS_HUMIDIFIER, DOMAIN, MODE_AUTO, MODE_AWAY, MODE_NORMAL, SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, - SUPPORT_MODES, HumidifierAction, HumidifierEntityFeature, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) @@ -71,6 +81,12 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(HumidifierDeviceClass)) # use the HumidifierDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in HumidifierDeviceClass] +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + # mypy: disallow-any-generics @@ -124,14 +140,26 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class HumidifierEntityDescription(ToggleEntityDescription): +class HumidifierEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes humidifier entities.""" device_class: HumidifierDeviceClass | None = None -class HumidifierEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "action", + "current_humidity", + "target_humidity", + "mode", + "available_modes", + "min_humidity", + "max_humidity", + "supported_features", +} + + +class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for humidifier entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -157,12 +185,12 @@ class HumidifierEntity(ToggleEntity): ATTR_MAX_HUMIDITY: self.max_humidity, } - if self.supported_features & HumidifierEntityFeature.MODES: + if HumidifierEntityFeature.MODES in self.supported_features_compat: data[ATTR_AVAILABLE_MODES] = self.available_modes return data - @property + @cached_property def device_class(self) -> HumidifierDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -186,27 +214,27 @@ class HumidifierEntity(ToggleEntity): if self.target_humidity is not None: data[ATTR_HUMIDITY] = self.target_humidity - if self.supported_features & HumidifierEntityFeature.MODES: + if HumidifierEntityFeature.MODES in self.supported_features: data[ATTR_MODE] = self.mode return data - @property + @cached_property def action(self) -> HumidifierAction | None: """Return the current action.""" return self._attr_action - @property + @cached_property def current_humidity(self) -> int | None: """Return the current humidity.""" return self._attr_current_humidity - @property + @cached_property def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" return self._attr_target_humidity - @property + @cached_property def mode(self) -> str | None: """Return the current mode, e.g., home, auto, baby. @@ -214,7 +242,7 @@ class HumidifierEntity(ToggleEntity): """ return self._attr_mode - @property + @cached_property def available_modes(self) -> list[str] | None: """Return a list of available modes. @@ -238,17 +266,30 @@ class HumidifierEntity(ToggleEntity): """Set new mode.""" await self.hass.async_add_executor_job(self.set_mode, mode) - @property + @cached_property def min_humidity(self) -> int: """Return the minimum humidity.""" return self._attr_min_humidity - @property + @cached_property def max_humidity(self) -> int: """Return the maximum humidity.""" return self._attr_max_humidity - @property + @cached_property def supported_features(self) -> HumidifierEntityFeature: """Return the list of supported features.""" return self._attr_supported_features + + @property + def supported_features_compat(self) -> HumidifierEntityFeature: + """Return the supported features as HumidifierEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = HumidifierEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features diff --git a/homeassistant/components/humidifier/const.py b/homeassistant/components/humidifier/const.py index 09c0714cbeb..a1a219ddce7 100644 --- a/homeassistant/components/humidifier/const.py +++ b/homeassistant/components/humidifier/const.py @@ -1,5 +1,13 @@ """Provides the constants needed for component.""" from enum import IntFlag, StrEnum +from functools import partial + +from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) MODE_NORMAL = "normal" MODE_ECO = "eco" @@ -35,8 +43,12 @@ DOMAIN = "humidifier" # DEVICE_CLASS_* below are deprecated as of 2021.12 # use the HumidifierDeviceClass enum instead. -DEVICE_CLASS_HUMIDIFIER = "humidifier" -DEVICE_CLASS_DEHUMIDIFIER = "dehumidifier" +_DEPRECATED_DEVICE_CLASS_HUMIDIFIER = DeprecatedConstant( + "humidifier", "HumidifierDeviceClass.HUMIDIFIER", "2025.1" +) +_DEPRECATED_DEVICE_CLASS_DEHUMIDIFIER = DeprecatedConstant( + "dehumidifier", "HumidifierDeviceClass.DEHUMIDIFIER", "2025.1" +) SERVICE_SET_MODE = "set_mode" SERVICE_SET_HUMIDITY = "set_humidity" @@ -50,4 +62,10 @@ class HumidifierEntityFeature(IntFlag): # The SUPPORT_MODES constant is deprecated as of Home Assistant 2022.5. # Please use the HumidifierEntityFeature enum instead. -SUPPORT_MODES = 1 +_DEPRECATED_SUPPORT_MODES = DeprecatedConstantEnum( + HumidifierEntityFeature.MODES, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) diff --git a/homeassistant/components/humidifier/significant_change.py b/homeassistant/components/humidifier/significant_change.py new file mode 100644 index 00000000000..cc279a9fa41 --- /dev/null +++ b/homeassistant/components/humidifier/significant_change.py @@ -0,0 +1,61 @@ +"""Helper to test significant Humidifier state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ATTR_ACTION, ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MODE + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_ACTION, + ATTR_CURRENT_HUMIDITY, + ATTR_HUMIDITY, + ATTR_MODE, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name in [ATTR_ACTION, ATTR_MODE]: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 1.0): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index 2e0bc1c413a..cb6bc72954f 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -23,14 +23,14 @@ from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData -@dataclass +@dataclass(frozen=True) class PowerviewButtonDescriptionMixin: """Mixin to describe a Button entity.""" press_action: Callable[[BaseShade], Any] -@dataclass +@dataclass(frozen=True) class PowerviewButtonDescription( ButtonEntityDescription, PowerviewButtonDescriptionMixin ): diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py index 151b3a58011..65fe61851df 100644 --- a/homeassistant/components/hunterdouglas_powerview/select.py +++ b/homeassistant/components/hunterdouglas_powerview/select.py @@ -27,7 +27,7 @@ from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData -@dataclass +@dataclass(frozen=True) class PowerviewSelectDescriptionMixin: """Mixin to describe a select entity.""" @@ -35,7 +35,7 @@ class PowerviewSelectDescriptionMixin: select_fn: Callable[[BaseShade, str], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class PowerviewSelectDescription( SelectEntityDescription, PowerviewSelectDescriptionMixin ): diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 330e5dddfa5..8e16d53ae09 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -33,7 +33,7 @@ from .entity import ShadeEntity from .model import PowerviewDeviceInfo, PowerviewEntryData -@dataclass +@dataclass(frozen=True) class PowerviewSensorDescriptionMixin: """Mixin to describe a Sensor entity.""" @@ -42,7 +42,7 @@ class PowerviewSensorDescriptionMixin: create_sensor_fn: Callable[[BaseShade], bool] -@dataclass +@dataclass(frozen=True) class PowerviewSensorDescription( SensorEntityDescription, PowerviewSensorDescriptionMixin ): diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index a8efb663c90..b30a9b375b0 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -30,6 +30,8 @@ ATTR_DIRECTION = "direction" ATTR_TYPE = "type" ATTR_DELAY = "delay" ATTR_NEXT = "next" +ATTR_CANCELLED = "cancelled" +ATTR_EXTRA = "extra" PARALLEL_UPDATES = 0 BERLIN_TIME_ZONE = get_time_zone("Europe/Berlin") @@ -142,6 +144,8 @@ class HVVDepartureSensor(SensorEntity): departure = data["departures"][0] line = departure["line"] delay = departure.get("delay", 0) + cancelled = departure.get("cancelled", False) + extra = departure.get("extra", False) self._attr_available = True self._attr_native_value = ( departure_time @@ -157,6 +161,8 @@ class HVVDepartureSensor(SensorEntity): ATTR_TYPE: line["type"]["shortInfo"], ATTR_ID: line["id"], ATTR_DELAY: delay, + ATTR_CANCELLED: cancelled, + ATTR_EXTRA: extra, } ) @@ -164,6 +170,8 @@ class HVVDepartureSensor(SensorEntity): for departure in data["departures"]: line = departure["line"] delay = departure.get("delay", 0) + cancelled = departure.get("cancelled", False) + extra = departure.get("extra", False) departures.append( { ATTR_DEPARTURE: departure_time @@ -175,6 +183,8 @@ class HVVDepartureSensor(SensorEntity): ATTR_TYPE: line["type"]["shortInfo"], ATTR_ID: line["id"], ATTR_DELAY: delay, + ATTR_CANCELLED: cancelled, + ATTR_EXTRA: extra, } ) self._attr_extra_state_attributes[ATTR_NEXT] = departures diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 65355a1829f..0b12fcb3ddb 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -70,7 +70,7 @@ async def async_setup_entry( config_entry.entry_id ] entities = [] - for controller in coordinator.data.controllers: + for controller in coordinator.data.controllers.values(): entities.append( HydrawiseBinarySensor(coordinator, BINARY_SENSOR_STATUS, controller) ) diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index dc53d847b1f..724b6ee6203 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -9,7 +9,7 @@ ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] CONF_WATERING_TIME = "watering_minutes" DOMAIN = "hydrawise" -DEFAULT_WATERING_TIME = 15 +DEFAULT_WATERING_TIME = timedelta(minutes=15) MANUFACTURER = "Hydrawise" diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py index 412108f859f..71922928651 100644 --- a/homeassistant/components/hydrawise/coordinator.py +++ b/homeassistant/components/hydrawise/coordinator.py @@ -2,10 +2,11 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta from pydrawise import HydrawiseBase -from pydrawise.schema import User +from pydrawise.schema import Controller, User, Zone from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -13,9 +14,20 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER -class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[User]): +@dataclass +class HydrawiseData: + """Container for data fetched from the Hydrawise API.""" + + user: User + controllers: dict[int, Controller] + zones: dict[int, Zone] + + +class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]): """The Hydrawise Data Update Coordinator.""" + api: HydrawiseBase + def __init__( self, hass: HomeAssistant, api: HydrawiseBase, scan_interval: timedelta ) -> None: @@ -23,6 +35,13 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[User]): super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval) self.api = api - async def _async_update_data(self) -> User: + async def _async_update_data(self) -> HydrawiseData: """Fetch the latest data from Hydrawise.""" - return await self.api.get_user() + user = await self.api.get_user() + controllers = {} + zones = {} + for controller in user.controllers: + controllers[controller.id] = controller + for zone in controller.zones: + zones[zone.id] = zone + return HydrawiseData(user=user, controllers=controllers, zones=zones) diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index c707690ce95..887de6ba648 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -48,5 +48,8 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): @callback def _handle_coordinator_update(self) -> None: """Get the latest data and updates the state.""" + self.controller = self.coordinator.data.controllers[self.controller.id] + if self.zone: + self.zone = self.coordinator.data.zones[self.zone.id] self._update_attrs() super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 79a318f778f..f8490ad00e1 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -76,7 +76,7 @@ async def async_setup_entry( ] async_add_entities( HydrawiseSensor(coordinator, description, controller, zone) - for controller in coordinator.data.controllers + for controller in coordinator.data.controllers.values() for zone in controller.zones for description in SENSOR_TYPES ) diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 5dd79d4a13e..8a92a56975a 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -52,9 +52,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_MONITORED_CONDITIONS, default=SWITCH_KEYS): vol.All( cv.ensure_list, [vol.In(SWITCH_KEYS)] ), - vol.Optional(CONF_WATERING_TIME, default=DEFAULT_WATERING_TIME): vol.All( - vol.In(ALLOWED_WATERING_TIME) - ), + vol.Optional( + CONF_WATERING_TIME, default=DEFAULT_WATERING_TIME.total_seconds() // 60 + ): vol.All(vol.In(ALLOWED_WATERING_TIME)), } ) @@ -81,7 +81,7 @@ async def async_setup_entry( ] async_add_entities( HydrawiseSwitch(coordinator, description, controller, zone) - for controller in coordinator.data.controllers + for controller in coordinator.data.controllers.values() for zone in controller.zones for description in SWITCH_TYPES ) @@ -96,7 +96,7 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): """Turn the device on.""" if self.entity_description.key == "manual_watering": await self.coordinator.api.start_zone( - self.zone, custom_run_duration=DEFAULT_WATERING_TIME + self.zone, custom_run_duration=DEFAULT_WATERING_TIME.total_seconds() ) elif self.entity_description.key == "auto_watering": await self.coordinator.api.resume_zone(self.zone) diff --git a/homeassistant/components/iammeter/__init__.py b/homeassistant/components/iammeter/__init__.py index b53cc35197c..46b8aaca3e7 100644 --- a/homeassistant/components/iammeter/__init__.py +++ b/homeassistant/components/iammeter/__init__.py @@ -1 +1 @@ -"""Support for IamMeter Devices.""" +"""Iammeter integration.""" diff --git a/homeassistant/components/iammeter/const.py b/homeassistant/components/iammeter/const.py new file mode 100644 index 00000000000..c2d122c9e32 --- /dev/null +++ b/homeassistant/components/iammeter/const.py @@ -0,0 +1,11 @@ +"""Constants for the Iammeter integration.""" +from __future__ import annotations + +DOMAIN = "iammeter" + +# Default config for iammeter. +DEFAULT_IP = "192.168.2.15" +DEFAULT_NAME = "IamMeter" +DEVICE_3080 = "WEM3080" +DEVICE_3080T = "WEM3080T" +DEVICE_TYPES = [DEVICE_3080, DEVICE_3080T] diff --git a/homeassistant/components/iammeter/manifest.json b/homeassistant/components/iammeter/manifest.json index 191dbdedb98..f1ebecab00d 100644 --- a/homeassistant/components/iammeter/manifest.json +++ b/homeassistant/components/iammeter/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/iammeter", "iot_class": "local_polling", "loggers": ["iammeter"], - "requirements": ["iammeter==0.1.7"] + "requirements": ["iammeter==0.2.1"] } diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py index ca468200370..df3a873b6c1 100644 --- a/homeassistant/components/iammeter/sensor.py +++ b/homeassistant/components/iammeter/sensor.py @@ -2,26 +2,44 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from asyncio import timeout +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta import logging -from iammeter import real_time_api -from iammeter.power_meter import IamMeterError +from iammeter.client import IamMeter import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + PERCENTAGE, + Platform, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import debounce +from homeassistant.helpers import debounce, entity_registry as er, update_coordinator import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEVICE_3080, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -40,6 +58,51 @@ SCAN_INTERVAL = timedelta(seconds=30) PLATFORM_TIMEOUT = 8 +def _migrate_to_new_unique_id( + hass: HomeAssistant, model: str, serial_number: str +) -> None: + """Migrate old unique ids to new unique ids.""" + ent_reg = er.async_get(hass) + name_list = [ + "Voltage", + "Current", + "Power", + "ImportEnergy", + "ExportGrid", + "Frequency", + "PF", + ] + phase_list = ["A", "B", "C", "NET"] + id_phase_range = 1 if model == DEVICE_3080 else 4 + id_name_range = 5 if model == DEVICE_3080 else 7 + for row in range(0, id_phase_range): + for idx in range(0, id_name_range): + old_unique_id = f"{serial_number}-{row}-{idx}" + new_unique_id = ( + f"{serial_number}_{name_list[idx]}" + if model == DEVICE_3080 + else f"{serial_number}_{name_list[idx]}_{phase_list[row]}" + ) + entity_id = ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, old_unique_id + ) + if entity_id is not None: + try: + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) + except ValueError: + _LOGGER.warning( + "Skip migration of id [%s] to [%s] because it already exists", + old_unique_id, + new_unique_id, + ) + else: + _LOGGER.debug( + "Migrating unique_id from [%s] to [%s]", + old_unique_id, + new_unique_id, + ) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -51,23 +114,24 @@ async def async_setup_platform( config_port = config[CONF_PORT] config_name = config[CONF_NAME] try: - async with asyncio.timeout(PLATFORM_TIMEOUT): - api = await real_time_api(config_host, config_port) - except (IamMeterError, asyncio.TimeoutError) as err: + api = await hass.async_add_executor_job( + IamMeter, config_host, config_port, config_name + ) + except asyncio.TimeoutError as err: _LOGGER.error("Device is not ready") raise PlatformNotReady from err async def async_update_data(): try: - async with asyncio.timeout(PLATFORM_TIMEOUT): - return await api.get_data() - except (IamMeterError, asyncio.TimeoutError) as err: + async with timeout(PLATFORM_TIMEOUT): + return await hass.async_add_executor_job(api.client.get_data) + except asyncio.TimeoutError as err: raise UpdateFailed from err coordinator = DataUpdateCoordinator( hass, _LOGGER, - name=DEFAULT_DEVICE_NAME, + name=config_name, update_method=async_update_data, update_interval=SCAN_INTERVAL, request_refresh_debouncer=debounce.Debouncer( @@ -75,46 +139,334 @@ async def async_setup_platform( ), ) await coordinator.async_refresh() - entities = [] - for sensor_name, (row, idx, unit) in api.iammeter.sensor_map().items(): - serial_number = api.iammeter.serial_number - uid = f"{serial_number}-{row}-{idx}" - entities.append(IamMeter(coordinator, uid, sensor_name, unit, config_name)) - async_add_entities(entities) + model = coordinator.data["Model"] + serial_number = coordinator.data["sn"] + _migrate_to_new_unique_id(hass, model, serial_number) + if model == DEVICE_3080: + async_add_entities( + IammeterSensor(coordinator, description) + for description in SENSOR_TYPES_3080 + ) + else: # DEVICE_3080T: + async_add_entities( + IammeterSensor(coordinator, description) + for description in SENSOR_TYPES_3080T + ) -class IamMeter(CoordinatorEntity, SensorEntity): - """Class for a sensor.""" +class IammeterSensor(update_coordinator.CoordinatorEntity, SensorEntity): + """Representation of a Sensor.""" - def __init__(self, coordinator, uid, sensor_name, unit, dev_name): - """Initialize an iammeter sensor.""" + entity_description: IammeterSensorEntityDescription + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: IammeterSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" super().__init__(coordinator) - self.uid = uid - self.sensor_name = sensor_name - self.unit = unit - self.dev_name = dev_name + self.entity_description = description + self._attr_unique_id = f"{coordinator.data['sn']}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data["sn"])}, + manufacturer="IamMeter", + name=coordinator.name, + ) @property def native_value(self): - """Return the state of the sensor.""" - return self.coordinator.data.data[self.sensor_name] + """Return the native sensor value.""" + raw_attr = self.coordinator.data.get(self.entity_description.key, None) + if self.entity_description.value: + return self.entity_description.value(raw_attr) + return raw_attr - @property - def unique_id(self): - """Return unique id.""" - return self.uid - @property - def name(self): - """Name of this iammeter attribute.""" - return f"{self.dev_name} {self.sensor_name}" +@dataclass(frozen=True) +class IammeterSensorEntityDescription(SensorEntityDescription): + """Describes Iammeter sensor entity.""" - @property - def icon(self): - """Icon for each sensor.""" - return "mdi:flash" + value: Callable[[float | int], float] | Callable[[datetime], datetime] | None = None - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self.unit + +SENSOR_TYPES_3080: tuple[IammeterSensorEntityDescription, ...] = ( + IammeterSensorEntityDescription( + key="Voltage", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Current", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IammeterSensorEntityDescription( + key="ImportEnergy", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="ExportGrid", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +) +SENSOR_TYPES_3080T: tuple[IammeterSensorEntityDescription, ...] = ( + IammeterSensorEntityDescription( + key="Voltage_A", + translation_key="voltage_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Current_A", + translation_key="current_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power_A", + translation_key="power_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IammeterSensorEntityDescription( + key="ImportEnergy_A", + translation_key="import_energy_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="ExportGrid_A", + translation_key="export_grid_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="Frequency_A", + translation_key="frequency_a", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="PF_A", + translation_key="pf_a", + icon="mdi:solar-power", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + value=lambda value: value * 100, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Voltage_B", + translation_key="voltage_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Current_B", + translation_key="current_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power_B", + translation_key="power_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IammeterSensorEntityDescription( + key="ImportEnergy_B", + translation_key="import_energy_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="ExportGrid_B", + translation_key="export_grid_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="Frequency_B", + translation_key="frequency_b", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="PF_B", + translation_key="pf_b", + icon="mdi:solar-power", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + value=lambda value: value * 100, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Voltage_C", + translation_key="voltage_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Current_C", + translation_key="current_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power_C", + translation_key="power_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), + IammeterSensorEntityDescription( + key="ImportEnergy_C", + translation_key="import_energy_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="ExportGrid_C", + translation_key="export_grid_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + IammeterSensorEntityDescription( + key="Frequency_C", + translation_key="frequency_c", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="PF_C", + translation_key="pf_c", + icon="mdi:solar-power", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + value=lambda value: value * 100, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Voltage_Net", + translation_key="voltage_net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Power_Net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="ImportEnergy_Net", + translation_key="import_energy_net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="ExportGrid_Net", + translation_key="export_grid_net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="Frequency_Net", + translation_key="frequency_net", + icon="mdi:solar-power", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + IammeterSensorEntityDescription( + key="PF_Net", + translation_key="pf_net", + icon="mdi:solar-power", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + value=lambda value: value * 100, + entity_registry_enabled_default=False, + ), +) diff --git a/homeassistant/components/iammeter/strings.json b/homeassistant/components/iammeter/strings.json new file mode 100644 index 00000000000..6d0c3797dfc --- /dev/null +++ b/homeassistant/components/iammeter/strings.json @@ -0,0 +1,69 @@ +{ + "entity": { + "sensor": { + "voltage_a": { + "name": "Voltage A" + }, + "voltage_b": { + "name": "Voltage B" + }, + "voltage_c": { + "name": "Voltage C" + }, + "current_a": { + "name": "Current A" + }, + "current_b": { + "name": "Current B" + }, + "current_c": { + "name": "Current C" + }, + "power_a": { + "name": "Power A" + }, + "power_b": { + "name": "Power B" + }, + "power_c": { + "name": "Power C" + }, + "import_energy_a": { + "name": "ImportEnergy A" + }, + "import_energy_b": { + "name": "ImportEnergy B" + }, + "import_energy_c": { + "name": "ImportEnergy C" + }, + "export_grid_a": { + "name": "ExportGrid A" + }, + "export_grid_b": { + "name": "ExportGrid B" + }, + "export_grid_c": { + "name": "ExportGrid C" + }, + "frequency_a": { + "name": "Frequency A" + }, + "frequency_b": { + "name": "Frequency B" + }, + "frequency_c": { + "name": "Frequency C" + }, + "pf_a": { + "name": "PF A" + }, + "pf_b": { + "name": "PF B" + }, + "pf_c": { + "name": "PF C" + } + } + } +} diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index fceb0d72213..062548666c4 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -185,7 +185,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def refresh_system( - func: Callable[Concatenate[_AqualinkEntityT, _P], Awaitable[Any]] + func: Callable[Concatenate[_AqualinkEntityT, _P], Awaitable[Any]], ) -> Callable[Concatenate[_AqualinkEntityT, _P], Coroutine[Any, Any, None]]: """Force update all entities after state change.""" diff --git a/homeassistant/components/ibeacon/sensor.py b/homeassistant/components/ibeacon/sensor.py index b3895ce23b4..3ce145fc3b9 100644 --- a/homeassistant/components/ibeacon/sensor.py +++ b/homeassistant/components/ibeacon/sensor.py @@ -23,14 +23,14 @@ from .coordinator import IBeaconCoordinator from .entity import IBeaconEntity -@dataclass +@dataclass(frozen=True) class IBeaconRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[iBeaconAdvertisement], str | int | None] -@dataclass +@dataclass(frozen=True) class IBeaconSensorEntityDescription(SensorEntityDescription, IBeaconRequiredKeysMixin): """Describes iBeacon sensor entity.""" diff --git a/homeassistant/components/idasen_desk/button.py b/homeassistant/components/idasen_desk/button.py index 6cae9a42895..d11738c6bcd 100644 --- a/homeassistant/components/idasen_desk/button.py +++ b/homeassistant/components/idasen_desk/button.py @@ -21,7 +21,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class IdasenDeskButtonDescriptionMixin: """Mixin to describe a IdasenDesk button entity.""" @@ -30,7 +30,7 @@ class IdasenDeskButtonDescriptionMixin: ] -@dataclass +@dataclass(frozen=True) class IdasenDeskButtonDescription( ButtonEntityDescription, IdasenDeskButtonDescriptionMixin ): diff --git a/homeassistant/components/idasen_desk/sensor.py b/homeassistant/components/idasen_desk/sensor.py index b67dec0f579..f4e04ea762b 100644 --- a/homeassistant/components/idasen_desk/sensor.py +++ b/homeassistant/components/idasen_desk/sensor.py @@ -22,14 +22,14 @@ from . import DeskData, IdasenDeskCoordinator from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class IdasenDeskSensorDescriptionMixin: """Required values for IdasenDesk sensors.""" value_fn: Callable[[IdasenDeskCoordinator], float | None] -@dataclass +@dataclass(frozen=True) class IdasenDeskSensorDescription( SensorEntityDescription, IdasenDeskSensorDescriptionMixin, diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index e5c40affe0f..4c5a9df8810 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta import logging from random import SystemRandom -from typing import Final, final +from typing import TYPE_CHECKING, Final, final from aiohttp import hdrs, web import httpx @@ -30,6 +30,12 @@ from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import DOMAIN, IMAGE_TIMEOUT # noqa: F401 +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL: Final = timedelta(seconds=30) @@ -44,8 +50,7 @@ _RND: Final = SystemRandom() GET_IMAGE_TIMEOUT: Final = 10 -@dataclass -class ImageEntityDescription(EntityDescription): +class ImageEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes image entities.""" @@ -123,7 +128,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -class ImageEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "content_type", + "image_last_updated", + "image_url", +} + + +class ImageEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """The base class for image entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -144,7 +156,7 @@ class ImageEntity(Entity): self.access_tokens: collections.deque = collections.deque([], 2) self.async_update_token() - @property + @cached_property def content_type(self) -> str: """Image content type.""" return self._attr_content_type @@ -156,12 +168,12 @@ class ImageEntity(Entity): return self._attr_entity_picture return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1]) - @property + @cached_property def image_last_updated(self) -> datetime | None: - """The time when the image was last updated.""" + """Time the image was last updated.""" return self._attr_image_last_updated - @property + @cached_property def image_url(self) -> str | None | UndefinedType: """Return URL of image.""" return self._attr_image_url diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 7640925451a..bb356c09367 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass from datetime import timedelta from enum import StrEnum import logging @@ -25,7 +24,6 @@ from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType -from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -120,8 +118,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -@dataclass -class ImageProcessingEntityDescription(EntityDescription): +class ImageProcessingEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes sensor entities.""" device_class: ImageProcessingDeviceClass | None = None @@ -235,9 +232,7 @@ class ImageProcessingFaceEntity(ImageProcessingEntity): def process_faces(self, faces: list[FaceInformation], total: int) -> None: """Send event with detected faces and store data.""" - run_callback_threadsafe( - self.hass.loop, self.async_process_faces, faces, total - ).result() + self.hass.loop.call_soon_threadsafe(self.async_process_faces, faces, total) @callback def async_process_faces(self, faces: list[FaceInformation], total: int) -> None: @@ -267,7 +262,7 @@ class ImageProcessingFaceEntity(ImageProcessingEntity): continue face.update({ATTR_ENTITY_ID: self.entity_id}) - self.hass.async_add_job(self.hass.bus.async_fire, EVENT_DETECT_FACE, face) + self.hass.bus.async_fire(EVENT_DETECT_FACE, face) # type: ignore[arg-type] # Update entity store self.faces = faces diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 34286ce49fa..5591980b2f1 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -62,7 +62,7 @@ async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" ssl_cipher_list: str = data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT) if data.get(CONF_VERIFY_SSL, True): - ssl_context = client_context(ssl_cipher_list=ssl_cipher_list) + ssl_context = client_context(ssl_cipher_list=SSLCipherList(ssl_cipher_list)) else: ssl_context = create_no_verify_ssl_context() client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT], ssl_context=ssl_context) diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index 9e8cabbe253..535d8b61653 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -23,7 +23,7 @@ INCOMFORT_PRESSURE = "CV Pressure" INCOMFORT_TAP_TEMP = "Tap Temp" -@dataclass +@dataclass(frozen=True) class IncomfortSensorEntityDescription(SensorEntityDescription): """Describes Incomfort sensor entity.""" diff --git a/homeassistant/components/indianamichiganpower/__init__.py b/homeassistant/components/indianamichiganpower/__init__.py new file mode 100644 index 00000000000..06870a50604 --- /dev/null +++ b/homeassistant/components/indianamichiganpower/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Indiana Michigan Power.""" diff --git a/homeassistant/components/indianamichiganpower/manifest.json b/homeassistant/components/indianamichiganpower/manifest.json new file mode 100644 index 00000000000..ee6ff0402c7 --- /dev/null +++ b/homeassistant/components/indianamichiganpower/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "indianamichiganpower", + "name": "Indiana Michigan Power", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py index f879ab37e8f..24c80dc1d54 100644 --- a/homeassistant/components/influxdb/__init__.py +++ b/homeassistant/components/influxdb/__init__.py @@ -22,9 +22,17 @@ import voluptuous as vol from homeassistant.const import ( CONF_DOMAIN, CONF_ENTITY_ID, + CONF_HOST, + CONF_PASSWORD, + CONF_PATH, + CONF_PORT, + CONF_SSL, CONF_TIMEOUT, + CONF_TOKEN, CONF_UNIT_OF_MEASUREMENT, CONF_URL, + CONF_USERNAME, + CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, STATE_UNAVAILABLE, @@ -56,23 +64,15 @@ from .const import ( CONF_COMPONENT_CONFIG_GLOB, CONF_DB_NAME, CONF_DEFAULT_MEASUREMENT, - CONF_HOST, CONF_IGNORE_ATTRIBUTES, CONF_MEASUREMENT_ATTR, CONF_ORG, CONF_OVERRIDE_MEASUREMENT, - CONF_PASSWORD, - CONF_PATH, - CONF_PORT, CONF_PRECISION, CONF_RETRY_COUNT, - CONF_SSL, CONF_SSL_CA_CERT, CONF_TAGS, CONF_TAGS_ATTRIBUTES, - CONF_TOKEN, - CONF_USERNAME, - CONF_VERIFY_SSL, CONNECTION_ERROR, DEFAULT_API_VERSION, DEFAULT_HOST_V2, diff --git a/homeassistant/components/influxdb/const.py b/homeassistant/components/influxdb/const.py index f3b0b66df54..5ffd70fe992 100644 --- a/homeassistant/components/influxdb/const.py +++ b/homeassistant/components/influxdb/const.py @@ -33,7 +33,6 @@ CONF_IGNORE_ATTRIBUTES = "ignore_attributes" CONF_PRECISION = "precision" CONF_SSL_CA_CERT = "ssl_ca_cert" -CONF_LANGUAGE = "language" CONF_QUERIES = "queries" CONF_QUERIES_FLUX = "queries_flux" CONF_GROUP_FUNCTION = "group_function" diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py index b4f643e876f..a46ec581207 100644 --- a/homeassistant/components/influxdb/sensor.py +++ b/homeassistant/components/influxdb/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( CONF_API_VERSION, + CONF_LANGUAGE, CONF_NAME, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -35,7 +36,6 @@ from .const import ( CONF_FIELD, CONF_GROUP_FUNCTION, CONF_IMPORTS, - CONF_LANGUAGE, CONF_MEASUREMENT_NAME, CONF_QUERIES, CONF_QUERIES_FLUX, diff --git a/homeassistant/components/intellifire/binary_sensor.py b/homeassistant/components/intellifire/binary_sensor.py index b19c592a5cf..503b97f183d 100644 --- a/homeassistant/components/intellifire/binary_sensor.py +++ b/homeassistant/components/intellifire/binary_sensor.py @@ -21,14 +21,14 @@ from .const import DOMAIN from .entity import IntellifireEntity -@dataclass +@dataclass(frozen=True) class IntellifireBinarySensorRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[IntellifirePollData], bool] -@dataclass +@dataclass(frozen=True) class IntellifireBinarySensorEntityDescription( BinarySensorEntityDescription, IntellifireBinarySensorRequiredKeysMixin ): diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py index 3911efeb5b9..7c376eeec4c 100644 --- a/homeassistant/components/intellifire/fan.py +++ b/homeassistant/components/intellifire/fan.py @@ -26,7 +26,7 @@ from .coordinator import IntellifireDataUpdateCoordinator from .entity import IntellifireEntity -@dataclass +@dataclass(frozen=True) class IntellifireFanRequiredKeysMixin: """Required keys for fan entity.""" @@ -35,7 +35,7 @@ class IntellifireFanRequiredKeysMixin: speed_range: tuple[int, int] -@dataclass +@dataclass(frozen=True) class IntellifireFanEntityDescription( FanEntityDescription, IntellifireFanRequiredKeysMixin ): diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py index 05994919296..a807735ed79 100644 --- a/homeassistant/components/intellifire/light.py +++ b/homeassistant/components/intellifire/light.py @@ -22,7 +22,7 @@ from .coordinator import IntellifireDataUpdateCoordinator from .entity import IntellifireEntity -@dataclass +@dataclass(frozen=True) class IntellifireLightRequiredKeysMixin: """Required keys for fan entity.""" @@ -30,7 +30,7 @@ class IntellifireLightRequiredKeysMixin: value_fn: Callable[[IntellifirePollData], bool] -@dataclass +@dataclass(frozen=True) class IntellifireLightEntityDescription( LightEntityDescription, IntellifireLightRequiredKeysMixin ): diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py index bc42b977f12..c974378fb71 100644 --- a/homeassistant/components/intellifire/sensor.py +++ b/homeassistant/components/intellifire/sensor.py @@ -24,14 +24,14 @@ from .coordinator import IntellifireDataUpdateCoordinator from .entity import IntellifireEntity -@dataclass +@dataclass(frozen=True) class IntellifireSensorRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[IntellifirePollData], int | str | datetime | None] -@dataclass +@dataclass(frozen=True) class IntellifireSensorEntityDescription( SensorEntityDescription, IntellifireSensorRequiredKeysMixin, diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py index 1af4d8c0e91..03e3a2be0a2 100644 --- a/homeassistant/components/intellifire/switch.py +++ b/homeassistant/components/intellifire/switch.py @@ -18,7 +18,7 @@ from .coordinator import IntellifireDataUpdateCoordinator from .entity import IntellifireEntity -@dataclass() +@dataclass(frozen=True) class IntellifireSwitchRequiredKeysMixin: """Mixin for required keys.""" @@ -27,7 +27,7 @@ class IntellifireSwitchRequiredKeysMixin: value_fn: Callable[[IntellifirePollData], bool] -@dataclass +@dataclass(frozen=True) class IntellifireSwitchEntityDescription( SwitchEntityDescription, IntellifireSwitchRequiredKeysMixin ): diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index 2f42edb4bc1..de6091e3638 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -52,9 +52,9 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> iOSNotificationService | None: """Get the iOS notification service.""" - if "notify.ios" not in hass.config.components: + if "ios.notify" not in hass.config.components: # Need this to enable requirements checking in the app. - hass.config.components.add("notify.ios") + hass.config.components.add("ios.notify") if not ios.devices_with_push(hass): return None diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 27ecc1574e3..4faac347c40 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -36,7 +36,7 @@ from .coordinator import IotawattUpdater _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class IotaWattSensorEntityDescription(SensorEntityDescription): """Class describing IotaWatt sensor entities.""" @@ -45,14 +45,14 @@ class IotaWattSensorEntityDescription(SensorEntityDescription): ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { "Amps": IotaWattSensorEntityDescription( - "Amps", + key="Amps", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, ), "Hz": IotaWattSensorEntityDescription( - "Hz", + key="Hz", native_unit_of_measurement=UnitOfFrequency.HERTZ, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.FREQUENCY, @@ -60,7 +60,7 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { entity_registry_enabled_default=False, ), "PF": IotaWattSensorEntityDescription( - "PF", + key="PF", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER_FACTOR, @@ -68,40 +68,40 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = { entity_registry_enabled_default=False, ), "Watts": IotaWattSensorEntityDescription( - "Watts", + key="Watts", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, ), "WattHours": IotaWattSensorEntityDescription( - "WattHours", + key="WattHours", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, device_class=SensorDeviceClass.ENERGY, ), "VA": IotaWattSensorEntityDescription( - "VA", + key="VA", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.APPARENT_POWER, entity_registry_enabled_default=False, ), "VAR": IotaWattSensorEntityDescription( - "VAR", + key="VAR", native_unit_of_measurement=VOLT_AMPERE_REACTIVE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash", entity_registry_enabled_default=False, ), "VARh": IotaWattSensorEntityDescription( - "VARh", + key="VARh", native_unit_of_measurement=VOLT_AMPERE_REACTIVE_HOURS, state_class=SensorStateClass.MEASUREMENT, icon="mdi:flash", entity_registry_enabled_default=False, ), "Volts": IotaWattSensorEntityDescription( - "Volts", + key="Volts", native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLTAGE, @@ -125,7 +125,7 @@ async def async_setup_entry( created.add(key) data = coordinator.data["sensors"][key] description = ENTITY_DESCRIPTION_KEY_MAP.get( - data.getUnit(), IotaWattSensorEntityDescription("base_sensor") + data.getUnit(), IotaWattSensorEntityDescription(key="base_sensor") ) return IotaWattSensor( diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py index cb0620ceca0..99e994069a5 100644 --- a/homeassistant/components/ipma/sensor.py +++ b/homeassistant/components/ipma/sensor.py @@ -23,18 +23,13 @@ from .entity import IPMADevice _LOGGER = logging.getLogger(__name__) -@dataclass -class IPMARequiredKeysMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, kw_only=True) +class IPMASensorEntityDescription(SensorEntityDescription): + """Describes a IPMA sensor entity.""" value_fn: Callable[[Location, IPMA_API], Coroutine[Location, IPMA_API, int | None]] -@dataclass -class IPMASensorEntityDescription(SensorEntityDescription, IPMARequiredKeysMixin): - """Describes IPMA sensor entity.""" - - async def async_retrieve_rcm(location: Location, api: IPMA_API) -> int | None: """Retrieve RCM.""" fire_risk: RCM = await location.fire_risk(api) diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py index a2cb5cd34dc..d1acbe9bd96 100644 --- a/homeassistant/components/ipp/sensor.py +++ b/homeassistant/components/ipp/sensor.py @@ -37,14 +37,14 @@ from .coordinator import IPPDataUpdateCoordinator from .entity import IPPEntity -@dataclass +@dataclass(frozen=True) class IPPSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[Printer], StateType | datetime] -@dataclass +@dataclass(frozen=True) class IPPSensorEntityDescription( SensorEntityDescription, IPPSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index def58d60201..aa5528cc06a 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client.disable_request_retries() async def async_get_data_from_api( - api_coro: Callable[..., Coroutine[Any, Any, dict[str, Any]]] + api_coro: Callable[..., Coroutine[Any, Any, dict[str, Any]]], ) -> dict[str, Any]: """Get data from a particular API coroutine.""" try: diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index 2925ca527bc..86ef3ce271f 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -1,8 +1,10 @@ """The islamic_prayer_times component.""" from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -13,6 +15,8 @@ PLATFORMS = [Platform.SENSOR] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Islamic Prayer Component.""" @@ -41,6 +45,34 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + new = {**config_entry.data} + if config_entry.minor_version < 2: + lat = hass.config.latitude + lon = hass.config.longitude + new = { + CONF_LATITUDE: lat, + CONF_LONGITUDE: lon, + } + unique_id = f"{lat}-{lon}" + config_entry.version = 1 + config_entry.minor_version = 2 + hass.config_entries.async_update_entry( + config_entry, data=new, unique_id=unique_id + ) + + _LOGGER.debug("Migration to version %s successful", config_entry.version) + + return True + + async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Islamic Prayer entry from config_entry.""" if unload_ok := await hass.config_entries.async_unload_platforms( diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 333b6b36c87..2fde06f576d 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -3,16 +3,22 @@ from __future__ import annotations from typing import Any +from prayer_times_calculator import InvalidResponseError, PrayerTimesCalculator +from requests.exceptions import ConnectionError as ConnError import voluptuous as vol from homeassistant import config_entries -from homeassistant.core import callback +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.selector import ( + LocationSelector, SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TextSelector, ) +import homeassistant.util.dt as dt_util from .const import ( CALC_METHODS, @@ -32,10 +38,31 @@ from .const import ( ) +async def async_validate_location( + hass: HomeAssistant, lon: float, lat: float +) -> dict[str, str]: + """Check if the selected location is valid.""" + errors = {} + calc = PrayerTimesCalculator( + latitude=lat, + longitude=lon, + calculation_method=DEFAULT_CALC_METHOD, + date=str(dt_util.now().date()), + ) + try: + await hass.async_add_executor_job(calc.fetch_prayer_times) + except InvalidResponseError: + errors["base"] = "invalid_location" + except ConnError: + errors["base"] = "conn_error" + return errors + + class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle the Islamic Prayer config flow.""" VERSION = 1 + MINOR_VERSION = 2 @staticmethod @callback @@ -49,13 +76,39 @@ class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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") + errors = {} - if user_input is None: - return self.async_show_form(step_id="user") + if user_input is not None: + lat: float = user_input[CONF_LOCATION][CONF_LATITUDE] + lon: float = user_input[CONF_LOCATION][CONF_LONGITUDE] + await self.async_set_unique_id(f"{lat}-{lon}") + self._abort_if_unique_id_configured() - return self.async_create_entry(title=NAME, data=user_input) + if not (errors := await async_validate_location(self.hass, lat, lon)): + return self.async_create_entry( + title=user_input[CONF_NAME], + data={ + CONF_LATITUDE: lat, + CONF_LONGITUDE: lon, + }, + ) + + home_location = { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional(CONF_NAME, default=NAME): TextSelector(), + vol.Required( + CONF_LOCATION, default=home_location + ): LocationSelector(), + } + ), + errors=errors, + ) class IslamicPrayerOptionsFlowHandler(config_entries.OptionsFlow): diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index aedaf43411a..be138e7b45b 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -9,6 +9,7 @@ from prayer_times_calculator import PrayerTimesCalculator, exceptions from requests.exceptions import ConnectionError as ConnError from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_call_later, async_track_point_in_time from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -36,12 +37,14 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim def __init__(self, hass: HomeAssistant) -> None: """Initialize the Islamic Prayer client.""" - self.event_unsub: CALLBACK_TYPE | None = None super().__init__( hass, _LOGGER, name=DOMAIN, ) + self.latitude = self.config_entry.data[CONF_LATITUDE] + self.longitude = self.config_entry.data[CONF_LONGITUDE] + self.event_unsub: CALLBACK_TYPE | None = None @property def calc_method(self) -> str: @@ -70,8 +73,8 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim def get_new_prayer_times(self) -> dict[str, Any]: """Fetch prayer times for today.""" calc = PrayerTimesCalculator( - latitude=self.hass.config.latitude, - longitude=self.hass.config.longitude, + latitude=self.latitude, + longitude=self.longitude, calculation_method=self.calc_method, latitudeAdjustmentMethod=self.lat_adj_method, midnightMode=self.midnight_mode, diff --git a/homeassistant/components/islamic_prayer_times/strings.json b/homeassistant/components/islamic_prayer_times/strings.json index e07a38ca107..87703e5fdae 100644 --- a/homeassistant/components/islamic_prayer_times/strings.json +++ b/homeassistant/components/islamic_prayer_times/strings.json @@ -8,7 +8,7 @@ } }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, "options": { diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py index e451ef882b4..ebdef4146e0 100644 --- a/homeassistant/components/isy994/fan.py +++ b/homeassistant/components/isy994/fan.py @@ -13,10 +13,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import _LOGGER, DOMAIN from .entity import ISYNodeEntity, ISYProgramEntity diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index b39bad14d45..ec7d78edd53 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -141,7 +141,7 @@ }, "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.", + "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 reloading the integration or restarting Home Assistant, and ONLY IF you have not already customized the name within Home Assistant.", "fields": { "name": { "name": "New Name", diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index de64741ba3a..da208dcc79c 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -31,7 +31,7 @@ from .entity import ISYAuxControlEntity, ISYNodeEntity, ISYProgramEntity from .models import IsyData -@dataclass +@dataclass(frozen=True) class ISYSwitchEntityDescription(SwitchEntityDescription): """Describes IST switch.""" diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py index cd0e9ab21a2..0f1afd30e9b 100644 --- a/homeassistant/components/jellyfin/sensor.py +++ b/homeassistant/components/jellyfin/sensor.py @@ -16,14 +16,14 @@ from .entity import JellyfinEntity from .models import JellyfinData -@dataclass +@dataclass(frozen=True) class JellyfinSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[JellyfinDataT], StateType] -@dataclass +@dataclass(frozen=True) class JellyfinSensorEntityDescription( SensorEntityDescription, JellyfinSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index e127d78229f..638d54d6159 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -22,14 +22,14 @@ import homeassistant.util.dt as dt_util from . import DOMAIN -@dataclass +@dataclass(frozen=True) class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription): """Binary Sensor description mixin class for Jewish Calendar.""" is_on: Callable[..., bool] = lambda _: False -@dataclass +@dataclass(frozen=True) class JewishCalendarBinarySensorEntityDescription( JewishCalendarBinarySensorMixIns, BinarySensorEntityDescription ): diff --git a/homeassistant/components/juicenet/number.py b/homeassistant/components/juicenet/number.py index e78f6189baf..fd2535c5bf3 100644 --- a/homeassistant/components/juicenet/number.py +++ b/homeassistant/components/juicenet/number.py @@ -19,14 +19,14 @@ from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR from .entity import JuiceNetDevice -@dataclass +@dataclass(frozen=True) class JuiceNetNumberEntityDescriptionMixin: """Mixin for required keys.""" setter_key: str -@dataclass +@dataclass(frozen=True) class JuiceNetNumberEntityDescription( NumberEntityDescription, JuiceNetNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/justnimbus/sensor.py b/homeassistant/components/justnimbus/sensor.py index 156fa37e982..cb428fa5eea 100644 --- a/homeassistant/components/justnimbus/sensor.py +++ b/homeassistant/components/justnimbus/sensor.py @@ -29,14 +29,14 @@ from .const import DOMAIN, VOLUME_FLOW_RATE_LITERS_PER_MINUTE from .entity import JustNimbusEntity -@dataclass +@dataclass(frozen=True) class JustNimbusEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[JustNimbusCoordinator], Any] -@dataclass +@dataclass(frozen=True) class JustNimbusEntityDescription( SensorEntityDescription, JustNimbusEntityDescriptionMixin ): diff --git a/homeassistant/components/kaiterra/sensor.py b/homeassistant/components/kaiterra/sensor.py index ab487aa1a25..bb780aab619 100644 --- a/homeassistant/components/kaiterra/sensor.py +++ b/homeassistant/components/kaiterra/sensor.py @@ -17,14 +17,14 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DISPATCHER_KAITERRA, DOMAIN -@dataclass +@dataclass(frozen=True) class KaiterraSensorRequiredKeysMixin: """Mixin for required keys.""" suffix: str -@dataclass +@dataclass(frozen=True) class KaiterraSensorEntityDescription( SensorEntityDescription, KaiterraSensorRequiredKeysMixin ): diff --git a/homeassistant/components/kaleidescape/sensor.py b/homeassistant/components/kaleidescape/sensor.py index 183036f3973..ba9eaca1e95 100644 --- a/homeassistant/components/kaleidescape/sensor.py +++ b/homeassistant/components/kaleidescape/sensor.py @@ -22,14 +22,14 @@ if TYPE_CHECKING: from homeassistant.helpers.typing import StateType -@dataclass +@dataclass(frozen=True) class BaseEntityDescriptionMixin: """Mixin for required descriptor keys.""" value_fn: Callable[[KaleidescapeDevice], StateType] -@dataclass +@dataclass(frozen=True) class KaleidescapeSensorEntityDescription( SensorEntityDescription, BaseEntityDescriptionMixin ): diff --git a/homeassistant/components/kef/manifest.json b/homeassistant/components/kef/manifest.json index e2aec2fbcae..29e398994f4 100644 --- a/homeassistant/components/kef/manifest.json +++ b/homeassistant/components/kef/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/kef", "iot_class": "local_polling", "loggers": ["aiokef", "tenacity"], - "requirements": ["aiokef==0.2.16", "getmac==0.8.2"] + "requirements": ["aiokef==0.2.16", "getmac==0.9.4"] } diff --git a/homeassistant/components/kentuckypower/__init__.py b/homeassistant/components/kentuckypower/__init__.py new file mode 100644 index 00000000000..cc4ab179682 --- /dev/null +++ b/homeassistant/components/kentuckypower/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Kentucky Power.""" diff --git a/homeassistant/components/kentuckypower/manifest.json b/homeassistant/components/kentuckypower/manifest.json new file mode 100644 index 00000000000..300cfd7dd9d --- /dev/null +++ b/homeassistant/components/kentuckypower/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "kentuckypower", + "name": "Kentucky Power", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index 274ced80146..94b5b51e401 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -6,18 +6,12 @@ from xknx.devices import RawValue as XknxRawValue from homeassistant import config_entries from homeassistant.components.button import ButtonEntity -from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform +from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_PAYLOAD, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_PAYLOAD, - CONF_PAYLOAD_LENGTH, - DATA_KNX_CONFIG, - DOMAIN, - KNX_ADDRESS, -) +from .const import CONF_PAYLOAD_LENGTH, DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .knx_entity import KnxEntity diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 519d5d0742d..3d1e3c62a34 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -68,7 +68,6 @@ CONF_KNX_SECURE_USER_PASSWORD: Final = "user_password" CONF_KNX_SECURE_DEVICE_AUTHENTICATION: Final = "device_authentication" -CONF_PAYLOAD: Final = "payload" CONF_PAYLOAD_LENGTH: Final = "payload_length" CONF_RESET_AFTER: Final = "reset_after" CONF_RESPOND_TO_READ: Final = "respond_to_read" diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 60db7e95a65..a22a16a6e69 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -14,10 +14,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DATA_KNX_CONFIG, DOMAIN, KNX_ADDRESS from .knx_entity import KnxEntity diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 8240fbaf3c1..c7bcd90538f 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -37,6 +37,7 @@ from homeassistant.const import ( CONF_EVENT, CONF_MODE, CONF_NAME, + CONF_PAYLOAD, CONF_TYPE, Platform, ) @@ -46,7 +47,6 @@ from homeassistant.helpers.entity import ENTITY_CATEGORIES_SCHEMA from .const import ( CONF_INVERT, CONF_KNX_EXPOSE, - CONF_PAYLOAD, CONF_PAYLOAD_LENGTH, CONF_RESET_AFTER, CONF_RESPOND_TO_READ, diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index 5baa068eaa6..2852917e021 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -9,6 +9,7 @@ from homeassistant.components.select import SelectEntity from homeassistant.const import ( CONF_ENTITY_CATEGORY, CONF_NAME, + CONF_PAYLOAD, STATE_UNAVAILABLE, STATE_UNKNOWN, Platform, @@ -19,7 +20,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_PAYLOAD, CONF_PAYLOAD_LENGTH, CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index dbfe8e9bd5e..2f09f7e8ed6 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -40,7 +40,7 @@ from .schema import SensorSchema SCAN_INTERVAL = timedelta(seconds=10) -@dataclass +@dataclass(frozen=True) class KNXSystemEntityDescription(SensorEntityDescription): """Class describing KNX system sensor entities.""" diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 32ecbbed626..89f0a992ff1 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -231,7 +231,7 @@ async def async_setup_entry( def cmd( - func: Callable[Concatenate[_KodiEntityT, _P], Awaitable[Any]] + func: Callable[Concatenate[_KodiEntityT, _P], Awaitable[Any]], ) -> Callable[Concatenate[_KodiEntityT, _P], Coroutine[Any, Any, None]]: """Catch command exceptions.""" diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py index 834057d63b8..36e1fc95eb8 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -26,7 +26,7 @@ from .helper import PlenticoreDataFormatter, SettingDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PlenticoreNumberEntityDescriptionMixin: """Define an entity description mixin for number entities.""" @@ -36,7 +36,7 @@ class PlenticoreNumberEntityDescriptionMixin: fmt_to: str -@dataclass +@dataclass(frozen=True) class PlenticoreNumberEntityDescription( NumberEntityDescription, PlenticoreNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py index 779cc24b0c4..321bc4e5d70 100644 --- a/homeassistant/components/kostal_plenticore/select.py +++ b/homeassistant/components/kostal_plenticore/select.py @@ -19,14 +19,14 @@ from .helper import Plenticore, SelectDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PlenticoreRequiredKeysMixin: """A class that describes required properties for plenticore select entities.""" module_id: str -@dataclass +@dataclass(frozen=True) class PlenticoreSelectEntityDescription( SelectEntityDescription, PlenticoreRequiredKeysMixin ): diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index ce18867511d..111d497b128 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -33,7 +33,7 @@ from .helper import PlenticoreDataFormatter, ProcessDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PlenticoreRequiredKeysMixin: """A class that describes required properties for plenticore sensor entities.""" @@ -41,7 +41,7 @@ class PlenticoreRequiredKeysMixin: formatter: str -@dataclass +@dataclass(frozen=True) class PlenticoreSensorEntityDescription( SensorEntityDescription, PlenticoreRequiredKeysMixin ): diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 554f8db2b68..509a3610884 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -20,7 +20,7 @@ from .helper import SettingDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PlenticoreRequiredKeysMixin: """A class that describes required properties for plenticore switch entities.""" @@ -32,7 +32,7 @@ class PlenticoreRequiredKeysMixin: off_label: str -@dataclass +@dataclass(frozen=True) class PlenticoreSwitchEntityDescription( SwitchEntityDescription, PlenticoreRequiredKeysMixin ): diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py index 21eb3f2e5a1..7e55da2b189 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -32,14 +32,14 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class KrakenRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[DataUpdateCoordinator[KrakenResponse], str], float | int] -@dataclass +@dataclass(frozen=True) class KrakenSensorEntityDescription(SensorEntityDescription, KrakenRequiredKeysMixin): """Describes Kraken sensor entity.""" diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index 76688af61ae..960ab0ff325 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace import logging from lacrosse_view import Sensor @@ -35,14 +35,14 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class LaCrosseSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[Sensor, str], float | int | str | None] -@dataclass +@dataclass(frozen=True) class LaCrosseSensorEntityDescription( SensorEntityDescription, LaCrosseSensorEntityDescriptionMixin ): @@ -97,7 +97,7 @@ SENSOR_DESCRIPTIONS = { key="Rain", state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, - native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), "WindHeading": LaCrosseSensorEntityDescription( @@ -130,7 +130,7 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), "WindChill": LaCrosseSensorEntityDescription( key="WindChill", @@ -138,9 +138,18 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), } +# map of API returned unit of measurement strings to their corresponding unit of measurement +UNIT_OF_MEASUREMENT_MAP = { + "degrees_celsius": UnitOfTemperature.CELSIUS, + "degrees_fahrenheit": UnitOfTemperature.FAHRENHEIT, + "inches": UnitOfPrecipitationDepth.INCHES, + "millimeters": UnitOfPrecipitationDepth.MILLIMETERS, + "kilometers_per_hour": UnitOfSpeed.KILOMETERS_PER_HOUR, + "miles_per_hour": UnitOfSpeed.MILES_PER_HOUR, +} async def async_setup_entry( @@ -171,6 +180,19 @@ async def async_setup_entry( _LOGGER.warning(message) continue + + # if the API returns a different unit of measurement from the description, update it + if sensor.data.get(field) is not None: + native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAP.get( + sensor.data[field].get("unit") + ) + + if native_unit_of_measurement is not None: + description = replace( + description, + native_unit_of_measurement=native_unit_of_measurement, + ) + sensor_list.append( LaCrosseViewSensor( coordinator=coordinator, diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 1de8c1d1717..dacbf8d2445 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -19,7 +19,7 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class LaMetricButtonEntityDescription(ButtonEntityDescription): """Class describing LaMetric button entities.""" diff --git a/homeassistant/components/lametric/helpers.py b/homeassistant/components/lametric/helpers.py index 884e6c451bc..3a3014a369e 100644 --- a/homeassistant/components/lametric/helpers.py +++ b/homeassistant/components/lametric/helpers.py @@ -19,7 +19,7 @@ _P = ParamSpec("_P") def lametric_exception_handler( - func: Callable[Concatenate[_LaMetricEntityT, _P], Coroutine[Any, Any, Any]] + func: Callable[Concatenate[_LaMetricEntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_LaMetricEntityT, _P], Coroutine[Any, Any, None]]: """Decorate LaMetric calls to handle LaMetric exceptions. diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index d8c70494264..9acdc6f1411 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -19,7 +19,7 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class LaMetricNumberEntityDescription(NumberEntityDescription): """Class describing LaMetric number entities.""" diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index f15147235ac..c7a3f55125b 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -19,7 +19,7 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class LaMetricSelectEntityDescription(SelectEntityDescription): """Class describing LaMetric select entities.""" diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index 88d461e9d4f..5ef3608d33b 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -21,7 +21,7 @@ from .coordinator import LaMetricDataUpdateCoordinator from .entity import LaMetricEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class LaMetricSensorEntityDescription(SensorEntityDescription): """Class describing LaMetric sensor entities.""" diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index ace492fe0cb..7fda3a22b8f 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -19,7 +19,7 @@ from .entity import LaMetricEntity from .helpers import lametric_exception_handler -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class LaMetricSwitchEntityDescription(SwitchEntityDescription): """Class describing LaMetric switch entities.""" diff --git a/homeassistant/components/landisgyr_heat_meter/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py index 4f7966ae90f..7d03ed2efaf 100644 --- a/homeassistant/components/landisgyr_heat_meter/config_flow.py +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -108,7 +108,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # validate and retrieve the model and device number for a unique id data = await self.hass.async_add_executor_job(heat_meter.read) - except (asyncio.TimeoutError, serial.serialutil.SerialException) as err: + except (asyncio.TimeoutError, serial.SerialException) as err: _LOGGER.warning("Failed read data from: %s. %s", port, err) raise CannotConnect(f"Error communicating with device: {err}") from err diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py index 27231dc7b92..db265449f37 100644 --- a/homeassistant/components/landisgyr_heat_meter/coordinator.py +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -33,5 +33,5 @@ class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]): try: async with asyncio.timeout(ULTRAHEAT_TIMEOUT): return await self.hass.async_add_executor_job(self.api.read) - except (FileNotFoundError, serial.serialutil.SerialException) as err: + except (FileNotFoundError, serial.SerialException) as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index d7485e88fb0..075aeb67b50 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -39,14 +39,14 @@ from . import DOMAIN _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class HeatMeterSensorEntityDescriptionMixin: """Mixin for additional Heat Meter sensor description attributes .""" value_fn: Callable[[HeatMeterResponse], StateType | datetime] -@dataclass +@dataclass(frozen=True) class HeatMeterSensorEntityDescription( SensorEntityDescription, HeatMeterSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index 5dab7da56ed..2c1934f0c16 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -31,7 +31,7 @@ from .const import DOMAIN DEFAULT_NEXT_LAUNCH_NAME = "Next launch" -@dataclass +@dataclass(frozen=True) class LaunchLibrarySensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -39,7 +39,7 @@ class LaunchLibrarySensorEntityDescriptionMixin: attributes_fn: Callable[[Launch | Event], dict[str, Any] | None] -@dataclass +@dataclass(frozen=True) class LaunchLibrarySensorEntityDescription( SensorEntityDescription, LaunchLibrarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py index 5388463316f..b1eac0a6609 100644 --- a/homeassistant/components/lawn_mower/__init__.py +++ b/homeassistant/components/lawn_mower/__init__.py @@ -1,10 +1,9 @@ """The lawn mower integration.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging -from typing import final +from typing import TYPE_CHECKING, final from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -25,6 +24,12 @@ from .const import ( LawnMowerEntityFeature, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) @@ -65,12 +70,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class LawnMowerEntityEntityDescription(EntityDescription): +class LawnMowerEntityEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes lawn mower entities.""" -class LawnMowerEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "activity", + "supported_features", +} + + +class LawnMowerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for lawn mower entities.""" entity_description: LawnMowerEntityEntityDescription @@ -85,12 +95,12 @@ class LawnMowerEntity(Entity): return None return str(activity) - @property + @cached_property def activity(self) -> LawnMowerActivity | None: """Return the current lawn mower activity.""" return self._attr_activity - @property + @cached_property def supported_features(self) -> LawnMowerEntityFeature: """Flag lawn mower features that are supported.""" return self._attr_supported_features diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index bb97658b880..e8da5b39073 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -21,7 +21,6 @@ CONNECTION = "connection" CONF_HARDWARE_SERIAL = "hardware_serial" CONF_SOFTWARE_SERIAL = "software_serial" CONF_HARDWARE_TYPE = "hardware_type" -CONF_RESOURCE = "resource" CONF_DOMAIN_DATA = "domain_data" CONF_CONNECTIONS = "connections" diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index e190b25eded..64a789f3a34 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -24,6 +24,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_PORT, + CONF_RESOURCE, CONF_SENSORS, CONF_SOURCE, CONF_SWITCHES, @@ -42,7 +43,6 @@ from .const import ( CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, CONF_OUTPUT, - CONF_RESOURCE, CONF_SCENES, CONF_SK_NUM_TRIES, CONF_SOFTWARE_SERIAL, diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 9fd407b1636..8b220f78e53 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.15.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.19.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 6ecd4ed636e..9a496dbd049 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.15.0", "led-ble==1.0.1"] + "requirements": ["bluetooth-data-tools==1.19.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index 1a2930c8051..027779f93fe 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -2,8 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from copy import deepcopy -from dataclasses import dataclass +import dataclasses from typing import Any, Generic from aiopyarr import LidarrQueue, LidarrQueueItem, LidarrRootFolder @@ -40,21 +39,23 @@ def get_modified_description( description: LidarrSensorEntityDescription[T], mount: LidarrRootFolder ) -> tuple[LidarrSensorEntityDescription[T], str]: """Return modified description and folder name.""" - desc = deepcopy(description) name = mount.path.rsplit("/")[-1].rsplit("\\")[-1] - desc.key = f"{description.key}_{name}" - desc.name = f"{description.name} {name}".capitalize() + desc = dataclasses.replace( + description, + key=f"{description.key}_{name}", + name=f"{description.name} {name}".capitalize(), + ) return desc, name -@dataclass +@dataclasses.dataclass(frozen=True) class LidarrSensorEntityDescriptionMixIn(Generic[T]): """Mixin for required keys.""" value_fn: Callable[[T, str], str | int] -@dataclass +@dataclasses.dataclass(frozen=True) class LidarrSensorEntityDescription( SensorEntityDescription, LidarrSensorEntityDescriptionMixIn[T], Generic[T] ): diff --git a/homeassistant/components/life360/coordinator.py b/homeassistant/components/life360/coordinator.py index 755fa1b8124..4ef6e20d703 100644 --- a/homeassistant/components/life360/coordinator.py +++ b/homeassistant/components/life360/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from contextlib import suppress from dataclasses import dataclass, field from datetime import datetime @@ -130,8 +131,10 @@ class Life360DataUpdateCoordinator(DataUpdateCoordinator[Life360Data]): for circle in await self._retrieve_data("get_circles"): circle_id = circle["id"] - circle_members = await self._retrieve_data("get_circle_members", circle_id) - circle_places = await self._retrieve_data("get_circle_places", circle_id) + circle_members, circle_places = await asyncio.gather( + self._retrieve_data("get_circle_members", circle_id), + self._retrieve_data("get_circle_places", circle_id), + ) data.circles[circle_id] = Life360Circle( circle["name"], diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 78cccde5890..ebd3696d61f 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -8,7 +8,7 @@ from datetime import timedelta from enum import IntFlag, StrEnum import logging import os -from typing import Any, Self, cast, final +from typing import TYPE_CHECKING, Any, Self, cast, final import voluptuous as vol @@ -33,6 +33,11 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass import homeassistant.util.color as color_util +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + DOMAIN = "light" SCAN_INTERVAL = timedelta(seconds=30) DATA_PROFILES = "light_profiles" @@ -340,11 +345,11 @@ def filter_turn_off_params( light: LightEntity, params: dict[str, Any] ) -> dict[str, Any]: """Filter out params not used in turn off or not supported by the light.""" - supported_features = light.supported_features + supported_features = light.supported_features_compat - if not supported_features & LightEntityFeature.FLASH: + if LightEntityFeature.FLASH not in supported_features: params.pop(ATTR_FLASH, None) - if not supported_features & LightEntityFeature.TRANSITION: + if LightEntityFeature.TRANSITION not in supported_features: params.pop(ATTR_TRANSITION, None) return {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)} @@ -352,13 +357,13 @@ def filter_turn_off_params( def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]: """Filter out params not supported by the light.""" - supported_features = light.supported_features + supported_features = light.supported_features_compat - if not supported_features & LightEntityFeature.EFFECT: + if LightEntityFeature.EFFECT not in supported_features: params.pop(ATTR_EFFECT, None) - if not supported_features & LightEntityFeature.FLASH: + if LightEntityFeature.FLASH not in supported_features: params.pop(ATTR_FLASH, None) - if not supported_features & LightEntityFeature.TRANSITION: + if LightEntityFeature.TRANSITION not in supported_features: params.pop(ATTR_TRANSITION, None) supported_color_modes = ( @@ -500,6 +505,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_hs_to_xy(*hs_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes: assert (rgb_color := params.pop(ATTR_RGB_COLOR)) is not None if ColorMode.RGBW in supported_color_modes: @@ -515,6 +528,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes: xy_color = params.pop(ATTR_XY_COLOR) if ColorMode.HS in supported_color_modes: @@ -529,6 +550,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww( *rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin ) + elif ColorMode.COLOR_TEMP in supported_color_modes: + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes: rgbw_color = params.pop(ATTR_RGBW_COLOR) rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color) @@ -542,6 +570,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) elif ( ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes ): @@ -558,6 +594,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color) elif ColorMode.XY in supported_color_modes: params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color) + elif ColorMode.COLOR_TEMP in supported_color_modes: + xy_color = color_util.color_RGB_to_xy(*rgb_color) + params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature( + *xy_color + ) + params[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( + params[ATTR_COLOR_TEMP_KELVIN] + ) # If white is set to True, set it to the light's brightness # Add a warning in Home Assistant Core 2023.5 if the brightness is set to an @@ -777,12 +821,29 @@ class Profiles: params.setdefault(ATTR_TRANSITION, profile.transition) -@dataclasses.dataclass -class LightEntityDescription(ToggleEntityDescription): +class LightEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes binary sensor entities.""" -class LightEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "brightness", + "color_mode", + "hs_color", + "xy_color", + "rgb_color", + "rgbw_color", + "rgbww_color", + "color_temp", + "min_mireds", + "max_mireds", + "effect_list", + "effect", + "supported_color_modes", + "supported_features", +} + + +class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for light entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -817,12 +878,12 @@ class LightEntity(ToggleEntity): _attr_supported_features: LightEntityFeature = LightEntityFeature(0) _attr_xy_color: tuple[float, float] | None = None - @property + @cached_property def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" return self._attr_brightness - @property + @cached_property def color_mode(self) -> ColorMode | str | None: """Return the color mode of the light.""" return self._attr_color_mode @@ -847,22 +908,22 @@ class LightEntity(ToggleEntity): return color_mode - @property + @cached_property def hs_color(self) -> tuple[float, float] | None: """Return the hue and saturation color value [float, float].""" return self._attr_hs_color - @property + @cached_property def xy_color(self) -> tuple[float, float] | None: """Return the xy color value [float, float].""" return self._attr_xy_color - @property + @cached_property def rgb_color(self) -> tuple[int, int, int] | None: """Return the rgb color value [int, int, int].""" return self._attr_rgb_color - @property + @cached_property def rgbw_color(self) -> tuple[int, int, int, int] | None: """Return the rgbw color value [int, int, int, int].""" return self._attr_rgbw_color @@ -873,12 +934,12 @@ class LightEntity(ToggleEntity): rgbw_color = self.rgbw_color return rgbw_color - @property + @cached_property def rgbww_color(self) -> tuple[int, int, int, int, int] | None: """Return the rgbww color value [int, int, int, int, int].""" return self._attr_rgbww_color - @property + @cached_property def color_temp(self) -> int | None: """Return the CT color value in mireds.""" return self._attr_color_temp @@ -890,12 +951,12 @@ class LightEntity(ToggleEntity): return color_util.color_temperature_mired_to_kelvin(self.color_temp) return self._attr_color_temp_kelvin - @property + @cached_property def min_mireds(self) -> int: """Return the coldest color_temp that this light supports.""" return self._attr_min_mireds - @property + @cached_property def max_mireds(self) -> int: """Return the warmest color_temp that this light supports.""" return self._attr_max_mireds @@ -914,12 +975,12 @@ class LightEntity(ToggleEntity): return color_util.color_temperature_mired_to_kelvin(self.min_mireds) return self._attr_max_color_temp_kelvin - @property + @cached_property def effect_list(self) -> list[str] | None: """Return the list of supported effects.""" return self._attr_effect_list - @property + @cached_property def effect(self) -> str | None: """Return the current effect.""" return self._attr_effect @@ -928,7 +989,7 @@ class LightEntity(ToggleEntity): def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat supported_color_modes = self._light_internal_supported_color_modes if ColorMode.COLOR_TEMP in supported_color_modes: @@ -946,7 +1007,7 @@ class LightEntity(ToggleEntity): data[ATTR_MAX_MIREDS] = color_util.color_temperature_kelvin_to_mired( self.min_color_temp_kelvin ) - if supported_features & LightEntityFeature.EFFECT: + if LightEntityFeature.EFFECT in supported_features: data[ATTR_EFFECT_LIST] = self.effect_list data[ATTR_SUPPORTED_COLOR_MODES] = sorted(supported_color_modes) @@ -1000,8 +1061,9 @@ class LightEntity(ToggleEntity): def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat supported_color_modes = self._light_internal_supported_color_modes + supported_features_value = supported_features.value color_mode = self._light_internal_color_mode if self.is_on else None if color_mode and color_mode not in supported_color_modes: @@ -1020,7 +1082,7 @@ class LightEntity(ToggleEntity): data[ATTR_BRIGHTNESS] = self.brightness else: data[ATTR_BRIGHTNESS] = None - elif supported_features & SUPPORT_BRIGHTNESS: + elif supported_features_value & SUPPORT_BRIGHTNESS: # Backwards compatibility for ambiguous / incomplete states # Add warning in 2021.6, remove in 2021.10 if self.is_on: @@ -1042,7 +1104,7 @@ class LightEntity(ToggleEntity): else: data[ATTR_COLOR_TEMP_KELVIN] = None data[ATTR_COLOR_TEMP] = None - elif supported_features & SUPPORT_COLOR_TEMP: + elif supported_features_value & SUPPORT_COLOR_TEMP: # Backwards compatibility # Add warning in 2021.6, remove in 2021.10 if self.is_on: @@ -1072,7 +1134,7 @@ class LightEntity(ToggleEntity): if color_mode: data.update(self._light_internal_convert_color(color_mode)) - if supported_features & LightEntityFeature.EFFECT: + if LightEntityFeature.EFFECT in supported_features: data[ATTR_EFFECT] = self.effect if self.is_on else None return data @@ -1085,14 +1147,15 @@ class LightEntity(ToggleEntity): # Backwards compatibility for supported_color_modes added in 2021.4 # Add warning in 2021.6, remove in 2021.10 - supported_features = self.supported_features + supported_features = self.supported_features_compat + supported_features_value = supported_features.value supported_color_modes: set[ColorMode] = set() - if supported_features & SUPPORT_COLOR_TEMP: + if supported_features_value & SUPPORT_COLOR_TEMP: supported_color_modes.add(ColorMode.COLOR_TEMP) - if supported_features & SUPPORT_COLOR: + if supported_features_value & SUPPORT_COLOR: supported_color_modes.add(ColorMode.HS) - if supported_features & SUPPORT_BRIGHTNESS and not supported_color_modes: + if not supported_color_modes and supported_features_value & SUPPORT_BRIGHTNESS: supported_color_modes = {ColorMode.BRIGHTNESS} if not supported_color_modes: @@ -1100,12 +1163,43 @@ class LightEntity(ToggleEntity): return supported_color_modes - @property + @cached_property def supported_color_modes(self) -> set[ColorMode] | set[str] | None: """Flag supported color modes.""" return self._attr_supported_color_modes - @property + @cached_property def supported_features(self) -> LightEntityFeature: """Flag supported features.""" return self._attr_supported_features + + @property + def supported_features_compat(self) -> LightEntityFeature: + """Return the supported features as LightEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is not int: # noqa: E721 + return features + new_features = LightEntityFeature(features) + if self._deprecated_supported_features_reported is True: + return new_features + self._deprecated_supported_features_reported = True + report_issue = self._suggest_report_issue() + report_issue += ( + " and reference " + "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" + ) + _LOGGER.warning( + ( + "Entity %s (%s) is using deprecated supported features" + " values which will be removed in HA Core 2025.1. Instead it should use" + " %s and color modes, please %s" + ), + self.entity_id, + type(self), + repr(new_features), + report_issue, + ) + return new_features diff --git a/homeassistant/components/litejet/diagnostics.py b/homeassistant/components/litejet/diagnostics.py index b996dcc0413..48f38542dfd 100644 --- a/homeassistant/components/litejet/diagnostics.py +++ b/homeassistant/components/litejet/diagnostics.py @@ -15,6 +15,7 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for LiteJet config entry.""" system: LiteJet = hass.data[DOMAIN] return { + "model": system.model_name, "loads": list(system.loads()), "button_switches": list(system.button_switches()), "scenes": list(system.scenes()), diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json index 136880257ce..65dde31436d 100644 --- a/homeassistant/components/litejet/manifest.json +++ b/homeassistant/components/litejet/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["pylitejet"], "quality_scale": "platinum", - "requirements": ["pylitejet==0.5.0"] + "requirements": ["pylitejet==0.6.2"] } diff --git a/homeassistant/components/litejet/scene.py b/homeassistant/components/litejet/scene.py index ce04a537559..ec8e4d697fe 100644 --- a/homeassistant/components/litejet/scene.py +++ b/homeassistant/components/litejet/scene.py @@ -51,7 +51,7 @@ class LiteJetScene(Scene): identifiers={(DOMAIN, f"{entry_id}_mcp")}, name="LiteJet", manufacturer="Centralite", - model="CL24", + model=system.model_name, ) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/litejet/switch.py b/homeassistant/components/litejet/switch.py index 025770cdc35..5089b9ec0f9 100644 --- a/homeassistant/components/litejet/switch.py +++ b/homeassistant/components/litejet/switch.py @@ -49,10 +49,10 @@ class LiteJetSwitch(SwitchEntity): self._attr_name = name # Keypad #1 has switches 1-6, #2 has 7-12, ... - keypad_number = int((i - 1) / 6) + 1 + keypad_number = system.get_switch_keypad_number(i) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{entry_id}_keypad_{keypad_number}")}, - name=f"Keypad #{keypad_number}", + name=system.get_switch_keypad_name(i), manufacturer="Centralite", via_device=(DOMAIN, f"{entry_id}_mcp"), ) diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 0872c5c831d..6a588c36d6c 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -22,14 +22,14 @@ from .entity import LitterRobotEntity, _RobotT from .hub import LitterRobotHub -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT]): """A class that describes robot binary sensor entity required keys.""" is_on_fn: Callable[[_RobotT], bool] -@dataclass +@dataclass(frozen=True) class RobotBinarySensorEntityDescription( BinarySensorEntityDescription, RequiredKeysMixin[_RobotT] ): diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py index 06c4fe75888..de93ead5190 100644 --- a/homeassistant/components/litterrobot/button.py +++ b/homeassistant/components/litterrobot/button.py @@ -46,14 +46,14 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT]): """A class that describes robot button entity required keys.""" press_fn: Callable[[_RobotT], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class RobotButtonEntityDescription(ButtonEntityDescription, RequiredKeysMixin[_RobotT]): """A class that describes robot button entities.""" diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py index 7f2ea62f956..726cfaebaeb 100644 --- a/homeassistant/components/litterrobot/select.py +++ b/homeassistant/components/litterrobot/select.py @@ -28,7 +28,7 @@ BRIGHTNESS_LEVEL_ICON_MAP: dict[BrightnessLevel | None, str] = { } -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]): """A class that describes robot select entity required keys.""" @@ -37,7 +37,7 @@ class RequiredKeysMixin(Generic[_RobotT, _CastTypeT]): select_fn: Callable[[_RobotT, str], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class RobotSelectEntityDescription( SelectEntityDescription, RequiredKeysMixin[_RobotT, _CastTypeT] ): diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 935bbaca595..a25921e440c 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -35,7 +35,7 @@ def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str return "mdi:gauge-low" -@dataclass +@dataclass(frozen=True) class RobotSensorEntityDescription(SensorEntityDescription, Generic[_RobotT]): """A class that describes robot sensor entities.""" diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index 6b4e5b56b48..84e6fa2be67 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -18,7 +18,7 @@ from .entity import LitterRobotEntity, _RobotT from .hub import LitterRobotHub -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT]): """A class that describes robot switch entity required keys.""" @@ -26,7 +26,7 @@ class RequiredKeysMixin(Generic[_RobotT]): set_fn: Callable[[_RobotT, bool], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class RobotSwitchEntityDescription(SwitchEntityDescription, RequiredKeysMixin[_RobotT]): """A class that describes robot switch entities.""" diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index f352b7cee70..bb840e17a8f 100644 --- a/homeassistant/components/litterrobot/time.py +++ b/homeassistant/components/litterrobot/time.py @@ -20,7 +20,7 @@ from .entity import LitterRobotEntity, _RobotT from .hub import LitterRobotHub -@dataclass +@dataclass(frozen=True) class RequiredKeysMixin(Generic[_RobotT]): """A class that describes robot time entity required keys.""" @@ -28,7 +28,7 @@ class RequiredKeysMixin(Generic[_RobotT]): set_fn: Callable[[_RobotT, time], Coroutine[Any, Any, bool]] -@dataclass +@dataclass(frozen=True) class RobotTimeEntityDescription(TimeEntityDescription, RequiredKeysMixin[_RobotT]): """A class that describes robot time entities.""" diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index 4b1a8effb98..a86f1e4be00 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -47,7 +47,7 @@ LITTER_BOX_STATUS_STATE_MAP = { } LITTER_BOX_ENTITY = StateVacuumEntityDescription( - "litter_box", translation_key="litter_box" + key="litter_box", translation_key="litter_box" ) diff --git a/homeassistant/components/livisi/config_flow.py b/homeassistant/components/livisi/config_flow.py index 16cccaacfd1..c8685eb2390 100644 --- a/homeassistant/components/livisi/config_flow.py +++ b/homeassistant/components/livisi/config_flow.py @@ -9,10 +9,11 @@ from aiolivisi import AioLivisi, errors as livisi_errors import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client -from .const import CONF_HOST, CONF_PASSWORD, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER class LivisiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/livisi/const.py b/homeassistant/components/livisi/const.py index f6435298f1e..2769e6030ee 100644 --- a/homeassistant/components/livisi/const.py +++ b/homeassistant/components/livisi/const.py @@ -5,8 +5,6 @@ from typing import Final LOGGER = logging.getLogger(__package__) DOMAIN = "livisi" -CONF_HOST = "host" -CONF_PASSWORD: Final = "password" AVATAR = "Avatar" AVATAR_PORT: Final = 9090 CLASSIC_PORT: Final = 8080 diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 56e928307c1..17a3b1828d0 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -9,6 +9,7 @@ from aiolivisi import AioLivisi, LivisiEvent, Websocket from aiolivisi.errors import TokenExpiredException from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -17,8 +18,6 @@ from .const import ( AVATAR, AVATAR_PORT, CLASSIC_PORT, - CONF_HOST, - CONF_PASSWORD, DEVICE_POLLING_DELAY, LIVISI_REACHABILITY_CHANGE, LIVISI_STATE_CHANGE, diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index c5cf25a8c2e..99fb6dcebfa 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -1,13 +1,9 @@ """A Local To-do todo platform.""" -from collections.abc import Iterable -import dataclasses import logging -from typing import Any from ical.calendar import Calendar from ical.calendar_stream import IcsCalendarStream -from ical.exceptions import CalendarParseError from ical.store import TodoStore from ical.todo import Todo, TodoStatus @@ -59,26 +55,18 @@ async def async_setup_entry( async_add_entities([entity], True) -def _todo_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: - """Convert TodoItem dataclass items to dictionary of attributes for ical consumption.""" - result: dict[str, str] = {} - for name, value in obj: - if value is None: - continue - if name == "status": - result[name] = ICS_TODO_STATUS_MAP_INV[value] - else: - result[name] = value - return result - - def _convert_item(item: TodoItem) -> Todo: """Convert a HomeAssistant TodoItem to an ical Todo.""" - try: - return Todo(**dataclasses.asdict(item, dict_factory=_todo_dict_factory)) - except CalendarParseError as err: - _LOGGER.debug("Error parsing todo input fields: %s (%s)", item, err) - raise HomeAssistantError("Error parsing todo input fields") from err + todo = Todo() + if item.uid: + todo.uid = item.uid + if item.summary: + todo.summary = item.summary + if item.status: + todo.status = ICS_TODO_STATUS_MAP_INV[item.status] + todo.due = item.due + todo.description = item.description + return todo class LocalTodoListEntity(TodoListEntity): diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index ed7e2070055..a9370f8d092 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -1,13 +1,12 @@ """Component to interface with locks that can be controlled remotely.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag import functools as ft import logging import re -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -24,18 +23,28 @@ from homeassistant.const import ( STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.service import remove_entity_service_fields from homeassistant.helpers.typing import ConfigType, StateType +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) ATTR_CHANGED_BY = "changed_by" @@ -59,7 +68,11 @@ class LockEntityFeature(IntFlag): # The SUPPORT_OPEN constant is deprecated as of Home Assistant 2022.5. # Please use the LockEntityFeature enum instead. -SUPPORT_OPEN = 1 +_DEPRECATED_SUPPORT_OPEN = DeprecatedConstantEnum(LockEntityFeature.OPEN, "2025.1") + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) PROP_TO_ATTR = {"changed_by": ATTR_CHANGED_BY, "code_format": ATTR_CODE_FORMAT} @@ -75,48 +88,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await component.async_setup(config) component.async_register_entity_service( - SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, _async_unlock + SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA, "async_handle_unlock_service" ) component.async_register_entity_service( - SERVICE_LOCK, LOCK_SERVICE_SCHEMA, _async_lock + SERVICE_LOCK, LOCK_SERVICE_SCHEMA, "async_handle_lock_service" ) component.async_register_entity_service( - SERVICE_OPEN, LOCK_SERVICE_SCHEMA, _async_open, [LockEntityFeature.OPEN] + SERVICE_OPEN, + LOCK_SERVICE_SCHEMA, + "async_handle_open_service", + [LockEntityFeature.OPEN], ) return True -@callback -def _add_default_code(entity: LockEntity, service_call: ServiceCall) -> dict[Any, Any]: - data = remove_entity_service_fields(service_call) - code: str = data.pop(ATTR_CODE, "") - if not code: - code = entity._lock_option_default_code # pylint: disable=protected-access - if entity.code_format_cmp and not entity.code_format_cmp.match(code): - raise ValueError( - f"Code '{code}' for locking {entity.entity_id} doesn't match pattern {entity.code_format}" - ) - if code: - data[ATTR_CODE] = code - return data - - -async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: - """Lock the lock.""" - await entity.async_lock(**_add_default_code(entity, service_call)) - - -async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: - """Unlock the lock.""" - await entity.async_unlock(**_add_default_code(entity, service_call)) - - -async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None: - """Open the door latch.""" - await entity.async_open(**_add_default_code(entity, service_call)) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" component: EntityComponent[LockEntity] = hass.data[DOMAIN] @@ -129,12 +115,22 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class LockEntityDescription(EntityDescription): +class LockEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes lock entities.""" -class LockEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "changed_by", + "code_format", + "is_locked", + "is_locking", + "is_unlocking", + "is_jammed", + "supported_features", +} + + +class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for lock entities.""" entity_description: LockEntityDescription @@ -149,12 +145,35 @@ class LockEntity(Entity): _lock_option_default_code: str = "" __code_format_cmp: re.Pattern[str] | None = None - @property + @final + @callback + def add_default_code(self, data: dict[Any, Any]) -> dict[Any, Any]: + """Add default lock code.""" + code: str = data.pop(ATTR_CODE, "") + if not code: + code = self._lock_option_default_code + if self.code_format_cmp and not self.code_format_cmp.match(code): + if TYPE_CHECKING: + assert self.code_format + raise ServiceValidationError( + f"The code for {self.entity_id} doesn't match pattern {self.code_format}", + translation_domain=DOMAIN, + translation_key="add_default_code", + translation_placeholders={ + "entity_id": self.entity_id, + "code_format": self.code_format, + }, + ) + if code: + data[ATTR_CODE] = code + return data + + @cached_property def changed_by(self) -> str | None: """Last change triggered by.""" return self._attr_changed_by - @property + @cached_property def code_format(self) -> str | None: """Regex for code format or None if no code is required.""" return self._attr_code_format @@ -173,26 +192,31 @@ class LockEntity(Entity): self.__code_format_cmp = re.compile(self.code_format) return self.__code_format_cmp - @property + @cached_property def is_locked(self) -> bool | None: """Return true if the lock is locked.""" return self._attr_is_locked - @property + @cached_property def is_locking(self) -> bool | None: """Return true if the lock is locking.""" return self._attr_is_locking - @property + @cached_property def is_unlocking(self) -> bool | None: """Return true if the lock is unlocking.""" return self._attr_is_unlocking - @property + @cached_property def is_jammed(self) -> bool | None: """Return true if the lock is jammed (incomplete locking).""" return self._attr_is_jammed + @final + async def async_handle_lock_service(self, **kwargs: Any) -> None: + """Add default code and lock.""" + await self.async_lock(**self.add_default_code(kwargs)) + def lock(self, **kwargs: Any) -> None: """Lock the lock.""" raise NotImplementedError() @@ -201,6 +225,11 @@ class LockEntity(Entity): """Lock the lock.""" await self.hass.async_add_executor_job(ft.partial(self.lock, **kwargs)) + @final + async def async_handle_unlock_service(self, **kwargs: Any) -> None: + """Add default code and unlock.""" + await self.async_unlock(**self.add_default_code(kwargs)) + def unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" raise NotImplementedError() @@ -209,6 +238,11 @@ class LockEntity(Entity): """Unlock the lock.""" await self.hass.async_add_executor_job(ft.partial(self.unlock, **kwargs)) + @final + async def async_handle_open_service(self, **kwargs: Any) -> None: + """Add default code and open.""" + await self.async_open(**self.add_default_code(kwargs)) + def open(self, **kwargs: Any) -> None: """Open the door latch.""" raise NotImplementedError() @@ -241,10 +275,15 @@ class LockEntity(Entity): return None return STATE_LOCKED if locked else STATE_UNLOCKED - @property + @cached_property def supported_features(self) -> LockEntityFeature: """Return the list of supported features.""" - return self._attr_supported_features + features = self._attr_supported_features + if type(features) is int: # noqa: E721 + new_features = LockEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features async def async_internal_added_to_hass(self) -> None: """Call when the sensor entity is added to hass.""" diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index d041d6ac61a..152a06f9e53 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -66,5 +66,10 @@ } } } + }, + "exceptions": { + "add_default_code": { + "message": "The code for {entity_id} doesn't match pattern {code_format}." + } } } diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index e1641451221..d935ad9bff5 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_FILENAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, storage -from homeassistant.util.yaml import Secrets, load_yaml +from homeassistant.util.yaml import Secrets, load_yaml_dict from .const import ( CONF_ICON, @@ -201,7 +201,9 @@ class LovelaceYAML(LovelaceConfig): is_updated = self._cache is not None try: - config = load_yaml(self.path, Secrets(Path(self.hass.config.config_dir))) + config = load_yaml_dict( + self.path, Secrets(Path(self.hass.config.config_dir)) + ) except FileNotFoundError: raise ConfigNotFound from None diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 41369046d51..0788af76aca 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -322,7 +322,7 @@ def _async_setup_keypads( @callback def _async_build_trigger_schemas( - keypad_button_names_to_leap: dict[int, dict[str, int]] + keypad_button_names_to_leap: dict[int, dict[str, int]], ) -> dict[int, vol.Schema]: """Build device trigger schemas.""" diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index f0a4cdfbb99..1b9af351e71 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -41,7 +41,7 @@ LYRIC_SETPOINT_STATUS_NAMES = { } -@dataclass +@dataclass(frozen=True) class LyricSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -49,7 +49,7 @@ class LyricSensorEntityDescriptionMixin: suitable_fn: Callable[[LyricDevice], bool] -@dataclass +@dataclass(frozen=True) class LyricSensorEntityDescription( SensorEntityDescription, LyricSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 679abfd3164..623d0f06295 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -13,13 +13,10 @@ from aiohttp.web_exceptions import HTTPNotFound from homeassistant.components import frontend from homeassistant.components.http import HomeAssistantView +from homeassistant.config import config_per_platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_per_platform, - config_validation as cv, - discovery, -) +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index ddda50aa8b2..44a65a2de59 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -7,7 +7,7 @@ import logging import mimetypes import os import re -from typing import NewType, TypedDict +from typing import Final, NewType, Required, TypedDict import aiofiles.os from nio import AsyncClient, Event, MatrixRoom @@ -49,11 +49,11 @@ _LOGGER = logging.getLogger(__name__) SESSION_FILE = ".matrix.conf" -CONF_HOMESERVER = "homeserver" -CONF_ROOMS = "rooms" -CONF_COMMANDS = "commands" -CONF_WORD = "word" -CONF_EXPRESSION = "expression" +CONF_HOMESERVER: Final = "homeserver" +CONF_ROOMS: Final = "rooms" +CONF_COMMANDS: Final = "commands" +CONF_WORD: Final = "word" +CONF_EXPRESSION: Final = "expression" CONF_USERNAME_REGEX = "^@[^:]*:.*" CONF_ROOMS_REGEX = "^[!|#][^:]*:.*" @@ -78,10 +78,10 @@ RoomAnyID = RoomID | RoomAlias class ConfigCommand(TypedDict, total=False): """Corresponds to a single COMMAND_SCHEMA.""" - name: str # CONF_NAME - rooms: list[RoomID] | None # CONF_ROOMS - word: WordCommand | None # CONF_WORD - expression: ExpressionCommand | None # CONF_EXPRESSION + name: Required[str] # CONF_NAME + rooms: list[RoomID] # CONF_ROOMS + word: WordCommand # CONF_WORD + expression: ExpressionCommand # CONF_EXPRESSION COMMAND_SCHEMA = vol.All( @@ -223,15 +223,15 @@ class MatrixBot: def _load_commands(self, commands: list[ConfigCommand]) -> None: for command in commands: # Set the command for all listening_rooms, unless otherwise specified. - command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # type: ignore[misc] + command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set. if (word_command := command.get(CONF_WORD)) is not None: - for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + for room_id in command[CONF_ROOMS]: self._word_commands.setdefault(room_id, {}) - self._word_commands[room_id][word_command] = command # type: ignore[index] + self._word_commands[room_id][word_command] = command else: - for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + for room_id in command[CONF_ROOMS]: self._expression_commands.setdefault(room_id, []) self._expression_commands[room_id].append(command) @@ -263,7 +263,7 @@ class MatrixBot: # After single-word commands, check all regex commands in the room. for command in self._expression_commands.get(room_id, []): - match: re.Match = command[CONF_EXPRESSION].match(message.body) # type: ignore[literal-required] + match = command[CONF_EXPRESSION].match(message.body) if not match: continue message_data = { diff --git a/homeassistant/components/matter/api.py b/homeassistant/components/matter/api.py index 7b4b7d35b7f..2df21d8f7a2 100644 --- a/homeassistant/components/matter/api.py +++ b/homeassistant/components/matter/api.py @@ -1,9 +1,9 @@ """Handle websocket api for Matter.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from functools import wraps -from typing import Any +from typing import Any, Concatenate, ParamSpec from matter_server.common.errors import MatterError import voluptuous as vol @@ -15,6 +15,8 @@ from homeassistant.core import HomeAssistant, callback from .adapter import MatterAdapter from .helpers import get_matter +_P = ParamSpec("_P") + ID = "id" TYPE = "type" @@ -28,12 +30,19 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command(hass, websocket_set_wifi_credentials) -def async_get_matter_adapter(func: Callable) -> Callable: +def async_get_matter_adapter( + func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], MatterAdapter], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate function to get the MatterAdapter.""" @wraps(func) async def _get_matter( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide the Matter client to the function.""" matter = get_matter(hass) @@ -43,7 +52,15 @@ def async_get_matter_adapter(func: Callable) -> Callable: return _get_matter -def async_handle_failed_command(func: Callable) -> Callable: +def async_handle_failed_command( + func: Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], + ], +) -> Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], +]: """Decorate function to handle MatterError and send relevant error.""" @wraps(func) @@ -51,8 +68,8 @@ def async_handle_failed_command(func: Callable) -> Callable: hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - *args: Any, - **kwargs: Any, + *args: _P.args, + **kwargs: _P.kwargs, ) -> None: """Handle MatterError within function and send relevant error.""" try: @@ -68,6 +85,7 @@ def async_handle_failed_command(func: Callable) -> Callable: { vol.Required(TYPE): "matter/commission", vol.Required("code"): str, + vol.Optional("network_only"): bool, } ) @websocket_api.async_response @@ -80,7 +98,9 @@ async def websocket_commission( matter: MatterAdapter, ) -> None: """Add a device to the network and commission the device.""" - await matter.matter_client.commission_with_code(msg["code"]) + await matter.matter_client.commission_with_code( + msg["code"], network_only=msg.get("network_only", True) + ) connection.send_result(msg[ID]) @@ -89,6 +109,7 @@ async def websocket_commission( { vol.Required(TYPE): "matter/commission_on_network", vol.Required("pin"): int, + vol.Optional("ip_addr"): str, } ) @websocket_api.async_response @@ -101,7 +122,9 @@ async def websocket_commission_on_network( matter: MatterAdapter, ) -> None: """Commission a device already on the network.""" - await matter.matter_client.commission_on_network(msg["pin"]) + await matter.matter_client.commission_on_network( + msg["pin"], ip_addr=msg.get("ip_addr", None) + ) connection.send_result(msg[ID]) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index aabfc12eefb..ea87fabf3f5 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -32,7 +32,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.BINARY_SENSOR, async_add_entities) -@dataclass +@dataclass(frozen=True) class MatterBinarySensorEntityDescription( BinarySensorEntityDescription, MatterEntityDescription ): diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index de6e6ff83c2..e308699acad 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -37,7 +37,7 @@ LOGGER = logging.getLogger(__name__) EXTRA_POLL_DELAY = 3.0 -@dataclass +@dataclass(frozen=True) class MatterEntityDescription(EntityDescription): """Describe the Matter entity.""" diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index 8491f58e387..dd29638f765 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -89,10 +89,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock with pin if needed.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.LockDoor(code_bytes) @@ -100,10 +97,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock with pin if needed.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None if self.supports_unbolt: # if the lock reports it has separate unbolt support, @@ -119,10 +113,7 @@ class MatterLock(MatterEntity, LockEntity): async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - code: str = kwargs.get( - ATTR_CODE, - self._lock_option_default_code, - ) + code: str | None = kwargs.get(ATTR_CODE) code_bytes = code.encode() if code else None await self.send_device_command( command=clusters.DoorLock.Commands.UnlockDoor(code_bytes) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index f350cda9227..848e89660ed 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==5.0.0"] + "requirements": ["python-matter-server==5.1.1"] } diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 6262eb253aa..e7b18f308f7 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -45,7 +45,7 @@ async def async_setup_entry( matter.register_platform_handler(Platform.SENSOR, async_add_entities) -@dataclass +@dataclass(frozen=True) class MatterSensorEntityDescription(SensorEntityDescription, MatterEntityDescription): """Describe Matter sensor entities.""" diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index 98bb44947c8..a7e03ae7c22 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -27,7 +27,7 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class MeaterSensorEntityDescriptionMixin: """Mixin for MeaterSensorEntityDescription.""" @@ -35,7 +35,7 @@ class MeaterSensorEntityDescriptionMixin: value: Callable[[MeaterProbe], datetime | float | str | None] -@dataclass +@dataclass(frozen=True) class MeaterSensorEntityDescription( SensorEntityDescription, MeaterSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 50365f90f1f..113048421e1 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -5,15 +5,15 @@ import asyncio import collections 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 +from functools import lru_cache import hashlib from http import HTTPStatus import logging import secrets -from typing import Any, Final, Required, TypedDict, final +from typing import TYPE_CHECKING, Any, Final, Required, TypedDict, final from urllib.parse import quote, urlparse from aiohttp import web @@ -132,6 +132,11 @@ from .const import ( # noqa: F401 ) from .errors import BrowseError +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -449,14 +454,56 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class MediaPlayerEntityDescription(EntityDescription): +class MediaPlayerEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes media player entities.""" device_class: MediaPlayerDeviceClass | None = None + volume_step: float | None = None -class MediaPlayerEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "state", + "volume_level", + "volume_step", + "is_volume_muted", + "media_content_id", + "media_content_type", + "media_duration", + "media_position", + "media_position_updated_at", + "media_image_url", + "media_image_remotely_accessible", + "media_title", + "media_artist", + "media_album_name", + "media_album_artist", + "media_track", + "media_series_title", + "media_season", + "media_episode", + "media_channel", + "media_playlist", + "app_id", + "app_name", + "source", + "source_list", + "sound_mode", + "sound_mode_list", + "shuffle", + "repeat", + "group_members", + "supported_features", +} + + +@lru_cache +def _url_hash(url: str) -> str: + """Create hash for media image url.""" + return hashlib.sha256(url.encode("utf-8")).hexdigest()[:16] + + +class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ABC for media player entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -505,9 +552,10 @@ class MediaPlayerEntity(Entity): _attr_state: MediaPlayerState | None = None _attr_supported_features: MediaPlayerEntityFeature = MediaPlayerEntityFeature(0) _attr_volume_level: float | None = None + _attr_volume_step: float # Implement these for your media player - @property + @cached_property def device_class(self) -> MediaPlayerDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -516,7 +564,7 @@ class MediaPlayerEntity(Entity): return self.entity_description.device_class return None - @property + @cached_property def state(self) -> MediaPlayerState | None: """State of the player.""" return self._attr_state @@ -528,37 +576,49 @@ class MediaPlayerEntity(Entity): self._access_token = secrets.token_hex(32) return self._access_token - @property + @cached_property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" return self._attr_volume_level - @property + @cached_property + def volume_step(self) -> float: + """Return the step to be used by the volume_up and volume_down services.""" + if hasattr(self, "_attr_volume_step"): + return self._attr_volume_step + if ( + hasattr(self, "entity_description") + and (volume_step := self.entity_description.volume_step) is not None + ): + return volume_step + return 0.1 + + @cached_property def is_volume_muted(self) -> bool | None: """Boolean if volume is currently muted.""" return self._attr_is_volume_muted - @property + @cached_property def media_content_id(self) -> str | None: """Content ID of current playing media.""" return self._attr_media_content_id - @property + @cached_property def media_content_type(self) -> MediaType | str | None: """Content type of current playing media.""" return self._attr_media_content_type - @property + @cached_property def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" return self._attr_media_duration - @property + @cached_property def media_position(self) -> int | None: """Position of current playing media in seconds.""" return self._attr_media_position - @property + @cached_property def media_position_updated_at(self) -> dt.datetime | None: """When was the position of the current playing media valid. @@ -566,12 +626,12 @@ class MediaPlayerEntity(Entity): """ return self._attr_media_position_updated_at - @property + @cached_property def media_image_url(self) -> str | None: """Image url of current playing media.""" return self._attr_media_image_url - @property + @cached_property def media_image_remotely_accessible(self) -> bool: """If the image url is remotely accessible.""" return self._attr_media_image_remotely_accessible @@ -583,7 +643,7 @@ class MediaPlayerEntity(Entity): return self._attr_media_image_hash if (url := self.media_image_url) is not None: - return hashlib.sha256(url.encode("utf-8")).hexdigest()[:16] + return _url_hash(url) return None @@ -606,106 +666,119 @@ class MediaPlayerEntity(Entity): """ return None, None - @property + @cached_property def media_title(self) -> str | None: """Title of current playing media.""" return self._attr_media_title - @property + @cached_property def media_artist(self) -> str | None: """Artist of current playing media, music track only.""" return self._attr_media_artist - @property + @cached_property def media_album_name(self) -> str | None: """Album name of current playing media, music track only.""" return self._attr_media_album_name - @property + @cached_property def media_album_artist(self) -> str | None: """Album artist of current playing media, music track only.""" return self._attr_media_album_artist - @property + @cached_property def media_track(self) -> int | None: """Track number of current playing media, music track only.""" return self._attr_media_track - @property + @cached_property def media_series_title(self) -> str | None: """Title of series of current playing media, TV show only.""" return self._attr_media_series_title - @property + @cached_property def media_season(self) -> str | None: """Season of current playing media, TV show only.""" return self._attr_media_season - @property + @cached_property def media_episode(self) -> str | None: """Episode of current playing media, TV show only.""" return self._attr_media_episode - @property + @cached_property def media_channel(self) -> str | None: """Channel currently playing.""" return self._attr_media_channel - @property + @cached_property def media_playlist(self) -> str | None: """Title of Playlist currently playing.""" return self._attr_media_playlist - @property + @cached_property def app_id(self) -> str | None: """ID of the current running app.""" return self._attr_app_id - @property + @cached_property def app_name(self) -> str | None: """Name of the current running app.""" return self._attr_app_name - @property + @cached_property def source(self) -> str | None: """Name of the current input source.""" return self._attr_source - @property + @cached_property def source_list(self) -> list[str] | None: """List of available input sources.""" return self._attr_source_list - @property + @cached_property def sound_mode(self) -> str | None: """Name of the current sound mode.""" return self._attr_sound_mode - @property + @cached_property def sound_mode_list(self) -> list[str] | None: """List of available sound modes.""" return self._attr_sound_mode_list - @property + @cached_property def shuffle(self) -> bool | None: """Boolean if shuffle is enabled.""" return self._attr_shuffle - @property + @cached_property def repeat(self) -> RepeatMode | str | None: """Return current repeat mode.""" return self._attr_repeat - @property + @cached_property def group_members(self) -> list[str] | None: """List of members which are currently grouped together.""" return self._attr_group_members - @property + @cached_property def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> MediaPlayerEntityFeature: + """Return the supported features as MediaPlayerEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = MediaPlayerEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + def turn_on(self) -> None: """Turn the media player on.""" raise NotImplementedError() @@ -845,87 +918,87 @@ class MediaPlayerEntity(Entity): @property def support_play(self) -> bool: """Boolean if play is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.PLAY) + return MediaPlayerEntityFeature.PLAY in self.supported_features_compat @final @property def support_pause(self) -> bool: """Boolean if pause is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.PAUSE) + return MediaPlayerEntityFeature.PAUSE in self.supported_features_compat @final @property def support_stop(self) -> bool: """Boolean if stop is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.STOP) + return MediaPlayerEntityFeature.STOP in self.supported_features_compat @final @property def support_seek(self) -> bool: """Boolean if seek is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.SEEK) + return MediaPlayerEntityFeature.SEEK in self.supported_features_compat @final @property def support_volume_set(self) -> bool: """Boolean if setting volume is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.VOLUME_SET) + return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat @final @property def support_volume_mute(self) -> bool: """Boolean if muting volume is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.VOLUME_MUTE) + return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features_compat @final @property def support_previous_track(self) -> bool: """Boolean if previous track command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.PREVIOUS_TRACK) + return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features_compat @final @property def support_next_track(self) -> bool: """Boolean if next track command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.NEXT_TRACK) + return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features_compat @final @property def support_play_media(self) -> bool: """Boolean if play media command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.PLAY_MEDIA) + return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features_compat @final @property def support_select_source(self) -> bool: """Boolean if select source command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.SELECT_SOURCE) + return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features_compat @final @property def support_select_sound_mode(self) -> bool: """Boolean if select sound mode command supported.""" - return bool( - self.supported_features & MediaPlayerEntityFeature.SELECT_SOUND_MODE + return ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features_compat ) @final @property def support_clear_playlist(self) -> bool: """Boolean if clear playlist command supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.CLEAR_PLAYLIST) + return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features_compat @final @property def support_shuffle_set(self) -> bool: """Boolean if shuffle is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.SHUFFLE_SET) + return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features_compat @final @property def support_grouping(self) -> bool: """Boolean if player grouping is supported.""" - return bool(self.supported_features & MediaPlayerEntityFeature.GROUPING) + return MediaPlayerEntityFeature.GROUPING in self.supported_features_compat async def async_toggle(self) -> None: """Toggle the power on the media player.""" @@ -954,9 +1027,11 @@ class MediaPlayerEntity(Entity): if ( self.volume_level is not None and self.volume_level < 1 - and self.supported_features & MediaPlayerEntityFeature.VOLUME_SET + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat ): - await self.async_set_volume_level(min(1, self.volume_level + 0.1)) + await self.async_set_volume_level( + min(1, self.volume_level + self.volume_step) + ) async def async_volume_down(self) -> None: """Turn volume down for media player. @@ -970,9 +1045,11 @@ class MediaPlayerEntity(Entity): if ( self.volume_level is not None and self.volume_level > 0 - and self.supported_features & MediaPlayerEntityFeature.VOLUME_SET + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat ): - await self.async_set_volume_level(max(0, self.volume_level - 0.1)) + await self.async_set_volume_level( + max(0, self.volume_level - self.volume_step) + ) async def async_media_play_pause(self) -> None: """Play or pause the media player.""" @@ -1011,16 +1088,16 @@ class MediaPlayerEntity(Entity): def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features + supported_features = self.supported_features_compat - if supported_features & MediaPlayerEntityFeature.SELECT_SOURCE and ( + if ( source_list := self.source_list - ): + ) and MediaPlayerEntityFeature.SELECT_SOURCE in supported_features: data[ATTR_INPUT_SOURCE_LIST] = source_list - if supported_features & MediaPlayerEntityFeature.SELECT_SOUND_MODE and ( + if ( sound_mode_list := self.sound_mode_list - ): + ) and MediaPlayerEntityFeature.SELECT_SOUND_MODE in supported_features: data[ATTR_SOUND_MODE_LIST] = sound_mode_list return data @@ -1218,7 +1295,7 @@ async def websocket_browse_media( connection.send_error(msg["id"], "entity_not_found", "Entity not found") return - if not player.supported_features & MediaPlayerEntityFeature.BROWSE_MEDIA: + if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features: connection.send_message( websocket_api.error_message( msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media" diff --git a/homeassistant/components/media_player/significant_change.py b/homeassistant/components/media_player/significant_change.py new file mode 100644 index 00000000000..43a253d9220 --- /dev/null +++ b/homeassistant/components/media_player/significant_change.py @@ -0,0 +1,70 @@ +"""Helper to test significant Media Player state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ( + ATTR_ENTITY_PICTURE_LOCAL, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_TO_PROPERTY, +) + +INSIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, +} + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_ENTITY_PICTURE_LOCAL, + *ATTR_TO_PROPERTY, +} - INSIGNIFICANT_ATTRIBUTES + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name != ATTR_MEDIA_VOLUME_LEVEL: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 0.1): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 5f007f3a8e5..d1ed5cafcbf 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any -from aiohttp import ClientConnectionError +from aiohttp import ClientConnectionError, ClientResponseError from pymelcloud import Device, get_devices from pymelcloud.atw_device import Zone import voluptuous as vol @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -66,7 +66,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Establish connection with MELClooud.""" conf = entry.data - mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) + try: + mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) + except ClientResponseError as ex: + if isinstance(ex, ClientResponseError) and ex.code == 401: + raise ConfigEntryAuthFailed from ex + raise ConfigEntryNotReady from ex + except (asyncio.TimeoutError, ClientConnectionError) as ex: + raise ConfigEntryNotReady from ex + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices}) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -162,17 +170,13 @@ async def mel_devices_setup( ) -> dict[str, list[MelCloudDevice]]: """Query connected devices from MELCloud.""" session = async_get_clientsession(hass) - try: - async with asyncio.timeout(10): - all_devices = await get_devices( - token, - session, - conf_update_interval=timedelta(minutes=5), - device_set_debounce=timedelta(seconds=1), - ) - except (asyncio.TimeoutError, ClientConnectionError) as ex: - raise ConfigEntryNotReady() from ex - + async with asyncio.timeout(10): + all_devices = await get_devices( + token, + session, + conf_update_interval=timedelta(minutes=5), + device_set_debounce=timedelta(seconds=1), + ) wrapped_devices: dict[str, list[MelCloudDevice]] = {} for device_type, devices in all_devices.items(): wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices] diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index 0ff17ea751a..9293c9bb3d5 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -2,7 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from http import HTTPStatus +import logging +from typing import Any from aiohttp import ClientError, ClientResponseError import pymelcloud @@ -11,12 +14,14 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.data_entry_flow import AbortFlow, FlowResultType +from homeassistant.data_entry_flow import AbortFlow, FlowResult, FlowResultType from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + async def async_create_import_issue( hass: HomeAssistant, source: str, issue: str, success: bool = False @@ -56,7 +61,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _create_entry(self, username: str, token: str): + entry: config_entries.ConfigEntry | None = None + + async def _create_entry(self, username: str, token: str) -> FlowResult: """Register new entry.""" await self.async_set_unique_id(username) try: @@ -74,7 +81,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): *, password: str | None = None, token: str | None = None, - ): + ) -> FlowResult: """Create client.""" try: async with asyncio.timeout(10): @@ -106,7 +113,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self._create_entry(username, acquired_token) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """User initiated config flow.""" if user_input is None: return self.async_show_form( @@ -118,7 +127,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): username = user_input[CONF_USERNAME] return await self._create_client(username, password=user_input[CONF_PASSWORD]) - async def async_step_import(self, user_input): + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Import a config entry.""" result = await self._create_client( user_input[CONF_USERNAME], token=user_input[CONF_TOKEN] @@ -126,3 +135,67 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if result["type"] == FlowResultType.CREATE_ENTRY: await async_create_import_issue(self.hass, self.context["source"], "", True) return result + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with MELCloud.""" + self.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: + """Handle re-authentication with MELCloud.""" + errors: dict[str, str] = {} + + if user_input is not None and self.entry: + aquired_token, errors = await self.async_reauthenticate_client(user_input) + + if not errors: + self.hass.config_entries.async_update_entry( + self.entry, + data={CONF_TOKEN: aquired_token}, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.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): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + async def async_reauthenticate_client( + self, user_input: dict[str, Any] + ) -> tuple[str | None, dict[str, str]]: + """Reauthenticate with MELCloud.""" + errors: dict[str, str] = {} + acquired_token = None + + try: + async with asyncio.timeout(10): + acquired_token = await pymelcloud.login( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + async_get_clientsession(self.hass), + ) + except (ClientResponseError, AttributeError) as err: + if isinstance(err, ClientResponseError) and err.status in ( + HTTPStatus.UNAUTHORIZED, + HTTPStatus.FORBIDDEN, + ): + errors["base"] = "invalid_auth" + elif isinstance(err, AttributeError) and err.name == "get": + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except ( + asyncio.TimeoutError, + ClientError, + ): + errors["base"] = "cannot_connect" + + return acquired_token, errors diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index ca02d15db01..cf53fe42b77 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +import dataclasses from typing import Any from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW @@ -23,7 +23,7 @@ from . import MelCloudDevice from .const import DOMAIN -@dataclass +@dataclasses.dataclass(frozen=True) class MelcloudRequiredKeysMixin: """Mixin for required keys.""" @@ -31,7 +31,7 @@ class MelcloudRequiredKeysMixin: enabled: Callable[[Any], bool] -@dataclass +@dataclasses.dataclass(frozen=True) class MelcloudSensorEntityDescription( SensorEntityDescription, MelcloudRequiredKeysMixin ): @@ -203,7 +203,10 @@ class AtwZoneSensor(MelDeviceSensor): ) -> None: """Initialize the sensor.""" if zone.zone_index != 1: - description.key = f"{description.key}-zone-{zone.zone_index}" + description = dataclasses.replace( + description, + key=f"{description.key}-zone-{zone.zone_index}", + ) super().__init__(api, description) self._attr_device_info = api.zone_device_info(zone) diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index eefd5a07a8d..3abb30bf9ac 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -8,6 +8,14 @@ "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]" } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Melcloud integration needs to re-authenticate your connection details", + "data": { + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -16,6 +24,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "already_configured": "MELCloud integration already configured for this email. Access token has been refreshed." } }, diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py index e0f9c7d3bf6..caf2d499851 100644 --- a/homeassistant/components/melnor/number.py +++ b/homeassistant/components/melnor/number.py @@ -26,7 +26,7 @@ from .models import ( ) -@dataclass +@dataclass(frozen=True) class MelnorZoneNumberEntityDescriptionMixin: """Mixin for required keys.""" @@ -34,7 +34,7 @@ class MelnorZoneNumberEntityDescriptionMixin: state_fn: Callable[[Valve], Any] -@dataclass +@dataclass(frozen=True) class MelnorZoneNumberEntityDescription( NumberEntityDescription, MelnorZoneNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index edb906cc80f..255c3c9747d 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -54,28 +54,28 @@ def next_cycle(valve: Valve) -> datetime | None: return None -@dataclass +@dataclass(frozen=True) class MelnorSensorEntityDescriptionMixin: """Mixin for required keys.""" state_fn: Callable[[Device], Any] -@dataclass +@dataclass(frozen=True) class MelnorZoneSensorEntityDescriptionMixin: """Mixin for required keys.""" state_fn: Callable[[Valve], Any] -@dataclass +@dataclass(frozen=True) class MelnorZoneSensorEntityDescription( SensorEntityDescription, MelnorZoneSensorEntityDescriptionMixin ): """Describes Melnor sensor entity.""" -@dataclass +@dataclass(frozen=True) class MelnorSensorEntityDescription( SensorEntityDescription, MelnorSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index 03bd28faa9d..e3c0e0afa15 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -25,7 +25,7 @@ from .models import ( ) -@dataclass +@dataclass(frozen=True) class MelnorSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -33,7 +33,7 @@ class MelnorSwitchEntityDescriptionMixin: state_fn: Callable[[Valve], Any] -@dataclass +@dataclass(frozen=True) class MelnorSwitchEntityDescription( SwitchEntityDescription, MelnorSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py index 943a7996aeb..36afe2d976d 100644 --- a/homeassistant/components/melnor/time.py +++ b/homeassistant/components/melnor/time.py @@ -23,7 +23,7 @@ from .models import ( ) -@dataclass +@dataclass(frozen=True) class MelnorZoneTimeEntityDescriptionMixin: """Mixin for required keys.""" @@ -31,7 +31,7 @@ class MelnorZoneTimeEntityDescriptionMixin: state_fn: Callable[[Valve], Any] -@dataclass +@dataclass(frozen=True) class MelnorZoneTimeEntityDescription( TimeEntityDescription, MelnorZoneTimeEntityDescriptionMixin ): diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index dd8fd4af83b..451d617e65b 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -51,14 +51,14 @@ from .const import ( _DataT = TypeVar("_DataT", bound=Rain | Forecast | CurrentPhenomenons) -@dataclass +@dataclass(frozen=True) class MeteoFranceRequiredKeysMixin: """Mixin for required keys.""" data_path: str -@dataclass +@dataclass(frozen=True) class MeteoFranceSensorEntityDescription( SensorEntityDescription, MeteoFranceRequiredKeysMixin ): diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index af7dfb2ab2c..d03e46a1d0b 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -91,7 +91,7 @@ class MikrotikData: def get_info(self, param: str) -> str: """Return device model name.""" cmd = IDENTITY if param == NAME else INFO - if data := self.command(MIKROTIK_SERVICES[cmd]): + if data := self.command(MIKROTIK_SERVICES[cmd], suppress_errors=(cmd == INFO)): return str(data[0].get(param)) return "" @@ -101,10 +101,18 @@ class MikrotikData: self.model = self.get_info(ATTR_MODEL) self.firmware = self.get_info(ATTR_FIRMWARE) self.serial_number = self.get_info(ATTR_SERIAL_NUMBER) - self.support_capsman = bool(self.command(MIKROTIK_SERVICES[IS_CAPSMAN])) - self.support_wireless = bool(self.command(MIKROTIK_SERVICES[IS_WIRELESS])) - self.support_wifiwave2 = bool(self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2])) - self.support_wifi = bool(self.command(MIKROTIK_SERVICES[IS_WIFI])) + self.support_capsman = bool( + self.command(MIKROTIK_SERVICES[IS_CAPSMAN], suppress_errors=True) + ) + self.support_wireless = bool( + self.command(MIKROTIK_SERVICES[IS_WIRELESS], suppress_errors=True) + ) + self.support_wifiwave2 = bool( + self.command(MIKROTIK_SERVICES[IS_WIFIWAVE2], suppress_errors=True) + ) + self.support_wifi = bool( + self.command(MIKROTIK_SERVICES[IS_WIFI], suppress_errors=True) + ) def get_list_from_interface(self, interface: str) -> dict[str, dict[str, Any]]: """Get devices from interface.""" @@ -205,7 +213,10 @@ class MikrotikData: return True def command( - self, cmd: str, params: dict[str, Any] | None = None + self, + cmd: str, + params: dict[str, Any] | None = None, + suppress_errors: bool = False, ) -> list[dict[str, Any]]: """Retrieve data from Mikrotik API.""" _LOGGER.debug("Running command %s", cmd) @@ -224,12 +235,11 @@ class MikrotikData: # we still have to raise CannotConnect to fail the update. raise CannotConnect from api_error except librouteros.exceptions.ProtocolError as api_error: - _LOGGER.warning( - "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", - self._host, - cmd, - api_error, - ) + emsg = "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s" + if suppress_errors and "no such command prefix" in str(api_error): + _LOGGER.debug(emsg, self._host, cmd, api_error) + return [] + _LOGGER.warning(emsg, self._host, cmd, api_error) return [] diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 7bb78eb05e7..16e7bf552ba 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.11.7", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.8", "mill-local==0.3.0"] } diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 4e5ab9290f0..0e2debda33e 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -30,13 +30,16 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" - # Check and create API instance. + # Create API instance. + api = MinecraftServer( + hass, + entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), + entry.data[CONF_ADDRESS], + ) + + # Initialize API instance. try: - api = await hass.async_add_executor_job( - MinecraftServer, - entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), - entry.data[CONF_ADDRESS], - ) + await api.async_initialize() except MinecraftServerAddressError as error: raise ConfigEntryError( f"Server address in configuration entry is invalid: {error}" @@ -102,9 +105,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_data = config_entry.data # Migrate config entry. + address = config_data[CONF_HOST] + api = MinecraftServer(hass, MinecraftServerType.JAVA_EDITION, address) + try: - address = config_data[CONF_HOST] - MinecraftServer(MinecraftServerType.JAVA_EDITION, address) + await api.async_initialize() host_only_lookup_success = True except MinecraftServerAddressError as error: host_only_lookup_success = False @@ -114,9 +119,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) if not host_only_lookup_success: + address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" + api = MinecraftServer(hass, MinecraftServerType.JAVA_EDITION, address) + try: - address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" - MinecraftServer(MinecraftServerType.JAVA_EDITION, address) + await api.async_initialize() except MinecraftServerAddressError as error: _LOGGER.exception( "Can't migrate configuration entry due to error while parsing server address, try again later: %s", diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py index 4ab7865f369..fc872d37bde 100644 --- a/homeassistant/components/minecraft_server/api.py +++ b/homeassistant/components/minecraft_server/api.py @@ -9,6 +9,8 @@ from dns.resolver import LifetimeTimeout from mcstatus import BedrockServer, JavaServer from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +from homeassistant.core import HomeAssistant + _LOGGER = logging.getLogger(__name__) LOOKUP_TIMEOUT: float = 10 @@ -52,35 +54,51 @@ class MinecraftServerConnectionError(Exception): """Raised when no data can be fechted from the server.""" +class MinecraftServerNotInitializedError(Exception): + """Raised when APIs are used although server instance is not initialized yet.""" + + class MinecraftServer: """Minecraft Server wrapper class for 3rd party library mcstatus.""" - _server: BedrockServer | JavaServer + _server: BedrockServer | JavaServer | None - def __init__(self, server_type: MinecraftServerType, address: str) -> None: + def __init__( + self, hass: HomeAssistant, server_type: MinecraftServerType, address: str + ) -> None: """Initialize server instance.""" + self._server = None + self._hass = hass + self._server_type = server_type + self._address = address + + async def async_initialize(self) -> None: + """Perform async initialization of server instance.""" try: - if server_type == MinecraftServerType.JAVA_EDITION: - self._server = JavaServer.lookup(address, timeout=LOOKUP_TIMEOUT) + if self._server_type == MinecraftServerType.JAVA_EDITION: + self._server = await JavaServer.async_lookup(self._address) else: - self._server = BedrockServer.lookup(address, timeout=LOOKUP_TIMEOUT) + self._server = await self._hass.async_add_executor_job( + BedrockServer.lookup, self._address + ) except (ValueError, LifetimeTimeout) as error: raise MinecraftServerAddressError( - f"Lookup of '{address}' failed: {self._get_error_message(error)}" + f"Lookup of '{self._address}' failed: {self._get_error_message(error)}" ) from error self._server.timeout = DATA_UPDATE_TIMEOUT - self._address = address _LOGGER.debug( - "%s server instance created with address '%s'", server_type, address + "%s server instance created with address '%s'", + self._server_type, + self._address, ) async def async_is_online(self) -> bool: """Check if the server is online, supporting both Java and Bedrock Edition servers.""" try: await self.async_get_data() - except MinecraftServerConnectionError: + except (MinecraftServerConnectionError, MinecraftServerNotInitializedError): return False return True @@ -89,6 +107,9 @@ class MinecraftServer: """Get updated data from the server, supporting both Java and Bedrock Edition servers.""" status_response: BedrockStatusResponse | JavaStatusResponse + if self._server is None: + raise MinecraftServerNotInitializedError() + try: status_response = await self._server.async_status(tries=DATA_UPDATE_RETRIES) except OSError as error: diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 520d7342b35..6c0a2a248f3 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -19,7 +19,7 @@ ICON_STATUS = "mdi:lan" KEY_STATUS = "status" -@dataclass +@dataclass(frozen=True) class MinecraftServerBinarySensorEntityDescription(BinarySensorEntityDescription): """Class describing Minecraft Server binary sensor entities.""" diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index f064a4ac1ef..045133421fb 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -35,10 +35,10 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): # Some Bedrock Edition servers mimic a Java Edition server, therefore check for a Bedrock Edition server first. for server_type in MinecraftServerType: + api = MinecraftServer(self.hass, server_type, address) + try: - api = await self.hass.async_add_executor_job( - MinecraftServer, server_type, address - ) + await api.async_initialize() except MinecraftServerAddressError: pass else: diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index f7a60318c64..e498375cafc 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -7,7 +7,12 @@ import logging from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .api import MinecraftServer, MinecraftServerConnectionError, MinecraftServerData +from .api import ( + MinecraftServer, + MinecraftServerConnectionError, + MinecraftServerData, + MinecraftServerNotInitializedError, +) SCAN_INTERVAL = timedelta(seconds=60) @@ -32,5 +37,8 @@ class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): """Get updated data from the server.""" try: return await self._api.async_get_data() - except MinecraftServerConnectionError as error: + except ( + MinecraftServerConnectionError, + MinecraftServerNotInitializedError, + ) as error: raise UpdateFailed(error) from error diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 661ce00dac5..671bbdb7a05 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -41,7 +41,7 @@ UNIT_PLAYERS_MAX = "players" UNIT_PLAYERS_ONLINE = "players" -@dataclass +@dataclass(frozen=True) class MinecraftServerEntityDescriptionMixin: """Mixin values for Minecraft Server entities.""" @@ -50,7 +50,7 @@ class MinecraftServerEntityDescriptionMixin: supported_server_types: set[MinecraftServerType] -@dataclass +@dataclass(frozen=True) class MinecraftServerSensorEntityDescription( SensorEntityDescription, MinecraftServerEntityDescriptionMixin ): diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 3d33af38761..cb5c0ae5c3d 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -103,6 +103,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}" webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook) + async def create_cloud_hook() -> None: + """Create a cloud hook.""" + hook = await cloud.async_create_cloudhook(hass, webhook_id) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_CLOUDHOOK_URL: hook} + ) + + async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: + if ( + state is cloud.CloudConnectionState.CLOUD_CONNECTED + and CONF_CLOUDHOOK_URL not in entry.data + ): + await create_cloud_hook() + + if ( + CONF_CLOUDHOOK_URL not in entry.data + and cloud.async_active_subscription(hass) + and cloud.async_is_connected(hass) + ): + await create_cloud_hook() + entry.async_on_unload(cloud.async_listen_connection_change(hass, manage_cloudhook)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass_notify.async_reload(hass, DOMAIN) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 14f8b59ddee..cc1b3c74356 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -63,6 +63,18 @@ from .const import ( # noqa: F401 CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_DIFFUSE, + CONF_FAN_MODE_FOCUS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_MIDDLE, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_TOP, + CONF_FAN_MODE_VALUES, CONF_FANS, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, @@ -100,7 +112,6 @@ from .const import ( # noqa: F401 CONF_STOPBITS, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_TARGET_TEMP, @@ -123,6 +134,7 @@ from .const import ( # noqa: F401 from .modbus import ModbusHub, async_modbus_setup from .validators import ( duplicate_entity_validator, + duplicate_fan_mode_validator, duplicate_modbus_validator, nan_validator, number_validator, @@ -145,7 +157,7 @@ BASE_COMPONENT_SCHEMA = vol.Schema( vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL ): cv.positive_int, - vol.Optional(CONF_LAZY_ERROR, default=0): cv.positive_int, + vol.Optional(CONF_LAZY_ERROR): cv.positive_int, vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -178,10 +190,11 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( vol.Optional(CONF_STRUCTURE): cv.string, vol.Optional(CONF_SCALE, default=1): number_validator, vol.Optional(CONF_OFFSET, default=0): number_validator, - vol.Optional(CONF_PRECISION, default=0): cv.positive_int, - vol.Optional(CONF_SWAP, default=CONF_SWAP_NONE): vol.In( + vol.Optional(CONF_PRECISION): cv.positive_int, + vol.Optional( + CONF_SWAP, + ): vol.In( [ - CONF_SWAP_NONE, CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, @@ -265,6 +278,26 @@ CLIMATE_SCHEMA = vol.All( vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean, } ), + vol.Optional(CONF_FAN_MODE_REGISTER): vol.Maybe( + vol.All( + { + CONF_ADDRESS: cv.positive_int, + CONF_FAN_MODE_VALUES: { + vol.Optional(CONF_FAN_MODE_ON): cv.positive_int, + vol.Optional(CONF_FAN_MODE_OFF): cv.positive_int, + vol.Optional(CONF_FAN_MODE_AUTO): cv.positive_int, + vol.Optional(CONF_FAN_MODE_LOW): cv.positive_int, + vol.Optional(CONF_FAN_MODE_MEDIUM): cv.positive_int, + vol.Optional(CONF_FAN_MODE_HIGH): cv.positive_int, + vol.Optional(CONF_FAN_MODE_TOP): cv.positive_int, + vol.Optional(CONF_FAN_MODE_MIDDLE): cv.positive_int, + vol.Optional(CONF_FAN_MODE_FOCUS): cv.positive_int, + vol.Optional(CONF_FAN_MODE_DIFFUSE): cv.positive_int, + }, + }, + duplicate_fan_mode_validator, + ), + ), } ), ) @@ -341,7 +374,7 @@ MODBUS_SCHEMA = vol.Schema( vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, vol.Optional(CONF_CLOSE_COMM_ON_ERROR): cv.boolean, vol.Optional(CONF_DELAY, default=0): cv.positive_int, - vol.Optional(CONF_RETRIES, default=3): cv.positive_int, + vol.Optional(CONF_RETRIES): cv.positive_int, vol.Optional(CONF_RETRY_ON_EMPTY): cv.boolean, vol.Optional(CONF_MSG_WAIT): cv.positive_int, vol.Optional(CONF_BINARY_SENSORS): vol.All( diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index edfca94979e..d3ec06bbdd7 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -24,10 +24,11 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from .const import ( @@ -55,13 +56,13 @@ from .const import ( CONF_STATE_ON, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VERIFY, CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, + MODBUS_DOMAIN, SIGNAL_START_ENTITY, SIGNAL_STOP_ENTITY, DataType, @@ -75,8 +76,34 @@ _LOGGER = logging.getLogger(__name__) class BasePlatform(Entity): """Base for readonly platforms.""" - def __init__(self, hub: ModbusHub, entry: dict[str, Any]) -> None: + def __init__( + self, hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any] + ) -> None: """Initialize the Modbus binary sensor.""" + + if CONF_LAZY_ERROR in entry: + async_create_issue( + hass, + MODBUS_DOMAIN, + "removed_lazy_error_count", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="removed_lazy_error_count", + translation_placeholders={ + "config_key": "lazy_error_count", + "integration": MODBUS_DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`close_comm_on_error`: is deprecated and will be removed in version 2024.4" + ) + + _LOGGER.warning( + "`lazy_error_count`: is deprecated and will be removed in version 2024.7" + ) + self._hub = hub self._slave = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) self._address = int(entry[CONF_ADDRESS]) @@ -93,8 +120,6 @@ class BasePlatform(Entity): self._attr_device_class = entry.get(CONF_DEVICE_CLASS) self._attr_available = True self._attr_unit_of_measurement = None - self._lazy_error_count = entry[CONF_LAZY_ERROR] - self._lazy_errors = self._lazy_error_count def get_optional_numeric_config(config_name: str) -> int | float | None: if (val := entry.get(config_name)) is None: @@ -154,18 +179,14 @@ class BasePlatform(Entity): class BaseStructPlatform(BasePlatform, RestoreEntity): """Base class representing a sensor/climate.""" - def __init__(self, hub: ModbusHub, config: dict) -> None: + def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None: """Initialize the switch.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._swap = config[CONF_SWAP] - if self._swap == CONF_SWAP_NONE: - self._swap = None self._data_type = config[CONF_DATA_TYPE] self._structure: str = config[CONF_STRUCTURE] - self._precision = config[CONF_PRECISION] self._scale = config[CONF_SCALE] - if self._scale < 1 and not self._precision: - self._precision = 2 + self._precision = config.get(CONF_PRECISION, 2 if self._scale < 1 else 0) self._offset = config[CONF_OFFSET] self._slave_count = config.get(CONF_SLAVE_COUNT, None) or config.get( CONF_VIRTUAL_COUNT, 0 @@ -250,10 +271,10 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): """Base class representing a Modbus switch.""" - def __init__(self, hub: ModbusHub, config: dict) -> None: + def __init__(self, hass: HomeAssistant, hub: ModbusHub, config: dict) -> None: """Initialize the switch.""" config[CONF_INPUT_TYPE] = "" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._attr_is_on = False convert = { CALL_TYPE_REGISTER_HOLDING: ( @@ -346,15 +367,10 @@ class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): ) self._call_active = False if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._verify_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): self._attr_is_on = bool(result.bits[0] & 1) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 39174ae8931..6c0f6422df2 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -54,7 +54,7 @@ async def async_setup_platform( slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( CONF_VIRTUAL_COUNT, 0 ) - sensor = ModbusBinarySensor(hub, entry, slave_count) + sensor = ModbusBinarySensor(hass, hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) sensors.append(sensor) @@ -64,12 +64,18 @@ async def async_setup_platform( class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): """Modbus binary sensor.""" - def __init__(self, hub: ModbusHub, entry: dict[str, Any], slave_count: int) -> None: + def __init__( + self, + hass: HomeAssistant, + hub: ModbusHub, + entry: dict[str, Any], + slave_count: int, + ) -> None: """Initialize the Modbus binary sensor.""" self._count = slave_count + 1 self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None self._result: list[int] = [] - super().__init__(hub, entry) + super().__init__(hass, hub, entry) async def async_setup_slaves( self, hass: HomeAssistant, slave_count: int, entry: dict[str, Any] @@ -109,14 +115,9 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): ) self._call_active = False if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self._result = [] else: - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._input_type in (CALL_TYPE_COIL, CALL_TYPE_DISCRETE): self._result = result.bits diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index df2983e9070..76132014413 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -6,6 +6,16 @@ import struct from typing import Any, cast from homeassistant.components.climate import ( + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, + FAN_TOP, ClimateEntity, ClimateEntityFeature, HVACMode, @@ -31,6 +41,18 @@ from .const import ( CALL_TYPE_WRITE_REGISTER, CALL_TYPE_WRITE_REGISTERS, CONF_CLIMATES, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_DIFFUSE, + CONF_FAN_MODE_FOCUS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_MIDDLE, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_TOP, + CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -67,7 +89,7 @@ async def async_setup_platform( entities = [] for entity in discovery_info[CONF_CLIMATES]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - entities.append(ModbusThermostat(hub, entity)) + entities.append(ModbusThermostat(hass, hub, entity)) async_add_entities(entities) @@ -79,11 +101,12 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any], ) -> None: """Initialize the modbus thermostat.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._target_temperature_register = config[CONF_TARGET_TEMP] self._target_temperature_write_registers = config[ CONF_TARGET_TEMP_WRITE_REGISTERS @@ -137,6 +160,42 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = HVACMode.AUTO self._attr_hvac_modes = [HVACMode.AUTO] + if CONF_FAN_MODE_REGISTER in config: + self._attr_supported_features = ( + self._attr_supported_features | ClimateEntityFeature.FAN_MODE + ) + mode_config = config[CONF_FAN_MODE_REGISTER] + self._fan_mode_register = mode_config[CONF_ADDRESS] + self._attr_fan_modes = cast(list[str], []) + self._attr_fan_mode = None + self._fan_mode_mapping_to_modbus: dict[str, int] = {} + self._fan_mode_mapping_from_modbus: dict[int, str] = {} + mode_value_config = mode_config[CONF_FAN_MODE_VALUES] + + for fan_mode_kw, fan_mode in ( + (CONF_FAN_MODE_ON, FAN_ON), + (CONF_FAN_MODE_OFF, FAN_OFF), + (CONF_FAN_MODE_AUTO, FAN_AUTO), + (CONF_FAN_MODE_LOW, FAN_LOW), + (CONF_FAN_MODE_MEDIUM, FAN_MEDIUM), + (CONF_FAN_MODE_HIGH, FAN_HIGH), + (CONF_FAN_MODE_TOP, FAN_TOP), + (CONF_FAN_MODE_MIDDLE, FAN_MIDDLE), + (CONF_FAN_MODE_FOCUS, FAN_FOCUS), + (CONF_FAN_MODE_DIFFUSE, FAN_DIFFUSE), + ): + if fan_mode_kw in mode_value_config: + value = mode_value_config[fan_mode_kw] + self._fan_mode_mapping_from_modbus[value] = fan_mode + self._fan_mode_mapping_to_modbus[fan_mode] = value + self._attr_fan_modes.append(fan_mode) + + else: + # No HVAC modes defined + self._fan_mode_register = None + self._attr_fan_mode = FAN_AUTO + self._attr_fan_modes = [FAN_AUTO] + if CONF_HVAC_ONOFF_REGISTER in config: self._hvac_onoff_register = config[CONF_HVAC_ONOFF_REGISTER] self._hvac_onoff_write_registers = config[CONF_WRITE_REGISTERS] @@ -193,6 +252,21 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): await self.async_update() + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + + if self._fan_mode_register is not None: + # Write a value to the mode register for the desired mode. + value = self._fan_mode_mapping_to_modbus[fan_mode] + await self._hub.async_pb_call( + self._slave, + self._fan_mode_register, + value, + CALL_TYPE_WRITE_REGISTER, + ) + + await self.async_update() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_temperature = ( @@ -254,7 +328,7 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._input_type, self._address ) - # Read the mode register if defined + # Read the HVAC mode register if defined if self._hvac_mode_register is not None: hvac_mode = await self._async_read_register( CALL_TYPE_REGISTER_HOLDING, self._hvac_mode_register, raw=True @@ -268,7 +342,17 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._attr_hvac_mode = mode break - # Read th on/off register if defined. If the value in this + # Read the Fan mode register if defined + if self._fan_mode_register is not None: + fan_mode = await self._async_read_register( + CALL_TYPE_REGISTER_HOLDING, self._fan_mode_register, raw=True + ) + + # Translate the value received + if fan_mode is not None: + self._attr_fan_mode = self._fan_mode_mapping_from_modbus[int(fan_mode)] + + # Read the on/off register if defined. If the value in this # register is "OFF", it will take precedence over the value # in the mode register. if self._hvac_onoff_register is not None: @@ -288,15 +372,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): self._slave, register, self._count, register_type ) if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return -1 - self._lazy_errors = self._lazy_error_count self._attr_available = False return -1 - self._lazy_errors = self._lazy_error_count - if raw: # Return the raw value read from the register, do not change # the object's state diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index a52f8ccfc97..e536a31c4f6 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -45,13 +45,23 @@ CONF_STEP = "temp_step" CONF_STOPBITS = "stopbits" CONF_SWAP = "swap" CONF_SWAP_BYTE = "byte" -CONF_SWAP_NONE = "none" CONF_SWAP_WORD = "word" CONF_SWAP_WORD_BYTE = "word_byte" CONF_TARGET_TEMP = "target_temp_register" CONF_TARGET_TEMP_WRITE_REGISTERS = "target_temp_write_registers" +CONF_FAN_MODE_REGISTER = "fan_mode_register" +CONF_FAN_MODE_ON = "state_fan_on" +CONF_FAN_MODE_OFF = "state_fan_off" +CONF_FAN_MODE_LOW = "state_fan_low" +CONF_FAN_MODE_MEDIUM = "state_fan_medium" +CONF_FAN_MODE_HIGH = "state_fan_high" +CONF_FAN_MODE_AUTO = "state_fan_auto" +CONF_FAN_MODE_TOP = "state_fan_top" +CONF_FAN_MODE_MIDDLE = "state_fan_middle" +CONF_FAN_MODE_FOCUS = "state_fan_focus" +CONF_FAN_MODE_DIFFUSE = "state_fan_diffuse" +CONF_FAN_MODE_VALUES = "values" CONF_HVAC_MODE_REGISTER = "hvac_mode_register" -CONF_HVAC_MODE_VALUES = "values" CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register" CONF_HVAC_MODE_OFF = "state_off" CONF_HVAC_MODE_HEAT = "state_heat" @@ -60,6 +70,7 @@ CONF_HVAC_MODE_HEAT_COOL = "state_heat_cool" CONF_HVAC_MODE_AUTO = "state_auto" CONF_HVAC_MODE_DRY = "state_dry" CONF_HVAC_MODE_FAN_ONLY = "state_fan_only" +CONF_HVAC_MODE_VALUES = "values" CONF_WRITE_REGISTERS = "write_registers" CONF_VERIFY = "verify" CONF_VIRTUAL_COUNT = "virtual_count" diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 27f9cb1fc18..072f1bb3d93 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -51,7 +51,7 @@ async def async_setup_platform( covers = [] for cover in discovery_info[CONF_COVERS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - covers.append(ModbusCover(hub, cover)) + covers.append(ModbusCover(hass, hub, cover)) async_add_entities(covers) @@ -63,11 +63,12 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, config: dict[str, Any], ) -> None: """Initialize the modbus cover.""" - super().__init__(hub, config) + super().__init__(hass, hub, config) self._state_closed = config[CONF_STATE_CLOSED] self._state_closing = config[CONF_STATE_CLOSING] self._state_open = config[CONF_STATE_OPEN] @@ -142,14 +143,9 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._slave, self._address, 1, self._input_type ) if result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self.async_write_ha_state() return - self._lazy_errors = self._lazy_error_count self._attr_available = True if self._input_type == CALL_TYPE_COIL: self._set_attr_state(bool(result.bits[0] & 1)) diff --git a/homeassistant/components/modbus/fan.py b/homeassistant/components/modbus/fan.py index a986b243c1b..e5006b66f81 100644 --- a/homeassistant/components/modbus/fan.py +++ b/homeassistant/components/modbus/fan.py @@ -30,7 +30,7 @@ async def async_setup_platform( for entry in discovery_info[CONF_FANS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - fans.append(ModbusFan(hub, entry)) + fans.append(ModbusFan(hass, hub, entry)) async_add_entities(fans) diff --git a/homeassistant/components/modbus/light.py b/homeassistant/components/modbus/light.py index 2e5ac62be21..acc01f39b46 100644 --- a/homeassistant/components/modbus/light.py +++ b/homeassistant/components/modbus/light.py @@ -29,7 +29,7 @@ async def async_setup_platform( lights = [] for entry in discovery_info[CONF_LIGHTS]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - lights.append(ModbusLight(hub, entry)) + lights.append(ModbusLight(hass, hub, entry)) async_add_entities(lights) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index c0474ad75d5..95c0cd45332 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -260,6 +260,26 @@ class ModbusHub: def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: """Initialize the Modbus hub.""" + if CONF_RETRIES in client_config: + async_create_issue( + hass, + DOMAIN, + "deprecated_retries", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_retries", + translation_placeholders={ + "config_key": "retries", + "integration": DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`retries`: is deprecated and will be removed in version 2024.7" + ) + else: + client_config[CONF_RETRIES] = 3 if CONF_CLOSE_COMM_ON_ERROR in client_config: async_create_issue( hass, @@ -315,7 +335,7 @@ class ModbusHub: self._pb_params = { "port": client_config[CONF_PORT], "timeout": client_config[CONF_TIMEOUT], - "retries": client_config[CONF_RETRIES], + "retries": 3, "retry_on_empty": True, } if self._config_type == SERIAL: diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 52aa37535d6..c015d117b13 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -1,7 +1,7 @@ """Support for Modbus Register sensors.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime import logging from typing import Any @@ -19,7 +19,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -53,7 +52,7 @@ async def async_setup_platform( slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( CONF_VIRTUAL_COUNT, 0 ) - sensor = ModbusRegisterSensor(hub, entry, slave_count) + sensor = ModbusRegisterSensor(hass, hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) sensors.append(sensor) @@ -65,12 +64,13 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): def __init__( self, + hass: HomeAssistant, hub: ModbusHub, entry: dict[str, Any], slave_count: int, ) -> None: """Initialize the modbus register sensor.""" - super().__init__(hub, entry) + super().__init__(hass, hub, entry) if slave_count: self._count = self._count * (slave_count + 1) self._coordinator: DataUpdateCoordinator[list[int] | None] | None = None @@ -114,13 +114,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): self._slave, self._address, self._count, self._input_type ) if raw_result is None: - if self._lazy_errors: - self._lazy_errors -= 1 - self._cancel_call = async_call_later( - self.hass, timedelta(seconds=1), self.async_update - ) - return - self._lazy_errors = self._lazy_error_count self._attr_available = False self._attr_native_value = None if self._coordinator: @@ -142,7 +135,6 @@ class ModbusRegisterSensor(BaseStructPlatform, RestoreSensor, SensorEntity): else: self._attr_native_value = result self._attr_available = self._attr_native_value is not None - self._lazy_errors = self._lazy_error_count self.async_write_ha_state() diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 5f45d0df596..12e66f5d2ca 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -70,9 +70,17 @@ } }, "issues": { + "removed_lazy_error_count": { + "title": "`{config_key}` configuration key is being removed", + "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue. All errors will be reported, as lazy_error_count is accepted but ignored" + }, + "deprecated_retries": { + "title": "`{config_key}` configuration key is being removed", + "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nThe maximum number of retries is now fixed to 3." + }, "deprecated_close_comm_config": { "title": "`{config_key}` configuration key is being removed", - "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nCommunication is automatically closed on errors, see [the documentation]({url}) for other error handling parameters." + "description": "Please remove the `{config_key}` key from the {integration} entry in your `configuration.yaml` file and restart Home Assistant to fix this issue. All errors will be reported, as `lazy_error_count` is accepted but ignored." }, "deprecated_retry_on_empty": { "title": "`{config_key}` configuration key is being removed", diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index beb84096006..0c955ea409d 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -30,7 +30,7 @@ async def async_setup_platform( for entry in discovery_info[CONF_SWITCHES]: hub: ModbusHub = get_hub(hass, discovery_info[CONF_NAME]) - switches.append(ModbusSwitch(hub, entry)) + switches.append(ModbusSwitch(hass, hub, entry)) async_add_entities(switches) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 52919a24ac7..5e2129bd90a 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -26,13 +26,16 @@ from homeassistant.const import ( from .const import ( CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, + CONF_HVAC_MODE_REGISTER, CONF_INPUT_TYPE, CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_TARGET_TEMP, CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, DEFAULT_HUB, @@ -115,37 +118,32 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: count = config.get(CONF_COUNT, None) structure = config.get(CONF_STRUCTURE, None) slave_count = config.get(CONF_SLAVE_COUNT, config.get(CONF_VIRTUAL_COUNT)) - swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm + swap_type = config.get(CONF_SWAP) + swap_dict = { + CONF_SWAP_BYTE: validator.swap_byte, + CONF_SWAP_WORD: validator.swap_word, + CONF_SWAP_WORD_BYTE: validator.swap_word, + } + swap_type_validator = swap_dict[swap_type] if swap_type else OPTIONAL for entry in ( (count, validator.count, CONF_COUNT), (structure, validator.structure, CONF_STRUCTURE), ( slave_count, validator.slave_count, - f"{CONF_VIRTUAL_COUNT} / {CONF_SLAVE_COUNT}", + f"{CONF_VIRTUAL_COUNT} / {CONF_SLAVE_COUNT}:", ), + (swap_type, swap_type_validator, f"{CONF_SWAP}:{swap_type}"), ): if entry[0] is None: if entry[1] == DEMANDED: - error = f"{name}: `{entry[2]}:` missing, demanded with `{CONF_DATA_TYPE}: {data_type}`" + error = f"{name}: `{entry[2]}` missing, demanded with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) elif entry[1] == ILLEGAL: - error = ( - f"{name}: `{entry[2]}:` illegal with `{CONF_DATA_TYPE}: {data_type}`" - ) + error = f"{name}: `{entry[2]}` illegal with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) - if swap_type != CONF_SWAP_NONE: - swap_type_validator = { - CONF_SWAP_NONE: validator.swap_byte, - CONF_SWAP_BYTE: validator.swap_byte, - CONF_SWAP_WORD: validator.swap_word, - CONF_SWAP_WORD_BYTE: validator.swap_word, - }[swap_type] - if swap_type_validator == ILLEGAL: - error = f"{name}: `{CONF_SWAP}:{swap_type}` illegal with `{CONF_DATA_TYPE}: {data_type}`" - raise vol.Invalid(error) if config[CONF_DATA_TYPE] == DataType.CUSTOM: try: size = struct.calcsize(structure) @@ -266,12 +264,31 @@ def duplicate_entity_validator(config: dict) -> dict: addr += "_" + str(entry[CONF_COMMAND_OFF]) inx = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) addr += "_" + str(inx) - if addr in addresses: - err = ( - f"Modbus {component}/{name} address {addr} is duplicate, second" - " entry not loaded!" - ) - _LOGGER.warning(err) + entry_addrs: set[str] = set() + entry_addrs.add(addr) + + if CONF_TARGET_TEMP in entry: + a = str(entry[CONF_TARGET_TEMP]) + a += "_" + str(inx) + entry_addrs.add(a) + if CONF_HVAC_MODE_REGISTER in entry: + a = str(entry[CONF_HVAC_MODE_REGISTER][CONF_ADDRESS]) + a += "_" + str(inx) + entry_addrs.add(a) + if CONF_FAN_MODE_REGISTER in entry: + a = str(entry[CONF_FAN_MODE_REGISTER][CONF_ADDRESS]) + a += "_" + str(inx) + entry_addrs.add(a) + + dup_addrs = entry_addrs.intersection(addresses) + + if len(dup_addrs) > 0: + for addr in dup_addrs: + err = ( + f"Modbus {component}/{name} address {addr} is duplicate, second" + " entry not loaded!" + ) + _LOGGER.warning(err) errors.append(index) elif name in names: err = ( @@ -282,7 +299,7 @@ def duplicate_entity_validator(config: dict) -> dict: errors.append(index) else: names.add(name) - addresses.add(addr) + addresses.update(entry_addrs) for i in reversed(errors): del config[hub_index][conf_key][i] @@ -301,11 +318,11 @@ def duplicate_modbus_validator(config: list) -> list: else: host = f"{hub[CONF_HOST]}_{hub[CONF_PORT]}" if host in hosts: - err = f"Modbus {name}  contains duplicate host/port {host}, not loaded!" + err = f"Modbus {name} contains duplicate host/port {host}, not loaded!" _LOGGER.warning(err) errors.append(index) elif name in names: - err = f"Modbus {name}  is duplicate, second entry not loaded!" + err = f"Modbus {name} is duplicate, second entry not loaded!" _LOGGER.warning(err) errors.append(index) else: @@ -315,3 +332,20 @@ def duplicate_modbus_validator(config: list) -> list: for i in reversed(errors): del config[i] return config + + +def duplicate_fan_mode_validator(config: dict[str, Any]) -> dict: + """Control modbus climate fan mode values for duplicates.""" + fan_modes: set[int] = set() + errors = [] + for key, value in config[CONF_FAN_MODE_VALUES].items(): + if value in fan_modes: + wrn = f"Modbus fan mode {key} has a duplicate value {value}, not loaded, values must be unique!" + _LOGGER.warning(wrn) + errors.append(key) + else: + fan_modes.add(value) + + for key in reversed(errors): + del config[CONF_FAN_MODE_VALUES][key] + return config diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 9d5a3c32235..e6bcff715b8 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -12,10 +12,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import ( ModernFormsDataUpdateCoordinator, diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 59fc41df9b0..37519a236ab 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -111,7 +111,7 @@ def get_motioneye_device_identifier( def split_motioneye_device_identifier( - identifier: tuple[str, str] + identifier: tuple[str, str], ) -> tuple[str, str, int] | None: """Get the identifiers for a motionEye device.""" if len(identifier) != 2 or identifier[0] != DOMAIN or "_" not in identifier[1]: diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py new file mode 100644 index 00000000000..8baceb104c3 --- /dev/null +++ b/homeassistant/components/motionmount/__init__.py @@ -0,0 +1,61 @@ +"""The Vogel's MotionMount integration.""" +from __future__ import annotations + +import socket + +import motionmount + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN, EMPTY_MAC + +PLATFORMS: list[Platform] = [ + Platform.NUMBER, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Vogel's MotionMount from a config entry.""" + + host = entry.data[CONF_HOST] + + # Create API instance + mm = motionmount.MotionMount(host, entry.data[CONF_PORT]) + + # Validate the API connection + try: + await mm.connect() + except (ConnectionError, TimeoutError, socket.gaierror) as ex: + raise ConfigEntryNotReady(f"Failed to connect to {host}") from ex + + found_mac = format_mac(mm.mac.hex()) + if found_mac not in (EMPTY_MAC, entry.unique_id): + # If the mac address of the device does not match the unique_id + # of the config entry, it likely means the DHCP lease has expired + # and the device has been assigned a new IP address. We need to + # wait for the next discovery to find the device at its new address + # and update the config entry so we do not mix up devices. + await mm.disconnect() + raise ConfigEntryNotReady( + f"Unexpected device found at {host}; expected {entry.unique_id}, found {found_mac}" + ) + + # Store an API object for your platforms to access + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = mm + + 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): + mm: motionmount.MotionMount = hass.data[DOMAIN].pop(entry.entry_id) + await mm.disconnect() + + return unload_ok diff --git a/homeassistant/components/motionmount/config_flow.py b/homeassistant/components/motionmount/config_flow.py new file mode 100644 index 00000000000..a593b30201e --- /dev/null +++ b/homeassistant/components/motionmount/config_flow.py @@ -0,0 +1,176 @@ +"""Config flow for Vogel's MotionMount.""" +import logging +import socket +from typing import Any + +import motionmount +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_UUID +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac + +from .const import DOMAIN, EMPTY_MAC + +_LOGGER = logging.getLogger(__name__) + + +# A MotionMount can be in four states: +# 1. Old CE and old Pro FW -> It doesn't supply any kind of mac +# 2. Old CE but new Pro FW -> It supplies its mac using DNS-SD, but a read of the mac fails +# 3. New CE but old Pro FW -> It doesn't supply the mac using DNS-SD but we can read it (returning the EMPTY_MAC) +# 4. New CE and new Pro FW -> Both DNS-SD and a read gives us the mac +# If we can't get the mac, we use DEFAULT_DISCOVERY_UNIQUE_ID as an ID, so we can always configure a single MotionMount. Most households will only have a single MotionMount +class MotionMountFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Vogel's MotionMount config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Set up the instance.""" + self.discovery_info: dict[str, Any] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + if user_input is None: + return self._show_setup_form() + + info = {} + try: + info = await self._validate_input(user_input) + except (ConnectionError, socket.gaierror): + return self.async_abort(reason="cannot_connect") + except TimeoutError: + return self.async_abort(reason="time_out") + except motionmount.NotConnectedError: + return self.async_abort(reason="not_connected") + except motionmount.MotionMountResponseError: + # This is most likely due to missing support for the mac address property + # Abort if the handler has config entries already + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + + # Otherwise we try to continue with the generic uid + info[CONF_UUID] = config_entries.DEFAULT_DISCOVERY_UNIQUE_ID + + # If the device mac is valid we use it, otherwise we use the default id + if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: + unique_id = info[CONF_UUID] + else: + unique_id = config_entries.DEFAULT_DISCOVERY_UNIQUE_ID + + name = info.get(CONF_NAME, user_input[CONF_HOST]) + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: user_input[CONF_PORT], + } + ) + + return self.async_create_entry(title=name, data=user_input) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + + # Extract information from discovery + host = discovery_info.hostname + port = discovery_info.port + zctype = discovery_info.type + name = discovery_info.name.removesuffix(f".{zctype}") + unique_id = discovery_info.properties.get("mac") + + self.discovery_info.update( + { + CONF_HOST: host, + CONF_PORT: port, + CONF_NAME: name, + } + ) + + if unique_id: + # If we already have the unique id, try to set it now + # so we can avoid probing the device if its already + # configured or ignored + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host, CONF_PORT: port} + ) + else: + # Avoid probing devices that already have an entry + self._async_abort_entries_match({CONF_HOST: host}) + + self.context.update({"title_placeholders": {"name": name}}) + + try: + info = await self._validate_input(self.discovery_info) + except (ConnectionError, socket.gaierror): + return self.async_abort(reason="cannot_connect") + except TimeoutError: + return self.async_abort(reason="time_out") + except motionmount.NotConnectedError: + return self.async_abort(reason="not_connected") + except motionmount.MotionMountResponseError: + info = {} + # We continue as we want to be able to connect with older FW that does not support MAC address + + # If the device supplied as with a valid MAC we use that + if info.get(CONF_UUID, EMPTY_MAC) != EMPTY_MAC: + unique_id = info[CONF_UUID] + + if unique_id: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host, CONF_PORT: port} + ) + else: + await self._async_handle_discovery_without_unique_id() + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a confirmation flow initiated by zeroconf.""" + if user_input is None: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={CONF_NAME: self.discovery_info[CONF_NAME]}, + errors={}, + ) + + return self.async_create_entry( + title=self.discovery_info[CONF_NAME], + data=self.discovery_info, + ) + + async def _validate_input(self, data: dict) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + + mm = motionmount.MotionMount(data[CONF_HOST], data[CONF_PORT]) + try: + await mm.connect() + finally: + await mm.disconnect() + + return {CONF_UUID: format_mac(mm.mac.hex()), CONF_NAME: mm.name} + + def _show_setup_form(self, errors: dict[str, str] | None = None) -> FlowResult: + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=23): int, + } + ), + errors=errors or {}, + ) diff --git a/homeassistant/components/motionmount/const.py b/homeassistant/components/motionmount/const.py new file mode 100644 index 00000000000..92045193ad6 --- /dev/null +++ b/homeassistant/components/motionmount/const.py @@ -0,0 +1,5 @@ +"""Constants for the Vogel's MotionMount integration.""" + +DOMAIN = "motionmount" + +EMPTY_MAC = "00:00:00:00:00:00" diff --git a/homeassistant/components/motionmount/entity.py b/homeassistant/components/motionmount/entity.py new file mode 100644 index 00000000000..c3f7c9c9358 --- /dev/null +++ b/homeassistant/components/motionmount/entity.py @@ -0,0 +1,53 @@ +"""Support for MotionMount sensors.""" + +import motionmount + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, EMPTY_MAC + + +class MotionMountEntity(Entity): + """Representation of a MotionMount entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize general MotionMount entity.""" + self.mm = mm + mac = format_mac(mm.mac.hex()) + + # Create a base unique id + if mac == EMPTY_MAC: + self._base_unique_id = config_entry.entry_id + else: + self._base_unique_id = mac + + # Set device info + self._attr_device_info = DeviceInfo( + name=mm.name, + manufacturer="Vogel's", + model="TVM 7675", + ) + + if mac == EMPTY_MAC: + self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, config_entry.entry_id)} + else: + self._attr_device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, mac) + } + + async def async_added_to_hass(self) -> None: + """Store register state change callback.""" + self.mm.add_listener(self.async_write_ha_state) + await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + """Remove register state change callback.""" + self.mm.remove_listener(self.async_write_ha_state) + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json new file mode 100644 index 00000000000..bfe7e21fce9 --- /dev/null +++ b/homeassistant/components/motionmount/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "motionmount", + "name": "Vogel's MotionMount", + "codeowners": ["@RJPoelstra"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/motionmount", + "integration_type": "device", + "iot_class": "local_push", + "requirements": ["python-MotionMount==0.3.1"], + "zeroconf": ["_tvm._tcp.local."] +} diff --git a/homeassistant/components/motionmount/number.py b/homeassistant/components/motionmount/number.py new file mode 100644 index 00000000000..476e14c3a82 --- /dev/null +++ b/homeassistant/components/motionmount/number.py @@ -0,0 +1,71 @@ +"""Support for MotionMount numeric control.""" +import motionmount + +from homeassistant.components.number import NumberEntity +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 DOMAIN +from .entity import MotionMountEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Vogel's MotionMount from a config entry.""" + mm: motionmount.MotionMount = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + ( + MotionMountExtension(mm, entry), + MotionMountTurn(mm, entry), + ) + ) + + +class MotionMountExtension(MotionMountEntity, NumberEntity): + """The target extension position of a MotionMount.""" + + _attr_native_max_value = 100 + _attr_native_min_value = 0 + _attr_native_unit_of_measurement = PERCENTAGE + _attr_translation_key = "motionmount_extension" + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize Extension number.""" + super().__init__(mm, config_entry) + self._attr_unique_id = f"{self._base_unique_id}-extension" + + @property + def native_value(self) -> float: + """Get native value.""" + return float(self.mm.extension or 0) + + async def async_set_native_value(self, value: float) -> None: + """Set the new value for extension.""" + await self.mm.set_extension(int(value)) + + +class MotionMountTurn(MotionMountEntity, NumberEntity): + """The target turn position of a MotionMount.""" + + _attr_native_max_value = 100 + _attr_native_min_value = -100 + _attr_native_unit_of_measurement = PERCENTAGE + _attr_translation_key = "motionmount_turn" + + def __init__(self, mm: motionmount.MotionMount, config_entry: ConfigEntry) -> None: + """Initialize Turn number.""" + super().__init__(mm, config_entry) + self._attr_unique_id = f"{self._base_unique_id}-turn" + + @property + def native_value(self) -> float: + """Get native value.""" + return float(self.mm.turn or 0) * -1 + + async def async_set_native_value(self, value: float) -> None: + """Set the new value for turn.""" + await self.mm.set_turn(int(value * -1)) diff --git a/homeassistant/components/motionmount/strings.json b/homeassistant/components/motionmount/strings.json new file mode 100644 index 00000000000..00a409f3058 --- /dev/null +++ b/homeassistant/components/motionmount/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "title": "Link your MotionMount", + "description": "Set up your MotionMount to integrate with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + }, + "zeroconf_confirm": { + "description": "Do you want to set up {name}?", + "title": "Discovered MotionMount" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "time_out": "Failed to connect due to a time out.", + "not_connected": "Failed to connect.", + "invalid_response": "Failed to connect due to an invalid response from the MotionMount." + } + }, + "entity": { + "number": { + "motionmount_extension": { + "name": "Extension" + }, + "motionmount_turn": { + "name": "Turn" + } + } + } +} diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index eb9ab56208e..64d8c27f1de 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -166,6 +166,7 @@ ABBREVIATIONS = { "pl_ton": "payload_turn_on", "pl_trig": "payload_trigger", "pl_unlk": "payload_unlock", + "pos": "reports_position", "pos_clsd": "position_closed", "pos_open": "position_open", "pow_cmd_t": "power_command_topic", diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index c8696071fb4..65ffd4d17c0 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -417,8 +417,8 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): climate and water_heater platforms. """ - _attr_target_temperature_low: float | None = None - _attr_target_temperature_high: float | None = None + _attr_target_temperature_low: float | None + _attr_target_temperature_high: float | None _feature_preset_mode: bool = False _optimistic: bool @@ -608,6 +608,8 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): _default_name = DEFAULT_NAME _entity_id_format = climate.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED + _attr_target_temperature_low: float | None = None + _attr_target_temperature_high: float | None = None @staticmethod def config_schema() -> vol.Schema: diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 71260dc0239..0f2d617930d 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -53,6 +53,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( Platform.TEXT.value: vol.All(cv.ensure_list, [dict]), Platform.UPDATE.value: vol.All(cv.ensure_list, [dict]), Platform.VACUUM.value: vol.All(cv.ensure_list, [dict]), + Platform.VALVE.value: vol.All(cv.ensure_list, [dict]), Platform.WATER_HEATER.value: vol.All(cv.ensure_list, [dict]), } ) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 685e45700b5..50ea3860d9e 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -42,9 +42,18 @@ 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_PAYLOAD_CLOSE = "payload_close" +CONF_PAYLOAD_OPEN = "payload_open" +CONF_PAYLOAD_STOP = "payload_stop" +CONF_POSITION_CLOSED = "position_closed" +CONF_POSITION_OPEN = "position_open" CONF_POWER_COMMAND_TOPIC = "power_command_topic" CONF_POWER_COMMAND_TEMPLATE = "power_command_template" CONF_PRECISION = "precision" +CONF_STATE_CLOSED = "state_closed" +CONF_STATE_CLOSING = "state_closing" +CONF_STATE_OPEN = "state_open" +CONF_STATE_OPENING = "state_opening" CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" CONF_TEMP_STATE_TEMPLATE = "temperature_state_template" @@ -81,11 +90,16 @@ DEFAULT_ENCODING = "utf-8" DEFAULT_OPTIMISTIC = False DEFAULT_QOS = 0 DEFAULT_PAYLOAD_AVAILABLE = "online" +DEFAULT_PAYLOAD_CLOSE = "CLOSE" DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline" +DEFAULT_PAYLOAD_OPEN = "OPEN" DEFAULT_PORT = 1883 DEFAULT_RETAIN = False DEFAULT_WS_HEADERS: dict[str, str] = {} DEFAULT_WS_PATH = "/" +DEFAULT_POSITION_CLOSED = 0 +DEFAULT_POSITION_OPEN = 100 +DEFAULT_RETAIN = False PROTOCOL_31 = "3.1" PROTOCOL_311 = "3.1.1" @@ -146,6 +160,7 @@ PLATFORMS = [ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.VALVE, Platform.WATER_HEATER, ] @@ -173,5 +188,6 @@ RELOADABLE_PLATFORMS = [ Platform.TEXT, Platform.UPDATE, Platform.VACUUM, + Platform.VALVE, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 4e8cf0f4129..dce82774205 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -32,16 +32,34 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) from . import subscription from .config import MQTT_BASE_SCHEMA from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, + CONF_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN, + CONF_PAYLOAD_STOP, + CONF_POSITION_CLOSED, + CONF_POSITION_OPEN, CONF_QOS, CONF_RETAIN, + CONF_STATE_CLOSED, + CONF_STATE_CLOSING, + CONF_STATE_OPEN, + CONF_STATE_OPENING, CONF_STATE_TOPIC, DEFAULT_OPTIMISTIC, + DEFAULT_PAYLOAD_CLOSE, + DEFAULT_PAYLOAD_OPEN, + DEFAULT_POSITION_CLOSED, + DEFAULT_POSITION_OPEN, + DEFAULT_RETAIN, ) from .debug_info import log_messages from .mixins import ( @@ -64,15 +82,6 @@ CONF_TILT_COMMAND_TEMPLATE = "tilt_command_template" CONF_TILT_STATUS_TOPIC = "tilt_status_topic" CONF_TILT_STATUS_TEMPLATE = "tilt_status_template" -CONF_PAYLOAD_CLOSE = "payload_close" -CONF_PAYLOAD_OPEN = "payload_open" -CONF_PAYLOAD_STOP = "payload_stop" -CONF_POSITION_CLOSED = "position_closed" -CONF_POSITION_OPEN = "position_open" -CONF_STATE_CLOSED = "state_closed" -CONF_STATE_CLOSING = "state_closing" -CONF_STATE_OPEN = "state_open" -CONF_STATE_OPENING = "state_opening" CONF_STATE_STOPPED = "state_stopped" CONF_TILT_CLOSED_POSITION = "tilt_closed_value" CONF_TILT_MAX = "tilt_max" @@ -84,13 +93,10 @@ TILT_PAYLOAD = "tilt" COVER_PAYLOAD = "cover" DEFAULT_NAME = "MQTT Cover" -DEFAULT_PAYLOAD_CLOSE = "CLOSE" -DEFAULT_PAYLOAD_OPEN = "OPEN" -DEFAULT_PAYLOAD_STOP = "STOP" -DEFAULT_POSITION_CLOSED = 0 -DEFAULT_POSITION_OPEN = 100 -DEFAULT_RETAIN = False + DEFAULT_STATE_STOPPED = "stopped" +DEFAULT_PAYLOAD_STOP = "STOP" + DEFAULT_TILT_CLOSED_POSITION = 0 DEFAULT_TILT_MAX = 100 DEFAULT_TILT_MIN = 0 @@ -239,6 +245,10 @@ class MqttCover(MqttEntity, CoverEntity): _entity_id_format: str = cover.ENTITY_ID_FORMAT _optimistic: bool _tilt_optimistic: bool + _tilt_closed_percentage: int + _tilt_open_percentage: int + _pos_range: tuple[int, int] + _tilt_range: tuple[int, int] @staticmethod def config_schema() -> vol.Schema: @@ -246,6 +256,15 @@ class MqttCover(MqttEntity, CoverEntity): return DISCOVERY_SCHEMA def _setup_from_config(self, config: ConfigType) -> None: + """Set up cover from config.""" + self._pos_range = (config[CONF_POSITION_CLOSED] + 1, config[CONF_POSITION_OPEN]) + self._tilt_range = (config[CONF_TILT_MIN] + 1, config[CONF_TILT_MAX]) + self._tilt_closed_percentage = ranged_value_to_percentage( + self._tilt_range, config[CONF_TILT_CLOSED_POSITION] + ) + self._tilt_open_percentage = ranged_value_to_percentage( + self._tilt_range, config[CONF_TILT_OPEN_POSITION] + ) no_position = ( config.get(CONF_SET_POSITION_TOPIC) is None and config.get(CONF_GET_POSITION_TOPIC) is None @@ -284,23 +303,22 @@ class MqttCover(MqttEntity, CoverEntity): ) template_config_attributes = { - "position_open": self._config[CONF_POSITION_OPEN], - "position_closed": self._config[CONF_POSITION_CLOSED], - "tilt_min": self._config[CONF_TILT_MIN], - "tilt_max": self._config[CONF_TILT_MAX], + "position_open": config[CONF_POSITION_OPEN], + "position_closed": config[CONF_POSITION_CLOSED], + "tilt_min": config[CONF_TILT_MIN], + "tilt_max": config[CONF_TILT_MAX], } self._value_template = MqttValueTemplate( - self._config.get(CONF_VALUE_TEMPLATE), - entity=self, + config.get(CONF_VALUE_TEMPLATE), entity=self ).async_render_with_possible_json_value self._set_position_template = MqttCommandTemplate( - self._config.get(CONF_SET_POSITION_TEMPLATE), entity=self + config.get(CONF_SET_POSITION_TEMPLATE), entity=self ).async_render self._get_position_template = MqttValueTemplate( - self._config.get(CONF_GET_POSITION_TEMPLATE), + config.get(CONF_GET_POSITION_TEMPLATE), entity=self, config_attributes=template_config_attributes, ).async_render_with_possible_json_value @@ -443,19 +461,17 @@ class MqttCover(MqttEntity, CoverEntity): payload = payload_dict["position"] try: - percentage_payload = self.find_percentage_in_range( - float(payload), COVER_PAYLOAD + percentage_payload = ranged_value_to_percentage( + self._pos_range, float(payload) ) except ValueError: _LOGGER.warning("Payload '%s' is not numeric", payload) return - self._attr_current_cover_position = percentage_payload + self._attr_current_cover_position = min(100, max(0, percentage_payload)) if self._config.get(CONF_STATE_TOPIC) is None: self._update_state( - STATE_CLOSED - if percentage_payload == DEFAULT_POSITION_CLOSED - else STATE_OPEN + STATE_CLOSED if self.current_cover_position == 0 else STATE_OPEN ) if self._config.get(CONF_GET_POSITION_TOPIC): @@ -506,9 +522,7 @@ class MqttCover(MqttEntity, CoverEntity): # Optimistically assume that cover has changed state. self._update_state(STATE_OPEN) if self._config.get(CONF_GET_POSITION_TOPIC): - self._attr_current_cover_position = self.find_percentage_in_range( - self._config[CONF_POSITION_OPEN], COVER_PAYLOAD - ) + self._attr_current_cover_position = 100 self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: @@ -527,9 +541,7 @@ class MqttCover(MqttEntity, CoverEntity): # Optimistically assume that cover has changed state. self._update_state(STATE_CLOSED) if self._config.get(CONF_GET_POSITION_TOPIC): - self._attr_current_cover_position = self.find_percentage_in_range( - self._config[CONF_POSITION_CLOSED], COVER_PAYLOAD - ) + self._attr_current_cover_position = 0 self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: @@ -565,9 +577,7 @@ class MqttCover(MqttEntity, CoverEntity): self._config[CONF_ENCODING], ) if self._tilt_optimistic: - self._attr_current_cover_tilt_position = self.find_percentage_in_range( - float(self._config[CONF_TILT_OPEN_POSITION]) - ) + self._attr_current_cover_tilt_position = self._tilt_open_percentage self.async_write_ha_state() async def async_close_cover_tilt(self, **kwargs: Any) -> None: @@ -592,58 +602,60 @@ class MqttCover(MqttEntity, CoverEntity): self._config[CONF_ENCODING], ) if self._tilt_optimistic: - self._attr_current_cover_tilt_position = self.find_percentage_in_range( - float(self._config[CONF_TILT_CLOSED_POSITION]) - ) + self._attr_current_cover_tilt_position = self._tilt_closed_percentage self.async_write_ha_state() async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" - tilt = kwargs[ATTR_TILT_POSITION] - percentage_tilt = tilt - tilt = self.find_in_range_from_percent(tilt) + tilt_percentage = kwargs[ATTR_TILT_POSITION] + tilt_ranged = round( + percentage_to_ranged_value(self._tilt_range, tilt_percentage) + ) # Handover the tilt after calculated from percent would make it more # consistent with receiving templates variables = { - "tilt_position": percentage_tilt, + "tilt_position": tilt_percentage, "entity_id": self.entity_id, "position_open": self._config.get(CONF_POSITION_OPEN), "position_closed": self._config.get(CONF_POSITION_CLOSED), "tilt_min": self._config.get(CONF_TILT_MIN), "tilt_max": self._config.get(CONF_TILT_MAX), } - tilt = self._set_tilt_template(tilt, variables=variables) + tilt_rendered = self._set_tilt_template(tilt_ranged, variables=variables) await self.async_publish( self._config[CONF_TILT_COMMAND_TOPIC], - tilt, + tilt_rendered, self._config[CONF_QOS], self._config[CONF_RETAIN], self._config[CONF_ENCODING], ) if self._tilt_optimistic: _LOGGER.debug("Set tilt value optimistic") - self._attr_current_cover_tilt_position = percentage_tilt + self._attr_current_cover_tilt_position = tilt_percentage self.async_write_ha_state() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - position = kwargs[ATTR_POSITION] - percentage_position = position - position = self.find_in_range_from_percent(position, COVER_PAYLOAD) + position_percentage = kwargs[ATTR_POSITION] + position_ranged = round( + percentage_to_ranged_value(self._pos_range, position_percentage) + ) variables = { - "position": percentage_position, + "position": position_percentage, "entity_id": self.entity_id, "position_open": self._config[CONF_POSITION_OPEN], "position_closed": self._config[CONF_POSITION_CLOSED], "tilt_min": self._config[CONF_TILT_MIN], "tilt_max": self._config[CONF_TILT_MAX], } - position = self._set_position_template(position, variables=variables) + position_rendered = self._set_position_template( + position_ranged, variables=variables + ) await self.async_publish( self._config[CONF_SET_POSITION_TOPIC], - position, + position_rendered, self._config[CONF_QOS], self._config[CONF_RETAIN], self._config[CONF_ENCODING], @@ -651,87 +663,37 @@ class MqttCover(MqttEntity, CoverEntity): if self._optimistic: self._update_state( STATE_CLOSED - if percentage_position == self._config[CONF_POSITION_CLOSED] + if position_percentage <= self._config[CONF_POSITION_CLOSED] else STATE_OPEN ) - self._attr_current_cover_position = percentage_position + self._attr_current_cover_position = position_percentage self.async_write_ha_state() async def async_toggle_tilt(self, **kwargs: Any) -> None: """Toggle the entity.""" - if self.is_tilt_closed(): + if ( + self.current_cover_tilt_position is not None + and self.current_cover_tilt_position <= self._tilt_closed_percentage + ): await self.async_open_cover_tilt(**kwargs) else: await self.async_close_cover_tilt(**kwargs) - def is_tilt_closed(self) -> bool: - """Return if the cover is tilted closed.""" - return self._attr_current_cover_tilt_position == self.find_percentage_in_range( - float(self._config[CONF_TILT_CLOSED_POSITION]) - ) - - def find_percentage_in_range( - self, position: float, range_type: str = TILT_PAYLOAD - ) -> int: - """Find the 0-100% value within the specified range.""" - # the range of motion as defined by the min max values - if range_type == COVER_PAYLOAD: - max_range: int = self._config[CONF_POSITION_OPEN] - min_range: int = self._config[CONF_POSITION_CLOSED] - else: - max_range = self._config[CONF_TILT_MAX] - min_range = self._config[CONF_TILT_MIN] - current_range = max_range - min_range - # offset to be zero based - offset_position = position - min_range - position_percentage = round(float(offset_position) / current_range * 100.0) - - max_percent = 100 - min_percent = 0 - position_percentage = min(max(position_percentage, min_percent), max_percent) - - return position_percentage - - def find_in_range_from_percent( - self, percentage: float, range_type: str = TILT_PAYLOAD - ) -> int: - """Find the adjusted value for 0-100% within the specified range. - - if the range is 80-180 and the percentage is 90 - this method would determine the value to send on the topic - by offsetting the max and min, getting the percentage value and - returning the offset - """ - if range_type == COVER_PAYLOAD: - max_range: int = self._config[CONF_POSITION_OPEN] - min_range: int = self._config[CONF_POSITION_CLOSED] - else: - max_range = self._config[CONF_TILT_MAX] - min_range = self._config[CONF_TILT_MIN] - offset = min_range - current_range = max_range - min_range - position = round(current_range * (percentage / 100.0)) - position += offset - - return position - @callback def tilt_payload_received(self, _payload: Any) -> None: """Set the tilt value.""" try: - payload = int(round(float(_payload))) + payload = round(float(_payload)) except ValueError: _LOGGER.warning("Payload '%s' is not numeric", _payload) return if ( - self._config[CONF_TILT_MIN] <= int(payload) <= self._config[CONF_TILT_MAX] - or self._config[CONF_TILT_MAX] - <= int(payload) - <= self._config[CONF_TILT_MIN] + self._config[CONF_TILT_MIN] <= payload <= self._config[CONF_TILT_MAX] + or self._config[CONF_TILT_MAX] <= payload <= self._config[CONF_TILT_MIN] ): - level = self.find_percentage_in_range(payload) + level = ranged_value_to_percentage(self._tilt_range, payload) self._attr_current_cover_tilt_position = level else: _LOGGER.warning( diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index c78319bb46a..84163e217df 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -74,6 +74,7 @@ SUPPORTED_COMPONENTS = { "text", "update", "vacuum", + "valve", "water_heater", } diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index c9302bf65b1..351eb422edc 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -35,7 +35,6 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entity_entry_helper, - write_state_on_attr_change, ) from .models import ( MqttValueTemplate, @@ -43,6 +42,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) +from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -120,9 +120,15 @@ class MqttEvent(MqttEntity, EventEntity): @callback @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"state"}) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" + if msg.retain: + _LOGGER.debug( + "Ignoring event trigger from replayed retained payload '%s' on topic %s", + msg.payload, + msg.topic, + ) + return event_attributes: dict[str, Any] = {} event_type: str payload = self._template(msg.payload, PayloadSentinel.DEFAULT) @@ -183,6 +189,8 @@ class MqttEvent(MqttEntity, EventEntity): payload, ) return + mqtt_data = get_mqtt_data(self.hass) + mqtt_data.state_write_requests.write_state_request(self) topics["state_topic"] = { "topic": self._config[CONF_STATE_TOPIC], diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index e3dcf66c8b1..24783e171c8 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -31,10 +31,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import subscription from .config import MQTT_RW_SCHEMA diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index c48ce2c0a80..3479f1611d8 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -3,7 +3,7 @@ from __future__ import annotations from contextlib import suppress import logging -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import voluptuous as vol @@ -367,10 +367,10 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): if brightness_supported(self.supported_color_modes): try: if brightness := values["brightness"]: - scale = self._config[CONF_BRIGHTNESS_SCALE] - self._attr_brightness = min( - 255, - round(brightness * 255 / scale), # type: ignore[operator] + if TYPE_CHECKING: + assert isinstance(brightness, float) + self._attr_brightness = color_util.value_to_brightness( + (1, self._config[CONF_BRIGHTNESS_SCALE]), brightness ) else: _LOGGER.debug( @@ -594,13 +594,12 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._set_flash_and_transition(message, **kwargs) if ATTR_BRIGHTNESS in kwargs and self._config[CONF_BRIGHTNESS]: - brightness_normalized = kwargs[ATTR_BRIGHTNESS] / DEFAULT_BRIGHTNESS_SCALE - brightness_scale = self._config[CONF_BRIGHTNESS_SCALE] - device_brightness = min( - round(brightness_normalized * brightness_scale), brightness_scale + device_brightness = color_util.brightness_to_value( + (1, self._config[CONF_BRIGHTNESS_SCALE]), + kwargs[ATTR_BRIGHTNESS], ) # Make sure the brightness is not rounded down to 0 - device_brightness = max(device_brightness, 1) + device_brightness = max(round(device_brightness), 1) message["brightness"] = device_brightness if self._optimistic: diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 76300afb97a..412664ceedf 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -139,7 +139,6 @@ CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template" MQTT_ATTRIBUTES_BLOCKED = { "assumed_state", "available", - "context_recent_time", "device_class", "device_info", "entity_category", @@ -1136,7 +1135,7 @@ class MqttDiscoveryUpdate(Entity): def device_info_from_specifications( - specifications: dict[str, Any] | None + specifications: dict[str, Any] | None, ) -> DeviceInfo | None: """Return a device description for device registry.""" if not specifications: diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index f35cd7c2b58..fac2f32d284 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -51,6 +51,24 @@ "transport": "MQTT transport", "ws_headers": "WebSocket headers in JSON format", "ws_path": "WebSocket path" + }, + "data_description": { + "broker": "The hostname or IP address of your MQTT broker.", + "port": "The port your MQTT broker listens to. For example 1883.", + "username": "The username to login to your MQTT broker.", + "password": "The password to login to your MQTT broker.", + "advanced_options": "Enable and click `next` to set advanced options.", + "certificate": "The custom CA certificate file to validate your MQTT brokers certificate.", + "client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.", + "client_cert": "The client certificate to authenticate against your MQTT broker.", + "client_key": "The private key file that belongs to your client certificate.", + "tls_insecure": "Option to ignore validation of your MQTT broker's certificate.", + "protocol": "The MQTT protocol your broker operates at. For example 3.1.1.", + "set_ca_cert": "Select `Auto` for automatic CA validation, or `Custom` and click `next` to set a custom CA certificate, to allow validating your MQTT brokers certificate.", + "set_client_cert": "Enable and click `next` to set a client certifificate and private key to authenticate against your MQTT broker.", + "transport": "The transport to be used for the connection to your MQTT broker.", + "ws_headers": "The WebSocket headers to pass through the WebSocket based connection to your MQTT broker.", + "ws_path": "The WebSocket path to be used for the connection to your MQTT broker." } }, "hassio_confirm": { @@ -58,6 +76,9 @@ "description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the add-on {addon}?", "data": { "discovery": "Enable discovery" + }, + "data_description": { + "discovery": "Option to enable MQTT automatic discovery." } } }, @@ -123,6 +144,24 @@ "transport": "[%key:component::mqtt::config::step::broker::data::transport%]", "ws_headers": "[%key:component::mqtt::config::step::broker::data::ws_headers%]", "ws_path": "[%key:component::mqtt::config::step::broker::data::ws_path%]" + }, + "data_description": { + "broker": "[%key:component::mqtt::config::step::broker::data_description::broker%]", + "port": "[%key:component::mqtt::config::step::broker::data_description::port%]", + "username": "[%key:component::mqtt::config::step::broker::data_description::username%]", + "password": "[%key:component::mqtt::config::step::broker::data_description::password%]", + "advanced_options": "[%key:component::mqtt::config::step::broker::data_description::advanced_options%]", + "certificate": "[%key:component::mqtt::config::step::broker::data_description::certificate%]", + "client_id": "[%key:component::mqtt::config::step::broker::data_description::client_id%]", + "client_cert": "[%key:component::mqtt::config::step::broker::data_description::client_cert%]", + "client_key": "[%key:component::mqtt::config::step::broker::data_description::client_key%]", + "tls_insecure": "[%key:component::mqtt::config::step::broker::data_description::tls_insecure%]", + "protocol": "[%key:component::mqtt::config::step::broker::data_description::protocol%]", + "set_ca_cert": "[%key:component::mqtt::config::step::broker::data_description::set_ca_cert%]", + "set_client_cert": "[%key:component::mqtt::config::step::broker::data_description::set_client_cert%]", + "transport": "[%key:component::mqtt::config::step::broker::data_description::transport%]", + "ws_headers": "[%key:component::mqtt::config::step::broker::data_description::ws_headers%]", + "ws_path": "[%key:component::mqtt::config::step::broker::data_description::ws_path%]" } }, "options": { @@ -141,6 +180,20 @@ "will_payload": "Will message payload", "will_qos": "Will message QoS", "will_retain": "Will message retain" + }, + "data_description": { + "discovery": "Option to enable MQTT automatic discovery.", + "discovery_prefix": "The prefix of configuration topics the MQTT integration will subscribe to.", + "birth_enable": "When set, Home Assistant will publish an online message to your MQTT broker when MQTT is ready.", + "birth_topic": "The MQTT topic where Home Assistant will publish a `birth` message.", + "birth_payload": "The `birth` message that is published when MQTT is ready and connected.", + "birth_qos": "The quality of service of the `birth` message that is published when MQTT is ready and connected", + "birth_retain": "When set, Home Assistant will retain the `birth` message published to your MQTT broker.", + "will_enable": "When set, Home Assistant will ask your broker to publish a `will` message when MQTT is stopped or when it looses the connection to your broker.", + "will_topic": "The MQTT topic your MQTT broker will publish a `will` message to.", + "will_payload": "The message your MQTT broker `will` publish when the MQTT integration is stopped or when the connection is lost.", + "will_qos": "The quality of service of the `will` message that is published by your MQTT broker.", + "will_retain": "When set, your MQTT broker will retain the `will` message." } } }, diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py new file mode 100644 index 00000000000..9d167f42d12 --- /dev/null +++ b/homeassistant/components/mqtt/valve.py @@ -0,0 +1,447 @@ +"""Support for MQTT valve devices.""" +from __future__ import annotations + +from contextlib import suppress +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import valve +from homeassistant.components.valve import ( + DEVICE_CLASSES_SCHEMA, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_NAME, + CONF_OPTIMISTIC, + CONF_VALUE_TEMPLATE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +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 +from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from . import subscription +from .config import MQTT_BASE_SCHEMA +from .const import ( + CONF_COMMAND_TEMPLATE, + CONF_COMMAND_TOPIC, + CONF_ENCODING, + CONF_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN, + CONF_PAYLOAD_STOP, + CONF_POSITION_CLOSED, + CONF_POSITION_OPEN, + CONF_QOS, + CONF_RETAIN, + CONF_STATE_CLOSED, + CONF_STATE_CLOSING, + CONF_STATE_OPEN, + CONF_STATE_OPENING, + CONF_STATE_TOPIC, + DEFAULT_OPTIMISTIC, + DEFAULT_PAYLOAD_CLOSE, + DEFAULT_PAYLOAD_OPEN, + DEFAULT_POSITION_CLOSED, + DEFAULT_POSITION_OPEN, + DEFAULT_RETAIN, +) +from .debug_info import log_messages +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, + write_state_on_attr_change, +) +from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage +from .util import valid_publish_topic, valid_subscribe_topic + +_LOGGER = logging.getLogger(__name__) + +CONF_REPORTS_POSITION = "reports_position" + +DEFAULT_NAME = "MQTT Valve" + +MQTT_VALVE_ATTRIBUTES_BLOCKED = frozenset( + { + valve.ATTR_CURRENT_POSITION, + } +) + +NO_POSITION_KEYS = ( + CONF_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN, + CONF_STATE_CLOSED, + CONF_STATE_OPEN, +) + +DEFAULTS = { + CONF_PAYLOAD_CLOSE: DEFAULT_PAYLOAD_CLOSE, + CONF_PAYLOAD_OPEN: DEFAULT_PAYLOAD_OPEN, + CONF_STATE_OPEN: STATE_OPEN, + CONF_STATE_CLOSED: STATE_CLOSED, +} + +RESET_CLOSING_OPENING = "reset_opening_closing" + + +def _validate_and_add_defaults(config: ConfigType) -> ConfigType: + """Validate config options and set defaults.""" + if config[CONF_REPORTS_POSITION] and any(key in config for key in NO_POSITION_KEYS): + raise vol.Invalid( + "Options `payload_open`, `payload_close`, `state_open` and " + "`state_closed` are not allowed if the valve reports a position." + ) + return {**DEFAULTS, **config} + + +_PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( + { + vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), + vol.Optional(CONF_NAME): vol.Any(cv.string, None), + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_PAYLOAD_CLOSE): vol.Any(cv.string, None), + vol.Optional(CONF_PAYLOAD_OPEN): vol.Any(cv.string, None), + vol.Optional(CONF_PAYLOAD_STOP): vol.Any(cv.string, None), + vol.Optional(CONF_POSITION_CLOSED, default=DEFAULT_POSITION_CLOSED): int, + vol.Optional(CONF_POSITION_OPEN, default=DEFAULT_POSITION_OPEN): int, + vol.Optional(CONF_REPORTS_POSITION, default=False): cv.boolean, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_STATE_CLOSED): cv.string, + vol.Optional(CONF_STATE_CLOSING, default=STATE_CLOSING): cv.string, + vol.Optional(CONF_STATE_OPEN): cv.string, + vol.Optional(CONF_STATE_OPENING, default=STATE_OPENING): cv.string, + vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + } +).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) + +PLATFORM_SCHEMA_MODERN = vol.All(_PLATFORM_SCHEMA_BASE, _validate_and_add_defaults) + +DISCOVERY_SCHEMA = vol.All( + _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), + _validate_and_add_defaults, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up MQTT valve through YAML and through MQTT discovery.""" + await async_setup_entity_entry_helper( + hass, + config_entry, + MqttValve, + valve.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + ) + + +class MqttValve(MqttEntity, ValveEntity): + """Representation of a valve that can be controlled using MQTT.""" + + _attr_is_closed: bool | None = None + _attributes_extra_blocked: frozenset[str] = MQTT_VALVE_ATTRIBUTES_BLOCKED + _default_name = DEFAULT_NAME + _entity_id_format: str = valve.ENTITY_ID_FORMAT + _optimistic: bool + _range: tuple[int, int] + _tilt_optimistic: bool + + @staticmethod + def config_schema() -> vol.Schema: + """Return the config schema.""" + return DISCOVERY_SCHEMA + + def _setup_from_config(self, config: ConfigType) -> None: + """Set up valve from config.""" + self._attr_reports_position = config[CONF_REPORTS_POSITION] + self._range = ( + self._config[CONF_POSITION_CLOSED] + 1, + self._config[CONF_POSITION_OPEN], + ) + no_state_topic = config.get(CONF_STATE_TOPIC) is None + self._optimistic = config[CONF_OPTIMISTIC] or no_state_topic + self._attr_assumed_state = self._optimistic + + template_config_attributes = { + "position_open": config[CONF_POSITION_OPEN], + "position_closed": config[CONF_POSITION_CLOSED], + } + + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), entity=self + ).async_render_with_possible_json_value + + self._command_template = MqttCommandTemplate( + config.get(CONF_COMMAND_TEMPLATE), entity=self + ).async_render + + self._value_template = MqttValueTemplate( + config.get(CONF_VALUE_TEMPLATE), + entity=self, + config_attributes=template_config_attributes, + ).async_render_with_possible_json_value + + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + + supported_features = ValveEntityFeature(0) + if CONF_COMMAND_TOPIC in config: + if config[CONF_PAYLOAD_OPEN] is not None: + supported_features |= ValveEntityFeature.OPEN + if config[CONF_PAYLOAD_CLOSE] is not None: + supported_features |= ValveEntityFeature.CLOSE + + if config[CONF_REPORTS_POSITION]: + supported_features |= ValveEntityFeature.SET_POSITION + if config.get(CONF_PAYLOAD_STOP) is not None: + supported_features |= ValveEntityFeature.STOP + + self._attr_supported_features = supported_features + + @callback + def _update_state(self, state: str) -> None: + """Update the valve state properties.""" + self._attr_is_opening = state == STATE_OPENING + self._attr_is_closing = state == STATE_CLOSING + if self.reports_position: + return + self._attr_is_closed = state == STATE_CLOSED + + @callback + def _process_binary_valve_update( + self, msg: ReceiveMessage, state_payload: str + ) -> None: + """Process an update for a valve that does not report the position.""" + state: str | None = None + if state_payload == self._config[CONF_STATE_OPENING]: + state = STATE_OPENING + elif state_payload == self._config[CONF_STATE_CLOSING]: + state = STATE_CLOSING + elif state_payload == self._config[CONF_STATE_OPEN]: + state = STATE_OPEN + elif state_payload == self._config[CONF_STATE_CLOSED]: + state = STATE_CLOSED + if state is None: + _LOGGER.warning( + "Payload received on topic '%s' is not one of " + "[open, closed, opening, closing], got: %s", + msg.topic, + state_payload, + ) + return + self._update_state(state) + + @callback + def _process_position_valve_update( + self, msg: ReceiveMessage, position_payload: str, state_payload: str + ) -> None: + """Process an update for a valve that reports the position.""" + state: str | None = None + position_set: bool = False + if state_payload == self._config[CONF_STATE_OPENING]: + state = STATE_OPENING + elif state_payload == self._config[CONF_STATE_CLOSING]: + state = STATE_CLOSING + if state is None or position_payload != state_payload: + try: + percentage_payload = ranged_value_to_percentage( + self._range, float(position_payload) + ) + except ValueError: + _LOGGER.warning( + "Ignoring non numeric payload '%s' received on topic '%s'", + position_payload, + msg.topic, + ) + else: + percentage_payload = min(max(percentage_payload, 0), 100) + self._attr_current_valve_position = percentage_payload + # Reset closing and opening if the valve is fully opened or fully closed + if state is None and percentage_payload in (0, 100): + state = RESET_CLOSING_OPENING + position_set = True + if state_payload and state is None and not position_set: + _LOGGER.warning( + "Payload received on topic '%s' is not one of " + "[opening, closing], got: %s", + msg.topic, + state_payload, + ) + return + if state is None: + return + self._update_state(state) + + def _prepare_subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + topics = {} + + @callback + @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change( + self, + { + "_attr_current_valve_position", + "_attr_is_closed", + "_attr_is_closing", + "_attr_is_opening", + }, + ) + def state_message_received(msg: ReceiveMessage) -> None: + """Handle new MQTT state messages.""" + payload = self._value_template(msg.payload) + payload_dict: Any = None + position_payload: Any = payload + state_payload: Any = payload + + if not payload: + _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic) + return + + with suppress(*JSON_DECODE_EXCEPTIONS): + payload_dict = json_loads(payload) + if isinstance(payload_dict, dict): + if self.reports_position and "position" not in payload_dict: + _LOGGER.warning( + "Missing required `position` attribute in json payload " + "on topic '%s', got: %s", + msg.topic, + payload, + ) + return + if not self.reports_position and "state" not in payload_dict: + _LOGGER.warning( + "Missing required `state` attribute in json payload " + " on topic '%s', got: %s", + msg.topic, + payload, + ) + return + position_payload = payload_dict.get("position") + state_payload = payload_dict.get("state") + + if self._config[CONF_REPORTS_POSITION]: + self._process_position_valve_update( + msg, position_payload, state_payload + ) + else: + self._process_binary_valve_update(msg, state_payload) + + if self._config.get(CONF_STATE_TOPIC): + topics["state_topic"] = { + "topic": self._config.get(CONF_STATE_TOPIC), + "msg_callback": state_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) + + async def async_open_valve(self) -> None: + """Move the valve up. + + This method is a coroutine. + """ + payload = self._command_template( + self._config.get(CONF_PAYLOAD_OPEN, DEFAULT_PAYLOAD_OPEN) + ) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + if self._optimistic: + # Optimistically assume that valve has changed state. + self._update_state(STATE_OPEN) + self.async_write_ha_state() + + async def async_close_valve(self) -> None: + """Move the valve down. + + This method is a coroutine. + """ + payload = self._command_template( + self._config.get(CONF_PAYLOAD_CLOSE, DEFAULT_PAYLOAD_CLOSE) + ) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + if self._optimistic: + # Optimistically assume that valve has changed state. + self._update_state(STATE_CLOSED) + self.async_write_ha_state() + + async def async_stop_valve(self) -> None: + """Stop valve positioning. + + This method is a coroutine. + """ + payload = self._command_template(self._config[CONF_PAYLOAD_STOP]) + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + percentage_position = position + scaled_position = round( + percentage_to_ranged_value(self._range, percentage_position) + ) + variables = { + "position": percentage_position, + "position_open": self._config[CONF_POSITION_OPEN], + "position_closed": self._config[CONF_POSITION_CLOSED], + } + rendered_position = self._command_template(scaled_position, variables=variables) + + await self.async_publish( + self._config[CONF_COMMAND_TOPIC], + rendered_position, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + if self._optimistic: + self._update_state( + STATE_CLOSED + if percentage_position == self._config[CONF_POSITION_CLOSED] + else STATE_OPEN + ) + self._attr_current_valve_position = percentage_position + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 0ccd2dbc47d..a2cf2e511a0 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -186,6 +186,8 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): _default_name = DEFAULT_NAME _entity_id_format = water_heater.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_WATER_HEATER_ATTRIBUTES_BLOCKED + _attr_target_temperature_low: float | None = None + _attr_target_temperature_high: float | None = None @staticmethod def config_schema() -> vol.Schema: diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index 2b4edd99221..b70a7fc8d55 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -21,7 +21,7 @@ from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .helpers import on_unload -@dataclass +@dataclass(frozen=True) class MySensorsBinarySensorDescription(BinarySensorEntityDescription): """Describe a MySensors binary sensor entity.""" diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py index 8011bfcb155..fdf056c6c06 100644 --- a/homeassistant/components/mysensors/config_flow.py +++ b/homeassistant/components/mysensors/config_flow.py @@ -18,6 +18,7 @@ from homeassistant.components.mqtt import ( valid_subscribe_topic, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import selector @@ -25,7 +26,6 @@ import homeassistant.helpers.config_validation as cv from .const import ( CONF_BAUD_RATE, - CONF_DEVICE, CONF_GATEWAY_TYPE, CONF_GATEWAY_TYPE_MQTT, CONF_GATEWAY_TYPE_SERIAL, diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index a5c82c32b55..0a4b4c090ef 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -11,7 +11,6 @@ ATTR_GATEWAY_ID: Final = "gateway_id" ATTR_NODE_ID: Final = "node_id" CONF_BAUD_RATE: Final = "baud_rate" -CONF_DEVICE: Final = "device" CONF_PERSISTENCE_FILE: Final = "persistence_file" CONF_RETAIN: Final = "retain" CONF_TCP_PORT: Final = "tcp_port" diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index 6d7decf14f4..c70ef1f89ed 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -8,7 +8,13 @@ from typing import Any from mysensors import BaseAsyncGateway, Sensor from mysensors.sensor import ChildSensor -from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON, Platform +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + CONF_DEVICE, + STATE_OFF, + STATE_ON, + Platform, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo @@ -17,7 +23,6 @@ from homeassistant.helpers.entity import Entity from .const import ( CHILD_CALLBACK, - CONF_DEVICE, DOMAIN, NODE_CALLBACK, PLATFORM_TYPES, diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 590ad41d6a2..0818d68de2b 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -18,14 +18,13 @@ from homeassistant.components.mqtt import ( ReceivePayloadType, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( CONF_BAUD_RATE, - CONF_DEVICE, CONF_GATEWAY_TYPE, CONF_GATEWAY_TYPE_MQTT, CONF_GATEWAY_TYPE_SERIAL, diff --git a/homeassistant/components/mystrom/sensor.py b/homeassistant/components/mystrom/sensor.py index 606a6275acf..4551c9ebbec 100644 --- a/homeassistant/components/mystrom/sensor.py +++ b/homeassistant/components/mystrom/sensor.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, MANUFACTURER -@dataclass +@dataclass(frozen=True) class MyStromSwitchSensorEntityDescription(SensorEntityDescription): """Class describing mystrom switch sensor entities.""" diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index 3c0b8bc9ba4..5b3c6517f64 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -74,14 +74,14 @@ PARALLEL_UPDATES = 1 _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class NAMSensorRequiredKeysMixin: """Class for NAM entity required keys.""" value: Callable[[NAMSensors], StateType | datetime] -@dataclass +@dataclass(frozen=True) class NAMSensorEntityDescription(SensorEntityDescription, NAMSensorRequiredKeysMixin): """NAM sensor entity description.""" diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index f5f2d67947f..aee63e60016 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyatmo"], - "requirements": ["pyatmo==8.0.1"] + "requirements": ["pyatmo==8.0.2"] } diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 2f99b866cf2..692a1a806ea 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -70,14 +70,14 @@ SUPPORTED_PUBLIC_SENSOR_TYPES: tuple[str, ...] = ( ) -@dataclass +@dataclass(frozen=True) class NetatmoRequiredKeysMixin: """Mixin for required keys.""" netatmo_name: str -@dataclass +@dataclass(frozen=True) class NetatmoSensorEntityDescription(SensorEntityDescription, NetatmoRequiredKeysMixin): """Describes Netatmo sensor entity.""" diff --git a/homeassistant/components/netgear/button.py b/homeassistant/components/netgear/button.py index f3283f8d7b5..6ec988edbe1 100644 --- a/homeassistant/components/netgear/button.py +++ b/homeassistant/components/netgear/button.py @@ -19,14 +19,14 @@ from .entity import NetgearRouterCoordinatorEntity from .router import NetgearRouter -@dataclass +@dataclass(frozen=True) class NetgearButtonEntityDescriptionRequired: """Required attributes of NetgearButtonEntityDescription.""" action: Callable[[NetgearRouter], Callable[[], Coroutine[Any, Any, None]]] -@dataclass +@dataclass(frozen=True) class NetgearButtonEntityDescription( ButtonEntityDescription, NetgearButtonEntityDescriptionRequired ): diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 6e7771d44cb..897fe9da30c 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -77,7 +77,7 @@ SENSOR_TYPES = { } -@dataclass +@dataclass(frozen=True) class NetgearSensorEntityDescription(SensorEntityDescription): """Class describing Netgear sensor entities.""" diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py index a4548da16a4..4be13a0f32c 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -32,7 +32,7 @@ SWITCH_TYPES = [ ] -@dataclass +@dataclass(frozen=True) class NetgearSwitchEntityDescriptionRequired: """Required attributes of NetgearSwitchEntityDescription.""" @@ -40,7 +40,7 @@ class NetgearSwitchEntityDescriptionRequired: action: Callable[[NetgearRouter], bool] -@dataclass +@dataclass(frozen=True) class NetgearSwitchEntityDescription( SwitchEntityDescription, NetgearSwitchEntityDescriptionRequired ): diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index d6ce3cb0994..9faa2f361b9 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -1,12 +1,12 @@ """Support for Netgear LTE modems.""" -import asyncio from datetime import timedelta -import aiohttp +from aiohttp.cookiejar import CookieJar import attr import eternalegypt import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState from homeassistant.const import ( CONF_HOST, CONF_MONITORED_CONDITIONS, @@ -16,14 +16,15 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from . import sensor_types from .const import ( ATTR_FROM, ATTR_HOST, @@ -32,6 +33,7 @@ from .const import ( CONF_BINARY_SENSOR, CONF_NOTIFY, CONF_SENSOR, + DATA_HASS_CONFIG, DISPATCHER_NETGEAR_LTE, DOMAIN, LOGGER, @@ -42,6 +44,28 @@ SCAN_INTERVAL = timedelta(seconds=10) EVENT_SMS = "netgear_lte_sms" +ALL_SENSORS = [ + "sms", + "sms_total", + "usage", + "radio_quality", + "rx_level", + "tx_level", + "upstream", + "connection_text", + "connection_type", + "current_ps_service_type", + "register_network_display", + "current_band", + "cell_id", +] + +ALL_BINARY_SENSORS = [ + "roaming", + "wire_connected", + "mobile_connected", +] + NOTIFY_SCHEMA = vol.Schema( { @@ -52,17 +76,17 @@ NOTIFY_SCHEMA = vol.Schema( SENSOR_SCHEMA = vol.Schema( { - vol.Optional( - CONF_MONITORED_CONDITIONS, default=sensor_types.DEFAULT_SENSORS - ): vol.All(cv.ensure_list, [vol.In(sensor_types.ALL_SENSORS)]) + vol.Optional(CONF_MONITORED_CONDITIONS, default=["usage"]): vol.All( + cv.ensure_list, [vol.In(ALL_SENSORS)] + ) } ) BINARY_SENSOR_SCHEMA = vol.Schema( { - vol.Optional( - CONF_MONITORED_CONDITIONS, default=sensor_types.DEFAULT_BINARY_SENSORS - ): vol.All(cv.ensure_list, [vol.In(sensor_types.ALL_BINARY_SENSORS)]) + vol.Optional(CONF_MONITORED_CONDITIONS, default=["mobile_connected"]): vol.All( + cv.ensure_list, [vol.In(ALL_BINARY_SENSORS)] + ) } ) @@ -90,6 +114,12 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.NOTIFY, + Platform.SENSOR, +] + @attr.s class ModemData: @@ -137,90 +167,108 @@ class LTEData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Netgear LTE component.""" - if DOMAIN not in hass.data: - websession = async_create_clientsession( - hass, cookie_jar=aiohttp.CookieJar(unsafe=True) - ) - hass.data[DOMAIN] = LTEData(websession) + hass.data[DATA_HASS_CONFIG] = config - await async_setup_services(hass) - - netgear_lte_config = config[DOMAIN] - - # Set up each modem - tasks = [ - hass.async_create_task(_setup_lte(hass, lte_conf)) - for lte_conf in netgear_lte_config - ] - await asyncio.wait(tasks) - - # Load platforms for each modem - for lte_conf in netgear_lte_config: - # Notify - for notify_conf in lte_conf[CONF_NOTIFY]: - discovery_info = { - CONF_HOST: lte_conf[CONF_HOST], - CONF_NAME: notify_conf.get(CONF_NAME), - CONF_NOTIFY: notify_conf, - } - hass.async_create_task( - discovery.async_load_platform( - hass, Platform.NOTIFY, DOMAIN, discovery_info, config - ) - ) - - # Sensor - sensor_conf = lte_conf[CONF_SENSOR] - discovery_info = {CONF_HOST: lte_conf[CONF_HOST], CONF_SENSOR: sensor_conf} - hass.async_create_task( - discovery.async_load_platform( - hass, Platform.SENSOR, DOMAIN, discovery_info, config - ) - ) - - # Binary Sensor - binary_sensor_conf = lte_conf[CONF_BINARY_SENSOR] - discovery_info = { - CONF_HOST: lte_conf[CONF_HOST], - CONF_BINARY_SENSOR: binary_sensor_conf, - } - hass.async_create_task( - discovery.async_load_platform( - hass, Platform.BINARY_SENSOR, DOMAIN, discovery_info, config - ) - ) + if lte_config := config.get(DOMAIN): + hass.async_create_task(import_yaml(hass, lte_config)) return True -async def _setup_lte(hass, lte_config): - """Set up a Netgear LTE modem.""" +async def import_yaml(hass: HomeAssistant, lte_config: ConfigType) -> None: + """Import yaml if we can connect. Create appropriate issue registry entries.""" + for entry in lte_config: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + ) + if result.get("reason") == "cannot_connect": + async_create_issue( + hass, + DOMAIN, + "import_failure", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="import_failure", + ) + else: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Netgear LTE", + }, + ) - host = lte_config[CONF_HOST] - password = lte_config[CONF_PASSWORD] - websession = hass.data[DOMAIN].websession - modem = eternalegypt.Modem(hostname=host, websession=websession) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Netgear LTE from a config entry.""" + host = entry.data[CONF_HOST] + password = entry.data[CONF_PASSWORD] + if DOMAIN not in hass.data: + websession = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)) + + hass.data[DOMAIN] = LTEData(websession) + + modem = eternalegypt.Modem(hostname=host, websession=hass.data[DOMAIN].websession) modem_data = ModemData(hass, host, modem) - try: - await _login(hass, modem_data, password) - except eternalegypt.Error: - retry_task = hass.loop.create_task(_retry_login(hass, modem_data, password)) + await _login(hass, modem_data, password) - @callback - def cleanup_retry(event): - """Clean up retry task resources.""" - if not retry_task.done(): - retry_task.cancel() + async def _update(now): + """Periodic update.""" + await modem_data.async_update() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry) + update_unsub = async_track_time_interval(hass, _update, SCAN_INTERVAL) + + async def cleanup(event: Event | None = None) -> None: + """Clean up resources.""" + update_unsub() + await modem.logout() + if DOMAIN in hass.data: + del hass.data[DOMAIN].modem_data[modem_data.host] + + entry.async_on_unload(cleanup) + entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)) + + await async_setup_services(hass) + + _legacy_task(hass, entry) + + await hass.config_entries.async_forward_entry_setups( + entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] + ) + + return True -async def _login(hass, modem_data, password): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if len(loaded_entries) == 1: + hass.data.pop(DOMAIN) + + return unload_ok + + +async def _login(hass: HomeAssistant, modem_data: ModemData, password: str) -> None: """Log in and complete setup.""" - await modem_data.modem.login(password=password) + try: + await modem_data.modem.login(password=password) + except eternalegypt.Error as ex: + raise ConfigEntryNotReady("Cannot connect/authenticate") from ex def fire_sms_event(sms): """Send an SMS event.""" @@ -237,33 +285,63 @@ async def _login(hass, modem_data, password): await modem_data.async_update() hass.data[DOMAIN].modem_data[modem_data.host] = modem_data - async def _update(now): - """Periodic update.""" - await modem_data.async_update() - update_unsub = async_track_time_interval(hass, _update, SCAN_INTERVAL) +def _legacy_task(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Create notify service and add a repair issue when appropriate.""" + # Discovery can happen up to 2 times for notify depending on existing yaml config + # One for the name of the config entry, allows the user to customize the name + # One for each notify described in the yaml config which goes away with config flow + # One for the default if the user never specified one + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + {CONF_HOST: entry.data[CONF_HOST], CONF_NAME: entry.title}, + hass.data[DATA_HASS_CONFIG], + ) + ) + if not (lte_configs := hass.data[DATA_HASS_CONFIG].get(DOMAIN, [])): + return + async_create_issue( + hass, + DOMAIN, + "deprecated_notify", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_notify", + translation_placeholders={ + "name": f"{Platform.NOTIFY}.{entry.title.lower().replace(' ', '_')}" + }, + ) - async def cleanup(event): - """Clean up resources.""" - update_unsub() - await modem_data.modem.logout() - del hass.data[DOMAIN].modem_data[modem_data.host] - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) - - -async def _retry_login(hass, modem_data, password): - """Sleep and retry setup.""" - - LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host) - - modem_data.connected = False - delay = 15 - - while not modem_data.connected: - await asyncio.sleep(delay) - - try: - await _login(hass, modem_data, password) - except eternalegypt.Error: - delay = min(2 * delay, 300) + for lte_config in lte_configs: + if lte_config[CONF_HOST] == entry.data[CONF_HOST]: + if not lte_config[CONF_NOTIFY]: + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + {CONF_HOST: entry.data[CONF_HOST], CONF_NAME: DOMAIN}, + hass.data[DATA_HASS_CONFIG], + ) + ) + break + for notify_conf in lte_config[CONF_NOTIFY]: + discovery_info = { + CONF_HOST: lte_config[CONF_HOST], + CONF_NAME: notify_conf.get(CONF_NAME), + CONF_NOTIFY: notify_conf, + } + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + discovery_info, + hass.data[DATA_HASS_CONFIG], + ) + ) + break diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index add59096024..810e3733fbe 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -1,52 +1,58 @@ """Support for Netgear LTE binary sensors.""" from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_BINARY_SENSOR, DOMAIN +from . import ModemData +from .const import DOMAIN from .entity import LTEEntity -from .sensor_types import BINARY_SENSOR_CLASSES + +BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="roaming", + ), + BinarySensorEntityDescription( + key="wire_connected", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + BinarySensorEntityDescription( + key="mobile_connected", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), +) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up Netgear LTE binary sensor devices.""" - if discovery_info is None: - return + """Set up the Netgear LTE binary sensor.""" + modem_data = hass.data[DOMAIN].get_modem_data(entry.data) - modem_data = hass.data[DOMAIN].get_modem_data(discovery_info) - - if not modem_data or not modem_data.data: - raise PlatformNotReady - - binary_sensor_conf = discovery_info[CONF_BINARY_SENSOR] - monitored_conditions = binary_sensor_conf[CONF_MONITORED_CONDITIONS] - - binary_sensors = [] - for sensor_type in monitored_conditions: - binary_sensors.append(LTEBinarySensor(modem_data, sensor_type)) - - async_add_entities(binary_sensors) + async_add_entities( + NetgearLTEBinarySensor(modem_data, sensor) for sensor in BINARY_SENSORS + ) -class LTEBinarySensor(LTEEntity, BinarySensorEntity): +class NetgearLTEBinarySensor(LTEEntity, BinarySensorEntity): """Netgear LTE binary sensor entity.""" + def __init__( + self, + modem_data: ModemData, + entity_description: BinarySensorEntityDescription, + ) -> None: + """Initialize a Netgear LTE binary sensor entity.""" + super().__init__(modem_data, entity_description.key) + self.entity_description = entity_description + @property def is_on(self): """Return true if the binary sensor is on.""" - return getattr(self.modem_data.data, self.sensor_type) - - @property - def device_class(self): - """Return the class of binary sensor.""" - return BINARY_SENSOR_CLASSES[self.sensor_type] + return getattr(self.modem_data.data, self.entity_description.key) diff --git a/homeassistant/components/netgear_lte/config_flow.py b/homeassistant/components/netgear_lte/config_flow.py new file mode 100644 index 00000000000..a3a56bab03b --- /dev/null +++ b/homeassistant/components/netgear_lte/config_flow.py @@ -0,0 +1,103 @@ +"""Config flow for Netgear LTE integration.""" +from __future__ import annotations + +from typing import Any + +from aiohttp.cookiejar import CookieJar +from eternalegypt import Error, Modem +from eternalegypt.eternalegypt import Information +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .const import DEFAULT_HOST, DOMAIN, LOGGER, MANUFACTURER + + +class NetgearLTEFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Netgear LTE.""" + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a configuration from config.yaml.""" + host = config[CONF_HOST] + password = config[CONF_PASSWORD] + self._async_abort_entries_match({CONF_HOST: host}) + try: + info = await self._async_validate_input(host, password) + except InputValidationError: + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{MANUFACTURER} {info.items['general.devicename']}", + data={CONF_HOST: host, CONF_PASSWORD: password}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input: + host = user_input[CONF_HOST] + password = user_input[CONF_PASSWORD] + + try: + info = await self._async_validate_input(host, password) + except InputValidationError as ex: + errors["base"] = ex.base + else: + await self.async_set_unique_id(info.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{MANUFACTURER} {info.items['general.devicename']}", + data={CONF_HOST: host, CONF_PASSWORD: password}, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PASSWORD): str, + } + ), + user_input or {CONF_HOST: DEFAULT_HOST}, + ), + errors=errors, + ) + + async def _async_validate_input(self, host: str, password: str) -> Information: + """Validate login credentials.""" + websession = async_create_clientsession( + self.hass, cookie_jar=CookieJar(unsafe=True) + ) + + modem = Modem( + hostname=host, + password=password, + websession=websession, + ) + try: + await modem.login() + info = await modem.information() + except Error as ex: + raise InputValidationError("cannot_connect") from ex + except Exception as ex: + LOGGER.exception("Unexpected exception") + raise InputValidationError("unknown") from ex + await modem.logout() + return info + + +class InputValidationError(exceptions.HomeAssistantError): + """Error to indicate we cannot proceed due to invalid input.""" + + def __init__(self, base: str) -> None: + """Initialize with error base.""" + super().__init__() + self.base = base diff --git a/homeassistant/components/netgear_lte/const.py b/homeassistant/components/netgear_lte/const.py index 12c8f06b695..b47218bf4e1 100644 --- a/homeassistant/components/netgear_lte/const.py +++ b/homeassistant/components/netgear_lte/const.py @@ -14,9 +14,14 @@ CONF_BINARY_SENSOR: Final = "binary_sensor" CONF_NOTIFY: Final = "notify" CONF_SENSOR: Final = "sensor" +DATA_HASS_CONFIG = "netgear_lte_hass_config" +# https://kb.netgear.com/31160/How-do-I-change-my-4G-LTE-Modem-s-IP-address-range +DEFAULT_HOST = "192.168.5.1" DISPATCHER_NETGEAR_LTE = "netgear_lte_update" DOMAIN: Final = "netgear_lte" FAILOVER_MODES = ["auto", "wire", "mobile"] LOGGER = logging.getLogger(__package__) + +MANUFACTURER: Final = "Netgear" diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json index c9a5245da41..bc103018359 100644 --- a/homeassistant/components/netgear_lte/manifest.json +++ b/homeassistant/components/netgear_lte/manifest.json @@ -2,6 +2,7 @@ "domain": "netgear_lte", "name": "NETGEAR LTE", "codeowners": ["@tkdrob"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/netgear_lte", "iot_class": "local_polling", "loggers": ["eternalegypt"], diff --git a/homeassistant/components/netgear_lte/notify.py b/homeassistant/components/netgear_lte/notify.py index c21b56799eb..ddc5e93677c 100644 --- a/homeassistant/components/netgear_lte/notify.py +++ b/homeassistant/components/netgear_lte/notify.py @@ -38,8 +38,8 @@ class NetgearNotifyService(BaseNotificationService): if not modem_data: LOGGER.error("Modem not ready") return - - targets = kwargs.get(ATTR_TARGET, self.config[CONF_NOTIFY][CONF_RECIPIENT]) + if not (targets := kwargs.get(ATTR_TARGET)): + targets = self.config[CONF_NOTIFY][CONF_RECIPIENT] if not targets: LOGGER.warning("No recipients") return diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 4ca127e5724..b91bb9b561a 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -1,92 +1,101 @@ """Support for Netgear LTE sensors.""" from __future__ import annotations -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.const import CONF_MONITORED_CONDITIONS +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfInformation, +) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import StateType -from .const import CONF_SENSOR, DOMAIN +from . import ModemData +from .const import DOMAIN from .entity import LTEEntity -from .sensor_types import SENSOR_SMS, SENSOR_SMS_TOTAL, SENSOR_UNITS, SENSOR_USAGE -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +@dataclass(frozen=True, kw_only=True) +class NetgearLTESensorEntityDescription(SensorEntityDescription): + """Class describing Netgear LTE entities.""" + + value_fn: Callable[[ModemData], StateType] | None = None + + +SENSORS: tuple[NetgearLTESensorEntityDescription, ...] = ( + NetgearLTESensorEntityDescription( + key="sms", + native_unit_of_measurement="unread", + value_fn=lambda modem_data: sum(1 for x in modem_data.data.sms if x.unread), + ), + NetgearLTESensorEntityDescription( + key="sms_total", + native_unit_of_measurement="messages", + value_fn=lambda modem_data: len(modem_data.data.sms), + ), + NetgearLTESensorEntityDescription( + key="usage", + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.MEBIBYTES, + value_fn=lambda modem_data: round(modem_data.data.usage / 1024**2, 1), + ), + NetgearLTESensorEntityDescription( + key="radio_quality", + native_unit_of_measurement=PERCENTAGE, + ), + NetgearLTESensorEntityDescription( + key="rx_level", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + ), + NetgearLTESensorEntityDescription( + key="tx_level", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + ), + NetgearLTESensorEntityDescription(key="upstream"), + NetgearLTESensorEntityDescription(key="connection_text"), + NetgearLTESensorEntityDescription(key="connection_type"), + NetgearLTESensorEntityDescription(key="current_ps_service_type"), + NetgearLTESensorEntityDescription(key="register_network_display"), + NetgearLTESensorEntityDescription(key="current_band"), + NetgearLTESensorEntityDescription(key="cell_id"), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up Netgear LTE sensor devices.""" - if discovery_info is None: - return + """Set up the Netgear LTE sensor.""" + modem_data = hass.data[DOMAIN].get_modem_data(entry.data) - modem_data = hass.data[DOMAIN].get_modem_data(discovery_info) - - if not modem_data or not modem_data.data: - raise PlatformNotReady - - sensor_conf = discovery_info[CONF_SENSOR] - monitored_conditions = sensor_conf[CONF_MONITORED_CONDITIONS] - - sensors: list[SensorEntity] = [] - for sensor_type in monitored_conditions: - if sensor_type == SENSOR_SMS: - sensors.append(SMSUnreadSensor(modem_data, sensor_type)) - elif sensor_type == SENSOR_SMS_TOTAL: - sensors.append(SMSTotalSensor(modem_data, sensor_type)) - elif sensor_type == SENSOR_USAGE: - sensors.append(UsageSensor(modem_data, sensor_type)) - else: - sensors.append(GenericSensor(modem_data, sensor_type)) - - async_add_entities(sensors) + async_add_entities(NetgearLTESensor(modem_data, sensor) for sensor in SENSORS) -class LTESensor(LTEEntity, SensorEntity): +class NetgearLTESensor(LTEEntity, SensorEntity): """Base LTE sensor entity.""" - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return SENSOR_UNITS[self.sensor_type] + entity_description: NetgearLTESensorEntityDescription - -class SMSUnreadSensor(LTESensor): - """Unread SMS sensor entity.""" + def __init__( + self, + modem_data: ModemData, + entity_description: NetgearLTESensorEntityDescription, + ) -> None: + """Initialize a Netgear LTE sensor entity.""" + super().__init__(modem_data, entity_description.key) + self.entity_description = entity_description @property - def native_value(self): - """Return the state of the sensor.""" - return sum(1 for x in self.modem_data.data.sms if x.unread) - - -class SMSTotalSensor(LTESensor): - """Total SMS sensor entity.""" - - @property - def native_value(self): - """Return the state of the sensor.""" - return len(self.modem_data.data.sms) - - -class UsageSensor(LTESensor): - """Data usage sensor entity.""" - - _attr_device_class = SensorDeviceClass.DATA_SIZE - - @property - def native_value(self) -> float: - """Return the state of the sensor.""" - return round(self.modem_data.data.usage / 1024**2, 1) - - -class GenericSensor(LTESensor): - """Sensor entity with raw state.""" - - @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" + if self.entity_description.value_fn is not None: + return self.entity_description.value_fn(self.modem_data) return getattr(self.modem_data.data, self.sensor_type) diff --git a/homeassistant/components/netgear_lte/sensor_types.py b/homeassistant/components/netgear_lte/sensor_types.py deleted file mode 100644 index 01aa267e953..00000000000 --- a/homeassistant/components/netgear_lte/sensor_types.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Define possible sensor types.""" - -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ( - PERCENTAGE, - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - UnitOfInformation, -) - -SENSOR_SMS = "sms" -SENSOR_SMS_TOTAL = "sms_total" -SENSOR_USAGE = "usage" - -SENSOR_UNITS = { - SENSOR_SMS: "unread", - SENSOR_SMS_TOTAL: "messages", - SENSOR_USAGE: UnitOfInformation.MEBIBYTES, - "radio_quality": PERCENTAGE, - "rx_level": SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - "tx_level": SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - "upstream": None, - "connection_text": None, - "connection_type": None, - "current_ps_service_type": None, - "register_network_display": None, - "current_band": None, - "cell_id": None, -} - -BINARY_SENSOR_MOBILE_CONNECTED = "mobile_connected" - -BINARY_SENSOR_CLASSES = { - "roaming": None, - "wire_connected": BinarySensorDeviceClass.CONNECTIVITY, - BINARY_SENSOR_MOBILE_CONNECTED: BinarySensorDeviceClass.CONNECTIVITY, -} - -ALL_SENSORS = list(SENSOR_UNITS) -DEFAULT_SENSORS = [SENSOR_USAGE] - -ALL_BINARY_SENSORS = list(BINARY_SENSOR_CLASSES) -DEFAULT_BINARY_SENSORS = [BINARY_SENSOR_MOBILE_CONNECTED] diff --git a/homeassistant/components/netgear_lte/strings.json b/homeassistant/components/netgear_lte/strings.json index 1fd10282991..8992fb50670 100644 --- a/homeassistant/components/netgear_lte/strings.json +++ b/homeassistant/components/netgear_lte/strings.json @@ -1,4 +1,32 @@ { + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_notify": { + "title": "The Netgear LTE notify service is changing", + "description": "The Netgear LTE notify service was previously set up via YAML configuration.\n\nThis created a service for a specified recipient without having to include the phone number.\n\nPlease adjust any automations or scripts you may have to use the `{name}` service and include target for specifying a recipient." + }, + "import_failure": { + "title": "The Netgear LTE integration failed to import", + "description": "The Netgear LTE notify service was previously set up via YAML configuration.\n\nAn error occurred when trying to communicate with the device while attempting to import the configuration to the UI.\n\nPlease remove the Netgear LTE notify section from your YAML configuration and set it up in the UI instead." + } + }, "services": { "delete_sms": { "name": "Delete SMS", @@ -52,6 +80,5 @@ } } } - }, - "selector": {} + } } diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py index e1f4dcc2840..e8c0bc224fe 100644 --- a/homeassistant/components/nextbus/__init__.py +++ b/homeassistant/components/nextbus/__init__.py @@ -1,10 +1,10 @@ """NextBus platform.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_STOP, Platform from homeassistant.core import HomeAssistant -from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .const import CONF_AGENCY, CONF_ROUTE, DOMAIN from .coordinator import NextBusDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/nextbus/config_flow.py b/homeassistant/components/nextbus/config_flow.py index 84417a29c8d..a4045ada372 100644 --- a/homeassistant/components/nextbus/config_flow.py +++ b/homeassistant/components/nextbus/config_flow.py @@ -6,7 +6,7 @@ from py_nextbus import NextBusClient import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.selector import ( SelectOptionDict, @@ -15,7 +15,7 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .const import CONF_AGENCY, CONF_ROUTE, DOMAIN from .util import listify _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nextbus/const.py b/homeassistant/components/nextbus/const.py index 9d9d0a5262f..0a2eabf57b3 100644 --- a/homeassistant/components/nextbus/const.py +++ b/homeassistant/components/nextbus/const.py @@ -3,4 +3,3 @@ DOMAIN = "nextbus" CONF_AGENCY = "agency" CONF_ROUTE = "route" -CONF_STOP = "stop" diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 6ef647f98ad..f62bf07eeef 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -22,7 +22,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp -from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .const import CONF_AGENCY, CONF_ROUTE, DOMAIN from .coordinator import NextBusDataUpdateCoordinator from .util import listify, maybe_first diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 6800c403ee8..851cb9f3cd3 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -30,7 +30,7 @@ from .entity import NextcloudEntity UNIT_OF_LOAD: Final[str] = "load" -@dataclass +@dataclass(frozen=True) class NextcloudSensorEntityDescription(SensorEntityDescription): """Describes Nextcloud sensor entity.""" diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index e2e37ccab2d..dad29893161 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -24,14 +24,14 @@ from .const import ATTR_CONNECTION, DOMAIN PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class NextDnsBinarySensorRequiredKeysMixin(Generic[CoordinatorDataT]): """Mixin for required keys.""" state: Callable[[CoordinatorDataT, str], bool] -@dataclass +@dataclass(frozen=True) class NextDnsBinarySensorEntityDescription( BinarySensorEntityDescription, NextDnsBinarySensorRequiredKeysMixin[CoordinatorDataT], diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index 3985644a478..c502f788a86 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -9,11 +9,11 @@ from nextdns import ApiError, InvalidApiKeyError, NextDns import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_PROFILE_ID, CONF_PROFILE_NAME, DOMAIN +from .const import CONF_PROFILE_ID, DOMAIN class NextDnsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/nextdns/const.py b/homeassistant/components/nextdns/const.py index 8cac556c87c..031dd1c5814 100644 --- a/homeassistant/components/nextdns/const.py +++ b/homeassistant/components/nextdns/const.py @@ -10,7 +10,6 @@ ATTR_SETTINGS = "settings" ATTR_STATUS = "status" CONF_PROFILE_ID = "profile_id" -CONF_PROFILE_NAME = "profile_name" UPDATE_INTERVAL_CONNECTION = timedelta(minutes=5) UPDATE_INTERVAL_ANALYTICS = timedelta(minutes=10) diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index ccbbb5e534e..c501142697e 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -38,7 +38,7 @@ from .const import ( PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class NextDnsSensorRequiredKeysMixin(Generic[CoordinatorDataT]): """Class for NextDNS entity required keys.""" @@ -46,7 +46,7 @@ class NextDnsSensorRequiredKeysMixin(Generic[CoordinatorDataT]): value: Callable[[CoordinatorDataT], StateType] -@dataclass +@dataclass(frozen=True) class NextDnsSensorEntityDescription( SensorEntityDescription, NextDnsSensorRequiredKeysMixin[CoordinatorDataT], diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 0a310bc29e7..177b4970a93 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -24,14 +24,14 @@ from .const import ATTR_SETTINGS, DOMAIN PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class NextDnsSwitchRequiredKeysMixin(Generic[CoordinatorDataT]): """Class for NextDNS entity required keys.""" state: Callable[[CoordinatorDataT], bool] -@dataclass +@dataclass(frozen=True) class NextDnsSwitchEntityDescription( SwitchEntityDescription, NextDnsSwitchRequiredKeysMixin[CoordinatorDataT] ): diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py index eb5c7a7e506..b2c97503442 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -66,7 +66,7 @@ class NINADataUpdateCoordinator( @staticmethod def _remove_duplicate_warnings( - warnings: dict[str, list[Any]] + warnings: dict[str, list[Any]], ) -> dict[str, list[Any]]: """Remove warnings with the same title and expires timestamp in a region.""" all_filtered_warnings: dict[str, list[Any]] = {} diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index 2da0a4ae137..b9464020431 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -9,7 +9,7 @@ "loggers": ["nmap"], "requirements": [ "netmap==0.7.0.2", - "getmac==0.8.2", + "getmac==0.9.4", "mac-vendor-lookup==0.1.12" ] } diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 110671864e3..7c78bfc44d3 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -6,17 +6,18 @@ from collections.abc import Callable, Coroutine, Mapping from functools import partial from typing import Any, Protocol, cast +from homeassistant.config import config_per_platform from homeassistant.const import CONF_DESCRIPTION, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform, async_start_setup from homeassistant.util import slugify -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .const import ( ATTR_DATA, @@ -125,7 +126,7 @@ def async_setup_legacy( hass.data[NOTIFY_SERVICES].setdefault(integration_name, []).append( notify_service ) - hass.config.components.add(f"{DOMAIN}.{integration_name}") + hass.config.components.add(f"{integration_name}.{DOMAIN}") async def async_platform_discovered( platform: str, info: DiscoveryInfoType | None @@ -279,8 +280,8 @@ class BaseNotificationService: # Load service descriptions from notify/services.yaml integration = await async_get_integration(hass, DOMAIN) services_yaml = integration.file_path / "services.yaml" - self.services_dict = cast( - dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + self.services_dict = await hass.async_add_executor_job( + load_yaml_dict, str(services_yaml) ) async def async_register_services(self) -> None: diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index ff58d566a34..a1c519f228f 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -33,14 +33,14 @@ from .const import ( from .model import NotionEntityDescriptionMixin -@dataclass +@dataclass(frozen=True) class NotionBinarySensorDescriptionMixin: """Define an entity description mixin for binary and regular sensors.""" on_state: Literal["alarm", "leak", "low", "not_missing", "open"] -@dataclass +@dataclass(frozen=True) class NotionBinarySensorDescription( BinarySensorEntityDescription, NotionBinarySensorDescriptionMixin, diff --git a/homeassistant/components/notion/model.py b/homeassistant/components/notion/model.py index 0999df3abdb..cdfd6e63dad 100644 --- a/homeassistant/components/notion/model.py +++ b/homeassistant/components/notion/model.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from aionotion.sensor.models import ListenerKind -@dataclass +@dataclass(frozen=True) class NotionEntityDescriptionMixin: """Define an description mixin Notion entities.""" diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 4777cc94fbf..8c4242aec2a 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -19,7 +19,7 @@ from .const import DOMAIN, SENSOR_MOLD, SENSOR_TEMPERATURE from .model import NotionEntityDescriptionMixin -@dataclass +@dataclass(frozen=True) class NotionSensorDescription(SensorEntityDescription, NotionEntityDescriptionMixin): """Describe a Notion sensor.""" diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 201fa8fedb6..55b281e02e1 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -7,7 +7,7 @@ import dataclasses from datetime import timedelta import logging from math import ceil, floor -from typing import Any, Self, final +from typing import TYPE_CHECKING, Any, Self, final import voluptuous as vol @@ -42,6 +42,11 @@ from .const import ( # noqa: F401 ) from .websocket_api import async_setup as async_setup_ws_api +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -120,8 +125,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclasses.dataclass -class NumberEntityDescription(EntityDescription): +class NumberEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes number entities.""" device_class: NumberDeviceClass | None = None @@ -154,7 +158,18 @@ def floor_decimal(value: float, precision: float = 0) -> float: return floor(value * factor) / factor -class NumberEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "native_max_value", + "native_min_value", + "native_step", + "mode", + "native_unit_of_measurement", + "native_value", +} + + +class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Number entity.""" _entity_component_unrecorded_attributes = frozenset( @@ -239,7 +254,7 @@ class NumberEntity(Entity): """ return self.device_class is not None - @property + @cached_property def device_class(self) -> NumberDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -248,7 +263,7 @@ class NumberEntity(Entity): return self.entity_description.device_class return None - @property + @cached_property def native_min_value(self) -> float: """Return the minimum value.""" if hasattr(self, "_attr_native_min_value"): @@ -268,7 +283,7 @@ class NumberEntity(Entity): self.native_min_value, floor_decimal, self.device_class ) - @property + @cached_property def native_max_value(self) -> float: """Return the maximum value.""" if hasattr(self, "_attr_native_max_value"): @@ -288,9 +303,11 @@ class NumberEntity(Entity): self.native_max_value, ceil_decimal, self.device_class ) - @property + @cached_property def native_step(self) -> float | None: """Return the increment/decrement step.""" + if hasattr(self, "_attr_native_step"): + return self._attr_native_step if ( hasattr(self, "entity_description") and self.entity_description.native_step is not None @@ -306,8 +323,6 @@ class NumberEntity(Entity): def _calculate_step(self, min_value: float, max_value: float) -> float: """Return the increment/decrement step.""" - if hasattr(self, "_attr_native_step"): - return self._attr_native_step if (native_step := self.native_step) is not None: return native_step step = DEFAULT_STEP @@ -317,7 +332,7 @@ class NumberEntity(Entity): step /= 10.0 return step - @property + @cached_property def mode(self) -> NumberMode: """Return the mode of the entity.""" if hasattr(self, "_attr_mode"): @@ -335,7 +350,7 @@ class NumberEntity(Entity): """Return the entity state.""" return self.value - @property + @cached_property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the entity, if any.""" if hasattr(self, "_attr_native_unit_of_measurement"): @@ -363,7 +378,7 @@ class NumberEntity(Entity): return native_unit_of_measurement - @property + @cached_property def native_value(self) -> float | None: """Return the value reported by the number.""" return self._attr_native_value diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 9248d3f9e57..55d22c86648 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -2,6 +2,7 @@ from __future__ import annotations from enum import StrEnum +from functools import partial from typing import Final import voluptuous as vol @@ -35,6 +36,11 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumetricFlux, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.util.unit_conversion import BaseUnitConverter, TemperatureConverter ATTR_VALUE = "value" @@ -50,16 +56,29 @@ DOMAIN = "number" SERVICE_SET_VALUE = "set_value" + +class NumberMode(StrEnum): + """Modes for number entities.""" + + AUTO = "auto" + BOX = "box" + SLIDER = "slider" + + # MODE_* are deprecated as of 2021.12, use the NumberMode enum instead. -MODE_AUTO: Final = "auto" -MODE_BOX: Final = "box" -MODE_SLIDER: Final = "slider" +_DEPRECATED_MODE_AUTO: Final = DeprecatedConstantEnum(NumberMode.AUTO, "2025.1") +_DEPRECATED_MODE_BOX: Final = DeprecatedConstantEnum(NumberMode.BOX, "2025.1") +_DEPRECATED_MODE_SLIDER: Final = DeprecatedConstantEnum(NumberMode.SLIDER, "2025.1") + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) class NumberDeviceClass(StrEnum): """Device class for numbers.""" - # NumberDeviceClass should be aligned with NumberDeviceClass + # NumberDeviceClass should be aligned with SensorDeviceClass APPARENT_POWER = "apparent_power" """Apparent power. @@ -385,14 +404,6 @@ class NumberDeviceClass(StrEnum): """ -class NumberMode(StrEnum): - """Modes for number entities.""" - - AUTO = "auto" - BOX = "box" - SLIDER = "slider" - - DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(NumberDeviceClass)) DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), diff --git a/homeassistant/components/number/significant_change.py b/homeassistant/components/number/significant_change.py new file mode 100644 index 00000000000..11bca6457f1 --- /dev/null +++ b/homeassistant/components/number/significant_change.py @@ -0,0 +1,92 @@ +"""Helper to test significant Number state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_percentage_change, + check_valid_float, +) + +from .const import NumberDeviceClass + + +def _absolute_and_relative_change( + old_state: int | float | None, + new_state: int | float | None, + absolute_change: int | float, + percentage_change: int | float, +) -> bool: + return check_absolute_change( + old_state, new_state, absolute_change + ) and check_percentage_change(old_state, new_state, percentage_change) + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if (device_class := new_attrs.get(ATTR_DEVICE_CLASS)) is None: + return None + + absolute_change: float | None = None + percentage_change: float | None = None + + # special for temperature + if device_class == NumberDeviceClass.TEMPERATURE: + if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + # special for percentage + elif device_class in ( + NumberDeviceClass.BATTERY, + NumberDeviceClass.HUMIDITY, + NumberDeviceClass.MOISTURE, + ): + absolute_change = 1.0 + + # special for power factor + elif device_class == NumberDeviceClass.POWER_FACTOR: + if new_attrs.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE: + absolute_change = 1.0 + else: + absolute_change = 0.1 + percentage_change = 2.0 + + # default for all other classified + else: + absolute_change = 1.0 + percentage_change = 2.0 + + if not check_valid_float(new_state): + # New state is invalid, don't report it + return False + + if not check_valid_float(old_state): + # Old state was invalid, we should report again + return True + + if absolute_change is not None and percentage_change is not None: + return _absolute_and_relative_change( + float(old_state), float(new_state), absolute_change, percentage_change + ) + if absolute_change is not None: + return check_absolute_change( + float(old_state), float(new_state), absolute_change + ) diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index ecf9d39ae55..35fb6c0ec1f 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -39,7 +39,7 @@ from .const import ATTRIBUTION, CONF_STATION, DOMAIN, OBSERVATION_VALID_TIME PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class NWSSensorEntityDescription(SensorEntityDescription): """Class describing NWSSensor entities.""" diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index b405140bc32..2840cde704b 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -25,7 +25,7 @@ from .onewire_entities import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub -@dataclass +@dataclass(frozen=True) class OneWireBinarySensorEntityDescription( OneWireEntityDescription, BinarySensorEntityDescription ): diff --git a/homeassistant/components/onewire/onewire_entities.py b/homeassistant/components/onewire/onewire_entities.py index a6eddece5c6..cad55234181 100644 --- a/homeassistant/components/onewire/onewire_entities.py +++ b/homeassistant/components/onewire/onewire_entities.py @@ -14,7 +14,7 @@ from homeassistant.helpers.typing import StateType from .const import READ_MODE_BOOL, READ_MODE_INT -@dataclass +@dataclass(frozen=True) class OneWireEntityDescription(EntityDescription): """Class describing OneWire entities.""" diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 34ed66bd511..cc8b14b5d6e 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -2,8 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Mapping -import copy -from dataclasses import dataclass +import dataclasses import logging import os from types import MappingProxyType @@ -43,7 +42,7 @@ from .onewire_entities import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub -@dataclass +@dataclasses.dataclass(frozen=True) class OneWireSensorEntityDescription(OneWireEntityDescription, SensorEntityDescription): """Class describing OneWire sensor entities.""" @@ -393,11 +392,12 @@ def get_entities( ).decode() ) if is_leaf: - description = copy.deepcopy(description) - description.device_class = SensorDeviceClass.HUMIDITY - description.native_unit_of_measurement = PERCENTAGE - description.translation_key = f"wetness_{s_id}" - _LOGGER.info(description.translation_key) + description = dataclasses.replace( + description, + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + translation_key=f"wetness_{s_id}", + ) override_key = None if description.override_key: override_key = description.override_key(device_id, options) diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 986be11d169..db9e8f5b0f8 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -22,7 +22,7 @@ from .onewire_entities import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub -@dataclass +@dataclass(frozen=True) class OneWireSwitchEntityDescription(OneWireEntityDescription, SwitchEntityDescription): """Class describing OneWire switch entities.""" diff --git a/homeassistant/components/onvif/switch.py b/homeassistant/components/onvif/switch.py index 4f7de67386b..673f77f558c 100644 --- a/homeassistant/components/onvif/switch.py +++ b/homeassistant/components/onvif/switch.py @@ -16,7 +16,7 @@ from .device import ONVIFDevice from .models import Profile -@dataclass +@dataclass(frozen=True) class ONVIFSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -31,7 +31,7 @@ class ONVIFSwitchEntityDescriptionMixin: supported_fn: Callable[[ONVIFDevice], bool] -@dataclass +@dataclass(frozen=True) class ONVIFSwitchEntityDescription( SwitchEntityDescription, ONVIFSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 054ccbdbe37..b0762979ca2 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -1,12 +1,10 @@ """The OpenAI Conversation integration.""" from __future__ import annotations -from functools import partial import logging from typing import Literal import openai -from openai import error import voluptuous as vol from homeassistant.components import conversation @@ -23,7 +21,13 @@ from homeassistant.exceptions import ( HomeAssistantError, TemplateError, ) -from homeassistant.helpers import config_validation as cv, intent, selector, template +from homeassistant.helpers import ( + config_validation as cv, + intent, + issue_registry as ir, + selector, + template, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import ulid @@ -52,17 +56,38 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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"]}', + client = hass.data[DOMAIN][call.data["config_entry"]] + + if call.data["size"] in ("256", "512", "1024"): + ir.async_create_issue( + hass, + DOMAIN, + "image_size_deprecated_format", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + is_persistent=True, + learn_more_url="https://www.home-assistant.io/integrations/openai_conversation/", + severity=ir.IssueSeverity.WARNING, + translation_key="image_size_deprecated_format", ) - except error.OpenAIError as err: + size = "1024x1024" + else: + size = call.data["size"] + + try: + response = await client.images.generate( + model="dall-e-3", + prompt=call.data["prompt"], + size=size, + quality=call.data["quality"], + style=call.data["style"], + response_format="url", + n=1, + ) + except openai.OpenAIError as err: raise HomeAssistantError(f"Error generating image: {err}") from err - return response["data"][0] + return response.data[0].model_dump(exclude={"b64_json"}) hass.services.async_register( DOMAIN, @@ -76,7 +101,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: } ), vol.Required("prompt"): cv.string, - vol.Optional("size", default="512"): vol.In(("256", "512", "1024")), + vol.Optional("size", default="1024x1024"): vol.In( + ("1024x1024", "1024x1792", "1792x1024", "256", "512", "1024") + ), + vol.Optional("quality", default="standard"): vol.In(("standard", "hd")), + vol.Optional("style", default="vivid"): vol.In(("vivid", "natural")), } ), supports_response=SupportsResponse.ONLY, @@ -86,21 +115,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up OpenAI Conversation from a config entry.""" + client = openai.AsyncOpenAI(api_key=entry.data[CONF_API_KEY]) try: - await hass.async_add_executor_job( - partial( - openai.Model.list, - api_key=entry.data[CONF_API_KEY], - request_timeout=10, - ) - ) - except error.AuthenticationError as err: + await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list) + except openai.AuthenticationError as err: _LOGGER.error("Invalid API key: %s", err) return False - except error.OpenAIError as err: + except openai.OpenAIError as err: raise ConfigEntryNotReady(err) from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry.data[CONF_API_KEY] + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client conversation.async_set_agent(hass, entry, OpenAIAgent(hass, entry)) return True @@ -160,9 +184,10 @@ class OpenAIAgent(conversation.AbstractConversationAgent): _LOGGER.debug("Prompt for %s: %s", model, messages) + client = self.hass.data[DOMAIN][self.entry.entry_id] + try: - result = await openai.ChatCompletion.acreate( - api_key=self.entry.data[CONF_API_KEY], + result = await client.chat.completions.create( model=model, messages=messages, max_tokens=max_tokens, @@ -170,7 +195,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent): temperature=temperature, user=conversation_id, ) - except error.OpenAIError as err: + except openai.OpenAIError as err: intent_response = intent.IntentResponse(language=user_input.language) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, @@ -181,7 +206,7 @@ class OpenAIAgent(conversation.AbstractConversationAgent): ) _LOGGER.debug("Response %s", result) - response = result["choices"][0]["message"] + response = result.choices[0].message.model_dump(include={"role", "content"}) messages.append(response) self.history[conversation_id] = messages diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 9c5ef32d796..ef1e498d061 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -1,14 +1,12 @@ """Config flow for OpenAI Conversation integration.""" from __future__ import annotations -from functools import partial import logging import types from types import MappingProxyType from typing import Any import openai -from openai import error import voluptuous as vol from homeassistant import config_entries @@ -59,8 +57,8 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - openai.api_key = data[CONF_API_KEY] - await hass.async_add_executor_job(partial(openai.Model.list, request_timeout=10)) + client = openai.AsyncOpenAI(api_key=data[CONF_API_KEY]) + await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list) class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -81,9 +79,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await validate_input(self.hass, user_input) - except error.APIConnectionError: + except openai.APIConnectionError: errors["base"] = "cannot_connect" - except error.AuthenticationError: + except openai.AuthenticationError: errors["base"] = "invalid_auth" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 88d347355e9..5138be96b55 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==0.27.2"] + "requirements": ["openai==1.3.8"] } diff --git a/homeassistant/components/openai_conversation/services.yaml b/homeassistant/components/openai_conversation/services.yaml index 81818fb3e71..3db71cae383 100644 --- a/homeassistant/components/openai_conversation/services.yaml +++ b/homeassistant/components/openai_conversation/services.yaml @@ -11,12 +11,30 @@ generate_image: text: multiline: true size: - required: true - example: "512" - default: "512" + required: false + example: "1024x1024" + default: "1024x1024" selector: select: options: - - "256" - - "512" - - "1024" + - "1024x1024" + - "1024x1792" + - "1792x1024" + quality: + required: false + example: "standard" + default: "standard" + selector: + select: + options: + - "standard" + - "hd" + style: + required: false + example: "vivid" + default: "vivid" + selector: + select: + options: + - "vivid" + - "natural" diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 542fe06dd56..1a7d5a03c65 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -43,8 +43,22 @@ "size": { "name": "Size", "description": "The size of the image to generate" + }, + "quality": { + "name": "Quality", + "description": "The quality of the image that will be generated" + }, + "style": { + "name": "Style", + "description": "The style of the generated image" } } } + }, + "issues": { + "image_size_deprecated_format": { + "title": "Deprecated size format for image generation service", + "description": "OpenAI is now using Dall-E 3 to generate images when calling `openai_conversation.generate_image`, which supports different sizes. Valid values are now \"1024x1024\", \"1024x1792\", \"1792x1024\". The old values of \"256\", \"512\", \"1024\" are currently interpreted as \"1024x1024\".\nPlease update your scripts or automations with the new parameters." + } } } diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 51d7774a2fb..a4a16c6713c 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -79,7 +79,7 @@ def catch_request_errors() -> ( """Catch asyncio.TimeoutError, aiohttp.ClientError, UpnpError errors.""" def call_wrapper( - func: _FuncType[_OpenhomeDeviceT, _P, _R] + func: _FuncType[_OpenhomeDeviceT, _P, _R], ) -> _ReturnFuncType[_OpenhomeDeviceT, _P, _R]: """Call wrapper for decorator.""" diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index d33dfec6adf..106103cf752 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/opensky", "iot_class": "cloud_polling", - "requirements": ["python-opensky==0.2.1"] + "requirements": ["python-opensky==1.0.0"] } diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index a6c75c17113..7dc2d206912 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -409,25 +409,25 @@ SENSOR_INFO: dict[str, list] = { ], gw_vars.DATA_TOTAL_BURNER_STARTS: [ None, - None, + "starts", "Total Burner Starts {}", [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_CH_PUMP_STARTS: [ None, - None, + "starts", "Central Heating Pump Starts {}", [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_PUMP_STARTS: [ None, - None, + "starts", "Hot Water Pump Starts {}", [gw_vars.BOILER, gw_vars.THERMOSTAT], ], gw_vars.DATA_DHW_BURNER_STARTS: [ None, - None, + "starts", "Hot Water Burner Starts {}", [gw_vars.BOILER, gw_vars.THERMOSTAT], ], diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 8434b6d5591..431fa41a288 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -71,14 +71,14 @@ def get_uv_label(uv_index: int) -> str: return label.value -@dataclass +@dataclass(frozen=True) class OpenUvSensorEntityDescriptionMixin: """Define a mixin for OpenUV sensor descriptions.""" value_fn: Callable[[dict[str, Any]], int | str] -@dataclass +@dataclass(frozen=True) class OpenUvSensorEntityDescription( SensorEntityDescription, OpenUvSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py index d462e34cd84..cfe28e2eacc 100644 --- a/homeassistant/components/openweathermap/__init__.py +++ b/homeassistant/components/openweathermap/__init__.py @@ -10,6 +10,7 @@ from pyowm.utils.config import get_default_config from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, @@ -18,7 +19,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .const import ( - CONF_LANGUAGE, CONFIG_FLOW_VERSION, DOMAIN, ENTRY_NAME, diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index c418231946f..799be35fb42 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( CONF_API_KEY, + CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, @@ -17,7 +18,6 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from .const import ( - CONF_LANGUAGE, CONFIG_FLOW_VERSION, DEFAULT_FORECAST_MODE, DEFAULT_LANGUAGE, diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 1420b1170ca..d7deab21743 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -24,7 +24,6 @@ DEFAULT_NAME = "OpenWeatherMap" DEFAULT_LANGUAGE = "en" ATTRIBUTION = "Data provided by OpenWeatherMap" MANUFACTURER = "OpenWeather" -CONF_LANGUAGE = "language" CONFIG_FLOW_VERSION = 2 ENTRY_NAME = "name" ENTRY_WEATHER_COORDINATOR = "weather_coordinator" diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 239f23e7523..a474255e34d 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -93,7 +93,9 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): ( self.api.utility.subdomain(), account.meter_type.name.lower(), - account.utility_account_id, + # Some utilities like AEP have "-" in their account id. + # Replace it with "_" to avoid "Invalid statistic_id" + account.utility_account_id.replace("-", "_"), ) ) cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost" diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 1022ab07e2c..89b62912710 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.39"] + "requirements": ["opower==0.1.0"] } diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 175bef01449..9940132dac2 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -24,14 +24,14 @@ from .const import DOMAIN from .coordinator import OpowerCoordinator -@dataclass +@dataclass(frozen=True) class OpowerEntityDescriptionMixin: """Mixin values for required keys.""" value_fn: Callable[[Forecast], str | float] -@dataclass +@dataclass(frozen=True) class OpowerEntityDescription(SensorEntityDescription, OpowerEntityDescriptionMixin): """Class describing Opower sensors entities.""" diff --git a/homeassistant/components/osoenergy/__init__.py b/homeassistant/components/osoenergy/__init__.py new file mode 100644 index 00000000000..f0b89eaea90 --- /dev/null +++ b/homeassistant/components/osoenergy/__init__.py @@ -0,0 +1,81 @@ +"""Support for the OSO Energy devices and services.""" +from typing import Any, Generic, TypeVar + +from aiohttp.web_exceptions import HTTPException +from apyosoenergyapi import OSOEnergy +from apyosoenergyapi.helper.const import ( + OSOEnergyBinarySensorData, + OSOEnergySensorData, + OSOEnergyWaterHeaterData, +) +from apyosoenergyapi.helper.osoenergy_exceptions import OSOEnergyReauthRequired + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + +_T = TypeVar( + "_T", OSOEnergyBinarySensorData, OSOEnergySensorData, OSOEnergyWaterHeaterData +) + +PLATFORMS = [ + Platform.WATER_HEATER, +] +PLATFORM_LOOKUP = { + Platform.WATER_HEATER: "water_heater", +} + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up OSO Energy from a config entry.""" + subscription_key = entry.data[CONF_API_KEY] + websession = aiohttp_client.async_get_clientsession(hass) + osoenergy = OSOEnergy(subscription_key, websession) + + osoenergy_config = dict(entry.data) + + hass.data.setdefault(DOMAIN, {}) + + try: + devices: Any = await osoenergy.session.start_session(osoenergy_config) + except HTTPException as error: + raise ConfigEntryNotReady() from error + except OSOEnergyReauthRequired as err: + raise ConfigEntryAuthFailed from err + + hass.data[DOMAIN][entry.entry_id] = osoenergy + + platforms = set() + for ha_type, oso_type in PLATFORM_LOOKUP.items(): + device_list = devices.get(oso_type, []) + if device_list: + platforms.add(ha_type) + if platforms: + 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.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class OSOEnergyEntity(Entity, Generic[_T]): + """Initiate OSO Energy Base Class.""" + + _attr_has_entity_name = True + + def __init__(self, osoenergy: OSOEnergy, osoenergy_device: _T) -> None: + """Initialize the instance.""" + self.osoenergy = osoenergy + self.device = osoenergy_device + self._attr_unique_id = osoenergy_device.device_id diff --git a/homeassistant/components/osoenergy/config_flow.py b/homeassistant/components/osoenergy/config_flow.py new file mode 100644 index 00000000000..a7632b19bcb --- /dev/null +++ b/homeassistant/components/osoenergy/config_flow.py @@ -0,0 +1,75 @@ +"""Config Flow for OSO Energy.""" +from collections.abc import Mapping +import logging +from typing import Any + +from apyosoenergyapi import OSOEnergy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +_SCHEMA_STEP_USER = vol.Schema({vol.Required(CONF_API_KEY): str}) + + +class OSOEnergyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a OSO Energy config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self) -> None: + """Initialize.""" + self.entry: ConfigEntry | None = None + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + # Verify Subscription key + if user_email := await self.get_user_email(user_input[CONF_API_KEY]): + await self.async_set_unique_id(user_email) + + if ( + self.context["source"] == config_entries.SOURCE_REAUTH + and self.entry + ): + self.hass.config_entries.async_update_entry( + self.entry, title=user_email, data=user_input + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + self._abort_if_unique_id_configured() + return self.async_create_entry(title=user_email, data=user_input) + + errors["base"] = "invalid_auth" + + return self.async_show_form( + step_id="user", + data_schema=_SCHEMA_STEP_USER, + errors=errors, + ) + + async def get_user_email(self, subscription_key: str) -> str | None: + """Return the user email for the provided subscription key.""" + try: + websession = aiohttp_client.async_get_clientsession(self.hass) + client = OSOEnergy(subscription_key, websession) + return await client.get_user_email() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error occurred") + return None + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Re Authenticate a user.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + data = {CONF_API_KEY: user_input[CONF_API_KEY]} + return await self.async_step_user(data) diff --git a/homeassistant/components/osoenergy/const.py b/homeassistant/components/osoenergy/const.py new file mode 100644 index 00000000000..c3925f5259b --- /dev/null +++ b/homeassistant/components/osoenergy/const.py @@ -0,0 +1,3 @@ +"""Constants for OSO Energy.""" + +DOMAIN = "osoenergy" diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json new file mode 100644 index 00000000000..d6813108242 --- /dev/null +++ b/homeassistant/components/osoenergy/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "osoenergy", + "name": "OSO Energy", + "codeowners": ["@osohotwateriot"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/osoenergy", + "iot_class": "cloud_polling", + "requirements": ["pyosoenergyapi==1.1.3"] +} diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json new file mode 100644 index 00000000000..a45482bf030 --- /dev/null +++ b/homeassistant/components/osoenergy/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "title": "OSO Energy Auth", + "description": "Enter the generated 'Subscription Key' for your account at 'https://portal.osoenergy.no/'", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "reauth": { + "title": "OSO Energy Auth", + "description": "Generate and enter a new 'Subscription Key' for your account at 'https://portal.osoenergy.no/'.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py new file mode 100644 index 00000000000..4b2ad7c48d6 --- /dev/null +++ b/homeassistant/components/osoenergy/water_heater.py @@ -0,0 +1,142 @@ +"""Support for OSO Energy water heaters.""" +from typing import Any + +from apyosoenergyapi.helper.const import OSOEnergyWaterHeaterData + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_ELECTRIC, + STATE_HIGH_DEMAND, + STATE_OFF, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import OSOEnergyEntity +from .const import DOMAIN + +CURRENT_OPERATION_MAP: dict[str, Any] = { + "default": { + "off": STATE_OFF, + "powersave": STATE_OFF, + "extraenergy": STATE_HIGH_DEMAND, + }, + "oso": { + "auto": STATE_ECO, + "off": STATE_OFF, + "powersave": STATE_OFF, + "extraenergy": STATE_HIGH_DEMAND, + }, +} +HEATER_MIN_TEMP = 10 +HEATER_MAX_TEMP = 80 +MANUFACTURER = "OSO Energy" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up OSO Energy heater based on a config entry.""" + osoenergy = hass.data[DOMAIN][entry.entry_id] + devices = osoenergy.session.device_list.get("water_heater") + entities = [] + if devices: + for dev in devices: + entities.append(OSOEnergyWaterHeater(osoenergy, dev)) + async_add_entities(entities, True) + + +class OSOEnergyWaterHeater( + OSOEnergyEntity[OSOEnergyWaterHeaterData], WaterHeaterEntity +): + """OSO Energy Water Heater Device.""" + + _attr_name = None + _attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + + @property + def device_info(self) -> DeviceInfo: + """Return device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device.device_id)}, + manufacturer=MANUFACTURER, + model=self.device.device_type, + name=self.device.device_name, + ) + + @property + def available(self) -> bool: + """Return if the device is available.""" + return self.device.available + + @property + def current_operation(self) -> str: + """Return current operation.""" + status = self.device.current_operation + if status == "off": + return STATE_OFF + + optimization_mode = self.device.optimization_mode.lower() + heater_mode = self.device.heater_mode.lower() + if optimization_mode in CURRENT_OPERATION_MAP: + return CURRENT_OPERATION_MAP[optimization_mode].get( + heater_mode, STATE_ELECTRIC + ) + + return CURRENT_OPERATION_MAP["default"].get(heater_mode, STATE_ELECTRIC) + + @property + def current_temperature(self) -> float: + """Return the current temperature of the heater.""" + return self.device.current_temperature + + @property + def target_temperature(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature + + @property + def target_temperature_high(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature_high + + @property + def target_temperature_low(self) -> float: + """Return the temperature we try to reach.""" + return self.device.target_temperature_low + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self.device.min_temperature + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.device.max_temperature + + async def async_turn_on(self, **kwargs) -> None: + """Turn on hotwater.""" + await self.osoenergy.hotwater.turn_on(self.device, True) + + async def async_turn_off(self, **kwargs) -> None: + """Turn off hotwater.""" + await self.osoenergy.hotwater.turn_off(self.device, True) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + target_temperature = int(kwargs.get("temperature", self.target_temperature)) + profile = [target_temperature] * 24 + + await self.osoenergy.hotwater.set_profile(self.device, profile) + + async def async_update(self) -> None: + """Update all Node data from Hive.""" + await self.osoenergy.session.update_data() + self.device = await self.osoenergy.hotwater.get_water_heater(self.device) diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index 067282108f1..85e97209a44 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -49,7 +49,7 @@ INSECURE_PASSPHRASES = ( def _handle_otbr_error( - func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]] + func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]], ) -> Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]]: """Handle OTBR errors.""" diff --git a/homeassistant/components/overkiz/alarm_control_panel.py b/homeassistant/components/overkiz/alarm_control_panel.py index b08ede7df10..e2555308e34 100644 --- a/homeassistant/components/overkiz/alarm_control_panel.py +++ b/homeassistant/components/overkiz/alarm_control_panel.py @@ -35,7 +35,7 @@ from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizAlarmDescriptionMixin: """Define an entity description mixin for switch entities.""" @@ -43,7 +43,7 @@ class OverkizAlarmDescriptionMixin: fn_state: Callable[[Callable[[str], OverkizStateType]], str] -@dataclass +@dataclass(frozen=True) class OverkizAlarmDescription( AlarmControlPanelEntityDescription, OverkizAlarmDescriptionMixin ): @@ -95,7 +95,7 @@ MAP_CORE_ACTIVE_ZONES: dict[str, str] = { def _state_stateful_alarm_controller( - select_state: Callable[[str], OverkizStateType] + select_state: Callable[[str], OverkizStateType], ) -> str: """Return the state of the device.""" if state := cast(str, select_state(OverkizState.CORE_ACTIVE_ZONES)): @@ -118,7 +118,7 @@ MAP_MYFOX_STATUS_STATE: dict[str, str] = { def _state_myfox_alarm_controller( - select_state: Callable[[str], OverkizStateType] + select_state: Callable[[str], OverkizStateType], ) -> str: """Return the state of the device.""" if ( @@ -141,7 +141,7 @@ MAP_ARM_TYPE: dict[str, str] = { def _state_alarm_panel_controller( - select_state: Callable[[str], OverkizStateType] + select_state: Callable[[str], OverkizStateType], ) -> str: """Return the state of the device.""" return MAP_ARM_TYPE[ diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py index 0d00179ee81..975ef4ff834 100644 --- a/homeassistant/components/overkiz/binary_sensor.py +++ b/homeassistant/components/overkiz/binary_sensor.py @@ -22,14 +22,14 @@ from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizBinarySensorDescriptionMixin: """Define an entity description mixin for binary sensor entities.""" value_fn: Callable[[OverkizStateType], bool] -@dataclass +@dataclass(frozen=True) class OverkizBinarySensorDescription( BinarySensorEntityDescription, OverkizBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/overkiz/button.py b/homeassistant/components/overkiz/button.py index 8388e2c3b2d..f8f33db7eed 100644 --- a/homeassistant/components/overkiz/button.py +++ b/homeassistant/components/overkiz/button.py @@ -17,7 +17,7 @@ from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizButtonDescription(ButtonEntityDescription): """Class to describe an Overkiz button.""" diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index c90c4446339..c15a7bd3acc 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -26,14 +26,14 @@ BOOST_MODE_DURATION_DELAY = 1 OPERATING_MODE_DELAY = 3 -@dataclass +@dataclass(frozen=True) class OverkizNumberDescriptionMixin: """Define an entity description mixin for number entities.""" command: str -@dataclass +@dataclass(frozen=True) class OverkizNumberDescription(NumberEntityDescription, OverkizNumberDescriptionMixin): """Class to describe an Overkiz number.""" diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index 5f72ca23a80..c225d475f63 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -17,14 +17,14 @@ from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizSelectDescriptionMixin: """Define an entity description mixin for select entities.""" select_option: Callable[[str, Callable[..., Awaitable[None]]], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class OverkizSelectDescription(SelectEntityDescription, OverkizSelectDescriptionMixin): """Class to describe an Overkiz select entity.""" diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index a267b54b398..3f1de4c381e 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -44,7 +44,7 @@ from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizDescriptiveEntity, OverkizEntity -@dataclass +@dataclass(frozen=True) class OverkizSensorDescription(SensorEntityDescription): """Class to describe an Overkiz sensor.""" @@ -484,6 +484,10 @@ class OverkizStateSensor(OverkizDescriptiveEntity, SensorEntity): if ( state is None or state.value is None + # It seems that in some cases we return `None` if state.value is falsy. + # This is probably incorrect and should be fixed in a follow up PR. + # To ensure measurement sensors do not get an `unknown` state on + # a falsy value (e.g. 0 or 0.0) we also check the state_class. or self.state_class != SensorStateClass.MEASUREMENT and not state.value ): diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py index b7416711e77..0396e385a3c 100644 --- a/homeassistant/components/overkiz/switch.py +++ b/homeassistant/components/overkiz/switch.py @@ -24,7 +24,7 @@ from .const import DOMAIN from .entity import OverkizDescriptiveEntity -@dataclass +@dataclass(frozen=True) class OverkizSwitchDescriptionMixin: """Define an entity description mixin for switch entities.""" @@ -32,7 +32,7 @@ class OverkizSwitchDescriptionMixin: turn_off: str -@dataclass +@dataclass(frozen=True) class OverkizSwitchDescription(SwitchEntityDescription, OverkizSwitchDescriptionMixin): """Class to describe an Overkiz switch.""" diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index b32a17f0323..761515c9c84 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass +import dataclasses from datetime import datetime, timedelta from typing import Final @@ -33,7 +33,7 @@ KEY_LAST_ELECTRICITY_COST: Final = "last_electricity_cost" KEY_LAST_GAS_COST: Final = "last_gas_cost" -@dataclass +@dataclasses.dataclass(frozen=True) class OVOEnergySensorEntityDescription(SensorEntityDescription): """Class describing System Bridge sensor entities.""" @@ -130,8 +130,11 @@ async def async_setup_entry( and coordinator.data.electricity[-1] is not None and coordinator.data.electricity[-1].cost is not None ): - description.native_unit_of_measurement = ( - coordinator.data.electricity[-1].cost.currency_unit + description = dataclasses.replace( + description, + native_unit_of_measurement=( + coordinator.data.electricity[-1].cost.currency_unit + ), ) entities.append(OVOEnergySensor(coordinator, description, client)) if coordinator.data.gas: @@ -141,9 +144,12 @@ async def async_setup_entry( and coordinator.data.gas[-1] is not None and coordinator.data.gas[-1].cost is not None ): - description.native_unit_of_measurement = coordinator.data.gas[ - -1 - ].cost.currency_unit + description = dataclasses.replace( + description, + native_unit_of_measurement=coordinator.data.gas[ + -1 + ].cost.currency_unit, + ) entities.append(OVOEnergySensor(coordinator, description, client)) async_add_entities(entities, True) diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index 935f2b659f9..f9ad35fd251 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -24,7 +24,7 @@ from . import PECOCoordinatorData from .const import ATTR_CONTENT, CONF_COUNTY, DOMAIN -@dataclass +@dataclass(frozen=True) class PECOSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -32,7 +32,7 @@ class PECOSensorEntityDescriptionMixin: attribute_fn: Callable[[PECOCoordinatorData], dict[str, str]] -@dataclass +@dataclass(frozen=True) class PECOSensorEntityDescription( SensorEntityDescription, PECOSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index cf229f16d12..5f7f431ddf7 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -21,14 +21,14 @@ from .coordinator import PegelOnlineDataUpdateCoordinator from .entity import PegelOnlineEntity -@dataclass +@dataclass(frozen=True) class PegelOnlineRequiredKeysMixin: """Mixin for required keys.""" measurement_key: str -@dataclass +@dataclass(frozen=True) class PegelOnlineSensorEntityDescription( SensorEntityDescription, PegelOnlineRequiredKeysMixin ): diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py index e942aa265b8..a48741b0886 100644 --- a/homeassistant/components/permobil/sensor.py +++ b/homeassistant/components/permobil/sensor.py @@ -38,7 +38,7 @@ from .coordinator import MyPermobilCoordinator _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class PermobilRequiredKeysMixin: """Mixin for required keys.""" @@ -46,7 +46,7 @@ class PermobilRequiredKeysMixin: available_fn: Callable[[Any], bool] -@dataclass +@dataclass(frozen=True) class PermobilSensorEntityDescription( SensorEntityDescription, PermobilRequiredKeysMixin ): diff --git a/homeassistant/components/persistent_notification/strings.json b/homeassistant/components/persistent_notification/strings.json index 5f256233149..ca89a4d33cd 100644 --- a/homeassistant/components/persistent_notification/strings.json +++ b/homeassistant/components/persistent_notification/strings.json @@ -1,4 +1,5 @@ { + "title": "Persistent Notification", "services": { "create": { "name": "Create", diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py index 1e6c1241aea..74fe41bf722 100644 --- a/homeassistant/components/philips_js/binary_sensor.py +++ b/homeassistant/components/philips_js/binary_sensor.py @@ -18,14 +18,11 @@ from .const import DOMAIN from .entity import PhilipsJsEntity -@dataclass +@dataclass(frozen=True, kw_only=True) class PhilipsTVBinarySensorEntityDescription(BinarySensorEntityDescription): """A entity description for Philips TV binary sensor.""" - def __init__(self, recording_value, *args, **kwargs) -> None: - """Set up a binary sensor entity description and add additional attributes.""" - super().__init__(*args, **kwargs) - self.recording_value: str = recording_value + recording_value: str DESCRIPTIONS = ( diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 5d1419db8b2..2f3a5a4801c 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -21,14 +21,14 @@ from . import PiHoleEntity from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN as PIHOLE_DOMAIN -@dataclass +@dataclass(frozen=True) class RequiredPiHoleBinaryDescription: """Represent the required attributes of the PiHole binary description.""" state_value: Callable[[Hole], bool] -@dataclass +@dataclass(frozen=True) class PiHoleBinarySensorEntityDescription( BinarySensorEntityDescription, RequiredPiHoleBinaryDescription ): diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py index b9d8bf828d4..b559a1cf806 100644 --- a/homeassistant/components/pi_hole/update.py +++ b/homeassistant/components/pi_hole/update.py @@ -17,7 +17,7 @@ from . import PiHoleEntity from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN -@dataclass +@dataclass(frozen=True) class PiHoleUpdateEntityDescription(UpdateEntityDescription): """Describes PiHole update entity.""" diff --git a/homeassistant/components/picnic/__init__.py b/homeassistant/components/picnic/__init__.py index 6826d8940ab..d2f023af79f 100644 --- a/homeassistant/components/picnic/__init__.py +++ b/homeassistant/components/picnic/__init__.py @@ -3,10 +3,10 @@ from python_picnic_api import PicnicAPI from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY_CODE, Platform from homeassistant.core import HomeAssistant -from .const import CONF_API, CONF_COORDINATOR, CONF_COUNTRY_CODE, DOMAIN +from .const import CONF_API, CONF_COORDINATOR, DOMAIN from .coordinator import PicnicUpdateCoordinator from .services import async_register_services diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index 65ae201482a..b02c0a74bfc 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -12,10 +12,15 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_COUNTRY_CODE, + CONF_PASSWORD, + CONF_USERNAME, +) from homeassistant.data_entry_flow import FlowResult -from .const import CONF_COUNTRY_CODE, COUNTRY_CODES, DOMAIN +from .const import COUNTRY_CODES, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 7e983321f3d..851df6f41b2 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -5,7 +5,6 @@ DOMAIN = "picnic" CONF_API = "api" CONF_COORDINATOR = "coordinator" -CONF_COUNTRY_CODE = "country_code" SERVICE_ADD_PRODUCT_TO_CART = "add_product" @@ -15,7 +14,7 @@ ATTR_PRODUCT_NAME = "product_name" ATTR_AMOUNT = "amount" ATTR_PRODUCT_IDENTIFIERS = "product_identifiers" -COUNTRY_CODES = ["NL", "DE", "BE"] +COUNTRY_CODES = ["NL", "DE", "BE", "FR"] ATTRIBUTION = "Data provided by Picnic" ADDRESS = "address" CART_DATA = "cart_data" diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index 507ab82e8e2..56d2d22cf29 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -44,7 +44,7 @@ from .const import ( from .coordinator import PicnicUpdateCoordinator -@dataclass +@dataclass(frozen=True) class PicnicRequiredKeysMixin: """Mixin for required keys.""" @@ -54,7 +54,7 @@ class PicnicRequiredKeysMixin: value_fn: Callable[[Any], StateType | datetime] -@dataclass +@dataclass(frozen=True) class PicnicSensorEntityDescription(SensorEntityDescription, PicnicRequiredKeysMixin): """Describes Picnic sensor entity.""" diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py index 42cdd3f3a77..29b8a8ba2a5 100644 --- a/homeassistant/components/ping/config_flow.py +++ b/homeassistant/components/ping/config_flow.py @@ -8,6 +8,10 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.device_tracker import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, +) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -45,7 +49,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=user_input[CONF_HOST], data={}, - options={**user_input, CONF_PING_COUNT: DEFAULT_PING_COUNT}, + options={ + **user_input, + CONF_PING_COUNT: DEFAULT_PING_COUNT, + CONF_CONSIDER_HOME: DEFAULT_CONSIDER_HOME.seconds, + }, ) async def async_step_import(self, import_info: Mapping[str, Any]) -> FlowResult: @@ -54,6 +62,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): to_import = { CONF_HOST: import_info[CONF_HOST], CONF_PING_COUNT: import_info[CONF_PING_COUNT], + CONF_CONSIDER_HOME: import_info.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME + ).seconds, } title = import_info.get(CONF_NAME, import_info[CONF_HOST]) @@ -102,6 +113,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow): min=1, max=100, mode=selector.NumberSelectorMode.BOX ) ), + vol.Optional( + CONF_CONSIDER_HOME, + default=self.config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.seconds + ), + ): int, } ), ) diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index 417659aad5c..6b904043b30 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -1,12 +1,15 @@ """Tracks devices by sending a ICMP echo request (ping).""" from __future__ import annotations +from datetime import datetime, timedelta import logging from typing import Any import voluptuous as vol from homeassistant.components.device_tracker import ( + CONF_CONSIDER_HOME, + DEFAULT_CONSIDER_HOME, PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, AsyncSeeCallback, ScannerEntity, @@ -31,6 +34,7 @@ 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 homeassistant.util import dt as dt_util from . import PingDomainData from .const import CONF_IMPORTED_BY, CONF_PING_COUNT, DOMAIN @@ -91,6 +95,7 @@ async def async_setup_scanner( CONF_NAME: dev_name, CONF_HOST: dev_host, CONF_PING_COUNT: config[CONF_PING_COUNT], + CONF_CONSIDER_HOME: config[CONF_CONSIDER_HOME], }, ) ) @@ -131,6 +136,8 @@ async def async_setup_entry( class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity): """Representation of a Ping device tracker.""" + _last_seen: datetime | None = None + def __init__( self, config_entry: ConfigEntry, coordinator: PingUpdateCoordinator ) -> None: @@ -139,6 +146,11 @@ class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity) self._attr_name = config_entry.title self.config_entry = config_entry + self._consider_home_interval = timedelta( + seconds=config_entry.options.get( + CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.seconds + ) + ) @property def ip_address(self) -> str: @@ -157,8 +169,14 @@ class PingDeviceTracker(CoordinatorEntity[PingUpdateCoordinator], ScannerEntity) @property def is_connected(self) -> bool: - """Return true if ping returns is_alive.""" - return self.coordinator.data.is_alive + """Return true if ping returns is_alive or considered home.""" + if self.coordinator.data.is_alive: + self._last_seen = dt_util.utcnow() + + return ( + self._last_seen is not None + and (dt_util.utcnow() - self._last_seen) < self._consider_home_interval + ) @property def entity_registry_enabled_default(self) -> bool: diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json index 12bc1d25c7a..421d9079c62 100644 --- a/homeassistant/components/ping/strings.json +++ b/homeassistant/components/ping/strings.json @@ -5,8 +5,7 @@ "title": "Add Ping", "description": "Ping allows you to check the availability of a host.", "data": { - "host": "[%key:common::config_flow::data::host%]", - "count": "Ping count" + "host": "[%key:common::config_flow::data::host%]" }, "data_description": { "host": "The hostname or IP address of the device you want to ping." @@ -23,7 +22,11 @@ "init": { "data": { "host": "[%key:common::config_flow::data::host%]", - "count": "[%key:component::ping::config::step::user::data::count%]" + "count": "Ping count", + "consider_home": "Consider home interval" + }, + "data_description": { + "consider_home": "Seconds to wait till marking a device tracker as not home after not being seen." } } }, diff --git a/homeassistant/components/plex/button.py b/homeassistant/components/plex/button.py index 985b4ccb4e9..24bc09bac42 100644 --- a/homeassistant/components/plex/button.py +++ b/homeassistant/components/plex/button.py @@ -9,12 +9,9 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONF_SERVER, - CONF_SERVER_IDENTIFIER, - DOMAIN, - PLEX_UPDATE_PLATFORMS_SIGNAL, -) +from . import PlexServer +from .const import CONF_SERVER_IDENTIFIER, DOMAIN, PLEX_UPDATE_PLATFORMS_SIGNAL +from .helpers import get_plex_server async def async_setup_entry( @@ -24,22 +21,24 @@ async def async_setup_entry( ) -> None: """Set up Plex button from config entry.""" server_id: str = config_entry.data[CONF_SERVER_IDENTIFIER] - server_name: str = config_entry.data[CONF_SERVER] - async_add_entities([PlexScanClientsButton(server_id, server_name)]) + plex_server = get_plex_server(hass, server_id) + async_add_entities([PlexScanClientsButton(server_id, plex_server)]) class PlexScanClientsButton(ButtonEntity): """Representation of a scan_clients button entity.""" _attr_entity_category = EntityCategory.CONFIG + _attr_has_entity_name = True + _attr_translation_key = "scan_clients" - def __init__(self, server_id: str, server_name: str) -> None: + def __init__(self, server_id: str, plex_server: PlexServer) -> None: """Initialize a scan_clients Plex button entity.""" self.server_id = server_id - self._attr_name = f"Scan Clients ({server_name})" self._attr_unique_id = f"plex-scan_clients-{self.server_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, server_id)}, + name=plex_server.friendly_name, manufacturer="Plex", ) diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index a11d2d865c2..8fc01140787 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.15.4", + "PlexAPI==4.15.7", "plexauth==0.0.6", "plexwebsocket==0.0.14" ], diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 3e6875f98b9..3e817b4ea1a 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -53,7 +53,7 @@ _LOGGER = logging.getLogger(__name__) def needs_session( - func: Callable[Concatenate[_PlexMediaPlayerT, _P], _R] + func: Callable[Concatenate[_PlexMediaPlayerT, _P], _R], ) -> Callable[Concatenate[_PlexMediaPlayerT, _P], _R | None]: """Ensure session is available for certain attributes.""" diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 972cd8d4bc9..acc309ab14c 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -17,7 +17,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( CONF_SERVER_IDENTIFIER, DOMAIN, - NAME_FORMAT, PLEX_UPDATE_LIBRARY_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL, ) @@ -71,13 +70,15 @@ async def async_setup_entry( class PlexSensor(SensorEntity): """Representation of a Plex now playing sensor.""" + _attr_has_entity_name = True + _attr_name = None + _attr_icon = "mdi:plex" + _attr_should_poll = False + _attr_native_unit_of_measurement = "watching" + def __init__(self, hass, plex_server): """Initialize the sensor.""" - self._attr_icon = "mdi:plex" - self._attr_name = NAME_FORMAT.format(plex_server.friendly_name) - self._attr_should_poll = False self._attr_unique_id = f"sensor-{plex_server.machine_identifier}" - self._attr_native_unit_of_measurement = "Watching" self._server = plex_server self.async_refresh_sensor = Debouncer( @@ -113,9 +114,6 @@ class PlexSensor(SensorEntity): @property def device_info(self) -> DeviceInfo | None: """Return a device description for device registry.""" - if self.unique_id is None: - return None - return DeviceInfo( identifiers={(DOMAIN, self._server.machine_identifier)}, manufacturer="Plex", diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index 9cba83653fd..4f5ca3f2bc4 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -57,6 +57,13 @@ } } }, + "entity": { + "button": { + "scan_clients": { + "name": "Scan clients" + } + } + }, "services": { "refresh_library": { "name": "Refresh library", diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 5da82ab4105..0c67e20d7ab 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -23,7 +23,7 @@ from .entity import PlugwiseEntity SEVERITIES = ["other", "info", "warning", "error"] -@dataclass +@dataclass(frozen=True) class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes a Plugwise binary sensor entity.""" diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index c21ecbd94c7..c71b52cf5c8 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -22,7 +22,7 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class PlugwiseNumberEntityDescription(NumberEntityDescription): """Class describing Plugwise Number entities.""" diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index eef873703c1..4be21fe9026 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -17,7 +17,7 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class PlugwiseSelectEntityDescription(SelectEntityDescription): """Class describing Plugwise Select entities.""" diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py index 0cc878178fe..95dfc2ba6a3 100644 --- a/homeassistant/components/plugwise/sensor.py +++ b/homeassistant/components/plugwise/sensor.py @@ -32,7 +32,7 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity -@dataclass +@dataclass(frozen=True) class PlugwiseSensorEntityDescription(SensorEntityDescription): """Describes Plugwise sensor entity.""" diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index 8639826e37a..dfd11127332 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -22,7 +22,7 @@ from .entity import PlugwiseEntity from .util import plugwise_command -@dataclass +@dataclass(frozen=True) class PlugwiseSwitchEntityDescription(SwitchEntityDescription): """Describes Plugwise switch entity.""" diff --git a/homeassistant/components/plugwise/util.py b/homeassistant/components/plugwise/util.py index 2abb1051d74..4f8d4c8d8fe 100644 --- a/homeassistant/components/plugwise/util.py +++ b/homeassistant/components/plugwise/util.py @@ -14,7 +14,7 @@ _P = ParamSpec("_P") def plugwise_command( - func: Callable[Concatenate[_PlugwiseEntityT, _P], Awaitable[_R]] + func: Callable[Concatenate[_PlugwiseEntityT, _P], Awaitable[_R]], ) -> Callable[Concatenate[_PlugwiseEntityT, _P], Coroutine[Any, Any, _R]]: """Decorate Plugwise calls that send commands/make changes to the device. diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index 462d8270f0a..471fa72c6c5 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -23,14 +23,14 @@ from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class MinutPointRequiredKeysMixin: """Mixin for required keys.""" precision: int -@dataclass +@dataclass(frozen=True) class MinutPointSensorEntityDescription( SensorEntityDescription, MinutPointRequiredKeysMixin ): diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 33395f5fe6a..8587101a42a 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -122,9 +122,9 @@ class PowerwallDataManager: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tesla Powerwall from a config entry.""" http_session = requests.Session() - ip_address = entry.data[CONF_IP_ADDRESS] + ip_address: str = entry.data[CONF_IP_ADDRESS] - password = entry.data.get(CONF_PASSWORD) + password: str | None = entry.data.get(CONF_PASSWORD) power_wall = Powerwall(ip_address, http_session=http_session) try: base_info = await hass.async_add_executor_job( @@ -184,7 +184,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _login_and_fetch_base_info( - power_wall: Powerwall, host: str, password: str + power_wall: Powerwall, host: str, password: str | None ) -> PowerwallBaseInfo: """Login to the powerwall and fetch the base info.""" if password is not None: diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index 3f02c925f9d..bfa75392efb 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -32,14 +32,14 @@ _METER_DIRECTION_EXPORT = "export" _METER_DIRECTION_IMPORT = "import" -@dataclass +@dataclass(frozen=True) class PowerwallRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[Meter], float] -@dataclass +@dataclass(frozen=True) class PowerwallSensorEntityDescription( SensorEntityDescription, PowerwallRequiredKeysMixin ): diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index b18716d8020..bc4ad0f2912 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.15.0"] + "requirements": ["bluetooth-data-tools==1.19.0"] } diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py index d15ed1163b7..fb094de3d58 100644 --- a/homeassistant/components/private_ble_device/sensor.py +++ b/homeassistant/components/private_ble_device/sensor.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import BasePrivateDeviceEntity -@dataclass +@dataclass(frozen=True) class PrivateDeviceSensorEntityDescriptionRequired: """Required domain specific fields for sensor entity.""" @@ -35,7 +35,7 @@ class PrivateDeviceSensorEntityDescriptionRequired: ] -@dataclass +@dataclass(frozen=True) class PrivateDeviceSensorEntityDescription( SensorEntityDescription, PrivateDeviceSensorEntityDescriptionRequired ): diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 8c5c206ae9f..5e4408bba20 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -11,7 +11,7 @@ import time import traceback from typing import Any, cast -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU import voluptuous as vol from homeassistant.components import persistent_notification diff --git a/homeassistant/components/prosegur/__init__.py b/homeassistant/components/prosegur/__init__.py index 9f594fc6dae..fd79a091e39 100644 --- a/homeassistant/components/prosegur/__init__.py +++ b/homeassistant/components/prosegur/__init__.py @@ -4,12 +4,12 @@ import logging from pyprosegur.auth import Auth from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .const import CONF_COUNTRY, DOMAIN +from .const import DOMAIN PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.CAMERA] diff --git a/homeassistant/components/prosegur/config_flow.py b/homeassistant/components/prosegur/config_flow.py index ac2b704b012..c28245a09ff 100644 --- a/homeassistant/components/prosegur/config_flow.py +++ b/homeassistant/components/prosegur/config_flow.py @@ -9,11 +9,11 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, selector -from .const import CONF_CONTRACT, CONF_COUNTRY, DOMAIN +from .const import CONF_CONTRACT, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/prosegur/const.py b/homeassistant/components/prosegur/const.py index ea823e76062..495bec5d4ca 100644 --- a/homeassistant/components/prosegur/const.py +++ b/homeassistant/components/prosegur/const.py @@ -2,7 +2,6 @@ DOMAIN = "prosegur" -CONF_COUNTRY = "country" CONF_CONTRACT = "contract" SERVICE_REQUEST_IMAGE = "request_image" diff --git a/homeassistant/components/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index e81901dad52..b6a00bbaf10 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -6,13 +6,22 @@ import asyncio from datetime import timedelta import logging from time import monotonic -from typing import Generic, TypeVar +from typing import TypeVar -from pyprusalink import InvalidAuth, JobInfo, PrinterInfo, PrusaLink, PrusaLinkError +from pyprusalink import JobInfo, LegacyPrinterStatus, PrinterStatus, PrusaLink +from pyprusalink.types import InvalidAuth, PrusaLinkError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import ( @@ -21,6 +30,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) +from .config_flow import ConfigFlow from .const import DOMAIN PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.CAMERA, Platform.SENSOR] @@ -29,14 +39,19 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up PrusaLink from a config entry.""" + if entry.version == 1 and entry.minor_version < 2: + raise ConfigEntryError("Please upgrade your printer's firmware.") + api = PrusaLink( async_get_clientsession(hass), - entry.data["host"], - entry.data["api_key"], + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], ) coordinators = { - "printer": PrinterUpdateCoordinator(hass, api), + "legacy_status": LegacyStatusCoordinator(hass, api), + "status": StatusCoordinator(hass, api), "job": JobUpdateCoordinator(hass, api), } for coordinator in coordinators.values(): @@ -49,6 +64,62 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + if config_entry.version > ConfigFlow.VERSION: + # This means the user has downgraded from a future version + return False + + new_data = dict(config_entry.data) + if config_entry.version == 1: + if config_entry.minor_version < 2: + # Add username and password + # "maker" is currently hardcoded in the firmware + # https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/bfb0ffc745ee6546e7efdba618d0e7c0f4c909cd/lib/WUI/wui_api.h#L19 + username = "maker" + password = config_entry.data[CONF_API_KEY] + + api = PrusaLink( + async_get_clientsession(hass), + config_entry.data[CONF_HOST], + username, + password, + ) + try: + await api.get_info() + except InvalidAuth: + # We are unable to reach the new API which usually means + # that the user is running an outdated firmware version + ir.async_create_issue( + hass, + DOMAIN, + "firmware_5_1_required", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="firmware_5_1_required", + translation_placeholders={ + "entry_title": config_entry.title, + "prusa_mini_firmware_update": "https://help.prusa3d.com/article/firmware-updating-mini-mini_124784", + "prusa_mk4_xl_firmware_update": "https://help.prusa3d.com/article/how-to-update-firmware-mk4-xl_453086", + }, + ) + # There is a check in the async_setup_entry to prevent the setup if minor_version < 2 + # Currently we can't reload the config entry + # if the migration returns False. + # Return True here to workaround that. + return True + + new_data[CONF_USERNAME] = username + new_data[CONF_PASSWORD] = password + + ir.async_delete_issue(hass, DOMAIN, "firmware_5_1_required") + config_entry.minor_version = 2 + + hass.config_entries.async_update_entry(config_entry, data=new_data) + + 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): @@ -57,10 +128,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -T = TypeVar("T", PrinterInfo, JobInfo) +T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) -class PrusaLinkUpdateCoordinator(DataUpdateCoordinator, Generic[T], ABC): +class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): """Update coordinator for the printer.""" config_entry: ConfigEntry @@ -105,21 +176,20 @@ class PrusaLinkUpdateCoordinator(DataUpdateCoordinator, Generic[T], ABC): return timedelta(seconds=30) -class PrinterUpdateCoordinator(PrusaLinkUpdateCoordinator[PrinterInfo]): +class StatusCoordinator(PrusaLinkUpdateCoordinator[PrinterStatus]): """Printer update coordinator.""" - async def _fetch_data(self) -> PrinterInfo: + async def _fetch_data(self) -> PrinterStatus: """Fetch the printer data.""" - return await self.api.get_printer() + return await self.api.get_status() - def _get_update_interval(self, data: T) -> timedelta: - """Get new update interval.""" - if data and any( - data["state"]["flags"][key] for key in ("pausing", "cancelling") - ): - return timedelta(seconds=5) - return super()._get_update_interval(data) +class LegacyStatusCoordinator(PrusaLinkUpdateCoordinator[LegacyPrinterStatus]): + """Printer legacy update coordinator.""" + + async def _fetch_data(self) -> LegacyPrinterStatus: + """Fetch the printer data.""" + return await self.api.get_legacy_printer() class JobUpdateCoordinator(PrusaLinkUpdateCoordinator[JobInfo]): @@ -142,5 +212,5 @@ class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]): identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, name=self.coordinator.config_entry.title, manufacturer="Prusa", - configuration_url=self.coordinator.api.host, + configuration_url=self.coordinator.api.client.host, ) diff --git a/homeassistant/components/prusalink/button.py b/homeassistant/components/prusalink/button.py index 7e95b209bad..8f8a62794a9 100644 --- a/homeassistant/components/prusalink/button.py +++ b/homeassistant/components/prusalink/button.py @@ -5,7 +5,8 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, Generic, TypeVar, cast -from pyprusalink import Conflict, JobInfo, PrinterInfo, PrusaLink +from pyprusalink import JobInfo, LegacyPrinterStatus, PrinterStatus, PrusaLink +from pyprusalink.types import Conflict, PrinterState from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry @@ -15,17 +16,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, PrusaLinkEntity, PrusaLinkUpdateCoordinator -T = TypeVar("T", PrinterInfo, JobInfo) +T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) -@dataclass +@dataclass(frozen=True) class PrusaLinkButtonEntityDescriptionMixin(Generic[T]): """Mixin for required keys.""" - press_fn: Callable[[PrusaLink], Coroutine[Any, Any, None]] + press_fn: Callable[[PrusaLink], Callable[[int], Coroutine[Any, Any, None]]] -@dataclass +@dataclass(frozen=True) class PrusaLinkButtonEntityDescription( ButtonEntityDescription, PrusaLinkButtonEntityDescriptionMixin[T], Generic[T] ): @@ -35,33 +36,34 @@ class PrusaLinkButtonEntityDescription( BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = { - "printer": ( - PrusaLinkButtonEntityDescription[PrinterInfo]( + "status": ( + PrusaLinkButtonEntityDescription[PrinterStatus]( key="printer.cancel_job", translation_key="cancel_job", icon="mdi:cancel", - press_fn=lambda api: cast(Coroutine, api.cancel_job()), - available_fn=lambda data: any( - data["state"]["flags"][flag] - for flag in ("printing", "pausing", "paused") + press_fn=lambda api: api.cancel_job, + available_fn=lambda data: ( + data["printer"]["state"] + in [PrinterState.PRINTING.value, PrinterState.PAUSED.value] ), ), - PrusaLinkButtonEntityDescription[PrinterInfo]( + PrusaLinkButtonEntityDescription[PrinterStatus]( key="job.pause_job", translation_key="pause_job", icon="mdi:pause", - press_fn=lambda api: cast(Coroutine, api.pause_job()), - available_fn=lambda data: ( - data["state"]["flags"]["printing"] - and not data["state"]["flags"]["paused"] + press_fn=lambda api: api.pause_job, + available_fn=lambda data: cast( + bool, data["printer"]["state"] == PrinterState.PRINTING.value ), ), - PrusaLinkButtonEntityDescription[PrinterInfo]( + PrusaLinkButtonEntityDescription[PrinterStatus]( key="job.resume_job", translation_key="resume_job", icon="mdi:play", - press_fn=lambda api: cast(Coroutine, api.resume_job()), - available_fn=lambda data: cast(bool, data["state"]["flags"]["paused"]), + press_fn=lambda api: api.resume_job, + available_fn=lambda data: cast( + bool, data["printer"]["state"] == PrinterState.PAUSED.value + ), ), ), } @@ -113,8 +115,10 @@ class PrusaLinkButtonEntity(PrusaLinkEntity, ButtonEntity): async def async_press(self) -> None: """Press the button.""" + job_id = self.coordinator.data["job"]["id"] + func = self.entity_description.press_fn(self.coordinator.api) try: - await self.entity_description.press_fn(self.coordinator.api) + await func(job_id) except Conflict as err: raise HomeAssistantError( "Action conflicts with current printer state" diff --git a/homeassistant/components/prusalink/camera.py b/homeassistant/components/prusalink/camera.py index a8b8f387eff..7f6fab0583b 100644 --- a/homeassistant/components/prusalink/camera.py +++ b/homeassistant/components/prusalink/camera.py @@ -35,7 +35,11 @@ class PrusaLinkJobPreviewEntity(PrusaLinkEntity, Camera): @property def available(self) -> bool: """Get if camera is available.""" - return super().available and self.coordinator.data.get("job") is not None + return ( + super().available + and (file := self.coordinator.data.get("file")) + and file.get("refs", {}).get("thumbnail") + ) async def async_camera_image( self, width: int | None = None, height: int | None = None @@ -44,11 +48,11 @@ class PrusaLinkJobPreviewEntity(PrusaLinkEntity, Camera): if not self.available: return None - path = self.coordinator.data["job"]["file"]["path"] + path = self.coordinator.data["file"]["refs"]["thumbnail"] if self.last_path == path: return self.last_image - self.last_image = await self.coordinator.api.get_large_thumbnail(path) + self.last_image = await self.coordinator.api.get_file(path) self.last_path = path return self.last_image diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index b1faad6e3ea..378c5e7395a 100644 --- a/homeassistant/components/prusalink/config_flow.py +++ b/homeassistant/components/prusalink/config_flow.py @@ -7,11 +7,12 @@ from typing import Any from aiohttp import ClientError from awesomeversion import AwesomeVersion, AwesomeVersionException -from pyprusalink import InvalidAuth, PrusaLink +from pyprusalink import PrusaLink +from pyprusalink.types import InvalidAuth import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -25,7 +26,10 @@ _LOGGER = logging.getLogger(__name__) STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, - vol.Required(CONF_API_KEY): str, + # "maker" is currently hardcoded in the firmware + # https://github.com/prusa3d/Prusa-Firmware-Buddy/blob/bfb0ffc745ee6546e7efdba618d0e7c0f4c909cd/lib/WUI/wui_api.h#L19 + vol.Required(CONF_USERNAME, default="maker"): str, + vol.Required(CONF_PASSWORD): str, } ) @@ -35,7 +39,12 @@ async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - api = PrusaLink(async_get_clientsession(hass), data[CONF_HOST], data[CONF_API_KEY]) + api = PrusaLink( + async_get_clientsession(hass), + data[CONF_HOST], + data[CONF_USERNAME], + data[CONF_PASSWORD], + ) try: async with asyncio.timeout(5): @@ -58,6 +67,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for PrusaLink.""" VERSION = 1 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -74,7 +84,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data = { CONF_HOST: host, - CONF_API_KEY: user_input[CONF_API_KEY], + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], } errors = {} diff --git a/homeassistant/components/prusalink/manifest.json b/homeassistant/components/prusalink/manifest.json index ade39320a29..a9d8353690e 100644 --- a/homeassistant/components/prusalink/manifest.json +++ b/homeassistant/components/prusalink/manifest.json @@ -1,7 +1,7 @@ { "domain": "prusalink", "name": "PrusaLink", - "codeowners": ["@balloob"], + "codeowners": ["@balloob", "@Skaronator"], "config_flow": true, "dhcp": [ { @@ -10,5 +10,5 @@ ], "documentation": "https://www.home-assistant.io/integrations/prusalink", "iot_class": "local_polling", - "requirements": ["pyprusalink==1.1.0"] + "requirements": ["pyprusalink==2.0.0"] } diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index 1ee4274e5bb..29e1d5c9757 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -6,7 +6,8 @@ from dataclasses import dataclass from datetime import datetime, timedelta from typing import Generic, TypeVar, cast -from pyprusalink import JobInfo, PrinterInfo +from pyprusalink.types import JobInfo, PrinterState, PrinterStatus +from pyprusalink.types_legacy import LegacyPrinterStatus from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,7 +16,12 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfTemperature +from homeassistant.const import ( + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + UnitOfLength, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -24,17 +30,17 @@ from homeassistant.util.variance import ignore_variance from . import DOMAIN, PrusaLinkEntity, PrusaLinkUpdateCoordinator -T = TypeVar("T", PrinterInfo, JobInfo) +T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) -@dataclass +@dataclass(frozen=True) class PrusaLinkSensorEntityDescriptionMixin(Generic[T]): """Mixin for required keys.""" value_fn: Callable[[T], datetime | StateType] -@dataclass +@dataclass(frozen=True) class PrusaLinkSensorEntityDescription( SensorEntityDescription, PrusaLinkSensorEntityDescriptionMixin[T], Generic[T] ): @@ -44,78 +50,91 @@ class PrusaLinkSensorEntityDescription( SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { - "printer": ( - PrusaLinkSensorEntityDescription[PrinterInfo]( + "status": ( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.state", name=None, icon="mdi:printer-3d", - value_fn=lambda data: ( - "pausing" - if (flags := data["state"]["flags"])["pausing"] - else "cancelling" - if flags["cancelling"] - else "paused" - if flags["paused"] - else "printing" - if flags["printing"] - else "idle" - ), + value_fn=lambda data: (cast(str, data["printer"]["state"].lower())), device_class=SensorDeviceClass.ENUM, - options=["cancelling", "idle", "paused", "pausing", "printing"], + options=[state.value.lower() for state in PrinterState], translation_key="printer_state", ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.temp-bed", translation_key="heatbed_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["telemetry"]["temp-bed"]), + value_fn=lambda data: cast(float, data["printer"]["temp_bed"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.temp-nozzle", translation_key="nozzle_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["telemetry"]["temp-nozzle"]), + value_fn=lambda data: cast(float, data["printer"]["temp_nozzle"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.temp-bed.target", translation_key="heatbed_target_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["temperature"]["bed"]["target"]), + value_fn=lambda data: cast(float, data["printer"]["target_bed"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.temp-nozzle.target", translation_key="nozzle_target_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["temperature"]["tool0"]["target"]), + value_fn=lambda data: cast(float, data["printer"]["target_nozzle"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.z-height", translation_key="z_height", native_unit_of_measurement=UnitOfLength.MILLIMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: cast(float, data["telemetry"]["z-height"]), + value_fn=lambda data: cast(float, data["printer"]["axis_z"]), entity_registry_enabled_default=False, ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( key="printer.telemetry.print-speed", translation_key="print_speed", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: cast(float, data["telemetry"]["print-speed"]), + value_fn=lambda data: cast(float, data["printer"]["speed"]), ), - PrusaLinkSensorEntityDescription[PrinterInfo]( + PrusaLinkSensorEntityDescription[PrinterStatus]( + key="printer.telemetry.print-flow", + translation_key="print_flow", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: cast(float, data["printer"]["flow"]), + entity_registry_enabled_default=False, + ), + PrusaLinkSensorEntityDescription[PrinterStatus]( + key="printer.telemetry.fan-hotend", + translation_key="fan_hotend", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + value_fn=lambda data: cast(float, data["printer"]["fan_hotend"]), + entity_registry_enabled_default=False, + ), + PrusaLinkSensorEntityDescription[PrinterStatus]( + key="printer.telemetry.fan-print", + translation_key="fan_print", + native_unit_of_measurement=REVOLUTIONS_PER_MINUTE, + value_fn=lambda data: cast(float, data["printer"]["fan_print"]), + entity_registry_enabled_default=False, + ), + ), + "legacy_status": ( + PrusaLinkSensorEntityDescription[LegacyPrinterStatus]( key="printer.telemetry.material", translation_key="material", icon="mdi:palette-swatch-variant", @@ -128,15 +147,15 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { translation_key="progress", icon="mdi:progress-clock", native_unit_of_measurement=PERCENTAGE, - value_fn=lambda data: cast(float, data["progress"]["completion"]) * 100, + value_fn=lambda data: cast(float, data["progress"]), available_fn=lambda data: data.get("progress") is not None, ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.filename", translation_key="filename", icon="mdi:file-image-outline", - value_fn=lambda data: cast(str, data["job"]["file"]["display"]), - available_fn=lambda data: data.get("job") is not None, + value_fn=lambda data: cast(str, data["file"]["display_name"]), + available_fn=lambda data: data.get("file") is not None, ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.start", @@ -144,12 +163,10 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { device_class=SensorDeviceClass.TIMESTAMP, icon="mdi:clock-start", value_fn=ignore_variance( - lambda data: ( - utcnow() - timedelta(seconds=data["progress"]["printTime"]) - ), + lambda data: (utcnow() - timedelta(seconds=data["time_printing"])), timedelta(minutes=2), ), - available_fn=lambda data: data.get("progress") is not None, + available_fn=lambda data: data.get("time_printing") is not None, ), PrusaLinkSensorEntityDescription[JobInfo]( key="job.finish", @@ -157,12 +174,10 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { icon="mdi:clock-end", device_class=SensorDeviceClass.TIMESTAMP, value_fn=ignore_variance( - lambda data: ( - utcnow() + timedelta(seconds=data["progress"]["printTimeLeft"]) - ), + lambda data: (utcnow() + timedelta(seconds=data["time_remaining"])), timedelta(minutes=2), ), - available_fn=lambda data: data.get("progress") is not None, + available_fn=lambda data: data.get("time_remaining") is not None, ), ), } diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json index aa992b4874f..bb32770e357 100644 --- a/homeassistant/components/prusalink/strings.json +++ b/homeassistant/components/prusalink/strings.json @@ -4,7 +4,8 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" } } }, @@ -15,15 +16,25 @@ "not_supported": "Only PrusaLink API v2 is supported" } }, + "issues": { + "firmware_5_1_required": { + "description": "The PrusaLink integration has been updated to utilize the latest v1 API endpoints, which require firmware version 4.7.0 or later. If you own a Prusa Mini, please make sure your printer is running firmware 5.1.0 or a more recent version, as firmware versions 4.7.x and 5.0.x are not available for this model.\n\nFollow the guide below to update your {entry_title}.\n* [Prusa Mini Firmware Update]({prusa_mini_firmware_update})\n* [Prusa MK4/XL Firmware Update]({prusa_mk4_xl_firmware_update})\n\nAfter you've updated your printer's firmware, make sure to reload the config entry to fix this issue.", + "title": "Firmware update required" + } + }, "entity": { "sensor": { "printer_state": { "state": { - "cancelling": "Cancelling", "idle": "[%key:common::state::idle%]", + "busy": "Busy", + "printing": "Printing", "paused": "[%key:common::state::paused%]", - "pausing": "Pausing", - "printing": "Printing" + "finished": "Finished", + "stopped": "Stopped", + "error": "Error", + "attention": "Attention", + "ready": "Ready" } }, "heatbed_temperature": { @@ -56,6 +67,15 @@ "print_speed": { "name": "Print speed" }, + "print_flow": { + "name": "Print flow" + }, + "fan_hotend": { + "name": "Hotend fan" + }, + "fan_print": { + "name": "Print fan" + }, "z_height": { "name": "Z-Height" } diff --git a/homeassistant/components/psoklahoma/__init__.py b/homeassistant/components/psoklahoma/__init__.py new file mode 100644 index 00000000000..a0a3a4ca0bb --- /dev/null +++ b/homeassistant/components/psoklahoma/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Public Service Company of Oklahoma (PSO).""" diff --git a/homeassistant/components/psoklahoma/manifest.json b/homeassistant/components/psoklahoma/manifest.json new file mode 100644 index 00000000000..5a1aa460dd0 --- /dev/null +++ b/homeassistant/components/psoklahoma/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "psoklahoma", + "name": "Public Service Company of Oklahoma (PSO)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py index 4ab77fa7893..09470609c9e 100644 --- a/homeassistant/components/pure_energie/sensor.py +++ b/homeassistant/components/pure_energie/sensor.py @@ -22,14 +22,14 @@ from . import PureEnergieData, PureEnergieDataUpdateCoordinator from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class PureEnergieSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[PureEnergieData], int | float] -@dataclass +@dataclass(frozen=True) class PureEnergieSensorEntityDescription( SensorEntityDescription, PureEnergieSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index 6b998f6879e..f52d0799d35 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -7,12 +7,17 @@ from typing import Any from aiopurpleair.models.sensors import SensorModel from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, Platform +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_SHOW_ON_MAP, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_SHOW_ON_MAP, DOMAIN +from .const import DOMAIN from .coordinator import PurpleAirDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 3daa6f96fdf..e2b43726dc4 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -14,7 +14,12 @@ 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.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_SHOW_ON_MAP, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import ( @@ -35,7 +40,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.helpers.typing import EventType -from .const import CONF_SENSOR_INDICES, CONF_SHOW_ON_MAP, DOMAIN, LOGGER +from .const import CONF_SENSOR_INDICES, DOMAIN, LOGGER CONF_DISTANCE = "distance" CONF_NEARBY_SENSOR_OPTIONS = "nearby_sensor_options" diff --git a/homeassistant/components/purpleair/const.py b/homeassistant/components/purpleair/const.py index e3ea7807a21..60f51a9e7dd 100644 --- a/homeassistant/components/purpleair/const.py +++ b/homeassistant/components/purpleair/const.py @@ -7,4 +7,3 @@ 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 fffceffa343..1e78586dece 100644 --- a/homeassistant/components/purpleair/sensor.py +++ b/homeassistant/components/purpleair/sensor.py @@ -33,14 +33,14 @@ from .coordinator import PurpleAirDataUpdateCoordinator CONCENTRATION_PARTICLES_PER_100_MILLILITERS = f"particles/100{UnitOfVolume.MILLILITERS}" -@dataclass +@dataclass(frozen=True) class PurpleAirSensorEntityDescriptionMixin: """Define a description mixin for PurpleAir sensor entities.""" value_fn: Callable[[SensorModel], float | str | None] -@dataclass +@dataclass(frozen=True) class PurpleAirSensorEntityDescription( SensorEntityDescription, PurpleAirSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/pushbullet/api.py b/homeassistant/components/pushbullet/api.py index ff6a57aa931..691ef7413c3 100644 --- a/homeassistant/components/pushbullet/api.py +++ b/homeassistant/components/pushbullet/api.py @@ -1,4 +1,5 @@ """Pushbullet Notification provider.""" +from __future__ import annotations from typing import Any @@ -10,7 +11,7 @@ from homeassistant.helpers.dispatcher import dispatcher_send from .const import DATA_UPDATED -class PushBulletNotificationProvider(Listener): +class PushBulletNotificationProvider(Listener): # type: ignore[misc] """Provider for an account, leading to one or more sensors.""" def __init__(self, hass: HomeAssistant, pushbullet: PushBullet) -> None: diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index 1cc851bdb99..662240d0bf5 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .api import PushBulletNotificationProvider from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -34,8 +35,10 @@ async def async_get_service( """Get the Pushbullet notification service.""" if TYPE_CHECKING: assert discovery_info is not None - pushbullet: PushBullet = hass.data[DOMAIN][discovery_info["entry_id"]].pushbullet - return PushBulletNotificationService(hass, pushbullet) + pb_provider: PushBulletNotificationProvider = hass.data[DOMAIN][ + discovery_info["entry_id"] + ] + return PushBulletNotificationService(hass, pb_provider.pushbullet) class PushBulletNotificationService(BaseNotificationService): @@ -120,7 +123,7 @@ class PushBulletNotificationService(BaseNotificationService): pusher: PushBullet, email: str | None = None, phonenumber: str | None = None, - ): + ) -> None: """Create the message content.""" kwargs = {"body": message, "title": title} if email: diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py index d9ef71bee69..c003e3cfad8 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -28,7 +28,7 @@ from .const import CONF_SYSTEM_ID, DOMAIN from .coordinator import PVOutputDataUpdateCoordinator -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class PVOutputSensorEntityDescription(SensorEntityDescription): """Describes a PVOutput sensor entity.""" diff --git a/homeassistant/components/pvpc_hourly_pricing/__init__.py b/homeassistant/components/pvpc_hourly_pricing/__init__.py index 7071000ffd9..00a3a355477 100644 --- a/homeassistant/components/pvpc_hourly_pricing/__init__.py +++ b/homeassistant/components/pvpc_hourly_pricing/__init__.py @@ -10,10 +10,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import ATTR_POWER, ATTR_POWER_P3, ATTR_TARIFF, DOMAIN +from .helpers import get_enabled_sensor_keys _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -22,7 +24,12 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up pvpc hourly pricing from a config entry.""" - coordinator = ElecPricesDataUpdateCoordinator(hass, entry) + entity_registry = er.async_get(hass) + sensor_keys = get_enabled_sensor_keys( + using_private_api=entry.data.get(CONF_API_TOKEN) is not None, + entries=er.async_entries_for_config_entry(entity_registry, entry.entry_id), + ) + coordinator = ElecPricesDataUpdateCoordinator(hass, entry, sensor_keys) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator @@ -55,7 +62,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): """Class to manage fetching Electricity prices data from API.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, sensor_keys: set[str] + ) -> None: """Initialize.""" self.api = PVPCData( session=async_get_clientsession(hass), @@ -64,6 +73,7 @@ class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): power=entry.data[ATTR_POWER], power_valley=entry.data[ATTR_POWER_P3], api_token=entry.data.get(CONF_API_TOKEN), + sensor_keys=tuple(sensor_keys), ) super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30) @@ -84,7 +94,7 @@ class ElecPricesDataUpdateCoordinator(DataUpdateCoordinator[EsiosApiData]): if ( not api_data or not api_data.sensors - or not all(api_data.availability.values()) + or not any(api_data.availability.values()) ): raise UpdateFailed return api_data diff --git a/homeassistant/components/pvpc_hourly_pricing/const.py b/homeassistant/components/pvpc_hourly_pricing/const.py index ea4d97620ec..a6bfc6f3188 100644 --- a/homeassistant/components/pvpc_hourly_pricing/const.py +++ b/homeassistant/components/pvpc_hourly_pricing/const.py @@ -1,5 +1,5 @@ """Constant values for pvpc_hourly_pricing.""" -from aiopvpc import TARIFFS +from aiopvpc.const import TARIFFS import voluptuous as vol DOMAIN = "pvpc_hourly_pricing" diff --git a/homeassistant/components/pvpc_hourly_pricing/helpers.py b/homeassistant/components/pvpc_hourly_pricing/helpers.py new file mode 100644 index 00000000000..195d20aee89 --- /dev/null +++ b/homeassistant/components/pvpc_hourly_pricing/helpers.py @@ -0,0 +1,49 @@ +"""Helper functions to relate sensors keys and unique ids.""" +from aiopvpc.const import ( + ALL_SENSORS, + KEY_INJECTION, + KEY_MAG, + KEY_OMIE, + KEY_PVPC, + TARIFFS, +) + +from homeassistant.helpers.entity_registry import RegistryEntry + +_ha_uniqueid_to_sensor_key = { + TARIFFS[0]: KEY_PVPC, + TARIFFS[1]: KEY_PVPC, + f"{TARIFFS[0]}_{KEY_INJECTION}": KEY_INJECTION, + f"{TARIFFS[1]}_{KEY_INJECTION}": KEY_INJECTION, + f"{TARIFFS[0]}_{KEY_MAG}": KEY_MAG, + f"{TARIFFS[1]}_{KEY_MAG}": KEY_MAG, + f"{TARIFFS[0]}_{KEY_OMIE}": KEY_OMIE, + f"{TARIFFS[1]}_{KEY_OMIE}": KEY_OMIE, +} + + +def get_enabled_sensor_keys( + using_private_api: bool, entries: list[RegistryEntry] +) -> set[str]: + """Get enabled API indicators.""" + if not using_private_api: + return {KEY_PVPC} + if len(entries) > 1: + # activate only enabled sensors + return { + _ha_uniqueid_to_sensor_key[sensor.unique_id] + for sensor in entries + if not sensor.disabled + } + # default sensors when enabling token access + return {KEY_PVPC, KEY_INJECTION} + + +def make_sensor_unique_id(config_entry_id: str | None, sensor_key: str) -> str: + """Generate unique_id for each sensor kind and config entry.""" + assert sensor_key in ALL_SENSORS + assert config_entry_id is not None + if sensor_key == KEY_PVPC: + # for old compatibility + return config_entry_id + return f"{config_entry_id}_{sensor_key}" diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index 3368b24b3ff..9cc3ef35a4b 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -6,6 +6,8 @@ from datetime import datetime import logging from typing import Any +from aiopvpc.const import KEY_INJECTION, KEY_MAG, KEY_OMIE, KEY_PVPC + from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, @@ -22,19 +24,49 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import ElecPricesDataUpdateCoordinator from .const import DOMAIN +from .helpers import make_sensor_unique_id _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( - key="PVPC", + key=KEY_PVPC, icon="mdi:currency-eur", native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, name="PVPC", ), + SensorEntityDescription( + key=KEY_INJECTION, + icon="mdi:transmission-tower-export", + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, + name="Injection Price", + ), + SensorEntityDescription( + key=KEY_MAG, + icon="mdi:bank-transfer", + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, + name="MAG tax", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key=KEY_OMIE, + icon="mdi:shopping", + native_unit_of_measurement=f"{CURRENCY_EURO}/{UnitOfEnergy.KILO_WATT_HOUR}", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=5, + name="OMIE Price", + entity_registry_enabled_default=False, + ), ) _PRICE_SENSOR_ATTRIBUTES_MAP = { + "data_id": "data_id", + "name": "data_name", "tariff": "tariff", "period": "period", "available_power": "available_power", @@ -119,7 +151,11 @@ async def async_setup_entry( ) -> None: """Set up the electricity price sensor from config_entry.""" coordinator: ElecPricesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([ElecPriceSensor(coordinator, SENSOR_TYPES[0], entry.unique_id)]) + sensors = [ElecPriceSensor(coordinator, SENSOR_TYPES[0], entry.unique_id)] + if coordinator.api.using_private_api: + for sensor_desc in SENSOR_TYPES[1:]: + sensors.append(ElecPriceSensor(coordinator, sensor_desc, entry.unique_id)) + async_add_entities(sensors) class ElecPriceSensor(CoordinatorEntity[ElecPricesDataUpdateCoordinator], SensorEntity): @@ -137,7 +173,7 @@ class ElecPriceSensor(CoordinatorEntity[ElecPricesDataUpdateCoordinator], Sensor super().__init__(coordinator) self.entity_description = description self._attr_attribution = coordinator.api.attribution - self._attr_unique_id = unique_id + self._attr_unique_id = make_sensor_unique_id(unique_id, description.key) self._attr_device_info = DeviceInfo( configuration_url="https://api.esios.ree.es", entry_type=DeviceEntryType.SERVICE, @@ -146,9 +182,23 @@ class ElecPriceSensor(CoordinatorEntity[ElecPricesDataUpdateCoordinator], Sensor name="ESIOS", ) + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.data.availability.get( + self.entity_description.key, False + ) + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() + # Enable API downloads for this sensor + self.coordinator.api.update_active_sensors(self.entity_description.key, True) + self.async_on_remove( + lambda: self.coordinator.api.update_active_sensors( + self.entity_description.key, False + ) + ) # Update 'state' value in hour changes self.async_on_remove( @@ -157,10 +207,10 @@ class ElecPriceSensor(CoordinatorEntity[ElecPricesDataUpdateCoordinator], Sensor ) ) _LOGGER.debug( - "Setup of price sensor %s (%s) with tariff '%s'", - self.name, + "Setup of ESIOS sensor %s (%s, unique_id: %s)", + self.entity_description.key, self.entity_id, - self.coordinator.api.tariff, + self._attr_unique_id, ) @callback diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 10751d28c06..098603b9494 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -27,7 +27,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util import raise_if_invalid_filename import homeassistant.util.dt as dt_util -from homeassistant.util.yaml.loader import load_yaml +from homeassistant.util.yaml.loader import load_yaml_dict _LOGGER = logging.getLogger(__name__) @@ -120,7 +120,7 @@ def discover_scripts(hass): # Load user-provided service descriptions from python_scripts/services.yaml services_yaml = os.path.join(path, "services.yaml") if os.path.exists(services_yaml): - services_dict = load_yaml(services_yaml) + services_dict = load_yaml_dict(services_yaml) else: services_dict = {} diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index fd9577f5c73..84315186097 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -19,21 +19,21 @@ from .const import DOMAIN from .coordinator import QBittorrentDataCoordinator from .helpers import setup_client -PLATFORMS = [Platform.SENSOR] - _LOGGER = logging.getLogger(__name__) +PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up qBittorrent from a config entry.""" - hass.data.setdefault(DOMAIN, {}) + try: client = await hass.async_add_executor_job( setup_client, - entry.data[CONF_URL], - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - entry.data[CONF_VERIFY_SSL], + config_entry.data[CONF_URL], + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + config_entry.data[CONF_VERIFY_SSL], ) except LoginRequired as err: raise ConfigEntryNotReady("Invalid credentials") from err @@ -42,16 +42,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = QBittorrentDataCoordinator(hass, client) await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - 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: +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload qBittorrent config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] + if unload_ok := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + del hass.data[DOMAIN][config_entry.entry_id] if not hass.data[DOMAIN]: del hass.data[DOMAIN] return unload_ok diff --git a/homeassistant/components/qbittorrent/const.py b/homeassistant/components/qbittorrent/const.py index 0a79c67f400..96c60e9b380 100644 --- a/homeassistant/components/qbittorrent/const.py +++ b/homeassistant/components/qbittorrent/const.py @@ -5,3 +5,7 @@ DOMAIN: Final = "qbittorrent" DEFAULT_NAME = "qBittorrent" DEFAULT_URL = "http://127.0.0.1:8080" + +STATE_UP_DOWN = "up_down" +STATE_SEEDING = "seeding" +STATE_DOWNLOADING = "downloading" diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py index 8363a764d0a..11467ce62f4 100644 --- a/homeassistant/components/qbittorrent/coordinator.py +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """QBittorrent update coordinator.""" + """Coordinator for updating QBittorrent data.""" def __init__(self, hass: HomeAssistant, client: Client) -> None: """Initialize coordinator.""" diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json index e2c1526e4f8..fb51f177081 100644 --- a/homeassistant/components/qbittorrent/manifest.json +++ b/homeassistant/components/qbittorrent/manifest.json @@ -1,7 +1,7 @@ { "domain": "qbittorrent", "name": "qBittorrent", - "codeowners": ["@geoffreylagaisse"], + "codeowners": ["@geoffreylagaisse", "@finder39"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/qbittorrent", "integration_type": "service", diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index e2feee1e60c..9373aec8544 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -4,22 +4,21 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, - SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, UnitOfDataRate from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import DOMAIN, STATE_DOWNLOADING, STATE_SEEDING, STATE_UP_DOWN from .coordinator import QBittorrentDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -27,62 +26,94 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPE_CURRENT_STATUS = "current_status" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" +SENSOR_TYPE_ALL_TORRENTS = "all_torrents" +SENSOR_TYPE_PAUSED_TORRENTS = "paused_torrents" +SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents" +SENSOR_TYPE_INACTIVE_TORRENTS = "inactive_torrents" -@dataclass -class QBittorrentMixin: - """Mixin for required keys.""" - - value_fn: Callable[[dict[str, Any]], StateType] - - -@dataclass -class QBittorrentSensorEntityDescription(SensorEntityDescription, QBittorrentMixin): - """Describes QBittorrent sensor entity.""" - - -def _get_qbittorrent_state(data: dict[str, Any]) -> str: - download = data["server_state"]["dl_info_speed"] - upload = data["server_state"]["up_info_speed"] +def get_state(coordinator: QBittorrentDataCoordinator) -> str: + """Get current download/upload state.""" + upload = coordinator.data["server_state"]["up_info_speed"] + download = coordinator.data["server_state"]["dl_info_speed"] if upload > 0 and download > 0: - return "up_down" + return STATE_UP_DOWN if upload > 0 and download == 0: - return "seeding" + return STATE_SEEDING if upload == 0 and download > 0: - return "downloading" + return STATE_DOWNLOADING return STATE_IDLE -def format_speed(speed): - """Return a bytes/s measurement as a human readable string.""" - kb_spd = float(speed) / 1024 - return round(kb_spd, 2 if kb_spd < 0.1 else 1) +@dataclass(frozen=True, kw_only=True) +class QBittorrentSensorEntityDescription(SensorEntityDescription): + """Entity description class for qBittorent sensors.""" + + value_fn: Callable[[QBittorrentDataCoordinator], StateType] SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( QBittorrentSensorEntityDescription( key=SENSOR_TYPE_CURRENT_STATUS, - name="Status", - value_fn=_get_qbittorrent_state, + translation_key="current_status", + device_class=SensorDeviceClass.ENUM, + options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING], + value_fn=get_state, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, - name="Down Speed", + translation_key="download_speed", icon="mdi:cloud-download", device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: format_speed(data["server_state"]["dl_info_speed"]), + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + value_fn=lambda coordinator: float( + coordinator.data["server_state"]["dl_info_speed"] + ), ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, - name="Up Speed", + translation_key="upload_speed", icon="mdi:cloud-upload", device_class=SensorDeviceClass.DATA_RATE, - native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, - state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda data: format_speed(data["server_state"]["up_info_speed"]), + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + value_fn=lambda coordinator: float( + coordinator.data["server_state"]["up_info_speed"] + ), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ALL_TORRENTS, + translation_key="all_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states(coordinator, []), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ACTIVE_TORRENTS, + translation_key="active_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states( + coordinator, ["downloading", "uploading"] + ), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_INACTIVE_TORRENTS, + translation_key="inactive_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states( + coordinator, ["stalledDL", "stalledUP"] + ), + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_PAUSED_TORRENTS, + translation_key="paused_torrents", + native_unit_of_measurement="torrents", + value_fn=lambda coordinator: count_torrents_in_states( + coordinator, ["pausedDL", "pausedUP"] + ), ), ) @@ -90,36 +121,57 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, - async_add_entites: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up qBittorrent sensor entries.""" + coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] - entities = [ - QBittorrentSensor(description, coordinator, config_entry) + + async_add_entities( + QBittorrentSensor(coordinator, config_entry, description) for description in SENSOR_TYPES - ] - async_add_entites(entities) + ) class QBittorrentSensor(CoordinatorEntity[QBittorrentDataCoordinator], SensorEntity): """Representation of a qBittorrent sensor.""" + _attr_has_entity_name = True entity_description: QBittorrentSensorEntityDescription def __init__( self, - description: QBittorrentSensorEntityDescription, coordinator: QBittorrentDataCoordinator, config_entry: ConfigEntry, + entity_description: QBittorrentSensorEntityDescription, ) -> None: """Initialize the qBittorrent sensor.""" super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" - self._attr_name = f"{config_entry.title} {description.name}" - self._attr_available = False + self.entity_description = entity_description + self._attr_unique_id = f"{config_entry.entry_id}-{entity_description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="QBittorrent", + ) @property def native_value(self) -> StateType: - """Return value of sensor.""" - return self.entity_description.value_fn(self.coordinator.data) + """Return the value of the sensor.""" + return self.entity_description.value_fn(self.coordinator) + + +def count_torrents_in_states( + coordinator: QBittorrentDataCoordinator, states: list[str] +) -> int: + """Count the number of torrents in specified states.""" + if not states: + return len(coordinator.data["torrents"]) + + return len( + [ + torrent + for torrent in coordinator.data["torrents"].values() + if torrent["state"] in states + ] + ) diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 66c9430911e..8b20a3354dd 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -17,5 +17,36 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "download_speed": { + "name": "Download speed" + }, + "upload_speed": { + "name": "Upload speed" + }, + "transmission_status": { + "name": "Status", + "state": { + "idle": "[%key:common::state::idle%]", + "up_down": "Up/Down", + "seeding": "Seeding", + "downloading": "Downloading" + } + }, + "active_torrents": { + "name": "Active torrents" + }, + "inactive_torrents": { + "name": "Inactive torrents" + }, + "paused_torrents": { + "name": "Paused torrents" + }, + "all_torrents": { + "name": "All torrents" + } + } } } diff --git a/homeassistant/components/qingping/manifest.json b/homeassistant/components/qingping/manifest.json index 17593f8c404..5cde039c5ce 100644 --- a/homeassistant/components/qingping/manifest.json +++ b/homeassistant/components/qingping/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/qingping", "iot_class": "local_push", - "requirements": ["qingping-ble==0.8.2"] + "requirements": ["qingping-ble==0.9.0"] } diff --git a/homeassistant/components/qnap_qsw/binary_sensor.py b/homeassistant/components/qnap_qsw/binary_sensor.py index 5c3fbe13aff..f655beee3d4 100644 --- a/homeassistant/components/qnap_qsw/binary_sensor.py +++ b/homeassistant/components/qnap_qsw/binary_sensor.py @@ -30,7 +30,7 @@ from .coordinator import QswDataCoordinator from .entity import QswEntityDescription, QswEntityType, QswSensorEntity -@dataclass +@dataclass(frozen=True) class QswBinarySensorEntityDescription( BinarySensorEntityDescription, QswEntityDescription ): diff --git a/homeassistant/components/qnap_qsw/button.py b/homeassistant/components/qnap_qsw/button.py index acd8d3bd1ef..c2c4f9f6043 100644 --- a/homeassistant/components/qnap_qsw/button.py +++ b/homeassistant/components/qnap_qsw/button.py @@ -22,14 +22,14 @@ from .coordinator import QswDataCoordinator from .entity import QswDataEntity -@dataclass +@dataclass(frozen=True) class QswButtonDescriptionMixin: """Mixin to describe a Button entity.""" press_action: Callable[[QnapQswApi], Awaitable[bool]] -@dataclass +@dataclass(frozen=True) class QswButtonDescription(ButtonEntityDescription, QswButtonDescriptionMixin): """Class to describe a Button entity.""" diff --git a/homeassistant/components/qnap_qsw/entity.py b/homeassistant/components/qnap_qsw/entity.py index 4bbfba423e9..de92afe69a2 100644 --- a/homeassistant/components/qnap_qsw/entity.py +++ b/homeassistant/components/qnap_qsw/entity.py @@ -83,13 +83,14 @@ class QswDataEntity(CoordinatorEntity[QswDataCoordinator]): return value -@dataclass +@dataclass(frozen=True) class QswEntityDescriptionMixin: """Mixin to describe a QSW entity.""" subkey: str +@dataclass(frozen=True) class QswEntityDescription(EntityDescription, QswEntityDescriptionMixin): """Class to describe a QSW entity.""" diff --git a/homeassistant/components/qnap_qsw/sensor.py b/homeassistant/components/qnap_qsw/sensor.py index 0c287c66073..3168e4511d2 100644 --- a/homeassistant/components/qnap_qsw/sensor.py +++ b/homeassistant/components/qnap_qsw/sensor.py @@ -50,7 +50,7 @@ from .coordinator import QswDataCoordinator from .entity import QswEntityDescription, QswEntityType, QswSensorEntity -@dataclass +@dataclass(frozen=True) class QswSensorEntityDescription(SensorEntityDescription, QswEntityDescription): """A class that describes QNAP QSW sensor entities.""" diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index 8f9c9395ade..e47004f5fb7 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -7,12 +7,12 @@ from requests.exceptions import ConnectTimeout from homeassistant.components import cloud from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.const import CONF_API_KEY, CONF_WEBHOOK_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv -from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS, CONF_WEBHOOK_ID, DOMAIN +from .const import CONF_CLOUDHOOK_URL, CONF_MANUAL_RUN_MINS, DOMAIN from .device import RachioPerson from .webhooks import ( async_get_or_create_registered_webhook_id_and_url, diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index 92a57505a7c..dad044e5049 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -65,7 +65,6 @@ SIGNAL_RACHIO_RAIN_SENSOR_UPDATE = f"{SIGNAL_RACHIO_UPDATE}_rain_sensor" SIGNAL_RACHIO_ZONE_UPDATE = f"{SIGNAL_RACHIO_UPDATE}_zone" SIGNAL_RACHIO_SCHEDULE_UPDATE = f"{SIGNAL_RACHIO_UPDATE}_schedule" -CONF_WEBHOOK_ID = "webhook_id" CONF_CLOUDHOOK_URL = "cloudhook_url" # Webhook callbacks diff --git a/homeassistant/components/rachio/webhooks.py b/homeassistant/components/rachio/webhooks.py index 5c2fbe5965f..298b9c03701 100644 --- a/homeassistant/components/rachio/webhooks.py +++ b/homeassistant/components/rachio/webhooks.py @@ -5,13 +5,12 @@ from aiohttp import web from homeassistant.components import cloud, webhook from homeassistant.config_entries import ConfigEntry -from homeassistant.const import URL_API +from homeassistant.const import CONF_WEBHOOK_ID, URL_API from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( CONF_CLOUDHOOK_URL, - CONF_WEBHOOK_ID, DOMAIN, KEY_EXTERNAL_ID, KEY_TYPE, diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index 39258e2f787..b6b05b5b568 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -22,6 +22,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN from .coordinator import ( + CalendarUpdateCoordinator, DiskSpaceDataUpdateCoordinator, HealthDataUpdateCoordinator, MoviesDataUpdateCoordinator, @@ -31,7 +32,7 @@ from .coordinator import ( T, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -46,6 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) coordinators: dict[str, RadarrDataUpdateCoordinator[Any]] = { + "calendar": CalendarUpdateCoordinator(hass, host_configuration, radarr), "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), "health": HealthDataUpdateCoordinator(hass, host_configuration, radarr), "movie": MoviesDataUpdateCoordinator(hass, host_configuration, radarr), diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py new file mode 100644 index 00000000000..3a5308fffd5 --- /dev/null +++ b/homeassistant/components/radarr/calendar.py @@ -0,0 +1,63 @@ +"""Support for Radarr calendar items.""" +from __future__ import annotations + +from datetime import datetime + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RadarrEntity +from .const import DOMAIN +from .coordinator import CalendarUpdateCoordinator, RadarrEvent + +CALENDAR_TYPE = EntityDescription( + key="calendar", + name=None, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Radarr calendar entity.""" + coordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + async_add_entities([RadarrCalendarEntity(coordinator, CALENDAR_TYPE)]) + + +class RadarrCalendarEntity(RadarrEntity, CalendarEntity): + """A Radarr calendar entity.""" + + coordinator: CalendarUpdateCoordinator + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + if not self.coordinator.event: + return None + return CalendarEvent( + summary=self.coordinator.event.summary, + start=self.coordinator.event.start, + end=self.coordinator.event.end, + description=self.coordinator.event.description, + ) + + # pylint: disable-next=hass-return-type + async def async_get_events( # type: ignore[override] + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[RadarrEvent]: + """Get all events in a specific time frame.""" + return await self.coordinator.async_get_events(start_date, end_date) + + @callback + def async_write_ha_state(self) -> None: + """Write the state to the state machine.""" + if self.coordinator.event: + self._attr_extra_state_attributes = { + "release_type": self.coordinator.event.release_type + } + else: + self._attr_extra_state_attributes = {} + super().async_write_ha_state() diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index bd41810bfb8..c14603fe9ca 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -2,13 +2,23 @@ from __future__ import annotations from abc import ABC, abstractmethod -from datetime import timedelta +import asyncio +from dataclasses import dataclass +from datetime import date, datetime, timedelta from typing import Generic, TypeVar, cast -from aiopyarr import Health, RadarrMovie, RootFolder, SystemStatus, exceptions +from aiopyarr import ( + Health, + RadarrCalendarItem, + RadarrMovie, + RootFolder, + SystemStatus, + exceptions, +) from aiopyarr.models.host_configuration import PyArrHostConfiguration from aiopyarr.radarr_client import RadarrClient +from homeassistant.components.calendar import CalendarEvent from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -16,13 +26,26 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER -T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int) +T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int | None) + + +@dataclass +class RadarrEventMixIn: + """Mixin for Radarr calendar event.""" + + release_type: str + + +@dataclass +class RadarrEvent(CalendarEvent, RadarrEventMixIn): + """A class to describe a Radarr calendar event.""" class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): """Data update coordinator for the Radarr integration.""" config_entry: ConfigEntry + update_interval = timedelta(seconds=30) def __init__( self, @@ -35,7 +58,7 @@ class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=self.update_interval, ) self.api_client = api_client self.host_configuration = host_configuration @@ -101,3 +124,77 @@ class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator): return ( await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS) ).totalRecords + + +class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]): + """Calendar update coordinator.""" + + update_interval = timedelta(hours=1) + + def __init__( + self, + hass: HomeAssistant, + host_configuration: PyArrHostConfiguration, + api_client: RadarrClient, + ) -> None: + """Initialize.""" + super().__init__(hass, host_configuration, api_client) + self.event: RadarrEvent | None = None + self._events: list[RadarrEvent] = [] + + async def _fetch_data(self) -> None: + """Fetch the calendar.""" + self.event = None + _date = datetime.today() + while self.event is None: + await self.async_get_events(_date, _date + timedelta(days=1)) + for event in self._events: + if event.start >= _date.date(): + self.event = event + break + # Prevent infinite loop in case there is nothing recent in the calendar + if (_date - datetime.today()).days > 45: + break + _date = _date + timedelta(days=1) + + async def async_get_events( + self, start_date: datetime, end_date: datetime + ) -> list[RadarrEvent]: + """Get cached events and request missing dates.""" + # remove older events to prevent memory leak + self._events = [ + e + for e in self._events + if e.start >= datetime.now().date() - timedelta(days=30) + ] + _days = (end_date - start_date).days + await asyncio.gather( + *( + self._async_get_events(d) + for d in ((start_date + timedelta(days=x)).date() for x in range(_days)) + if d not in (event.start for event in self._events) + ) + ) + return self._events + + async def _async_get_events(self, _date: date) -> None: + """Return events from specified date.""" + self._events.extend( + _get_calendar_event(evt) + for evt in await self.api_client.async_get_calendar( + start_date=_date, end_date=_date + timedelta(days=1) + ) + if evt.title not in (e.summary for e in self._events) + ) + + +def _get_calendar_event(event: RadarrCalendarItem) -> RadarrEvent: + """Return a RadarrEvent from an API event.""" + _date, _type = event.releaseDateType() + return RadarrEvent( + summary=event.title, + start=_date - timedelta(days=1), + end=_date, + description=event.overview.replace(":", ";"), + release_type=_type, + ) diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index ab4315b269a..ad9dd4e1ae0 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -2,8 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from copy import deepcopy -from dataclasses import dataclass +import dataclasses from datetime import UTC, datetime from typing import Any, Generic @@ -39,21 +38,23 @@ def get_modified_description( description: RadarrSensorEntityDescription[T], mount: RootFolder ) -> tuple[RadarrSensorEntityDescription[T], str]: """Return modified description and folder name.""" - desc = deepcopy(description) name = mount.path.rsplit("/")[-1].rsplit("\\")[-1] - desc.key = f"{description.key}_{name}" - desc.name = f"{description.name} {name}".capitalize() + desc = dataclasses.replace( + description, + key=f"{description.key}_{name}", + name=f"{description.name} {name}".capitalize(), + ) return desc, name -@dataclass +@dataclasses.dataclass(frozen=True) class RadarrSensorEntityDescriptionMixIn(Generic[T]): """Mixin for required keys.""" value_fn: Callable[[T, str], str | int | datetime] -@dataclass +@dataclasses.dataclass(frozen=True) class RadarrSensorEntityDescription( SensorEntityDescription, RadarrSensorEntityDescriptionMixIn[T], Generic[T] ): diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index c29154a941c..5c3ff18f71c 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta from functools import partial, wraps @@ -38,6 +38,7 @@ from homeassistant.util.network import is_ip_address from .config_flow import get_client_controller from .const import ( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, CONF_DURATION, CONF_USE_APP_RUN_TIMES, @@ -48,6 +49,7 @@ from .const import ( DATA_RESTRICTIONS_CURRENT, DATA_RESTRICTIONS_UNIVERSAL, DATA_ZONES, + DEFAULT_ZONE_RUN, DOMAIN, LOGGER, ) @@ -249,8 +251,13 @@ async def async_setup_entry( # noqa: C901 **entry.options, CONF_DEFAULT_ZONE_RUN_TIME: data.pop(CONF_DEFAULT_ZONE_RUN_TIME), } + entry_updates["options"] = {**entry.options} if CONF_USE_APP_RUN_TIMES not in entry.options: - entry_updates["options"] = {**entry.options, CONF_USE_APP_RUN_TIMES: False} + entry_updates["options"][CONF_USE_APP_RUN_TIMES] = False + if CONF_DEFAULT_ZONE_RUN_TIME not in entry.options: + entry_updates["options"][CONF_DEFAULT_ZONE_RUN_TIME] = DEFAULT_ZONE_RUN + if CONF_ALLOW_INACTIVE_ZONES_TO_RUN not in entry.options: + entry_updates["options"][CONF_ALLOW_INACTIVE_ZONES_TO_RUN] = False if entry_updates: hass.config_entries.async_update_entry(entry, **entry_updates) @@ -326,10 +333,17 @@ async def async_setup_entry( # noqa: C901 entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - def call_with_controller(update_programs_and_zones: bool = True) -> Callable: + def call_with_controller( + update_programs_and_zones: bool = True, + ) -> Callable[ + [Callable[[ServiceCall, Controller], Coroutine[Any, Any, None]]], + Callable[[ServiceCall], Coroutine[Any, Any, None]], + ]: """Hydrate a service call with the appropriate controller.""" - def decorator(func: Callable) -> Callable[..., Awaitable]: + def decorator( + func: Callable[[ServiceCall, Controller], Coroutine[Any, Any, None]], + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define the decorator.""" @wraps(func) diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 7f93db67c4c..f0cbfd636fa 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -32,7 +32,7 @@ TYPE_RAINSENSOR = "rainsensor" TYPE_WEEKDAY = "weekday" -@dataclass +@dataclass(frozen=True) class RainMachineBinarySensorDescription( BinarySensorEntityDescription, RainMachineEntityDescription, diff --git a/homeassistant/components/rainmachine/button.py b/homeassistant/components/rainmachine/button.py index 82829094957..a13d2069007 100644 --- a/homeassistant/components/rainmachine/button.py +++ b/homeassistant/components/rainmachine/button.py @@ -24,14 +24,14 @@ from .const import DATA_PROVISION_SETTINGS, DOMAIN from .model import RainMachineEntityDescription -@dataclass +@dataclass(frozen=True) class RainMachineButtonDescriptionMixin: """Define an entity description mixin for RainMachine buttons.""" push_action: Callable[[Controller], Awaitable] -@dataclass +@dataclass(frozen=True) class RainMachineButtonDescription( ButtonEntityDescription, RainMachineEntityDescription, diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 1ad97de7d0b..1d73ef3dd88 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import ( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, CONF_USE_APP_RUN_TIMES, DEFAULT_PORT, @@ -188,6 +189,12 @@ class RainMachineOptionsFlowHandler(config_entries.OptionsFlow): CONF_USE_APP_RUN_TIMES, default=self.config_entry.options.get(CONF_USE_APP_RUN_TIMES), ): bool, + vol.Optional( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, + default=self.config_entry.options.get( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN + ), + ): bool, } ), ) diff --git a/homeassistant/components/rainmachine/const.py b/homeassistant/components/rainmachine/const.py index 00af0bd0b75..e28b2326b79 100644 --- a/homeassistant/components/rainmachine/const.py +++ b/homeassistant/components/rainmachine/const.py @@ -8,6 +8,7 @@ DOMAIN = "rainmachine" CONF_DURATION = "duration" CONF_DEFAULT_ZONE_RUN_TIME = "zone_run_time" CONF_USE_APP_RUN_TIMES = "use_app_run_times" +CONF_ALLOW_INACTIVE_ZONES_TO_RUN = "allow_inactive_zones_to_run" DATA_API_VERSIONS = "api.versions" DATA_MACHINE_FIRMWARE_UPDATE_STATUS = "machine.firmware_update_status" diff --git a/homeassistant/components/rainmachine/model.py b/homeassistant/components/rainmachine/model.py index 9ae99fe247a..e45448c0fe4 100644 --- a/homeassistant/components/rainmachine/model.py +++ b/homeassistant/components/rainmachine/model.py @@ -4,28 +4,28 @@ from dataclasses import dataclass from homeassistant.helpers.entity import EntityDescription -@dataclass +@dataclass(frozen=True) class RainMachineEntityDescriptionMixinApiCategory: """Define an entity description mixin to include an API category.""" api_category: str -@dataclass +@dataclass(frozen=True) class RainMachineEntityDescriptionMixinDataKey: """Define an entity description mixin to include a data payload key.""" data_key: str -@dataclass +@dataclass(frozen=True) class RainMachineEntityDescriptionMixinUid: """Define an entity description mixin to include an activity UID.""" uid: int -@dataclass +@dataclass(frozen=True) class RainMachineEntityDescription( EntityDescription, RainMachineEntityDescriptionMixinApiCategory ): diff --git a/homeassistant/components/rainmachine/select.py b/homeassistant/components/rainmachine/select.py index 2a5bc93f601..513c02ddc19 100644 --- a/homeassistant/components/rainmachine/select.py +++ b/homeassistant/components/rainmachine/select.py @@ -22,7 +22,7 @@ from .model import ( from .util import key_exists -@dataclass +@dataclass(frozen=True) class RainMachineSelectDescription( SelectEntityDescription, RainMachineEntityDescription, @@ -40,14 +40,14 @@ class FreezeProtectionSelectOption: metric_label: str -@dataclass +@dataclass(frozen=True) class FreezeProtectionTemperatureMixin: """Define an entity description mixin to include an options list.""" extended_options: list[FreezeProtectionSelectOption] -@dataclass +@dataclass(frozen=True) class FreezeProtectionSelectDescription( RainMachineSelectDescription, FreezeProtectionTemperatureMixin ): diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index bdae62c1bd8..624deeb46c6 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -48,7 +48,7 @@ TYPE_RAIN_SENSOR_RAIN_START = "rain_sensor_rain_start" TYPE_ZONE_RUN_COMPLETION_TIME = "zone_run_completion_time" -@dataclass +@dataclass(frozen=True) class RainMachineSensorDataDescription( SensorEntityDescription, RainMachineEntityDescription, @@ -57,7 +57,7 @@ class RainMachineSensorDataDescription( """Describe a RainMachine sensor.""" -@dataclass +@dataclass(frozen=True) class RainMachineSensorCompletionTimerDescription( SensorEntityDescription, RainMachineEntityDescription, diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index ac2b86754e5..a564d33e777 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -24,7 +24,8 @@ "title": "Configure RainMachine", "data": { "zone_run_time": "Default zone run time (in seconds)", - "use_app_run_times": "Use zone run times from RainMachine app" + "use_app_run_times": "Use zone run times from the RainMachine app", + "allow_inactive_zones_to_run": "Allow disabled zones to be run manually" } } } diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index e6ed92d04dc..b47396bc9e5 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -20,6 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RainMachineData, RainMachineEntity, async_update_programs_and_zones from .const import ( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, CONF_DURATION, CONF_USE_APP_RUN_TIMES, @@ -117,7 +118,7 @@ _P = ParamSpec("_P") def raise_on_request_error( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Define a decorator to raise on a request error.""" @@ -133,7 +134,7 @@ def raise_on_request_error( return decorator -@dataclass +@dataclass(frozen=True) class RainMachineSwitchDescription( SwitchEntityDescription, RainMachineEntityDescription, @@ -141,14 +142,14 @@ class RainMachineSwitchDescription( """Describe a RainMachine switch.""" -@dataclass +@dataclass(frozen=True) class RainMachineActivitySwitchDescription( RainMachineSwitchDescription, RainMachineEntityDescriptionMixinUid ): """Describe a RainMachine activity (program/zone) switch.""" -@dataclass +@dataclass(frozen=True) class RainMachineRestrictionSwitchDescription( RainMachineSwitchDescription, RainMachineEntityDescriptionMixinDataKey ): @@ -300,7 +301,10 @@ class RainMachineActivitySwitch(RainMachineBaseSwitch): The only way this could occur is if someone rapidly turns a disabled activity off right after turning it on. """ - if not self.coordinator.data[self.entity_description.uid]["active"]: + if ( + not self._entry.options[CONF_ALLOW_INACTIVE_ZONES_TO_RUN] + and not self.coordinator.data[self.entity_description.uid]["active"] + ): raise HomeAssistantError( f"Cannot turn off an inactive program/zone: {self.name}" ) @@ -314,7 +318,10 @@ class RainMachineActivitySwitch(RainMachineBaseSwitch): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - if not self.coordinator.data[self.entity_description.uid]["active"]: + if ( + not self._entry.options[CONF_ALLOW_INACTIVE_ZONES_TO_RUN] + and not self.coordinator.data[self.entity_description.uid]["active"] + ): self._attr_is_on = False self.async_write_ha_state() raise HomeAssistantError( diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py index 96311266db4..ce8e2908251 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -23,7 +23,7 @@ from homeassistant.helpers.update_coordinator import ( from .const import DOMAIN -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class RDWBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes RDW binary sensor entity.""" diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py index d25c23c09bd..a6ad9047852 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -24,7 +24,7 @@ from homeassistant.helpers.update_coordinator import ( from .const import CONF_LICENSE_PLATE, DOMAIN -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class RDWSensorEntityDescription(SensorEntityDescription): """Describes RDW sensor entity.""" diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 78c475753a2..ad6cdd31e2c 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -782,7 +782,7 @@ def _statistic_by_id_from_metadata( def _flatten_list_statistic_ids_metadata_result( - result: dict[str, dict[str, Any]] + result: dict[str, dict[str, Any]], ) -> list[dict]: """Return a flat dict of metadata.""" return [ diff --git a/homeassistant/components/recorder/table_managers/__init__.py b/homeassistant/components/recorder/table_managers/__init__.py index e56ee4f3415..9a0945dc4d9 100644 --- a/homeassistant/components/recorder/table_managers/__init__.py +++ b/homeassistant/components/recorder/table_managers/__init__.py @@ -1,9 +1,8 @@ """Managers for each table.""" -from collections.abc import MutableMapping from typing import TYPE_CHECKING, Generic, TypeVar -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU if TYPE_CHECKING: from ..core import Recorder @@ -14,6 +13,8 @@ _DataT = TypeVar("_DataT") class BaseTableManager(Generic[_DataT]): """Base class for table managers.""" + _id_map: "LRU[str, int]" + def __init__(self, recorder: "Recorder") -> None: """Initialize the table manager. @@ -24,7 +25,6 @@ class BaseTableManager(Generic[_DataT]): self.active = False self.recorder = recorder self._pending: dict[str, _DataT] = {} - self._id_map: MutableMapping[str, int] = {} def get_from_cache(self, data: str) -> int | None: """Resolve data to the id without accessing the underlying database. @@ -62,7 +62,7 @@ class BaseLRUTableManager(BaseTableManager[_DataT]): and evict the least recently used items when the cache is full. """ super().__init__(recorder) - self._id_map: MutableMapping[str, int] = LRU(lru_size) + self._id_map = LRU(lru_size) def adjust_lru_size(self, new_size: int) -> None: """Adjust the LRU cache size. @@ -70,6 +70,6 @@ class BaseLRUTableManager(BaseTableManager[_DataT]): This call is not thread-safe and must be called from the recorder thread. """ - lru: LRU = self._id_map + lru = self._id_map if new_size > lru.get_size(): lru.set_size(new_size) diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index 45b3b96353c..c74684a0f77 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Iterable from typing import TYPE_CHECKING, cast -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU from sqlalchemy.orm.session import Session from homeassistant.core import Event @@ -28,7 +28,7 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): def __init__(self, recorder: Recorder) -> None: """Initialize the event type manager.""" super().__init__(recorder, CACHE_SIZE) - self._non_existent_event_types: LRU = LRU(CACHE_SIZE) + self._non_existent_event_types: LRU[str, None] = LRU(CACHE_SIZE) def load(self, events: list[Event], session: Session) -> None: """Load the event_type to event_type_ids mapping into memory. diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index a484bdf145e..76def3a22fe 100644 --- a/homeassistant/components/recorder/table_managers/statistics_meta.py +++ b/homeassistant/components/recorder/table_managers/statistics_meta.py @@ -5,7 +5,7 @@ import logging import threading from typing import TYPE_CHECKING, Literal, cast -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU from sqlalchemy import lambda_stmt, select from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import true @@ -74,7 +74,7 @@ class StatisticsMetaManager: def __init__(self, recorder: Recorder) -> None: """Initialize the statistics meta manager.""" self.recorder = recorder - self._stat_id_to_id_meta: dict[str, tuple[int, StatisticMetaData]] = LRU( + self._stat_id_to_id_meta: LRU[str, tuple[int, StatisticMetaData]] = LRU( CACHE_SIZE ) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 2d518d8874b..4a1bf940b24 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -658,7 +658,7 @@ def database_job_retry_wrapper( """ def decorator( - job: _WrappedFuncType[_RecorderT, _P] + job: _WrappedFuncType[_RecorderT, _P], ) -> _WrappedFuncType[_RecorderT, _P]: @functools.wraps(job) def wrapper(instance: _RecorderT, *args: _P.args, **kwargs: _P.kwargs) -> None: diff --git a/homeassistant/components/refoss/__init__.py b/homeassistant/components/refoss/__init__.py new file mode 100644 index 00000000000..d83ca17dd6b --- /dev/null +++ b/homeassistant/components/refoss/__init__.py @@ -0,0 +1,56 @@ +"""Refoss devices platform loader.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Final + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_track_time_interval + +from .bridge import DiscoveryService +from .const import COORDINATORS, DATA_DISCOVERY_SERVICE, DISCOVERY_SCAN_INTERVAL, DOMAIN +from .util import refoss_discovery_server + +PLATFORMS: Final = [ + Platform.SWITCH, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Refoss from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + discover = await refoss_discovery_server(hass) + refoss_discovery = DiscoveryService(hass, discover) + hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] = refoss_discovery + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def _async_scan_update(_=None): + await refoss_discovery.discovery.broadcast_msg() + + await _async_scan_update() + + entry.async_on_unload( + async_track_time_interval( + hass, _async_scan_update, timedelta(seconds=DISCOVERY_SCAN_INTERVAL) + ) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if hass.data[DOMAIN].get(DATA_DISCOVERY_SERVICE) is not None: + refoss_discovery: DiscoveryService = hass.data[DOMAIN][DATA_DISCOVERY_SERVICE] + refoss_discovery.discovery.clean_up() + hass.data[DOMAIN].pop(DATA_DISCOVERY_SERVICE) + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(COORDINATORS) + + return unload_ok diff --git a/homeassistant/components/refoss/bridge.py b/homeassistant/components/refoss/bridge.py new file mode 100644 index 00000000000..888179e8a7c --- /dev/null +++ b/homeassistant/components/refoss/bridge.py @@ -0,0 +1,45 @@ +"""Refoss integration.""" +from __future__ import annotations + +from refoss_ha.device import DeviceInfo +from refoss_ha.device_manager import async_build_base_device +from refoss_ha.discovery import Discovery, Listener + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .coordinator import RefossDataUpdateCoordinator + + +class DiscoveryService(Listener): + """Discovery event handler for refoss devices.""" + + def __init__(self, hass: HomeAssistant, discovery: Discovery) -> None: + """Init discovery service.""" + self.hass = hass + + self.discovery = discovery + self.discovery.add_listener(self) + + hass.data[DOMAIN].setdefault(COORDINATORS, []) + + async def device_found(self, device_info: DeviceInfo) -> None: + """Handle new device found on the network.""" + + device = await async_build_base_device(device_info) + if device is None: + return None + + coordo = RefossDataUpdateCoordinator(self.hass, device) + self.hass.data[DOMAIN][COORDINATORS].append(coordo) + await coordo.async_refresh() + + async_dispatcher_send(self.hass, DISPATCH_DEVICE_DISCOVERED, coordo) + + async def device_update(self, device_info: DeviceInfo) -> None: + """Handle updates in device information, update if ip has changed.""" + for coordinator in self.hass.data[DOMAIN][COORDINATORS]: + if coordinator.device.device_info.mac == device_info.mac: + coordinator.device.device_info.inner_ip = device_info.inner_ip + await coordinator.async_refresh() diff --git a/homeassistant/components/refoss/config_flow.py b/homeassistant/components/refoss/config_flow.py new file mode 100644 index 00000000000..fe33cefc1bd --- /dev/null +++ b/homeassistant/components/refoss/config_flow.py @@ -0,0 +1,20 @@ +"""Config Flow for Refoss integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_flow + +from .const import DISCOVERY_TIMEOUT, DOMAIN +from .util import refoss_discovery_server + + +async def _async_has_devices(hass: HomeAssistant) -> bool: + """Return if there are devices that can be discovered.""" + + refoss_discovery = await refoss_discovery_server(hass) + devices = await refoss_discovery.broadcast_msg(wait_for=DISCOVERY_TIMEOUT) + return len(devices) > 0 + + +config_entry_flow.register_discovery_flow(DOMAIN, "Refoss", _async_has_devices) diff --git a/homeassistant/components/refoss/const.py b/homeassistant/components/refoss/const.py new file mode 100644 index 00000000000..dd11921c75e --- /dev/null +++ b/homeassistant/components/refoss/const.py @@ -0,0 +1,20 @@ +"""const.""" +from __future__ import annotations + +from logging import Logger, getLogger + +_LOGGER: Logger = getLogger(__package__) + +COORDINATORS = "coordinators" + +DATA_DISCOVERY_SERVICE = "refoss_discovery" + +DISCOVERY_SCAN_INTERVAL = 30 +DISCOVERY_TIMEOUT = 8 +DISPATCH_DEVICE_DISCOVERED = "refoss_device_discovered" +DISPATCHERS = "dispatchers" + +DOMAIN = "refoss" +COORDINATOR = "coordinator" + +MAX_ERRORS = 2 diff --git a/homeassistant/components/refoss/coordinator.py b/homeassistant/components/refoss/coordinator.py new file mode 100644 index 00000000000..a542f0e1ae8 --- /dev/null +++ b/homeassistant/components/refoss/coordinator.py @@ -0,0 +1,39 @@ +"""Helper and coordinator for refoss.""" +from __future__ import annotations + +from datetime import timedelta + +from refoss_ha.controller.device import BaseDevice +from refoss_ha.exceptions import DeviceTimeoutError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import _LOGGER, DOMAIN, MAX_ERRORS + + +class RefossDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Manages polling for state changes from the device.""" + + def __init__(self, hass: HomeAssistant, device: BaseDevice) -> None: + """Initialize the data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}-{device.device_info.dev_name}", + update_interval=timedelta(seconds=15), + ) + self.device = device + self._error_count = 0 + + async def _async_update_data(self) -> None: + """Update the state of the device.""" + try: + await self.device.async_handle_update() + self.last_update_success = True + self._error_count = 0 + except DeviceTimeoutError: + self._error_count += 1 + + if self._error_count >= MAX_ERRORS: + self.last_update_success = False diff --git a/homeassistant/components/refoss/entity.py b/homeassistant/components/refoss/entity.py new file mode 100644 index 00000000000..d3425974bb1 --- /dev/null +++ b/homeassistant/components/refoss/entity.py @@ -0,0 +1,31 @@ +"""Entity object for shared properties of Refoss entities.""" +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .bridge import RefossDataUpdateCoordinator +from .const import DOMAIN + + +class RefossEntity(CoordinatorEntity[RefossDataUpdateCoordinator]): + """Refoss entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: RefossDataUpdateCoordinator, channel: int) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + mac = coordinator.device.mac + self.channel_id = channel + if channel == 0: + self._attr_name = None + else: + self._attr_name = str(channel) + + self._attr_unique_id = f"{mac}_{channel}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, mac)}, + identifiers={(DOMAIN, mac)}, + manufacturer="Refoss", + name=coordinator.device.dev_name, + ) diff --git a/homeassistant/components/refoss/manifest.json b/homeassistant/components/refoss/manifest.json new file mode 100644 index 00000000000..8e5b3864bcc --- /dev/null +++ b/homeassistant/components/refoss/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "refoss", + "name": "Refoss", + "codeowners": ["@ashionky"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/refoss", + "iot_class": "local_polling", + "requirements": ["refoss-ha==1.2.0"] +} diff --git a/homeassistant/components/refoss/strings.json b/homeassistant/components/refoss/strings.json new file mode 100644 index 00000000000..ad8f0f41ae7 --- /dev/null +++ b/homeassistant/components/refoss/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/homeassistant/components/refoss/switch.py b/homeassistant/components/refoss/switch.py new file mode 100644 index 00000000000..c51f166059e --- /dev/null +++ b/homeassistant/components/refoss/switch.py @@ -0,0 +1,69 @@ +"""Switch for Refoss.""" + +from __future__ import annotations + +from typing import Any + +from refoss_ha.controller.toggle import ToggleXMix + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN +from .entity import RefossEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Refoss device from a config entry.""" + + @callback + def init_device(coordinator): + """Register the device.""" + device = coordinator.device + if not isinstance(device, ToggleXMix): + return + + new_entities = [] + for channel in device.channels: + entity = RefossSwitch(coordinator=coordinator, channel=channel) + new_entities.append(entity) + + async_add_entities(new_entities) + + for coordinator in hass.data[DOMAIN][COORDINATORS]: + init_device(coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, DISPATCH_DEVICE_DISCOVERED, init_device) + ) + + +class RefossSwitch(RefossEntity, SwitchEntity): + """Refoss Switch Device.""" + + @property + def is_on(self) -> bool | None: + """Return true if switch is on.""" + return self.coordinator.device.is_on(channel=self.channel_id) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.coordinator.device.async_turn_on(self.channel_id) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.coordinator.device.async_turn_off(self.channel_id) + self.async_write_ha_state() + + async def async_toggle(self, **kwargs: Any) -> None: + """Toggle the switch.""" + await self.coordinator.device.async_toggle(channel=self.channel_id) + self.async_write_ha_state() diff --git a/homeassistant/components/refoss/util.py b/homeassistant/components/refoss/util.py new file mode 100644 index 00000000000..cd589022d73 --- /dev/null +++ b/homeassistant/components/refoss/util.py @@ -0,0 +1,15 @@ +"""Refoss helpers functions.""" +from __future__ import annotations + +from refoss_ha.discovery import Discovery + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import singleton + + +@singleton.singleton("refoss_discovery_server") +async def refoss_discovery_server(hass: HomeAssistant) -> Discovery: + """Get refoss Discovery server.""" + discovery_server = Discovery() + await discovery_server.initialize() + return discovery_server diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 17915e1be19..7e9ebfe12b9 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations from collections.abc import Iterable -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag import functools as ft import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -26,11 +25,22 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) ATTR_ACTIVITY = "activity" @@ -71,9 +81,20 @@ class RemoteEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the RemoteEntityFeature enum instead. -SUPPORT_LEARN_COMMAND = 1 -SUPPORT_DELETE_COMMAND = 2 -SUPPORT_ACTIVITY = 4 +_DEPRECATED_SUPPORT_LEARN_COMMAND = DeprecatedConstantEnum( + RemoteEntityFeature.LEARN_COMMAND, "2025.1" +) +_DEPRECATED_SUPPORT_DELETE_COMMAND = DeprecatedConstantEnum( + RemoteEntityFeature.DELETE_COMMAND, "2025.1" +) +_DEPRECATED_SUPPORT_ACTIVITY = DeprecatedConstantEnum( + RemoteEntityFeature.ACTIVITY, "2025.1" +) + + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) REMOTE_SERVICE_ACTIVITY_SCHEMA = make_entity_service_schema( {vol.Optional(ATTR_ACTIVITY): cv.string} @@ -155,12 +176,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class RemoteEntityDescription(ToggleEntityDescription): +class RemoteEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes remote entities.""" -class RemoteEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "supported_features", + "current_activity", + "activity_list", +} + + +class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for remote entities.""" entity_description: RemoteEntityDescription @@ -168,17 +195,30 @@ class RemoteEntity(ToggleEntity): _attr_current_activity: str | None = None _attr_supported_features: RemoteEntityFeature = RemoteEntityFeature(0) - @property + @cached_property def supported_features(self) -> RemoteEntityFeature: """Flag supported features.""" return self._attr_supported_features @property + def supported_features_compat(self) -> RemoteEntityFeature: + """Return the supported features as RemoteEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = RemoteEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + + @cached_property def current_activity(self) -> str | None: """Active activity.""" return self._attr_current_activity - @property + @cached_property def activity_list(self) -> list[str] | None: """List of available activities.""" return self._attr_activity_list @@ -187,7 +227,7 @@ class RemoteEntity(ToggleEntity): @property def state_attributes(self) -> dict[str, Any] | None: """Return optional state attributes.""" - if not self.supported_features & RemoteEntityFeature.ACTIVITY: + if RemoteEntityFeature.ACTIVITY not in self.supported_features_compat: return None return { diff --git a/homeassistant/components/remote/significant_change.py b/homeassistant/components/remote/significant_change.py new file mode 100644 index 00000000000..8e5a3669041 --- /dev/null +++ b/homeassistant/components/remote/significant_change.py @@ -0,0 +1,27 @@ +"""Helper to test significant Remote state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback + +from . import ATTR_CURRENT_ACTIVITY + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + if old_attrs.get(ATTR_CURRENT_ACTIVITY) != new_attrs.get(ATTR_CURRENT_ACTIVITY): + return True + + return False diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index ef2d7196f04..0d66e5444e7 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -22,7 +22,7 @@ from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub -@dataclass +@dataclass(frozen=True) class RenaultBinarySensorRequiredKeysMixin: """Mixin for required keys.""" @@ -30,7 +30,7 @@ class RenaultBinarySensorRequiredKeysMixin: on_value: StateType -@dataclass +@dataclass(frozen=True) class RenaultBinarySensorEntityDescription( BinarySensorEntityDescription, RenaultDataEntityDescription, diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py index 5f916a2d140..87883204890 100644 --- a/homeassistant/components/renault/button.py +++ b/homeassistant/components/renault/button.py @@ -15,14 +15,14 @@ from .entity import RenaultEntity from .renault_hub import RenaultHub -@dataclass +@dataclass(frozen=True) class RenaultButtonRequiredKeysMixin: """Mixin for required keys.""" async_press: Callable[[RenaultButtonEntity], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class RenaultButtonEntityDescription( ButtonEntityDescription, RenaultButtonRequiredKeysMixin ): diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py index aa83c935957..fd7f0eb3654 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -12,14 +12,14 @@ from .coordinator import RenaultDataUpdateCoordinator, T from .renault_vehicle import RenaultVehicleProxy -@dataclass +@dataclass(frozen=True) class RenaultDataRequiredKeysMixin: """Mixin for required keys.""" coordinator: str -@dataclass +@dataclass(frozen=True) class RenaultDataEntityDescription(EntityDescription, RenaultDataRequiredKeysMixin): """Class describing Renault data entities.""" diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index 6dd0dc2611e..e44a50d57a1 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -26,7 +26,7 @@ _P = ParamSpec("_P") def with_error_wrapping( - func: Callable[Concatenate[RenaultVehicleProxy, _P], Awaitable[_T]] + func: Callable[Concatenate[RenaultVehicleProxy, _P], Awaitable[_T]], ) -> Callable[Concatenate[RenaultVehicleProxy, _P], Coroutine[Any, Any, _T]]: """Catch Renault errors.""" diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index 1ec891a51e4..9dcc52abc87 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -18,7 +18,7 @@ from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_hub import RenaultHub -@dataclass +@dataclass(frozen=True) class RenaultSelectRequiredKeysMixin: """Mixin for required keys.""" @@ -26,7 +26,7 @@ class RenaultSelectRequiredKeysMixin: icon_lambda: Callable[[RenaultSelectEntity], str] -@dataclass +@dataclass(frozen=True) class RenaultSelectEntityDescription( SelectEntityDescription, RenaultDataEntityDescription, diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 92deb3438de..d30b8d01fb3 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -43,7 +43,7 @@ from .renault_hub import RenaultHub from .renault_vehicle import RenaultVehicleProxy -@dataclass +@dataclass(frozen=True) class RenaultSensorRequiredKeysMixin(Generic[T]): """Mixin for required keys.""" @@ -51,7 +51,7 @@ class RenaultSensorRequiredKeysMixin(Generic[T]): entity_class: type[RenaultSensor[T]] -@dataclass +@dataclass(frozen=True) class RenaultSensorEntityDescription( SensorEntityDescription, RenaultDataEntityDescription, diff --git a/homeassistant/components/renson/binary_sensor.py b/homeassistant/components/renson/binary_sensor.py index 39c2b1b883d..012ecee2e98 100644 --- a/homeassistant/components/renson/binary_sensor.py +++ b/homeassistant/components/renson/binary_sensor.py @@ -30,14 +30,14 @@ from .coordinator import RensonCoordinator from .entity import RensonEntity -@dataclass +@dataclass(frozen=True) class RensonBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" field: FieldEnum -@dataclass +@dataclass(frozen=True) class RensonBinarySensorEntityDescription( BinarySensorEntityDescription, RensonBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py index a91a057e0e7..117fadb502b 100644 --- a/homeassistant/components/renson/button.py +++ b/homeassistant/components/renson/button.py @@ -21,14 +21,14 @@ from .const import DOMAIN from .entity import RensonEntity -@dataclass +@dataclass(frozen=True) class RensonButtonEntityDescriptionMixin: """Action function called on press.""" action_fn: Callable[[RensonVentilation], None] -@dataclass +@dataclass(frozen=True) class RensonButtonEntityDescription( ButtonEntityDescription, RensonButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/renson/const.py b/homeassistant/components/renson/const.py index 840e1ce428a..53bbd90c4b7 100644 --- a/homeassistant/components/renson/const.py +++ b/homeassistant/components/renson/const.py @@ -1,3 +1,4 @@ """Constants for the Renson integration.""" + DOMAIN = "renson" diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index da6850859a6..a60adccade5 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -7,16 +7,19 @@ from typing import Any from renson_endura_delta.field_enum import CURRENT_LEVEL_FIELD, DataType from renson_endura_delta.renson import Level, RensonVentilation +import voluptuous as vol 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 +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .const import DOMAIN from .coordinator import RensonCoordinator @@ -41,6 +44,33 @@ SPEED_MAPPING = { Level.LEVEL4.value: 4, } +SET_TIMER_LEVEL_SCHEMA = { + vol.Required("timer_level"): vol.In( + ["level1", "level2", "level3", "level4", "holiday", "breeze"] + ), + vol.Required("minutes"): cv.positive_int, +} + +SET_BREEZE_SCHEMA = { + vol.Required("breeze_level"): vol.In(["level1", "level2", "level3", "level4"]), + vol.Required("temperature"): cv.positive_int, + vol.Required("activate"): bool, +} + +SET_POLLUTION_SETTINGS_SCHEMA = { + vol.Required("day_pollution_level"): vol.In( + ["level1", "level2", "level3", "level4"] + ), + vol.Required("night_pollution_level"): vol.In( + ["level1", "level2", "level3", "level4"] + ), + vol.Optional("humidity_control", default=True): bool, + vol.Optional("airquality_control", default=True): bool, + vol.Optional("co2_control", default=True): bool, + vol.Optional("co2_threshold", default=600): cv.positive_int, + vol.Optional("co2_hysteresis", default=100): cv.positive_int, +} + SPEED_RANGE: tuple[float, float] = (1, 4) @@ -59,6 +89,24 @@ async def async_setup_entry( async_add_entities([RensonFan(api, coordinator)]) + platform = entity_platform.async_get_current_platform() + + platform.async_register_entity_service( + "set_timer_level", + SET_TIMER_LEVEL_SCHEMA, + "set_timer_level", + ) + + platform.async_register_entity_service( + "set_breeze", SET_BREEZE_SCHEMA, "set_breeze" + ) + + platform.async_register_entity_service( + "set_pollution_settings", + SET_POLLUTION_SETTINGS_SCHEMA, + "set_pollution_settings", + ) + class RensonFan(RensonEntity, FanEntity): """Representation of the Renson fan platform.""" @@ -116,3 +164,43 @@ class RensonFan(RensonEntity, FanEntity): await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) await self.coordinator.async_request_refresh() + + async def set_timer_level(self, timer_level: str, minutes: int) -> None: + """Set timer level.""" + level = Level[str(timer_level).upper()] + + await self.hass.async_add_executor_job(self.api.set_timer_level, level, minutes) + + async def set_breeze( + self, breeze_level: str, temperature: int, activate: bool + ) -> None: + """Configure breeze feature.""" + level = Level[str(breeze_level).upper()] + + await self.hass.async_add_executor_job( + self.api.set_breeze, level, temperature, activate + ) + + async def set_pollution_settings( + self, + day_pollution_level: str, + night_pollution_level: str, + humidity_control: bool, + airquality_control: bool, + co2_control: str, + co2_threshold: int, + co2_hysteresis: int, + ) -> None: + """Configure pollutions settings.""" + day = Level[str(day_pollution_level).upper()] + night = Level[str(night_pollution_level).upper()] + + await self.api.set_pollution( + day, + night, + humidity_control, + airquality_control, + co2_control, + co2_threshold, + co2_hysteresis, + ) diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json index 1a7f367a946..fa94207748e 100644 --- a/homeassistant/components/renson/manifest.json +++ b/homeassistant/components/renson/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/renson", "iot_class": "local_polling", - "requirements": ["renson-endura-delta==1.6.0"] + "requirements": ["renson-endura-delta==1.7.1"] } diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index 004be661f02..380a83b6818 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -52,7 +52,7 @@ from .coordinator import RensonCoordinator from .entity import RensonEntity -@dataclass +@dataclass(frozen=True) class RensonSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -60,7 +60,7 @@ class RensonSensorEntityDescriptionMixin: raw_format: bool -@dataclass +@dataclass(frozen=True) class RensonSensorEntityDescription( SensorEntityDescription, RensonSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/renson/services.yaml b/homeassistant/components/renson/services.yaml new file mode 100644 index 00000000000..ad79af8649e --- /dev/null +++ b/homeassistant/components/renson/services.yaml @@ -0,0 +1,117 @@ +set_timer_level: + target: + entity: + integration: renson + domain: fan + fields: + timer_level: + required: true + default: "level1" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + - "holiday" + - "breeze" + minutes: + required: true + default: 0 + selector: + number: + min: 0 + max: 1440 + step: 10 + unit_of_measurement: "min" + mode: slider + +set_breeze: + target: + entity: + integration: renson + domain: fan + fields: + breeze_level: + default: "level3" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + temperature: + default: 18 + selector: + number: + min: 15 + max: 35 + step: 1 + unit_of_measurement: "°C" + mode: slider + activate: + required: true + default: false + selector: + boolean: + +set_pollution_settings: + target: + entity: + integration: renson + domain: fan + fields: + day_pollution_level: + default: "level3" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + night_pollution_level: + default: "level2" + selector: + select: + translation_key: "level_setting" + options: + - "level1" + - "level2" + - "level3" + - "level4" + humidity_control: + default: true + selector: + boolean: + airquality_control: + default: true + selector: + boolean: + co2_control: + default: true + selector: + boolean: + co2_threshold: + default: 600 + selector: + number: + min: 400 + max: 2000 + step: 50 + unit_of_measurement: "ppm" + mode: slider + co2_hysteresis: + default: 100 + selector: + number: + min: 50 + max: 400 + step: 50 + unit_of_measurement: "ppm" + mode: slider diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index 8aa7c6244ea..a826b5a3dd3 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -162,5 +162,86 @@ "name": "Bypass level" } } + }, + "selector": { + "level_setting": { + "options": { + "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%]" + } + } + }, + "services": { + "set_timer_level": { + "name": "Set timer", + "description": "Set the ventilation timer", + "fields": { + "timer_level": { + "name": "Level", + "description": "Level setting" + }, + "minutes": { + "name": "Time", + "description": "Time of the timer (0 will disable the timer)" + } + } + }, + "set_breeze": { + "name": "Set breeze", + "description": "Set the breeze function of the ventilation system", + "fields": { + "breeze_level": { + "name": "[%key:component::renson::services::set_timer_level::fields::timer_level::name%]", + "description": "Ventilation level when breeze function is activated" + }, + "temperature": { + "name": "Temperature", + "description": "Temperature when the breeze function should be activated" + }, + "activate": { + "name": "Activate", + "description": "Activate or disable the breeze feature" + } + } + }, + "set_pollution_settings": { + "name": "Set pollution settings", + "description": "Set all the pollution settings of the ventilation system", + "fields": { + "day_pollution_level": { + "name": "Day pollution Level", + "description": "Ventilation level when pollution is detected in the day" + }, + "night_pollution_level": { + "name": "Night pollution Level", + "description": "Ventilation level when pollution is detected in the night" + }, + "humidity_control": { + "name": "Enable humidity control", + "description": "Activate or disable the humidity control" + }, + "airquality_control": { + "name": "Enable air quality control", + "description": "Activate or disable the air quality control" + }, + "co2_control": { + "name": "Enable CO2 control", + "description": "Activate or disable the CO2 control" + }, + "co2_threshold": { + "name": "CO2 threshold", + "description": "Sets the CO2 pollution threshold level in ppm" + }, + "co2_hysteresis": { + "name": "CO2 hysteresis", + "description": "Sets the CO2 pollution threshold hysteresis level in ppm" + } + } + } } } diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 46761beae00..028b2c89311 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -16,6 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -148,6 +149,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b firmware_coordinator=firmware_coordinator, ) + cleanup_disconnected_cams(hass, config_entry.entry_id, host) + + # Can be remove in HA 2024.6.0 + entity_reg = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_reg, config_entry.entry_id) + for entity in entities: + if entity.domain == "light" and entity.unique_id.endswith("ir_lights"): + entity_reg.async_remove(entity.entity_id) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) config_entry.async_on_unload( @@ -175,3 +185,51 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok + + +def cleanup_disconnected_cams( + hass: HomeAssistant, config_entry_id: str, host: ReolinkHost +) -> None: + """Clean-up disconnected camera channels.""" + if not host.api.is_nvr: + return + + device_reg = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(device_reg, config_entry_id) + for device in devices: + device_id = [ + dev_id[1].split("_ch") + for dev_id in device.identifiers + if dev_id[0] == DOMAIN + ][0] + + if len(device_id) < 2: + # Do not consider the NVR itself + continue + + ch = int(device_id[1]) + ch_model = host.api.camera_model(ch) + remove = False + if ch not in host.api.channels: + remove = True + _LOGGER.debug( + "Removing Reolink device %s, " + "since no camera is connected to NVR channel %s anymore", + device.name, + ch, + ) + if ch_model not in [device.model, "Unknown"]: + remove = True + _LOGGER.debug( + "Removing Reolink device %s, " + "since the camera model connected to channel %s changed from %s to %s", + device.name, + ch, + device.model, + ch_model, + ) + if not remove: + continue + + # clean device registry and associated entities + device_reg.async_remove_device(device.id) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index e2e8e6b24f9..03b30d8195e 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -25,16 +25,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription -@dataclass(kw_only=True) -class ReolinkBinarySensorEntityDescription(BinarySensorEntityDescription): +@dataclass(frozen=True, kw_only=True) +class ReolinkBinarySensorEntityDescription( + BinarySensorEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes binary sensor entities.""" icon_off: str = "mdi:motion-sensor-off" icon: str = "mdi:motion-sensor" - supported: Callable[[Host, int], bool] = lambda host, ch: True value: Callable[[Host, int], bool] @@ -128,8 +130,8 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt entity_description: ReolinkBinarySensorEntityDescription, ) -> None: """Initialize Reolink binary sensor.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) if self._host.api.model in DUAL_LENS_DUAL_MOTION_MODELS: if entity_description.translation_key is not None: @@ -138,10 +140,6 @@ class ReolinkBinarySensorEntity(ReolinkChannelCoordinatorEntity, BinarySensorEnt key = entity_description.key self._attr_translation_key = f"{key}_lens_{self._channel}" - self._attr_unique_id = ( - f"{self._host.unique_id}_{self._channel}_{entity_description.key}" - ) - @property def icon(self) -> str | None: """Icon of the sensor.""" diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py index 6e9c9c2e386..5656f178db6 100644 --- a/homeassistant/components/reolink/button.py +++ b/homeassistant/components/reolink/button.py @@ -27,30 +27,37 @@ from homeassistant.helpers.entity_platform import ( from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) ATTR_SPEED = "speed" SUPPORT_PTZ_SPEED = CameraEntityFeature.STREAM -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkButtonEntityDescription( ButtonEntityDescription, + ReolinkChannelEntityDescription, ): """A class that describes button entities for a camera channel.""" enabled_default: Callable[[Host, int], bool] | None = None method: Callable[[Host, int], Any] - supported: Callable[[Host, int], bool] = lambda api, ch: True ptz_cmd: str | None = None -@dataclass(kw_only=True) -class ReolinkHostButtonEntityDescription(ButtonEntityDescription): +@dataclass(frozen=True, kw_only=True) +class ReolinkHostButtonEntityDescription( + ButtonEntityDescription, + ReolinkHostEntityDescription, +): """A class that describes button entities for the host.""" method: Callable[[Host], Any] - supported: Callable[[Host], bool] = lambda api: True BUTTON_ENTITIES = ( @@ -195,12 +202,9 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity): entity_description: ReolinkButtonEntityDescription, ) -> None: """Initialize Reolink button entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) if entity_description.enabled_default is not None: self._attr_entity_registry_enabled_default = ( entity_description.enabled_default(self._host.api, self._channel) @@ -241,10 +245,8 @@ class ReolinkHostButtonEntity(ReolinkHostCoordinatorEntity, ButtonEntity): 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}" + super().__init__(reolink_data) async def async_press(self) -> None: """Execute the button action.""" diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index ea9b84cd53f..715588a8225 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -1,11 +1,10 @@ """Component providing support for Reolink IP cameras.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass import logging -from reolink_aio.api import DUAL_LENS_MODELS, Host +from reolink_aio.api import DUAL_LENS_MODELS from reolink_aio.exceptions import ReolinkError from homeassistant.components.camera import ( @@ -20,19 +19,19 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription _LOGGER = logging.getLogger(__name__) -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class ReolinkCameraEntityDescription( CameraEntityDescription, + ReolinkChannelEntityDescription, ): """A class that describes camera entities for a camera channel.""" stream: str - supported: Callable[[Host, int], bool] = lambda api, ch: True CAMERA_ENTITIES = ( @@ -135,10 +134,6 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): f"{entity_description.translation_key}_lens_{self._channel}" ) - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) - async def stream_source(self) -> str | None: """Return the source of the stream.""" return await self._host.api.get_stream_source( diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index a27c84b9593..fc9b717f89b 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -10,13 +10,19 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult 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 .const import CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkException, ReolinkWebhookException, UserNotAdmin from .host import ReolinkHost from .util import is_connected diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 2a35a0f723d..8aa01bfac41 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -3,4 +3,3 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" -CONF_PROTOCOL = "protocol" diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 5c874fb7ff9..042e6b45717 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -1,11 +1,14 @@ """Reolink parent entity class.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import TypeVar -from reolink_aio.api import DUAL_LENS_MODELS +from reolink_aio.api import DUAL_LENS_MODELS, Host from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -17,8 +20,24 @@ from .const import DOMAIN _T = TypeVar("_T") +@dataclass(frozen=True, kw_only=True) +class ReolinkChannelEntityDescription(EntityDescription): + """A class that describes entities for a camera channel.""" + + cmd_key: str | None = None + supported: Callable[[Host, int], bool] = lambda api, ch: True + + +@dataclass(frozen=True, kw_only=True) +class ReolinkHostEntityDescription(EntityDescription): + """A class that describes host entities.""" + + cmd_key: str | None = None + supported: Callable[[Host], bool] = lambda api: True + + class ReolinkBaseCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[_T]]): - """Parent class fo Reolink entities.""" + """Parent class for Reolink entities.""" _attr_has_entity_name = True @@ -59,14 +78,29 @@ class ReolinkHostCoordinatorEntity(ReolinkBaseCoordinatorEntity[None]): basically a NVR with a single channel that has the camera connected to that channel. """ + entity_description: ReolinkHostEntityDescription | ReolinkChannelEntityDescription + def __init__(self, reolink_data: ReolinkData) -> None: """Initialize ReolinkHostCoordinatorEntity.""" super().__init__(reolink_data, reolink_data.device_coordinator) + self._attr_unique_id = f"{self._host.unique_id}_{self.entity_description.key}" + + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + if ( + self.entity_description.cmd_key is not None + and self.entity_description.cmd_key not in self._host.update_cmd_list + ): + self._host.update_cmd_list.append(self.entity_description.cmd_key) + class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): """Parent class for Reolink hardware camera entities connected to a channel of the NVR.""" + entity_description: ReolinkChannelEntityDescription + def __init__( self, reolink_data: ReolinkData, @@ -76,6 +110,9 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): super().__init__(reolink_data) self._channel = channel + self._attr_unique_id = ( + f"{self._host.unique_id}_{channel}_{self.entity_description.key}" + ) dev_ch = channel if self._host.api.model in DUAL_LENS_MODELS: diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 11cf8f665ad..77aeffd5412 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -13,7 +13,13 @@ from reolink_aio.enums import SubType from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError from homeassistant.components import webhook -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import format_mac @@ -21,7 +27,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url -from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN +from .const import CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotAdmin DEFAULT_TIMEOUT = 30 @@ -60,6 +66,8 @@ class ReolinkHost: timeout=DEFAULT_TIMEOUT, ) + self.update_cmd_list: list[str] = [] + self.webhook_id: str | None = None self._onvif_push_supported: bool = True self._onvif_long_poll_supported: bool = True @@ -311,7 +319,7 @@ class ReolinkHost: async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" - await self._api.get_states() + await self._api.get_states(cmd_list=self.update_cmd_list) async def disconnect(self) -> None: """Disconnect from the API, so the connection will be released.""" diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index f1aa0cb9ee2..222ab984e3f 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -22,46 +22,41 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription -@dataclass(kw_only=True) -class ReolinkLightEntityDescription(LightEntityDescription): +@dataclass(frozen=True, kw_only=True) +class ReolinkLightEntityDescription( + LightEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes light entities.""" get_brightness_fn: Callable[[Host, int], int | None] | None = None is_on_fn: Callable[[Host, int], bool] set_brightness_fn: Callable[[Host, int, int], Any] | None = None - supported_fn: Callable[[Host, int], bool] = lambda api, ch: True turn_on_off_fn: Callable[[Host, int, bool], Any] LIGHT_ENTITIES = ( ReolinkLightEntityDescription( key="floodlight", + cmd_key="GetWhiteLed", translation_key="floodlight", icon="mdi:spotlight-beam", - supported_fn=lambda api, ch: api.supported(ch, "floodLight"), + supported=lambda api, ch: api.supported(ch, "floodLight"), is_on_fn=lambda api, ch: api.whiteled_state(ch), turn_on_off_fn=lambda api, ch, value: api.set_whiteled(ch, state=value), get_brightness_fn=lambda api, ch: api.whiteled_brightness(ch), set_brightness_fn=lambda api, ch, value: api.set_whiteled(ch, brightness=value), ), - ReolinkLightEntityDescription( - key="ir_lights", - translation_key="ir_lights", - icon="mdi:led-off", - entity_category=EntityCategory.CONFIG, - supported_fn=lambda api, ch: api.supported(ch, "ir_lights"), - is_on_fn=lambda api, ch: api.ir_enabled(ch), - turn_on_off_fn=lambda api, ch, value: api.set_ir_lights(ch, value), - ), ReolinkLightEntityDescription( key="status_led", + cmd_key="GetPowerLed", translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, - supported_fn=lambda api, ch: api.supported(ch, "power_led"), + supported=lambda api, ch: api.supported(ch, "power_led"), is_on_fn=lambda api, ch: api.status_led_enabled(ch), turn_on_off_fn=lambda api, ch, value: api.set_status_led(ch, value), ), @@ -80,7 +75,7 @@ async def async_setup_entry( ReolinkLightEntity(reolink_data, channel, entity_description) for entity_description in LIGHT_ENTITIES for channel in reolink_data.host.api.channels - if entity_description.supported_fn(reolink_data.host.api, channel) + if entity_description.supported(reolink_data.host.api, channel) ) @@ -96,12 +91,8 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity): entity_description: ReolinkLightEntityDescription, ) -> None: """Initialize Reolink light entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) if entity_description.set_brightness_fn is None: self._attr_supported_color_modes = {ColorMode.ONOFF} diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index e687fc5d9b1..d5116af0071 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.8.4"] + "requirements": ["reolink-aio==0.8.5"] } diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 6a350e13836..2a1eee9e97d 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -5,6 +5,8 @@ from __future__ import annotations import datetime as dt import logging +from reolink_aio.enums import VodRequestType + from homeassistant.components.camera import DOMAIN as CAM_DOMAIN, DynamicStreamSettings from homeassistant.components.media_player import MediaClass, MediaType from homeassistant.components.media_source.error import Unresolvable @@ -56,7 +58,14 @@ class ReolinkVODMediaSource(MediaSource): channel = int(channel_str) host = self.data[config_entry_id].host - mime_type, url = await host.api.get_vod_source(channel, filename, stream_res) + + vod_type = VodRequestType.RTMP + if host.api.is_nvr: + vod_type = VodRequestType.FLV + + mime_type, url = await host.api.get_vod_source( + channel, filename, stream_res, vod_type + ) if _LOGGER.isEnabledFor(logging.DEBUG): url_log = f"{url.split('&user=')[0]}&user=xxxxx&password=xxxxx" _LOGGER.debug( diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 1780465850a..09869b06e96 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -21,24 +21,27 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription -@dataclass(kw_only=True) -class ReolinkNumberEntityDescription(NumberEntityDescription): +@dataclass(frozen=True, kw_only=True) +class ReolinkNumberEntityDescription( + NumberEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes number entities.""" get_max_value: Callable[[Host, int], float] | None = None get_min_value: Callable[[Host, int], float] | None = None method: Callable[[Host, int, float], Any] mode: NumberMode = NumberMode.AUTO - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], float | None] NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="zoom", + cmd_key="GetZoomFocus", translation_key="zoom", icon="mdi:magnify", mode=NumberMode.SLIDER, @@ -51,6 +54,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="focus", + cmd_key="GetZoomFocus", translation_key="focus", icon="mdi:focus-field", mode=NumberMode.SLIDER, @@ -66,6 +70,7 @@ NUMBER_ENTITIES = ( # or when using the "light.floodlight" entity. ReolinkNumberEntityDescription( key="floodlight_brightness", + cmd_key="GetWhiteLed", translation_key="floodlight_brightness", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, @@ -78,6 +83,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="volume", + cmd_key="GetAudioCfg", translation_key="volume", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, @@ -90,6 +96,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="guard_return_time", + cmd_key="GetPtzGuard", translation_key="guard_return_time", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, @@ -103,6 +110,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="motion_sensitivity", + cmd_key="GetMdAlarm", translation_key="motion_sensitivity", icon="mdi:motion-sensor", entity_category=EntityCategory.CONFIG, @@ -115,6 +123,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_face_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_face_sensititvity", icon="mdi:face-recognition", entity_category=EntityCategory.CONFIG, @@ -129,6 +138,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_person_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_person_sensititvity", icon="mdi:account", entity_category=EntityCategory.CONFIG, @@ -143,6 +153,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_vehicle_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_vehicle_sensititvity", icon="mdi:car", entity_category=EntityCategory.CONFIG, @@ -157,6 +168,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_pet_sensititvity", icon="mdi:dog-side", entity_category=EntityCategory.CONFIG, @@ -173,6 +185,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_sensititvity", + cmd_key="GetAiAlarm", translation_key="ai_animal_sensititvity", icon="mdi:paw", entity_category=EntityCategory.CONFIG, @@ -187,6 +200,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_face_delay", + cmd_key="GetAiAlarm", translation_key="ai_face_delay", icon="mdi:face-recognition", entity_category=EntityCategory.CONFIG, @@ -203,6 +217,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_person_delay", + cmd_key="GetAiAlarm", translation_key="ai_person_delay", icon="mdi:account", entity_category=EntityCategory.CONFIG, @@ -219,6 +234,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_vehicle_delay", + cmd_key="GetAiAlarm", translation_key="ai_vehicle_delay", icon="mdi:car", entity_category=EntityCategory.CONFIG, @@ -235,6 +251,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_delay", + cmd_key="GetAiAlarm", translation_key="ai_pet_delay", icon="mdi:dog-side", entity_category=EntityCategory.CONFIG, @@ -253,6 +270,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="ai_pet_delay", + cmd_key="GetAiAlarm", translation_key="ai_animal_delay", icon="mdi:paw", entity_category=EntityCategory.CONFIG, @@ -269,6 +287,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_quick_reply_time", + cmd_key="GetAutoReply", translation_key="auto_quick_reply_time", icon="mdi:message-reply-text-outline", entity_category=EntityCategory.CONFIG, @@ -282,6 +301,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_limit_left", + cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_left", icon="mdi:angle-acute", mode=NumberMode.SLIDER, @@ -295,6 +315,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_limit_right", + cmd_key="GetPtzTraceSection", translation_key="auto_track_limit_right", icon="mdi:angle-acute", mode=NumberMode.SLIDER, @@ -308,6 +329,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_disappear_time", + cmd_key="GetAiCfg", translation_key="auto_track_disappear_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -323,6 +345,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="auto_track_stop_time", + cmd_key="GetAiCfg", translation_key="auto_track_stop_time", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -336,6 +359,7 @@ NUMBER_ENTITIES = ( ), ReolinkNumberEntityDescription( key="day_night_switch_threshold", + cmd_key="GetIsp", translation_key="day_night_switch_threshold", icon="mdi:theme-light-dark", entity_category=EntityCategory.CONFIG, @@ -378,8 +402,8 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): entity_description: ReolinkNumberEntityDescription, ) -> None: """Initialize Reolink number entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) if entity_description.get_min_value is not None: self._attr_native_min_value = entity_description.get_min_value( @@ -390,9 +414,6 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity): self._host.api, channel ) self._attr_mode = entity_description.mode - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) @property def native_value(self) -> float | None: diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 566dbc92fbe..769ccdf7e01 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -24,24 +24,27 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription _LOGGER = logging.getLogger(__name__) -@dataclass(kw_only=True) -class ReolinkSelectEntityDescription(SelectEntityDescription): +@dataclass(frozen=True, kw_only=True) +class ReolinkSelectEntityDescription( + SelectEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes select entities.""" get_options: list[str] | Callable[[Host, int], list[str]] method: Callable[[Host, int, str], Any] - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], str] | None = None SELECT_ENTITIES = ( ReolinkSelectEntityDescription( key="floodlight_mode", + cmd_key="GetWhiteLed", translation_key="floodlight_mode", icon="mdi:spotlight-beam", entity_category=EntityCategory.CONFIG, @@ -52,6 +55,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="day_night_mode", + cmd_key="GetIsp", translation_key="day_night_mode", icon="mdi:theme-light-dark", entity_category=EntityCategory.CONFIG, @@ -70,6 +74,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="auto_quick_reply_message", + cmd_key="GetAutoReply", translation_key="auto_quick_reply_message", icon="mdi:message-reply-text-outline", entity_category=EntityCategory.CONFIG, @@ -82,6 +87,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="auto_track_method", + cmd_key="GetAiCfg", translation_key="auto_track_method", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -92,6 +98,7 @@ SELECT_ENTITIES = ( ), ReolinkSelectEntityDescription( key="status_led", + cmd_key="GetPowerLed", translation_key="status_led", icon="mdi:lightning-bolt-circle", entity_category=EntityCategory.CONFIG, @@ -131,14 +138,10 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity): entity_description: ReolinkSelectEntityDescription, ) -> None: """Initialize Reolink select entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description + super().__init__(reolink_data, channel) self._log_error = True - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) - if callable(entity_description.get_options): self._attr_options = entity_description.get_options(self._host.api, channel) else: diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py index 9a03f497944..6f4af489fe5 100644 --- a/homeassistant/components/reolink/sensor.py +++ b/homeassistant/components/reolink/sensor.py @@ -21,36 +21,38 @@ from homeassistant.helpers.typing import StateType from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) -@dataclass(kw_only=True) -class ReolinkSensorEntityDescription(SensorEntityDescription): +@dataclass(frozen=True, kw_only=True) +class ReolinkSensorEntityDescription( + SensorEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes sensor entities for a camera channel.""" - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], int] -@dataclass -class ReolinkHostSensorEntityDescriptionMixin: - """Mixin values for Reolink host sensor entities.""" - - value: Callable[[Host], int | None] - - -@dataclass +@dataclass(frozen=True, kw_only=True) class ReolinkHostSensorEntityDescription( - SensorEntityDescription, ReolinkHostSensorEntityDescriptionMixin + SensorEntityDescription, + ReolinkHostEntityDescription, ): """A class that describes host sensor entities.""" - supported: Callable[[Host], bool] = lambda api: True + value: Callable[[Host], int | None] SENSORS = ( ReolinkSensorEntityDescription( key="ptz_pan_position", + cmd_key="GetPtzCurPos", translation_key="ptz_pan_position", icon="mdi:pan", state_class=SensorStateClass.MEASUREMENT, @@ -63,6 +65,7 @@ SENSORS = ( HOST_SENSORS = ( ReolinkHostSensorEntityDescription( key="wifi_signal", + cmd_key="GetWifiSignal", translation_key="wifi_signal", icon="mdi:wifi", state_class=SensorStateClass.MEASUREMENT, @@ -110,12 +113,8 @@ class ReolinkSensorEntity(ReolinkChannelCoordinatorEntity, SensorEntity): entity_description: ReolinkSensorEntityDescription, ) -> None: """Initialize Reolink sensor.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) @property def native_value(self) -> StateType | date | datetime | Decimal: @@ -134,10 +133,8 @@ class ReolinkHostSensorEntity(ReolinkHostCoordinatorEntity, SensorEntity): entity_description: ReolinkHostSensorEntityDescription, ) -> None: """Initialize Reolink host sensor.""" - super().__init__(reolink_data) self.entity_description = entity_description - - self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" + super().__init__(reolink_data) @property def native_value(self) -> StateType | date | datetime | Decimal: diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py index f063b65e2b4..90590acb4e4 100644 --- a/homeassistant/components/reolink/siren.py +++ b/homeassistant/components/reolink/siren.py @@ -1,11 +1,9 @@ """Component providing support for Reolink siren entities.""" from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass from typing import Any -from reolink_aio.api import Host from reolink_aio.exceptions import InvalidParameterError, ReolinkError from homeassistant.components.siren import ( @@ -22,15 +20,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity +from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription -@dataclass -class ReolinkSirenEntityDescription(SirenEntityDescription): +@dataclass(frozen=True) +class ReolinkSirenEntityDescription( + SirenEntityDescription, ReolinkChannelEntityDescription +): """A class that describes siren entities.""" - supported: Callable[[Host, int], bool] = lambda api, ch: True - SIREN_ENTITIES = ( ReolinkSirenEntityDescription( @@ -76,12 +74,8 @@ class ReolinkSirenEntity(ReolinkChannelCoordinatorEntity, SirenEntity): entity_description: ReolinkSirenEntityDescription, ) -> None: """Initialize Reolink siren entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the siren.""" diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 5a27f0e38cb..04dd0e787ac 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -12,7 +12,11 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'." + "host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'.", + "port": "The port to connect to the Reolink device. For HTTP normally: '80', for HTTPS normally '443'.", + "use_https": "Use a HTTPS (SSL) connection to the Reolink device.", + "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.", + "password": "Password to login to the Reolink device itself. Not the Reolink cloud account." } }, "reauth_confirm": { @@ -38,6 +42,9 @@ "init": { "data": { "protocol": "Protocol" + }, + "data_description": { + "protocol": "Streaming protocol to use for the camera entities. RTSP supports 4K streams (h265 encoding) while RTMP and FLV do not. FLV is the least demanding on the camera." } } } @@ -231,9 +238,6 @@ "floodlight": { "name": "Floodlight" }, - "ir_lights": { - "name": "Infra red lights in night mode" - }, "status_led": { "name": "Status LED" } @@ -366,6 +370,9 @@ } }, "switch": { + "ir_lights": { + "name": "Infra red lights in night mode" + }, "record_audio": { "name": "Record audio" }, diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index eb77b16478f..7f57b78df1e 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -17,30 +17,50 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ReolinkData from .const import DOMAIN -from .entity import ReolinkChannelCoordinatorEntity, ReolinkHostCoordinatorEntity +from .entity import ( + ReolinkChannelCoordinatorEntity, + ReolinkChannelEntityDescription, + ReolinkHostCoordinatorEntity, + ReolinkHostEntityDescription, +) -@dataclass(kw_only=True) -class ReolinkSwitchEntityDescription(SwitchEntityDescription): +@dataclass(frozen=True, kw_only=True) +class ReolinkSwitchEntityDescription( + SwitchEntityDescription, + ReolinkChannelEntityDescription, +): """A class that describes switch entities.""" method: Callable[[Host, int, bool], Any] - supported: Callable[[Host, int], bool] = lambda api, ch: True value: Callable[[Host, int], bool] -@dataclass(kw_only=True) -class ReolinkNVRSwitchEntityDescription(SwitchEntityDescription): +@dataclass(frozen=True, kw_only=True) +class ReolinkNVRSwitchEntityDescription( + SwitchEntityDescription, + ReolinkHostEntityDescription, +): """A class that describes NVR switch entities.""" method: Callable[[Host, bool], Any] - supported: Callable[[Host], bool] = lambda api: True value: Callable[[Host], bool] SWITCH_ENTITIES = ( + ReolinkSwitchEntityDescription( + key="ir_lights", + cmd_key="GetIrLights", + translation_key="ir_lights", + icon="mdi:led-off", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "ir_lights"), + value=lambda api, ch: api.ir_enabled(ch), + method=lambda api, ch, value: api.set_ir_lights(ch, value), + ), ReolinkSwitchEntityDescription( key="record_audio", + cmd_key="GetEnc", translation_key="record_audio", icon="mdi:microphone", entity_category=EntityCategory.CONFIG, @@ -50,6 +70,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="siren_on_event", + cmd_key="GetAudioAlarm", translation_key="siren_on_event", icon="mdi:alarm-light", entity_category=EntityCategory.CONFIG, @@ -59,6 +80,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="auto_tracking", + cmd_key="GetAiCfg", translation_key="auto_tracking", icon="mdi:target-account", entity_category=EntityCategory.CONFIG, @@ -68,6 +90,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="auto_focus", + cmd_key="GetAutoFocus", translation_key="auto_focus", icon="mdi:focus-field", entity_category=EntityCategory.CONFIG, @@ -77,6 +100,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="gaurd_return", + cmd_key="GetPtzGuard", translation_key="gaurd_return", icon="mdi:crosshairs-gps", entity_category=EntityCategory.CONFIG, @@ -86,6 +110,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="email", + cmd_key="GetEmail", translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, @@ -95,6 +120,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="ftp_upload", + cmd_key="GetFtp", translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, @@ -104,6 +130,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="push_notifications", + cmd_key="GetPush", translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, @@ -113,6 +140,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="record", + cmd_key="GetRec", translation_key="record", icon="mdi:record-rec", entity_category=EntityCategory.CONFIG, @@ -122,6 +150,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="buzzer", + cmd_key="GetBuzzerAlarmV20", translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, @@ -131,6 +160,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="doorbell_button_sound", + cmd_key="GetAudioCfg", translation_key="doorbell_button_sound", icon="mdi:volume-high", entity_category=EntityCategory.CONFIG, @@ -140,6 +170,7 @@ SWITCH_ENTITIES = ( ), ReolinkSwitchEntityDescription( key="hdr", + cmd_key="GetIsp", translation_key="hdr", icon="mdi:hdr", entity_category=EntityCategory.CONFIG, @@ -153,6 +184,7 @@ SWITCH_ENTITIES = ( NVR_SWITCH_ENTITIES = ( ReolinkNVRSwitchEntityDescription( key="email", + cmd_key="GetEmail", translation_key="email", icon="mdi:email", entity_category=EntityCategory.CONFIG, @@ -162,6 +194,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="ftp_upload", + cmd_key="GetFtp", translation_key="ftp_upload", icon="mdi:swap-horizontal", entity_category=EntityCategory.CONFIG, @@ -171,6 +204,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="push_notifications", + cmd_key="GetPush", translation_key="push_notifications", icon="mdi:message-badge", entity_category=EntityCategory.CONFIG, @@ -180,6 +214,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="record", + cmd_key="GetRec", translation_key="record", icon="mdi:record-rec", entity_category=EntityCategory.CONFIG, @@ -189,6 +224,7 @@ NVR_SWITCH_ENTITIES = ( ), ReolinkNVRSwitchEntityDescription( key="buzzer", + cmd_key="GetBuzzerAlarmV20", translation_key="buzzer", icon="mdi:room-service", entity_category=EntityCategory.CONFIG, @@ -235,12 +271,8 @@ class ReolinkSwitchEntity(ReolinkChannelCoordinatorEntity, SwitchEntity): entity_description: ReolinkSwitchEntityDescription, ) -> None: """Initialize Reolink switch entity.""" - super().__init__(reolink_data, channel) self.entity_description = entity_description - - self._attr_unique_id = ( - f"{self._host.unique_id}_{channel}_{entity_description.key}" - ) + super().__init__(reolink_data, channel) @property def is_on(self) -> bool: @@ -275,8 +307,8 @@ class ReolinkNVRSwitchEntity(ReolinkHostCoordinatorEntity, SwitchEntity): entity_description: ReolinkNVRSwitchEntityDescription, ) -> None: """Initialize Reolink switch entity.""" - super().__init__(reolink_data) self.entity_description = entity_description + super().__init__(reolink_data) self._attr_unique_id = f"{self._host.unique_id}_{entity_description.key}" diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py index 6a0df99ce1d..91a16ea3fbe 100644 --- a/homeassistant/components/repetier/__init__.py +++ b/homeassistant/components/repetier/__init__.py @@ -124,14 +124,14 @@ def has_all_unique_names(value): return value -@dataclass +@dataclass(frozen=True) class RepetierRequiredKeysMixin: """Mixin for required keys.""" type: str -@dataclass +@dataclass(frozen=True) class RepetierSensorEntityDescription( SensorEntityDescription, RepetierRequiredKeysMixin ): diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 991bfff7da0..7dbe295afee 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -171,7 +171,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): try: req = await self.set_device_state(body_on_t) - if req.status_code == HTTPStatus.OK: + if HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES: self._attr_is_on = True else: _LOGGER.error( @@ -186,7 +186,7 @@ class RestSwitch(ManualTriggerEntity, SwitchEntity): try: req = await self.set_device_state(body_off_t) - if req.status_code == HTTPStatus.OK: + if HTTPStatus.OK <= req.status_code < HTTPStatus.MULTIPLE_CHOICES: self._attr_is_on = False else: _LOGGER.error( diff --git a/homeassistant/components/rest_command/manifest.json b/homeassistant/components/rest_command/manifest.json index f9acf3b5933..bd3b6070691 100644 --- a/homeassistant/components/rest_command/manifest.json +++ b/homeassistant/components/rest_command/manifest.json @@ -1,7 +1,7 @@ { "domain": "rest_command", "name": "RESTful Command", - "codeowners": [], + "codeowners": ["@jpbede"], "documentation": "https://www.home-assistant.io/integrations/rest_command", "iot_class": "local_push" } diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 9c5ffa586cd..cfacc627744 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -6,7 +6,7 @@ import binascii from collections.abc import Callable, Mapping import copy import logging -from typing import Any, NamedTuple, cast +from typing import Any, NamedTuple, TypeVarTuple, cast import RFXtrx as rfxtrxmod import voluptuous as vol @@ -50,6 +50,8 @@ DEFAULT_OFF_DELAY = 2.0 SIGNAL_EVENT = f"{DOMAIN}_event" +_Ts = TypeVarTuple("_Ts") + _LOGGER = logging.getLogger(__name__) @@ -145,7 +147,7 @@ def _create_rfx(config: Mapping[str, Any]) -> rfxtrxmod.Connect: def _get_device_lookup( - devices: dict[str, dict[str, Any]] + devices: dict[str, dict[str, Any]], ) -> dict[DeviceTuple, dict[str, Any]]: """Get a lookup structure for devices.""" lookup = {} @@ -438,7 +440,7 @@ def get_device_id( def get_device_tuple_from_identifiers( - identifiers: set[tuple[str, str]] + identifiers: set[tuple[str, str]], ) -> DeviceTuple | None: """Calculate the device tuple from a device entry.""" identifier = next((x for x in identifiers if x[0] == DOMAIN and len(x) == 4), None) @@ -559,6 +561,8 @@ class RfxtrxCommandEntity(RfxtrxEntity): """Initialzie a switch or light device.""" super().__init__(device, device_id, event=event) - async def _async_send(self, fun: Callable[..., None], *args: Any) -> None: - rfx_object = self.hass.data[DOMAIN][DATA_RFXOBJECT] + async def _async_send( + self, fun: Callable[[rfxtrxmod.PySerialTransport, *_Ts], None], *args: *_Ts + ) -> None: + rfx_object: rfxtrxmod.Connect = self.hass.data[DOMAIN][DATA_RFXOBJECT] await self.hass.async_add_executor_job(fun, rfx_object.transport, *args) diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 54a60d34229..12b9290af99 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -643,7 +643,7 @@ def _test_transport(host: str | None, port: int | None, device: str | None) -> b else: try: conn = rfxtrxmod.PySerialTransport(device) - except serial.serialutil.SerialException: + except serial.SerialException: return False if conn.serial is None: diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index 60f35a93d1a..66803edffc5 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -58,7 +58,7 @@ def _rssi_convert(value: int | None) -> str | None: return f"{value*8-120}" -@dataclass +@dataclass(frozen=True) class RfxtrxSensorEntityDescription(SensorEntityDescription): """Description of sensor entities.""" diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 05d26812f54..27eb82d34ee 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -18,14 +18,14 @@ from .const import DOMAIN, RING_API, RING_DEVICES, RING_NOTIFICATIONS_COORDINATO from .entity import RingEntityMixin -@dataclass +@dataclass(frozen=True) class RingRequiredKeysMixin: """Mixin for required keys.""" category: list[str] -@dataclass +@dataclass(frozen=True) class RingBinarySensorEntityDescription( BinarySensorEntityDescription, RingRequiredKeysMixin ): diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 465f6196689..a596d413ac7 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -173,7 +173,7 @@ class HistoryRingSensor(RingSensor): return attrs -@dataclass +@dataclass(frozen=True) class RingRequiredKeysMixin: """Mixin for required keys.""" @@ -181,7 +181,7 @@ class RingRequiredKeysMixin: cls: type[RingSensor] -@dataclass +@dataclass(frozen=True) class RingSensorEntityDescription(SensorEntityDescription, RingRequiredKeysMixin): """Describes Ring sensor entity.""" diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index ab13898394c..f33a687b88f 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -21,7 +21,7 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class RitualsBinarySensorEntityDescription(BinarySensorEntityDescription): """Class describing Rituals binary sensor entities.""" diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 35b5a3bd008..164b6de52c9 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -17,7 +17,7 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class RitualsNumberEntityDescription(NumberEntityDescription): """Class describing Rituals number entities.""" diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index 2126ecb147f..b9f0c29b267 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -17,7 +17,7 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class RitualsSelectEntityDescription(SelectEntityDescription): """Class describing Rituals select entities.""" diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 5f7ae45d330..cd139c94f1c 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -21,7 +21,7 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class RitualsSensorEntityDescription(SensorEntityDescription): """Class describing Rituals sensor entities.""" diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index 77776704a60..9c9a5f73d16 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -17,7 +17,7 @@ from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -@dataclass +@dataclass(frozen=True) class RitualsEntityDescriptionMixin: """Mixin values for Rituals entities.""" @@ -26,7 +26,7 @@ class RitualsEntityDescriptionMixin: turn_off_fn: Callable[[Diffuser], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class RitualsSwitchEntityDescription( SwitchEntityDescription, RitualsEntityDescriptionMixin ): diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index 203f981e51d..03e1eabe45a 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -22,14 +22,14 @@ from .coordinator import RoborockDataUpdateCoordinator from .device import RoborockCoordinatedEntity -@dataclass +@dataclass(frozen=True) class RoborockBinarySensorDescriptionMixin: """A class that describes binary sensor entities.""" value_fn: Callable[[DeviceProp], bool | int | None] -@dataclass +@dataclass(frozen=True) class RoborockBinarySensorDescription( BinarySensorEntityDescription, RoborockBinarySensorDescriptionMixin ): diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py index aba86ccb6b6..7744c5988d8 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -17,7 +17,7 @@ from .coordinator import RoborockDataUpdateCoordinator from .device import RoborockEntity -@dataclass +@dataclass(frozen=True) class RoborockButtonDescriptionMixin: """Define an entity description mixin for button entities.""" @@ -25,7 +25,7 @@ class RoborockButtonDescriptionMixin: param: list | dict | None -@dataclass +@dataclass(frozen=True) class RoborockButtonDescription( ButtonEntityDescription, RoborockButtonDescriptionMixin ): diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index beb467d69f9..c149b9fcf7f 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["roborock"], "requirements": [ - "python-roborock==0.36.2", + "python-roborock==0.38.0", "vacuum-map-parser-roborock==0.1.1" ] } diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py index d91606418d9..8957c487a64 100644 --- a/homeassistant/components/roborock/number.py +++ b/homeassistant/components/roborock/number.py @@ -23,7 +23,7 @@ from .device import RoborockEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class RoborockNumberDescriptionMixin: """Define an entity description mixin for button entities.""" @@ -33,7 +33,7 @@ class RoborockNumberDescriptionMixin: update_value: Callable[[AttributeCache, float], Coroutine[Any, Any, dict]] -@dataclass +@dataclass(frozen=True) class RoborockNumberDescription( NumberEntityDescription, RoborockNumberDescriptionMixin ): diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 1a05f3ec9c1..ae5dd12689d 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -18,7 +18,7 @@ from .coordinator import RoborockDataUpdateCoordinator from .device import RoborockCoordinatedEntity -@dataclass +@dataclass(frozen=True) class RoborockSelectDescriptionMixin: """Define an entity description mixin for select entities.""" @@ -32,7 +32,7 @@ class RoborockSelectDescriptionMixin: parameter_lambda: Callable[[str, Status], list[int]] -@dataclass +@dataclass(frozen=True) class RoborockSelectDescription( SelectEntityDescription, RoborockSelectDescriptionMixin ): diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 775fc0cfb5f..e3cea00476f 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -36,14 +36,14 @@ from .coordinator import RoborockDataUpdateCoordinator from .device import RoborockCoordinatedEntity -@dataclass +@dataclass(frozen=True) class RoborockSensorDescriptionMixin: """A class that describes sensor entities.""" value_fn: Callable[[DeviceProp], StateType | datetime.datetime] -@dataclass +@dataclass(frozen=True) class RoborockSensorDescription( SensorEntityDescription, RoborockSensorDescriptionMixin ): diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 3dd7307da72..37e8488dd22 100644 --- a/homeassistant/components/roborock/switch.py +++ b/homeassistant/components/roborock/switch.py @@ -24,7 +24,7 @@ from .device import RoborockEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class RoborockSwitchDescriptionMixin: """Define an entity description mixin for switch entities.""" @@ -36,7 +36,7 @@ class RoborockSwitchDescriptionMixin: attribute: str -@dataclass +@dataclass(frozen=True) class RoborockSwitchDescription( SwitchEntityDescription, RoborockSwitchDescriptionMixin ): diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py index d02d63597ac..7a8d21fc0f1 100644 --- a/homeassistant/components/roborock/time.py +++ b/homeassistant/components/roborock/time.py @@ -25,7 +25,7 @@ from .device import RoborockEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class RoborockTimeDescriptionMixin: """Define an entity description mixin for time entities.""" @@ -37,7 +37,7 @@ class RoborockTimeDescriptionMixin: get_value: Callable[[AttributeCache], datetime.time] -@dataclass +@dataclass(frozen=True) class RoborockTimeDescription(TimeEntityDescription, RoborockTimeDescriptionMixin): """Class to describe an Roborock time entity.""" diff --git a/homeassistant/components/roku/binary_sensor.py b/homeassistant/components/roku/binary_sensor.py index b08933dcd91..144fded24b9 100644 --- a/homeassistant/components/roku/binary_sensor.py +++ b/homeassistant/components/roku/binary_sensor.py @@ -19,14 +19,14 @@ from .const import DOMAIN from .entity import RokuEntity -@dataclass +@dataclass(frozen=True) class RokuBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[RokuDevice], bool | None] -@dataclass +@dataclass(frozen=True) class RokuBinarySensorEntityDescription( BinarySensorEntityDescription, RokuBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/roku/browse_media.py b/homeassistant/components/roku/browse_media.py index 4173d2f5c6e..acaf2e5adbc 100644 --- a/homeassistant/components/roku/browse_media.py +++ b/homeassistant/components/roku/browse_media.py @@ -39,7 +39,7 @@ EXPANDABLE_MEDIA_TYPES = [ MediaType.CHANNELS, ] -GetBrowseImageUrlType = Callable[[str, str, "str | None"], str] +GetBrowseImageUrlType = Callable[[str, str, "str | None"], str | None] def get_thumbnail_url_full( diff --git a/homeassistant/components/roku/helpers.py b/homeassistant/components/roku/helpers.py index 60392d89f1d..60a3cbeec30 100644 --- a/homeassistant/components/roku/helpers.py +++ b/homeassistant/components/roku/helpers.py @@ -32,7 +32,7 @@ def roku_exception_handler( """Decorate Roku calls to handle Roku exceptions.""" def decorator( - func: _FuncType[_RokuEntityT, _P] + func: _FuncType[_RokuEntityT, _P], ) -> _ReturnFuncType[_RokuEntityT, _P]: @wraps(func) async def wrapper( diff --git a/homeassistant/components/roku/select.py b/homeassistant/components/roku/select.py index 430133b7f77..ef0f198f586 100644 --- a/homeassistant/components/roku/select.py +++ b/homeassistant/components/roku/select.py @@ -18,7 +18,7 @@ from .entity import RokuEntity from .helpers import format_channel_name, roku_exception_handler -@dataclass +@dataclass(frozen=True) class RokuSelectEntityDescriptionMixin: """Mixin for required keys.""" @@ -85,7 +85,7 @@ async def _tune_channel(device: RokuDevice, roku: Roku, value: str) -> None: await roku.tune(_channel.number) -@dataclass +@dataclass(frozen=True) class RokuSelectEntityDescription( SelectEntityDescription, RokuSelectEntityDescriptionMixin ): diff --git a/homeassistant/components/roku/sensor.py b/homeassistant/components/roku/sensor.py index 69b8c34d312..b462b8c531b 100644 --- a/homeassistant/components/roku/sensor.py +++ b/homeassistant/components/roku/sensor.py @@ -17,14 +17,14 @@ from .coordinator import RokuDataUpdateCoordinator from .entity import RokuEntity -@dataclass +@dataclass(frozen=True) class RokuSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[RokuDevice], str | None] -@dataclass +@dataclass(frozen=True) class RokuSensorEntityDescription( SensorEntityDescription, RokuSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 8e6b92732eb..fbe6c925438 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -24,7 +24,7 @@ "documentation": "https://www.home-assistant.io/integrations/roomba", "iot_class": "local_push", "loggers": ["paho_mqtt", "roombapy"], - "requirements": ["roombapy==1.6.8"], + "requirements": ["roombapy==1.6.10"], "zeroconf": [ { "type": "_amzn-alexa._tcp.local.", diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 7d103111301..09d4d643be9 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -21,14 +21,14 @@ from .irobot_base import IRobotEntity from .models import RoombaData -@dataclass +@dataclass(frozen=True) class RoombaSensorEntityDescriptionMixin: """Mixin for describing Roomba data.""" value_fn: Callable[[IRobotEntity], StateType] -@dataclass +@dataclass(frozen=True) class RoombaSensorEntityDescription( SensorEntityDescription, RoombaSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json index 2598d9e8de1..0dcb5b87581 100644 --- a/homeassistant/components/roon/manifest.json +++ b/homeassistant/components/roon/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roon", "iot_class": "local_push", "loggers": ["roonapi"], - "requirements": ["roonapi==0.1.5"] + "requirements": ["roonapi==0.1.6"] } diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index dda323c2c2a..afbf0e6b4a7 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -207,13 +207,14 @@ class RoonDevice(MediaPlayerEntity): try: volume_max = volume_data["max"] volume_min = volume_data["min"] + raw_level = convert(volume_data["value"], float, 0) volume_range = volume_max - volume_min volume_percentage_factor = volume_range / 100 level = (raw_level - volume_min) / volume_percentage_factor - volume["level"] = convert(level, int, 0) / 100 + volume["level"] = round(level) / 100 except KeyError: pass diff --git a/homeassistant/components/ruuvi_gateway/bluetooth.py b/homeassistant/components/ruuvi_gateway/bluetooth.py index 47a9bbfdde0..c4fbe474776 100644 --- a/homeassistant/components/ruuvi_gateway/bluetooth.py +++ b/homeassistant/components/ruuvi_gateway/bluetooth.py @@ -1,17 +1,13 @@ """Bluetooth support for Ruuvi Gateway.""" from __future__ import annotations -from collections.abc import Callable import logging import time -from home_assistant_bluetooth import BluetoothServiceInfoBleak - from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, MONOTONIC_TIME, BaseHaRemoteScanner, - async_get_advertisement_callback, async_register_scanner, ) from homeassistant.config_entries import ConfigEntry @@ -27,19 +23,15 @@ class RuuviGatewayScanner(BaseHaRemoteScanner): def __init__( self, - hass: HomeAssistant, scanner_id: str, name: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], *, coordinator: RuuviGatewayUpdateCoordinator, ) -> None: """Initialize the scanner, using the given update coordinator as data source.""" super().__init__( - hass, scanner_id, name, - new_info_callback, connector=None, connectable=False, ) @@ -87,14 +79,12 @@ def async_connect_scanner( source, ) scanner = RuuviGatewayScanner( - hass=hass, scanner_id=source, name=entry.title, - new_info_callback=async_get_advertisement_callback(hass), coordinator=coordinator, ) unload_callbacks = [ - async_register_scanner(hass, scanner, connectable=False), + async_register_scanner(hass, scanner), scanner.async_setup(), scanner.start_polling(), ] diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index babdbc573bd..7d0437da033 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -1,8 +1,9 @@ """Support for monitoring an SABnzbd NZB client.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import logging +from typing import Any from pysabnzbd import SabnzbdApiException import voluptuous as vol @@ -189,7 +190,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_device_identifiers(hass, entry) @callback - def extract_api(func: Callable) -> Callable: + def extract_api( + func: Callable[[ServiceCall, SabnzbdApiData], Coroutine[Any, Any, None]], + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define a decorator to get the correct api for a service call.""" async def wrapper(call: ServiceCall) -> None: diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py index d4920ef77f3..ff33c084ffa 100644 --- a/homeassistant/components/sabnzbd/sensor.py +++ b/homeassistant/components/sabnzbd/sensor.py @@ -20,14 +20,14 @@ from . import DOMAIN, SIGNAL_SABNZBD_UPDATED from .const import DEFAULT_NAME, KEY_API_DATA -@dataclass +@dataclass(frozen=True) class SabnzbdRequiredKeysMixin: """Mixin for required keys.""" key: str -@dataclass +@dataclass(frozen=True) class SabnzbdSensorEntityDescription(SensorEntityDescription, SabnzbdRequiredKeysMixin): """Describes Sabnzbd sensor entity.""" diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 03a9c35c9ba..f2767ce693e 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -34,6 +34,7 @@ from samsungtvws.remote import ChannelEmitCommand, SendRemoteKey from websockets.exceptions import ConnectionClosedError, WebSocketException from homeassistant.const import ( + CONF_DESCRIPTION, CONF_HOST, CONF_ID, CONF_METHOD, @@ -50,7 +51,6 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.util import dt as dt_util from .const import ( - CONF_DESCRIPTION, CONF_SESSION_ID, ENCRYPTED_WEBSOCKET_PORT, LEGACY_PORT, diff --git a/homeassistant/components/samsungtv/const.py b/homeassistant/components/samsungtv/const.py index 6699d26243b..6c657145d7a 100644 --- a/homeassistant/components/samsungtv/const.py +++ b/homeassistant/components/samsungtv/const.py @@ -11,7 +11,6 @@ DEFAULT_MANUFACTURER = "Samsung" VALUE_CONF_NAME = "HomeAssistant" VALUE_CONF_ID = "ha.component.samsung" -CONF_DESCRIPTION = "description" CONF_MANUFACTURER = "manufacturer" CONF_SSDP_RENDERING_CONTROL_LOCATION = "ssdp_rendering_control_location" CONF_SSDP_MAIN_TV_AGENT_LOCATION = "ssdp_main_tv_agent_location" diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 48bdb7083b4..2b388cf706a 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -35,11 +35,11 @@ "iot_class": "local_push", "loggers": ["samsungctl", "samsungtvws"], "requirements": [ - "getmac==0.8.2", + "getmac==0.9.4", "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.36.2" + "async-upnp-client==0.38.0" ], "ssdp": [ { diff --git a/homeassistant/components/schlage/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py index 749a961a53b..5c97a903c72 100644 --- a/homeassistant/components/schlage/binary_sensor.py +++ b/homeassistant/components/schlage/binary_sensor.py @@ -20,7 +20,7 @@ from .coordinator import LockData, SchlageDataUpdateCoordinator from .entity import SchlageEntity -@dataclass +@dataclass(frozen=True) class SchlageBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -32,7 +32,7 @@ class SchlageBinarySensorEntityDescriptionMixin: value_fn: Callable[[LockData], bool] -@dataclass +@dataclass(frozen=True) class SchlageBinarySensorEntityDescription( BinarySensorEntityDescription, SchlageBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index e14a5bc706e..72d5ad54565 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.12.0"] + "requirements": ["pyschlage==2023.12.1"] } diff --git a/homeassistant/components/schlage/switch.py b/homeassistant/components/schlage/switch.py index 1a4eeb7bcc7..36c8fa74244 100644 --- a/homeassistant/components/schlage/switch.py +++ b/homeassistant/components/schlage/switch.py @@ -24,7 +24,7 @@ from .coordinator import SchlageDataUpdateCoordinator from .entity import SchlageEntity -@dataclass +@dataclass(frozen=True) class SchlageSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -38,7 +38,7 @@ class SchlageSwitchEntityDescriptionMixin: value_fn: Callable[[Lock], bool] -@dataclass +@dataclass(frozen=True) class SchlageSwitchEntityDescription( SwitchEntityDescription, SchlageSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/scl/__init__.py b/homeassistant/components/scl/__init__.py new file mode 100644 index 00000000000..ae3b8d58f5e --- /dev/null +++ b/homeassistant/components/scl/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Seattle City Light (SCL).""" diff --git a/homeassistant/components/scl/manifest.json b/homeassistant/components/scl/manifest.json new file mode 100644 index 00000000000..11fce2c4b47 --- /dev/null +++ b/homeassistant/components/scl/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "scl", + "name": "Seattle City Light (SCL)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 26603603198..708ecc14d16 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.12.2", "lxml==4.9.3"] + "requirements": ["beautifulsoup4==4.12.2", "lxml==4.9.4"] } diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index 9192458dde4..096c2c22918 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -1,6 +1,6 @@ """Support for a ScreenLogic Binary Sensor.""" from copy import copy -from dataclasses import dataclass +import dataclasses import logging from screenlogicpy.const.common import ON_OFF @@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator from .entity import ( - ScreenlogicEntity, + ScreenLogicEntity, ScreenLogicEntityDescription, ScreenLogicPushEntity, ScreenLogicPushEntityDescription, @@ -32,14 +32,14 @@ from .util import cleanup_excluded_entity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicBinarySensorDescription( BinarySensorEntityDescription, ScreenLogicEntityDescription ): """A class that describes ScreenLogic binary sensor eneites.""" -@dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicPushBinarySensorDescription( ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription ): @@ -232,7 +232,7 @@ async def async_setup_entry( async_add_entities(entities) -class ScreenLogicBinarySensor(ScreenlogicEntity, BinarySensorEntity): +class ScreenLogicBinarySensor(ScreenLogicEntity, BinarySensorEntity): """Representation of a ScreenLogic binary sensor entity.""" entity_description: ScreenLogicBinarySensorDescription @@ -261,5 +261,7 @@ class ScreenLogicPumpBinarySensor(ScreenLogicBinarySensor): pump_index: int, ) -> None: """Initialize of the entity.""" - entity_description.data_root = (DEVICE.PUMP, pump_index) + entity_description = dataclasses.replace( + entity_description, data_root=(DEVICE.PUMP, pump_index) + ) super().__init__(coordinator, entity_description) diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index 1d3f366a498..1e9a90395f4 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -3,7 +3,7 @@ from dataclasses import dataclass import logging from typing import Any -from screenlogicpy.const.common import UNIT +from screenlogicpy.const.common import UNIT, ScreenLogicCommunicationError from screenlogicpy.const.data import ATTR, DEVICE, VALUE from screenlogicpy.const.msg import CODE from screenlogicpy.device_const.heat import HEAT_MODE @@ -68,7 +68,7 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass +@dataclass(frozen=True) class ScreenLogicClimateDescription( ClimateEntityDescription, ScreenLogicPushEntityDescription ): @@ -150,13 +150,16 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: raise ValueError(f"Expected attribute {ATTR_TEMPERATURE}") - if not await self.gateway.async_set_heat_temp( - int(self._data_key), int(temperature) - ): + try: + await self.gateway.async_set_heat_temp( + int(self._data_key), int(temperature) + ) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_temperature {temperature} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set temperature for body %s to %s", self._data_key, temperature) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -166,13 +169,14 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): else: mode = HEAT_MODE.parse(self.preset_mode) - if not await self.gateway.async_set_heat_mode( - int(self._data_key), int(mode.value) - ): + try: + await self.gateway.async_set_heat_mode(int(self._data_key), int(mode.value)) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_hvac_mode {mode.name} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set hvac_mode on body %s to %s", self._data_key, mode.name) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -183,13 +187,14 @@ class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): if self.hvac_mode == HVACMode.OFF: return - if not await self.gateway.async_set_heat_mode( - int(self._data_key), int(mode.value) - ): + try: + await self.gateway.async_set_heat_mode(int(self._data_key), int(mode.value)) + except ScreenLogicCommunicationError as sle: raise HomeAssistantError( f"Failed to set_preset_mode {mode.name} on body" - f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" - ) + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}:" + f" {sle.msg}" + ) from sle _LOGGER.debug("Set preset_mode on body %s to %s", self._data_key, mode.name) async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py index 74f49927171..f16f2b9ff34 100644 --- a/homeassistant/components/screenlogic/coordinator.py +++ b/homeassistant/components/screenlogic/coordinator.py @@ -2,8 +2,13 @@ from datetime import timedelta import logging -from screenlogicpy import ScreenLogicError, ScreenLogicGateway -from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const.common import ( + SL_GATEWAY_IP, + SL_GATEWAY_NAME, + SL_GATEWAY_PORT, + ScreenLogicCommunicationError, +) from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.config_entries import ConfigEntry @@ -91,7 +96,7 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): await self.gateway.async_connect(**connect_info) await self._async_update_configured_data() - except ScreenLogicError as ex: + except ScreenLogicCommunicationError as sle: if self.gateway.is_connected: await self.gateway.async_disconnect() - raise UpdateFailed(ex.msg) from ex + raise UpdateFailed(sle.msg) from sle diff --git a/homeassistant/components/screenlogic/data.py b/homeassistant/components/screenlogic/data.py index 719cebc1ef6..cda1bc83f81 100644 --- a/homeassistant/components/screenlogic/data.py +++ b/homeassistant/components/screenlogic/data.py @@ -8,7 +8,10 @@ ENTITY_MIGRATIONS = { "new_name": "Active Alert", }, "chem_calcium_harness": { - "new_key": VALUE.CALCIUM_HARNESS, + "new_key": VALUE.CALCIUM_HARDNESS, + }, + "calcium_harness": { + "new_key": VALUE.CALCIUM_HARDNESS, }, "chem_current_orp": { "new_key": VALUE.ORP_NOW, diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 3b45aa699d3..fc2c855d682 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -6,7 +6,11 @@ import logging from typing import Any from screenlogicpy import ScreenLogicGateway -from screenlogicpy.const.common import ON_OFF +from screenlogicpy.const.common import ( + ON_OFF, + ScreenLogicCommunicationError, + ScreenLogicError, +) from screenlogicpy.const.data import ATTR from screenlogicpy.const.msg import CODE @@ -24,14 +28,14 @@ from .util import generate_unique_id _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ScreenLogicEntityRequiredKeyMixin: """Mixin for required ScreenLogic entity data_path.""" data_root: ScreenLogicDataPath -@dataclass +@dataclass(frozen=True) class ScreenLogicEntityDescription( EntityDescription, ScreenLogicEntityRequiredKeyMixin ): @@ -40,7 +44,7 @@ class ScreenLogicEntityDescription( enabled_lambda: Callable[..., bool] | None = None -class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): +class ScreenLogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Base class for all ScreenLogic entities.""" entity_description: ScreenLogicEntityDescription @@ -99,14 +103,14 @@ class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): raise HomeAssistantError(f"Data not found: {self._data_path}") from ke -@dataclass +@dataclass(frozen=True) class ScreenLogicPushEntityRequiredKeyMixin: """Mixin for required key for ScreenLogic push entities.""" subscription_code: CODE -@dataclass +@dataclass(frozen=True) class ScreenLogicPushEntityDescription( ScreenLogicEntityDescription, ScreenLogicPushEntityRequiredKeyMixin, @@ -114,7 +118,7 @@ class ScreenLogicPushEntityDescription( """Base class for a ScreenLogic push entity description.""" -class ScreenLogicPushEntity(ScreenlogicEntity): +class ScreenLogicPushEntity(ScreenLogicEntity): """Base class for all ScreenLogic push entities.""" entity_description: ScreenLogicPushEntityDescription @@ -153,8 +157,8 @@ class ScreenLogicPushEntity(ScreenlogicEntity): self._async_data_updated() -class ScreenLogicCircuitEntity(ScreenLogicPushEntity): - """Base class for all ScreenLogic switch and light entities.""" +class ScreenLogicSwitchingEntity(ScreenLogicEntity): + """Base class for all switchable entities.""" @property def is_on(self) -> bool: @@ -163,15 +167,24 @@ class ScreenLogicCircuitEntity(ScreenLogicPushEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Send the ON command.""" - await self._async_set_circuit(ON_OFF.ON) + await self._async_set_state(ON_OFF.ON) async def async_turn_off(self, **kwargs: Any) -> None: """Send the OFF command.""" - await self._async_set_circuit(ON_OFF.OFF) + await self._async_set_state(ON_OFF.OFF) - async def _async_set_circuit(self, state: ON_OFF) -> None: - if not await self.gateway.async_set_circuit(self._data_key, state.value): + async def _async_set_state(self, state: ON_OFF) -> None: + raise NotImplementedError() + + +class ScreenLogicCircuitEntity(ScreenLogicSwitchingEntity, ScreenLogicPushEntity): + """Base class for all ScreenLogic circuit switch and light entities.""" + + async def _async_set_state(self, state: ON_OFF) -> None: + try: + await self.gateway.async_set_circuit(self._data_key, state.value) + except (ScreenLogicCommunicationError, ScreenLogicError) as sle: raise HomeAssistantError( - f"Failed to set_circuit {self._data_key} {state.value}" - ) + f"Failed to set_circuit {self._data_key} {state.value}: {sle.msg}" + ) from sle _LOGGER.debug("Set circuit %s %s", self._data_key, state.value) diff --git a/homeassistant/components/screenlogic/light.py b/homeassistant/components/screenlogic/light.py index 80499f7790a..60cf7d52a48 100644 --- a/homeassistant/components/screenlogic/light.py +++ b/homeassistant/components/screenlogic/light.py @@ -60,7 +60,7 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass +@dataclass(frozen=True) class ScreenLogicLightDescription( LightEntityDescription, ScreenLogicPushEntityDescription ): diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 69bed1af700..434b8921bc2 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.9.4"] + "requirements": ["screenlogicpy==0.10.0"] } diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index a52e894c72b..cc5efa6c7ad 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging +from screenlogicpy.const.common import ScreenLogicCommunicationError, ScreenLogicError from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE from screenlogicpy.device_const.system import EQUIPMENT_FLAG @@ -15,11 +16,12 @@ from homeassistant.components.number import ( 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 DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator -from .entity import ScreenlogicEntity, ScreenLogicEntityDescription +from .entity import ScreenLogicEntity, ScreenLogicEntityDescription from .util import cleanup_excluded_entity, get_ha_unit _LOGGER = logging.getLogger(__name__) @@ -27,15 +29,14 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 -@dataclass +@dataclass(frozen=True) class ScreenLogicNumberRequiredMixin: """Describes a required mixin for a ScreenLogic number entity.""" set_value_name: str - set_value_args: tuple[tuple[str | int, ...], ...] -@dataclass +@dataclass(frozen=True) class ScreenLogicNumberDescription( NumberEntityDescription, ScreenLogicEntityDescription, @@ -47,20 +48,12 @@ class ScreenLogicNumberDescription( SUPPORTED_SCG_NUMBERS = [ ScreenLogicNumberDescription( set_value_name="async_set_scg_config", - set_value_args=( - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), - ), data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.POOL_SETPOINT, entity_category=EntityCategory.CONFIG, ), ScreenLogicNumberDescription( set_value_name="async_set_scg_config", - set_value_args=( - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), - (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), - ), data_root=(DEVICE.SCG, GROUP.CONFIGURATION), key=VALUE.SPA_SETPOINT, entity_category=EntityCategory.CONFIG, @@ -94,7 +87,7 @@ async def async_setup_entry( async_add_entities(entities) -class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): +class ScreenLogicNumber(ScreenLogicEntity, NumberEntity): """Class to represent a ScreenLogic Number entity.""" entity_description: ScreenLogicNumberDescription @@ -113,7 +106,6 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): f"set_value_name '{entity_description.set_value_name}' is not a coroutine" ) self._set_value_func: Callable[..., Awaitable[bool]] = func - self._set_value_args = entity_description.set_value_args self._attr_native_unit_of_measurement = get_ha_unit( self.entity_data.get(ATTR.UNIT) ) @@ -138,21 +130,14 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - # Current API requires certain values to be set at the same time. This - # gathers the existing values and updates the particular value being - # set by this entity. - args = {} - for data_path in self._set_value_args: - data_key = data_path[-1] - args[data_key] = self.coordinator.gateway.get_value(*data_path, strict=True) - # Current API requires int values for the currently supported numbers. value = int(value) - args[self._data_key] = value - - if await self._set_value_func(*args.values()): - _LOGGER.debug("Set '%s' to %s", self._data_key, value) - await self._async_refresh() - else: - _LOGGER.debug("Failed to set '%s' to %s", self._data_key, value) + try: + await self._set_value_func(**{self._data_key: value}) + except (ScreenLogicCommunicationError, ScreenLogicError) as sle: + raise HomeAssistantError( + f"Failed to set '{self._data_key}' to {value}: {sle.msg}" + ) from sle + _LOGGER.debug("Set '%s' to %s", self._data_key, value) + await self._async_refresh() diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index bbcf8458014..c73ce8be42c 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -1,7 +1,7 @@ """Support for a ScreenLogic Sensor.""" from collections.abc import Callable from copy import copy -from dataclasses import dataclass +import dataclasses import logging from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE @@ -25,7 +25,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as SL_DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator from .entity import ( - ScreenlogicEntity, + ScreenLogicEntity, ScreenLogicEntityDescription, ScreenLogicPushEntity, ScreenLogicPushEntityDescription, @@ -35,21 +35,21 @@ from .util import cleanup_excluded_entity, get_ha_unit _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicSensorMixin: """Mixin for SecreenLogic sensor entity.""" value_mod: Callable[[int | str], int | str] | None = None -@dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicSensorDescription( ScreenLogicSensorMixin, SensorEntityDescription, ScreenLogicEntityDescription ): """Describes a ScreenLogic sensor.""" -@dataclass +@dataclasses.dataclass(frozen=True) class ScreenLogicPushSensorDescription( ScreenLogicSensorDescription, ScreenLogicPushEntityDescription ): @@ -139,7 +139,7 @@ SUPPORTED_INTELLICHEM_SENSORS = [ ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, data_root=(DEVICE.INTELLICHEM, GROUP.CONFIGURATION), - key=VALUE.CALCIUM_HARNESS, + key=VALUE.CALCIUM_HARDNESS, ), ScreenLogicPushSensorDescription( subscription_code=CODE.CHEMISTRY_CHANGED, @@ -272,7 +272,9 @@ async def async_setup_entry( cleanup_excluded_entity(coordinator, DOMAIN, chem_sensor_data_path) continue if gateway.get_data(*chem_sensor_data_path): - chem_sensor_description.entity_category = EntityCategory.DIAGNOSTIC + chem_sensor_description = dataclasses.replace( + chem_sensor_description, entity_category=EntityCategory.DIAGNOSTIC + ) entities.append(ScreenLogicPushSensor(coordinator, chem_sensor_description)) scg_sensor_description: ScreenLogicSensorDescription @@ -285,13 +287,15 @@ async def async_setup_entry( cleanup_excluded_entity(coordinator, DOMAIN, scg_sensor_data_path) continue if gateway.get_data(*scg_sensor_data_path): - scg_sensor_description.entity_category = EntityCategory.DIAGNOSTIC + scg_sensor_description = dataclasses.replace( + scg_sensor_description, entity_category=EntityCategory.DIAGNOSTIC + ) entities.append(ScreenLogicSensor(coordinator, scg_sensor_description)) async_add_entities(entities) -class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): +class ScreenLogicSensor(ScreenLogicEntity, SensorEntity): """Representation of a ScreenLogic sensor entity.""" entity_description: ScreenLogicSensorDescription @@ -336,7 +340,9 @@ class ScreenLogicPumpSensor(ScreenLogicSensor): pump_type: int, ) -> None: """Initialize of the entity.""" - entity_description.data_root = (DEVICE.PUMP, pump_index) + entity_description = dataclasses.replace( + entity_description, data_root=(DEVICE.PUMP, pump_index) + ) super().__init__(coordinator, entity_description) if entity_description.enabled_lambda: self._attr_entity_registry_enabled_default = ( diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index 4900ed938a1..43f749db913 100644 --- a/homeassistant/components/screenlogic/switch.py +++ b/homeassistant/components/screenlogic/switch.py @@ -13,18 +13,29 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN as SL_DOMAIN, LIGHT_CIRCUIT_FUNCTIONS from .coordinator import ScreenlogicDataUpdateCoordinator -from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription +from .entity import ( + ScreenLogicCircuitEntity, + ScreenLogicPushEntityDescription, + ScreenLogicSwitchingEntity, +) _LOGGER = logging.getLogger(__name__) +@dataclass(frozen=True) +class ScreenLogicCircuitSwitchDescription( + SwitchEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic switch entity.""" + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - entities: list[ScreenLogicSwitch] = [] + entities: list[ScreenLogicSwitchingEntity] = [] coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] @@ -39,9 +50,9 @@ async def async_setup_entry( circuit_name = circuit_data[ATTR.NAME] circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) entities.append( - ScreenLogicSwitch( + ScreenLogicCircuitSwitch( coordinator, - ScreenLogicSwitchDescription( + ScreenLogicCircuitSwitchDescription( subscription_code=CODE.STATUS_CHANGED, data_root=(DEVICE.CIRCUIT,), key=circuit_index, @@ -56,14 +67,7 @@ async def async_setup_entry( async_add_entities(entities) -@dataclass -class ScreenLogicSwitchDescription( - SwitchEntityDescription, ScreenLogicPushEntityDescription -): - """Describes a ScreenLogic switch entity.""" - - -class ScreenLogicSwitch(ScreenLogicCircuitEntity, SwitchEntity): +class ScreenLogicCircuitSwitch(ScreenLogicCircuitEntity, SwitchEntity): """Class to represent a ScreenLogic Switch.""" - entity_description: ScreenLogicSwitchDescription + entity_description: ScreenLogicCircuitSwitchDescription diff --git a/homeassistant/components/script/config.py b/homeassistant/components/script/config.py index c11bb37294f..1cbab23d843 100644 --- a/homeassistant/components/script/config.py +++ b/homeassistant/components/script/config.py @@ -13,7 +13,7 @@ from homeassistant.components.blueprint import ( is_blueprint_instance_config, ) from homeassistant.components.trace import TRACE_CONFIG_SCHEMA -from homeassistant.config import config_without_domain +from homeassistant.config import config_per_platform, config_without_domain from homeassistant.const import ( CONF_ALIAS, CONF_DEFAULT, @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_per_platform, config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.script import ( SCRIPT_MODE_SINGLE, async_validate_actions_config, diff --git a/homeassistant/components/season/config_flow.py b/homeassistant/components/season/config_flow.py index 39a52e57b10..069037e53a0 100644 --- a/homeassistant/components/season/config_flow.py +++ b/homeassistant/components/season/config_flow.py @@ -8,6 +8,11 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_TYPE from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import DEFAULT_NAME, DOMAIN, TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL @@ -33,11 +38,15 @@ class SeasonConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In( - { - TYPE_ASTRONOMICAL: "Astronomical", - TYPE_METEOROLOGICAL: "Meteorological", - } + vol.Required(CONF_TYPE, default=TYPE_ASTRONOMICAL): SelectSelector( + SelectSelectorConfig( + translation_key="season_type", + mode=SelectSelectorMode.LIST, + options=[ + TYPE_ASTRONOMICAL, + TYPE_METEOROLOGICAL, + ], + ) ) }, ), diff --git a/homeassistant/components/season/strings.json b/homeassistant/components/season/strings.json index 162daddd412..b0313d227a3 100644 --- a/homeassistant/components/season/strings.json +++ b/homeassistant/components/season/strings.json @@ -23,5 +23,13 @@ } } } + }, + "selector": { + "season_type": { + "options": { + "astronomical": "Astronomical", + "meteorological": "Meteorological" + } + } } } diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 4997e088a54..8ec08f4606f 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -1,15 +1,15 @@ """Component to allow selecting an option from a list as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA, @@ -31,6 +31,11 @@ from .const import ( SERVICE_SELECT_PREVIOUS, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -86,7 +91,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service( SERVICE_SELECT_OPTION, {vol.Required(ATTR_OPTION): cv.string}, - async_select_option, + SelectEntity.async_handle_select_option.__name__, ) component.async_register_entity_service( @@ -98,14 +103,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_select_option(entity: SelectEntity, service_call: ServiceCall) -> None: - """Service call wrapper to set a new value.""" - option = service_call.data[ATTR_OPTION] - if option not in entity.options: - raise ValueError(f"Option {option} not valid for {entity.entity_id}") - await entity.async_select_option(option) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" component: EntityComponent[SelectEntity] = hass.data[DOMAIN] @@ -118,14 +115,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class SelectEntityDescription(EntityDescription): +class SelectEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes select entities.""" options: list[str] | None = None -class SelectEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "current_option", + "options", +} + + +class SelectEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Select entity.""" _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) @@ -151,7 +153,7 @@ class SelectEntity(Entity): return None return current_option - @property + @cached_property def options(self) -> list[str]: """Return a set of selectable options.""" if hasattr(self, "_attr_options"): @@ -163,11 +165,35 @@ class SelectEntity(Entity): return self.entity_description.options raise AttributeError() - @property + @cached_property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" return self._attr_current_option + @final + @callback + def _valid_option_or_raise(self, option: str) -> None: + """Raise ServiceValidationError on invalid option.""" + options = self.options + if not options or option not in options: + friendly_options: str = ", ".join(options or []) + raise ServiceValidationError( + f"Option {option} is not valid for {self.entity_id}", + translation_domain=DOMAIN, + translation_key="not_valid_option", + translation_placeholders={ + "entity_id": self.entity_id, + "option": option, + "options": friendly_options, + }, + ) + + @final + async def async_handle_select_option(self, option: str) -> None: + """Service call wrapper to set a new value.""" + self._valid_option_or_raise(option) + await self.async_select_option(option) + def select_option(self, option: str) -> None: """Change the selected option.""" raise NotImplementedError() diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index d058ff6e6f2..9c9d1136b99 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -64,5 +64,10 @@ } } } + }, + "exceptions": { + "not_valid_option": { + "message": "Option {option} is not valid for entity {entity_id}, valid options are: {options}." + } } } diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 08f45b94789..5cd71a2b0e4 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -24,28 +24,28 @@ from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class MotionBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" value_fn: Callable[[MotionSensor], bool | None] -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" value_fn: Callable[[SensiboDevice], bool | None] -@dataclass +@dataclass(frozen=True) class SensiboMotionBinarySensorEntityDescription( BinarySensorEntityDescription, MotionBaseEntityDescriptionMixin ): """Describes Sensibo Motion sensor entity.""" -@dataclass +@dataclass(frozen=True) class SensiboDeviceBinarySensorEntityDescription( BinarySensorEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index b47023f3ec4..942f7eaeb00 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -17,14 +17,14 @@ from .entity import SensiboDeviceBaseEntity, async_handle_api_call PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class SensiboEntityDescriptionMixin: """Mixin values for Sensibo entities.""" data_key: str -@dataclass +@dataclass(frozen=True) class SensiboButtonEntityDescription( ButtonEntityDescription, SensiboEntityDescriptionMixin ): diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 40aa54e5d56..89e1fafa213 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -22,7 +22,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter @@ -314,11 +314,17 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): """Set new target temperature.""" if "targetTemperature" not in self.device_data.active_features: raise HomeAssistantError( - "Current mode doesn't support setting Target Temperature" + "Current mode doesn't support setting Target Temperature", + translation_domain=DOMAIN, + translation_key="no_target_temperature_in_features", ) if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - raise ValueError("No target temperature provided") + raise ServiceValidationError( + "No target temperature provided", + translation_domain=DOMAIN, + translation_key="no_target_temperature", + ) if temperature == self.target_temperature: return @@ -334,10 +340,17 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" if "fanLevel" not in self.device_data.active_features: - raise HomeAssistantError("Current mode doesn't support setting Fanlevel") + raise HomeAssistantError( + "Current mode doesn't support setting Fanlevel", + translation_domain=DOMAIN, + translation_key="no_fan_level_in_features", + ) if fan_mode not in AVAILABLE_FAN_MODES: raise HomeAssistantError( - f"Climate fan mode {fan_mode} is not supported by the integration, please open an issue" + f"Climate fan mode {fan_mode} is not supported by the integration, please open an issue", + translation_domain=DOMAIN, + translation_key="fan_mode_not_supported", + translation_placeholders={"fan_mode": fan_mode}, ) transformation = self.device_data.fan_modes_translated @@ -379,10 +392,17 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" if "swing" not in self.device_data.active_features: - raise HomeAssistantError("Current mode doesn't support setting Swing") + raise HomeAssistantError( + "Current mode doesn't support setting Swing", + translation_domain=DOMAIN, + translation_key="no_swing_in_features", + ) if swing_mode not in AVAILABLE_SWING_MODES: raise HomeAssistantError( - f"Climate swing mode {swing_mode} is not supported by the integration, please open an issue" + f"Climate swing mode {swing_mode} is not supported by the integration, please open an issue", + translation_domain=DOMAIN, + translation_key="swing_not_supported", + translation_placeholders={"swing_mode": swing_mode}, ) transformation = self.device_data.swing_modes_translated diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index 9f20c051576..5a755a7730c 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -19,26 +19,40 @@ _P = ParamSpec("_P") def async_handle_api_call( - function: Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]] + function: Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, Any]]: """Decorate api calls.""" - async def wrap_api_call(*args: Any, **kwargs: Any) -> None: + async def wrap_api_call(entity: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: """Wrap services for api calls.""" res: bool = False + if TYPE_CHECKING: + assert isinstance(entity.name, str) try: async with asyncio.timeout(TIMEOUT): - res = await function(*args, **kwargs) + res = await function(entity, *args, **kwargs) except SENSIBO_ERRORS as err: - raise HomeAssistantError from err + raise HomeAssistantError( + str(err), + translation_domain=DOMAIN, + translation_key="service_raised", + translation_placeholders={"error": str(err), "name": entity.name}, + ) from err - LOGGER.debug("Result %s for entity %s with arguments %s", res, args[0], kwargs) - entity: SensiboDeviceBaseEntity = args[0] + LOGGER.debug("Result %s for entity %s with arguments %s", res, entity, kwargs) if res is not True: - raise HomeAssistantError(f"Could not execute service for {entity.name}") - if kwargs.get("key") is not None and kwargs.get("value") is not None: - setattr(entity.device_data, kwargs["key"], kwargs["value"]) - LOGGER.debug("Debug check key %s is now %s", kwargs["key"], kwargs["value"]) + raise HomeAssistantError( + f"Could not execute service for {entity.name}", + translation_domain=DOMAIN, + translation_key="service_result_not_true", + translation_placeholders={"name": entity.name}, + ) + if ( + isinstance(key := kwargs.get("key"), str) + and (value := kwargs.get("value")) is not None + ): + setattr(entity.device_data, key, value) + LOGGER.debug("Debug check key %s is now %s", key, value) entity.async_write_ha_state() await entity.coordinator.async_request_refresh() diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index d4e268ea44d..ac76277fb20 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -24,7 +24,7 @@ from .entity import SensiboDeviceBaseEntity, async_handle_api_call PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class SensiboEntityDescriptionMixin: """Mixin values for Sensibo entities.""" @@ -32,7 +32,7 @@ class SensiboEntityDescriptionMixin: value_fn: Callable[[SensiboDevice], float | None] -@dataclass +@dataclass(frozen=True) class SensiboNumberEntityDescription( NumberEntityDescription, SensiboEntityDescriptionMixin ): diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index cda8a972ede..bbac3fbdbd0 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -20,7 +20,7 @@ from .entity import SensiboDeviceBaseEntity, async_handle_api_call PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class SensiboSelectDescriptionMixin: """Mixin values for Sensibo entities.""" @@ -30,7 +30,7 @@ class SensiboSelectDescriptionMixin: transformation: Callable[[SensiboDevice], dict | None] -@dataclass +@dataclass(frozen=True) class SensiboSelectEntityDescription( SelectEntityDescription, SensiboSelectDescriptionMixin ): @@ -106,9 +106,16 @@ class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Set state to the selected option.""" if self.entity_description.key not in self.device_data.active_features: + hvac_mode = self.device_data.hvac_mode if self.device_data.hvac_mode else "" raise HomeAssistantError( f"Current mode {self.device_data.hvac_mode} doesn't support setting" - f" {self.entity_description.name}" + f" {self.entity_description.name}", + translation_domain=DOMAIN, + translation_key="select_option_not_available", + translation_placeholders={ + "hvac_mode": hvac_mode, + "key": self.entity_description.key, + }, ) await self.async_send_api_call( diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index f6d62d79dff..805b888204b 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -36,14 +36,14 @@ from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class MotionBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" value_fn: Callable[[MotionSensor], StateType] -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" @@ -51,14 +51,14 @@ class DeviceBaseEntityDescriptionMixin: extra_fn: Callable[[SensiboDevice], dict[str, str | bool | None] | None] | None -@dataclass +@dataclass(frozen=True) class SensiboMotionSensorEntityDescription( SensorEntityDescription, MotionBaseEntityDescriptionMixin ): """Describes Sensibo Motion sensor entity.""" -@dataclass +@dataclass(frozen=True) class SensiboDeviceSensorEntityDescription( SensorEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 6081c668d89..a5f71e53c17 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -478,5 +478,37 @@ } } } + }, + "exceptions": { + "no_target_temperature_in_features": { + "message": "Current mode doesn't support setting target temperature" + }, + "no_target_temperature": { + "message": "No target temperature provided" + }, + "no_fan_level_in_features": { + "message": "Current mode doesn't support setting fan level" + }, + "fan_mode_not_supported": { + "message": "Climate fan mode {fan_mode} is not supported by the integration, please open an issue" + }, + "no_swing_in_features": { + "message": "Current mode doesn't support setting swing" + }, + "swing_not_supported": { + "message": "Climate swing mode {swing_mode} is not supported by the integration, please open an issue" + }, + "service_result_not_true": { + "message": "Could not execute service for {name}" + }, + "service_raised": { + "message": "Could not execute service for {name} with error {error}" + }, + "select_option_not_available": { + "message": "Current mode {hvac_mode} doesn't support setting {key}" + }, + "climate_react_not_available": { + "message": "Use Sensibo Enable Climate React Service once to enable switch or the Sensibo app" + } } } diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 204ed622f13..0911985ed7d 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -24,7 +24,7 @@ from .entity import SensiboDeviceBaseEntity, async_handle_api_call PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo Device description keys.""" @@ -35,7 +35,7 @@ class DeviceBaseEntityDescriptionMixin: data_key: str -@dataclass +@dataclass(frozen=True) class SensiboDeviceSwitchEntityDescription( SwitchEntityDescription, DeviceBaseEntityDescriptionMixin ): @@ -184,7 +184,9 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): if self.device_data.smart_type is None: raise HomeAssistantError( "Use Sensibo Enable Climate React Service once to enable switch or the" - " Sensibo app" + " Sensibo app", + translation_domain=DOMAIN, + translation_key="climate_react_not_available", ) data: dict[str, Any] = {"enabled": value} result = await self._client.async_enable_climate_react(self._device_id, data) diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 62e8bbff3ae..c51d57dd9d1 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -23,7 +23,7 @@ from .entity import SensiboDeviceBaseEntity PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" @@ -31,7 +31,7 @@ class DeviceBaseEntityDescriptionMixin: value_available: Callable[[SensiboDevice], str | None] -@dataclass +@dataclass(frozen=True) class SensiboDeviceUpdateEntityDescription( UpdateEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 0fa270bb03d..d7c5cddc5db 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -7,9 +7,10 @@ from contextlib import suppress from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta from decimal import Decimal, InvalidOperation as DecimalInvalidOperation +from functools import partial import logging from math import ceil, floor, isfinite, log10 -from typing import Any, Final, Self, cast, final +from typing import TYPE_CHECKING, Any, Final, Self, cast, final from typing_extensions import override @@ -17,36 +18,36 @@ from homeassistant.config_entries import ConfigEntry # pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 + _DEPRECATED_DEVICE_CLASS_AQI, + _DEPRECATED_DEVICE_CLASS_BATTERY, + _DEPRECATED_DEVICE_CLASS_CO, + _DEPRECATED_DEVICE_CLASS_CO2, + _DEPRECATED_DEVICE_CLASS_CURRENT, + _DEPRECATED_DEVICE_CLASS_DATE, + _DEPRECATED_DEVICE_CLASS_ENERGY, + _DEPRECATED_DEVICE_CLASS_FREQUENCY, + _DEPRECATED_DEVICE_CLASS_GAS, + _DEPRECATED_DEVICE_CLASS_HUMIDITY, + _DEPRECATED_DEVICE_CLASS_ILLUMINANCE, + _DEPRECATED_DEVICE_CLASS_MONETARY, + _DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE, + _DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE, + _DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE, + _DEPRECATED_DEVICE_CLASS_OZONE, + _DEPRECATED_DEVICE_CLASS_PM1, + _DEPRECATED_DEVICE_CLASS_PM10, + _DEPRECATED_DEVICE_CLASS_PM25, + _DEPRECATED_DEVICE_CLASS_POWER, + _DEPRECATED_DEVICE_CLASS_POWER_FACTOR, + _DEPRECATED_DEVICE_CLASS_PRESSURE, + _DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH, + _DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE, + _DEPRECATED_DEVICE_CLASS_TEMPERATURE, + _DEPRECATED_DEVICE_CLASS_TIMESTAMP, + _DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + _DEPRECATED_DEVICE_CLASS_VOLTAGE, ATTR_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT, - DEVICE_CLASS_AQI, - DEVICE_CLASS_BATTERY, - DEVICE_CLASS_CO, - DEVICE_CLASS_CO2, - DEVICE_CLASS_CURRENT, - DEVICE_CLASS_DATE, - DEVICE_CLASS_ENERGY, - DEVICE_CLASS_FREQUENCY, - DEVICE_CLASS_GAS, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_MONETARY, - DEVICE_CLASS_NITROGEN_DIOXIDE, - DEVICE_CLASS_NITROGEN_MONOXIDE, - DEVICE_CLASS_NITROUS_OXIDE, - DEVICE_CLASS_OZONE, - DEVICE_CLASS_PM1, - DEVICE_CLASS_PM10, - DEVICE_CLASS_PM25, - DEVICE_CLASS_POWER, - DEVICE_CLASS_POWER_FACTOR, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_SIGNAL_STRENGTH, - DEVICE_CLASS_SULPHUR_DIOXIDE, - DEVICE_CLASS_TEMPERATURE, - DEVICE_CLASS_TIMESTAMP, - DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, - DEVICE_CLASS_VOLTAGE, EntityCategory, UnitOfTemperature, ) @@ -57,6 +58,10 @@ from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform @@ -66,6 +71,9 @@ from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum from .const import ( # noqa: F401 + _DEPRECATED_STATE_CLASS_MEASUREMENT, + _DEPRECATED_STATE_CLASS_TOTAL, + _DEPRECATED_STATE_CLASS_TOTAL_INCREASING, ATTR_LAST_RESET, ATTR_OPTIONS, ATTR_STATE_CLASS, @@ -76,9 +84,6 @@ from .const import ( # noqa: F401 DEVICE_CLASSES_SCHEMA, DOMAIN, NON_NUMERIC_DEVICE_CLASSES, - STATE_CLASS_MEASUREMENT, - STATE_CLASS_TOTAL, - STATE_CLASS_TOTAL_INCREASING, STATE_CLASSES, STATE_CLASSES_SCHEMA, UNIT_CONVERTERS, @@ -87,6 +92,11 @@ from .const import ( # noqa: F401 ) from .websocket_api import async_setup as async_setup_ws_api +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER: Final = logging.getLogger(__name__) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" @@ -110,6 +120,12 @@ __all__ = [ "SensorStateClass", ] +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + # mypy: disallow-any-generics @@ -136,8 +152,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class SensorEntityDescription(EntityDescription): +class SensorEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes sensor entities.""" device_class: SensorDeviceClass | None = None @@ -172,7 +187,21 @@ def _numeric_state_expected( return device_class is not None -class SensorEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", + "last_reset", + "native_unit_of_measurement", + "native_value", + "options", + "state_class", + "suggested_display_precision", + "suggested_unit_of_measurement", +} + +TEMPERATURE_UNITS = {UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT} + + +class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for sensor entities.""" _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) @@ -292,7 +321,7 @@ class SensorEntity(Entity): """ return self.device_class not in (None, SensorDeviceClass.ENUM) - @property + @cached_property @override def device_class(self) -> SensorDeviceClass | None: """Return the class of this entity.""" @@ -313,7 +342,7 @@ class SensorEntity(Entity): self.suggested_display_precision, ) - @property + @cached_property def options(self) -> list[str] | None: """Return a set of possible options.""" if hasattr(self, "_attr_options"): @@ -322,7 +351,7 @@ class SensorEntity(Entity): return self.entity_description.options return None - @property + @cached_property def state_class(self) -> SensorStateClass | str | None: """Return the state class of this entity, if any.""" if hasattr(self, "_attr_state_class"): @@ -331,7 +360,7 @@ class SensorEntity(Entity): return self.entity_description.state_class return None - @property + @cached_property def last_reset(self) -> datetime | None: """Return the time when the sensor was last reset, if any.""" if hasattr(self, "_attr_last_reset"): @@ -414,12 +443,12 @@ class SensorEntity(Entity): return None - @property + @cached_property def native_value(self) -> StateType | date | datetime | Decimal: """Return the value reported by the sensor.""" return self._attr_native_value - @property + @cached_property def suggested_display_precision(self) -> int | None: """Return the suggested number of decimal digits for display.""" if hasattr(self, "_attr_suggested_display_precision"): @@ -428,7 +457,7 @@ class SensorEntity(Entity): return self.entity_description.suggested_display_precision return None - @property + @cached_property def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of the sensor, if any.""" if hasattr(self, "_attr_native_unit_of_measurement"): @@ -437,7 +466,7 @@ class SensorEntity(Entity): return self.entity_description.native_unit_of_measurement return None - @property + @cached_property def suggested_unit_of_measurement(self) -> str | None: """Return the unit which should be used for the sensor's state. @@ -482,9 +511,8 @@ class SensorEntity(Entity): native_unit_of_measurement = self.native_unit_of_measurement if ( - native_unit_of_measurement - in {UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT} - and self.device_class == SensorDeviceClass.TEMPERATURE + native_unit_of_measurement in TEMPERATURE_UNITS + and self.device_class is SensorDeviceClass.TEMPERATURE ): return self.hass.config.units.temperature_unit @@ -545,7 +573,7 @@ class SensorEntity(Entity): return None # Received a datetime - if device_class == SensorDeviceClass.TIMESTAMP: + if device_class is SensorDeviceClass.TIMESTAMP: try: # We cast the value, to avoid using isinstance, but satisfy # typechecking. The errors are guarded in this try. @@ -567,7 +595,7 @@ class SensorEntity(Entity): ) from err # Received a date value - if device_class == SensorDeviceClass.DATE: + if device_class is SensorDeviceClass.DATE: try: # We cast the value, to avoid using isinstance, but satisfy # typechecking. The errors are guarded in this try. @@ -582,8 +610,8 @@ class SensorEntity(Entity): # Enum checks if ( options := self.options - ) is not None or device_class == SensorDeviceClass.ENUM: - if device_class != SensorDeviceClass.ENUM: + ) is not None or device_class is SensorDeviceClass.ENUM: + if device_class is not SensorDeviceClass.ENUM: reason = "is missing the enum device class" if device_class is not None: reason = f"has device class '{device_class}' instead of 'enum'" @@ -706,17 +734,6 @@ class SensorEntity(Entity): return value - def __repr__(self) -> str: - """Return the representation. - - Entity.__repr__ includes the state in the generated string, this fails if we're - called before self.hass is set. - """ - if not self.hass: - return f"" - - return super().__repr__() - def _suggested_precision_or_none(self) -> int | None: """Return suggested display precision, or None if not set.""" assert self.registry_entry diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index e8b1742f315..d57a09981ef 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -2,6 +2,7 @@ from __future__ import annotations from enum import StrEnum +from functools import partial from typing import Final import voluptuous as vol @@ -35,6 +36,11 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumetricFlux, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.util.unit_conversion import ( BaseUnitConverter, DataRateConverter, @@ -451,11 +457,21 @@ STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorStateClass)) # STATE_CLASS* is deprecated as of 2021.12 # use the SensorStateClass enum instead. -STATE_CLASS_MEASUREMENT: Final = "measurement" -STATE_CLASS_TOTAL: Final = "total" -STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" +_DEPRECATED_STATE_CLASS_MEASUREMENT: Final = DeprecatedConstantEnum( + SensorStateClass.MEASUREMENT, "2025.1" +) +_DEPRECATED_STATE_CLASS_TOTAL: Final = DeprecatedConstantEnum( + SensorStateClass.TOTAL, "2025.1" +) +_DEPRECATED_STATE_CLASS_TOTAL_INCREASING: Final = DeprecatedConstantEnum( + SensorStateClass.TOTAL_INCREASING, "2025.1" +) STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter, diff --git a/homeassistant/components/sensor/significant_change.py b/homeassistant/components/sensor/significant_change.py index 1a4dc65f010..f426674c32d 100644 --- a/homeassistant/components/sensor/significant_change.py +++ b/homeassistant/components/sensor/significant_change.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.significant_change import ( check_absolute_change, check_percentage_change, + check_valid_float, ) from . import SensorDeviceClass @@ -63,23 +64,20 @@ def async_check_significant_change( absolute_change = 1.0 percentage_change = 2.0 - try: + if not check_valid_float(new_state): # New state is invalid, don't report it - new_state_f = float(new_state) - except ValueError: return False - try: + if not check_valid_float(old_state): # Old state was invalid, we should report again - old_state_f = float(old_state) - except ValueError: return True if absolute_change is not None and percentage_change is not None: return _absolute_and_relative_change( - old_state_f, new_state_f, absolute_change, percentage_change + float(old_state), float(new_state), absolute_change, percentage_change ) if absolute_change is not None: - return check_absolute_change(old_state_f, new_state_f, absolute_change) - + return check_absolute_change( + float(old_state), float(new_state), absolute_change + ) return None diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py index 79533576efb..9bf053a3897 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -26,14 +26,14 @@ from .models import DomainData _T = TypeVar("_T") -@dataclass +@dataclass(frozen=True) class SFRBoxBinarySensorMixin(Generic[_T]): """Mixin for SFR Box sensors.""" value_fn: Callable[[_T], bool | None] -@dataclass +@dataclass(frozen=True) class SFRBoxBinarySensorEntityDescription( BinarySensorEntityDescription, SFRBoxBinarySensorMixin[_T] ): diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py index c9418bcc2e9..56c5335e908 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -30,7 +30,7 @@ _P = ParamSpec("_P") def with_error_wrapping( - func: Callable[Concatenate[SFRBoxButton, _P], Awaitable[_T]] + func: Callable[Concatenate[SFRBoxButton, _P], Awaitable[_T]], ) -> Callable[Concatenate[SFRBoxButton, _P], Coroutine[Any, Any, _T]]: """Catch SFR errors.""" @@ -49,14 +49,14 @@ def with_error_wrapping( return wrapper -@dataclass +@dataclass(frozen=True) class SFRBoxButtonMixin: """Mixin for SFR Box buttons.""" async_press: Callable[[SFRBox], Coroutine[None, None, None]] -@dataclass +@dataclass(frozen=True) class SFRBoxButtonEntityDescription(ButtonEntityDescription, SFRBoxButtonMixin): """Description for SFR Box buttons.""" diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index f56a9765618..6f77ca8d285 100644 --- a/homeassistant/components/sfr_box/sensor.py +++ b/homeassistant/components/sfr_box/sensor.py @@ -32,14 +32,14 @@ from .models import DomainData _T = TypeVar("_T") -@dataclass +@dataclass(frozen=True) class SFRBoxSensorMixin(Generic[_T]): """Mixin for SFR Box sensors.""" value_fn: Callable[[_T], StateType] -@dataclass +@dataclass(frozen=True) class SFRBoxSensorEntityDescription(SensorEntityDescription, SFRBoxSensorMixin[_T]): """Description for SFR Box sensors.""" diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index b29fdcc6d19..6b7a00db8e2 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -6,6 +6,7 @@ from typing import Any, Final from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.common import ConnectionOptions +from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, @@ -49,7 +50,6 @@ from .utils import ( get_block_device_sleep_period, get_coap_context, get_device_entry_gen, - get_rpc_device_sleep_period, get_rpc_device_wakeup_period, get_ws_context, ) @@ -63,6 +63,7 @@ BLOCK_PLATFORMS: Final = [ Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, + Platform.VALVE, ] BLOCK_SLEEPING_PLATFORMS: Final = [ Platform.BINARY_SENSOR, @@ -124,7 +125,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: get_entry_data(hass)[entry.entry_id] = ShellyEntryData() - if get_device_entry_gen(entry) == 2: + if get_device_entry_gen(entry) in RPC_GENERATIONS: return await _async_setup_rpc_entry(hass, entry) return await _async_setup_block_entry(hass, entry) @@ -266,9 +267,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ConfigEntry) -> boo if sleep_period is None: data = {**entry.data} - data[CONF_SLEEP_PERIOD] = get_rpc_device_sleep_period( - device.config - ) or get_rpc_device_wakeup_period(device.status) + data[CONF_SLEEP_PERIOD] = get_rpc_device_wakeup_period(device.status) hass.config_entries.async_update_entry(entry, data=data) hass.async_create_task(_async_rpc_device_setup()) @@ -316,7 +315,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not entry.data.get(CONF_SLEEP_PERIOD): platforms = RPC_PLATFORMS - if get_device_entry_gen(entry) == 2: + if get_device_entry_gen(entry) in RPC_GENERATIONS: if unload_ok := await hass.config_entries.async_unload_platforms( entry, platforms ): diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index a5889cd11a7..b07747f298e 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -4,6 +4,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import Final, cast +from aioshelly.const import RPC_GENERATIONS + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -37,19 +39,19 @@ from .utils import ( ) -@dataclass +@dataclass(frozen=True) class BlockBinarySensorDescription( BlockEntityDescription, BinarySensorEntityDescription ): """Class to describe a BLOCK binary sensor.""" -@dataclass +@dataclass(frozen=True) class RpcBinarySensorDescription(RpcEntityDescription, BinarySensorEntityDescription): """Class to describe a RPC binary sensor.""" -@dataclass +@dataclass(frozen=True) class RestBinarySensorDescription(RestEntityDescription, BinarySensorEntityDescription): """Class to describe a REST binary sensor.""" @@ -224,7 +226,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: if config_entry.data[CONF_SLEEP_PERIOD]: async_setup_entry_rpc( hass, diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py index 429fae1a9a1..2f9019ba5e6 100644 --- a/homeassistant/components/shelly/bluetooth/__init__.py +++ b/homeassistant/components/shelly/bluetooth/__init__.py @@ -14,7 +14,6 @@ from aioshelly.ble.const import ( from homeassistant.components.bluetooth import ( HaBluetoothConnector, - async_get_advertisement_callback, async_register_scanner, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback @@ -36,18 +35,15 @@ async def async_connect_scanner( device = coordinator.device entry = coordinator.entry source = format_mac(coordinator.mac).upper() - new_info_callback = async_get_advertisement_callback(hass) connector = HaBluetoothConnector( # no active connections to shelly yet client=None, # type: ignore[arg-type] source=source, can_connect=lambda: False, ) - scanner = ShellyBLEScanner( - hass, source, entry.title, new_info_callback, connector, False - ) + scanner = ShellyBLEScanner(source, entry.title, connector, False) unload_callbacks = [ - async_register_scanner(hass, scanner, False), + async_register_scanner(hass, scanner), scanner.async_setup(), coordinator.async_subscribe_events(scanner.async_on_event), ] diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index edc33c9a8a0..17f60f566aa 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -5,6 +5,8 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar +from aioshelly.const import RPC_GENERATIONS + from homeassistant.components.button import ( ButtonDeviceClass, ButtonEntity, @@ -28,14 +30,14 @@ _ShellyCoordinatorT = TypeVar( ) -@dataclass +@dataclass(frozen=True) class ShellyButtonDescriptionMixin(Generic[_ShellyCoordinatorT]): """Mixin to describe a Button entity.""" press_action: Callable[[_ShellyCoordinatorT], Coroutine[Any, Any, None]] -@dataclass +@dataclass(frozen=True) class ShellyButtonDescription( ButtonEntityDescription, ShellyButtonDescriptionMixin[_ShellyCoordinatorT] ): @@ -126,7 +128,7 @@ async def async_setup_entry( return async_migrate_unique_ids(entity_entry, coordinator) coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: coordinator = get_entry_data(hass)[config_entry.entry_id].rpc else: coordinator = get_entry_data(hass)[config_entry.entry_id].block diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 396fef5ac2e..7cc0027bbaf 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -6,6 +6,7 @@ from dataclasses import asdict, dataclass from typing import Any, cast from aioshelly.block_device import Block +from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError from homeassistant.components.climate import ( @@ -51,7 +52,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up climate device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: return async_setup_rpc_entry(hass, config_entry, async_add_entities) coordinator = get_entry_data(hass)[config_entry.entry_id].block diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 6cde265bc25..68b0f1f8ccc 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -6,13 +6,13 @@ from typing import Any, Final from aioshelly.block_device import BlockDevice from aioshelly.common import ConnectionOptions, get_info +from aioshelly.const import BLOCK_GENERATIONS, RPC_GENERATIONS from aioshelly.exceptions import ( DeviceConnectionError, FirmwareUnsupported, InvalidAuthError, ) from aioshelly.rpc_device import RpcDevice -from awesomeversion import AwesomeVersion import voluptuous as vol from homeassistant.components.zeroconf import ZeroconfServiceInfo @@ -24,7 +24,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig from .const import ( - BLE_MIN_VERSION, CONF_BLE_SCANNER_MODE, CONF_SLEEP_PERIOD, DOMAIN, @@ -32,14 +31,13 @@ from .const import ( MODEL_WALL_DISPLAY, BLEScannerMode, ) -from .coordinator import async_reconnect_soon, get_entry_data +from .coordinator import async_reconnect_soon from .utils import ( get_block_device_sleep_period, get_coap_context, get_info_auth, get_info_gen, get_model_name, - get_rpc_device_sleep_period, get_rpc_device_wakeup_period, get_ws_context, mac_address_from_name, @@ -69,7 +67,9 @@ async def validate_input( """ options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) - if get_info_gen(info) == 2: + gen = get_info_gen(info) + + if gen in RPC_GENERATIONS: ws_context = await get_ws_context(hass) rpc_device = await RpcDevice.create( async_get_clientsession(hass), @@ -78,15 +78,13 @@ async def validate_input( ) await rpc_device.shutdown() - sleep_period = get_rpc_device_sleep_period( - rpc_device.config - ) or get_rpc_device_wakeup_period(rpc_device.status) + sleep_period = get_rpc_device_wakeup_period(rpc_device.status) return { "title": rpc_device.name, CONF_SLEEP_PERIOD: sleep_period, "model": rpc_device.shelly.get("model"), - "gen": 2, + "gen": gen, } # Gen1 @@ -101,7 +99,7 @@ async def validate_input( "title": block_device.name, CONF_SLEEP_PERIOD: get_block_device_sleep_period(block_device.settings), "model": block_device.model, - "gen": 1, + "gen": gen, } @@ -170,7 +168,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the credentials step.""" errors: dict[str, str] = {} if user_input is not None: - if get_info_gen(self.info) == 2: + if get_info_gen(self.info) in RPC_GENERATIONS: user_input[CONF_USERNAME] = "admin" try: device_info = await validate_input( @@ -199,7 +197,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): else: user_input = {} - if get_info_gen(self.info) == 2: + if get_info_gen(self.info) in RPC_GENERATIONS: schema = { vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD)): str, } @@ -336,7 +334,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): await self.hass.config_entries.async_reload(self.entry.entry_id) return self.async_abort(reason="reauth_successful") - if self.entry.data.get("gen", 1) == 1: + if self.entry.data.get("gen", 1) in BLOCK_GENERATIONS: schema = { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, @@ -365,7 +363,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN): def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: """Return options flow support for this handler.""" return ( - config_entry.data.get("gen") == 2 + config_entry.data.get("gen") in RPC_GENERATIONS and not config_entry.data.get(CONF_SLEEP_PERIOD) and config_entry.data.get("model") != MODEL_WALL_DISPLAY ) @@ -383,15 +381,6 @@ class OptionsFlowHandler(OptionsFlow): ) -> FlowResult: """Handle options flow.""" if user_input is not None: - entry_data = get_entry_data(self.hass)[self.config_entry.entry_id] - if user_input[CONF_BLE_SCANNER_MODE] != BLEScannerMode.DISABLED and ( - not entry_data.rpc - or AwesomeVersion(entry_data.rpc.device.version) < BLE_MIN_VERSION - ): - return self.async_abort( - reason="ble_unsupported", - description_placeholders={"ble_min_version": BLE_MIN_VERSION}, - ) return self.async_create_entry(title="", data=user_input) return self.async_show_form( diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index a90aba8db62..ca1c450c9fa 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -22,7 +22,6 @@ from aioshelly.const import ( MODEL_VINTAGE_V2, MODEL_WALL_DISPLAY, ) -from awesomeversion import AwesomeVersion DOMAIN: Final = "shelly" @@ -33,9 +32,6 @@ CONF_COAP_PORT: Final = "coap_port" DEFAULT_COAP_PORT: Final = 5683 FIRMWARE_PATTERN: Final = re.compile(r"^(\d{8})") -# Firmware 1.11.0 release date, this firmware supports light transition -LIGHT_TRANSITION_MIN_FIRMWARE_DATE: Final = 20210226 - # max light transition time in milliseconds MAX_TRANSITION_TIME: Final = 5000 @@ -187,8 +183,6 @@ ENTRY_RELOAD_COOLDOWN = 60 SHELLY_GAS_MODELS = [MODEL_GAS] -BLE_MIN_VERSION = AwesomeVersion("0.12.0-beta2") - CONF_BLE_SCANNER_MODE = "ble_scanner_mode" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index d1f9d6943bf..a7659ecc392 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -13,7 +13,6 @@ from aioshelly.block_device import BlockDevice, BlockUpdateType from aioshelly.const import MODEL_VALVE from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from aioshelly.rpc_device import RpcDevice, RpcUpdateType -from awesomeversion import AwesomeVersion from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST, EVENT_HOMEASSISTANT_STOP @@ -33,7 +32,6 @@ from .const import ( ATTR_DEVICE, ATTR_GENERATION, BATTERY_DEVICES_WITH_PERMANENT_CONNECTION, - BLE_MIN_VERSION, CONF_BLE_SCANNER_MODE, CONF_SLEEP_PERIOD, DATA_CONFIG_ENTRY, @@ -587,14 +585,6 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected: await async_stop_scanner(self.device) return - if AwesomeVersion(self.device.version) < BLE_MIN_VERSION: - LOGGER.error( - "BLE not supported on device %s with firmware %s; upgrade to %s", - self.name, - self.device.version, - BLE_MIN_VERSION, - ) - return if await async_ensure_ble_enabled(self.device): # BLE enable required a reboot, don't bother connecting # the scanner since it will be disconnected anyway diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index 95f387f8f97..4390790c794 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any, cast from aioshelly.block_device import Block +from aioshelly.const import RPC_GENERATIONS from homeassistant.components.cover import ( ATTR_POSITION, @@ -26,7 +27,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up covers for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: return async_setup_rpc_entry(hass, config_entry, async_add_entities) return async_setup_block_entry(hass, config_entry, async_add_entities) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 368a997c62e..796402c8bba 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -266,7 +266,7 @@ def async_setup_entry_rest( ) -@dataclass +@dataclass(frozen=True) class BlockEntityDescription(EntityDescription): """Class to describe a BLOCK entity.""" @@ -283,14 +283,14 @@ class BlockEntityDescription(EntityDescription): extra_state_attributes: Callable[[Block], dict | None] | None = None -@dataclass +@dataclass(frozen=True) class RpcEntityRequiredKeysMixin: """Class for RPC entity required keys.""" sub_key: str -@dataclass +@dataclass(frozen=True) class RpcEntityDescription(EntityDescription, RpcEntityRequiredKeysMixin): """Class to describe a RPC entity.""" @@ -306,7 +306,7 @@ class RpcEntityDescription(EntityDescription, RpcEntityRequiredKeysMixin): supported: Callable = lambda _: False -@dataclass +@dataclass(frozen=True) class RestEntityDescription(EntityDescription): """Class to describe a REST entity.""" diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index af323c82a24..5425f71366f 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final from aioshelly.block_device import Block -from aioshelly.const import MODEL_I3 +from aioshelly.const import MODEL_I3, RPC_GENERATIONS from homeassistant.components.event import ( DOMAIN as EVENT_DOMAIN, @@ -37,14 +37,14 @@ from .utils import ( ) -@dataclass +@dataclass(frozen=True) class ShellyBlockEventDescription(EventEntityDescription): """Class to describe Shelly event.""" removal_condition: Callable[[dict, Block], bool] | None = None -@dataclass +@dataclass(frozen=True) class ShellyRpcEventDescription(EventEntityDescription): """Class to describe Shelly event.""" @@ -80,7 +80,7 @@ async def async_setup_entry( coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator | None = None - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: coordinator = get_entry_data(hass)[config_entry.entry_id].rpc if TYPE_CHECKING: assert coordinator diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 829a60b3a9e..7e49dc78e4d 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import MODEL_BULB +from aioshelly.const import MODEL_BULB, RPC_GENERATIONS from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -24,11 +24,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DUAL_MODE_LIGHT_MODELS, - FIRMWARE_PATTERN, KELVIN_MAX_VALUE, KELVIN_MIN_VALUE_COLOR, KELVIN_MIN_VALUE_WHITE, - LIGHT_TRANSITION_MIN_FIRMWARE_DATE, LOGGER, MAX_TRANSITION_TIME, MODELS_SUPPORTING_LIGHT_TRANSITION, @@ -55,7 +53,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up lights for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: return async_setup_rpc_entry(hass, config_entry, async_add_entities) return async_setup_block_entry(hass, config_entry, async_add_entities) @@ -155,12 +153,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): self._attr_supported_features |= LightEntityFeature.EFFECT if coordinator.model in MODELS_SUPPORTING_LIGHT_TRANSITION: - match = FIRMWARE_PATTERN.search(coordinator.device.settings.get("fw", "")) - if ( - match is not None - and int(match[0]) >= LIGHT_TRANSITION_MIN_FIRMWARE_DATE - ): - self._attr_supported_features |= LightEntityFeature.TRANSITION + self._attr_supported_features |= LightEntityFeature.TRANSITION @property def is_on(self) -> bool: diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index b8185712d31..b56ce07bc30 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==6.1.0"], + "requirements": ["aioshelly==7.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index f2b6bedb443..77d066a6106 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -30,7 +30,7 @@ from .entity import ( ) -@dataclass +@dataclass(frozen=True) class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription): """Class to describe a BLOCK sensor.""" diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 99ccd9ab2ff..89dc10f0530 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Final, cast from aioshelly.block_device import Block +from aioshelly.const import RPC_GENERATIONS from homeassistant.components.sensor import ( RestoreSensor, @@ -54,17 +55,17 @@ from .entity import ( from .utils import get_device_entry_gen, get_device_uptime -@dataclass +@dataclass(frozen=True) class BlockSensorDescription(BlockEntityDescription, SensorEntityDescription): """Class to describe a BLOCK sensor.""" -@dataclass +@dataclass(frozen=True) class RpcSensorDescription(RpcEntityDescription, SensorEntityDescription): """Class to describe a RPC sensor.""" -@dataclass +@dataclass(frozen=True) class RestSensorDescription(RestEntityDescription, SensorEntityDescription): """Class to describe a REST sensor.""" @@ -371,6 +372,14 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), + "power_light": RpcSensorDescription( + key="light", + sub_key="apower", + name="Power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), "power_pm1": RpcSensorDescription( key="pm1", sub_key="apower", @@ -501,6 +510,17 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "voltage_light": RpcSensorDescription( + key="light", + 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, + ), "voltage_pm1": RpcSensorDescription( key="pm1", sub_key="voltage", @@ -559,6 +579,16 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "current_light": RpcSensorDescription( + key="light", + 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, + ), "current_pm1": RpcSensorDescription( key="pm1", sub_key="current", @@ -627,6 +657,17 @@ RPC_SENSORS: Final = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "energy_light": RpcSensorDescription( + key="light", + 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, + ), "energy_pm1": RpcSensorDescription( key="pm1", sub_key="aenergy", @@ -837,6 +878,19 @@ RPC_SENSORS: Final = { entity_category=EntityCategory.DIAGNOSTIC, use_polling_coordinator=True, ), + "temperature_light": RpcSensorDescription( + key="light", + sub_key="temperature", + name="Device temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value=lambda status, _: status["tC"], + suggested_display_precision=1, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + use_polling_coordinator=True, + ), "temperature_0": RpcSensorDescription( key="temperature", sub_key="tC", @@ -925,7 +979,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: if config_entry.data[CONF_SLEEP_PERIOD]: async_setup_entry_rpc( hass, diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 9230ae605e0..c1f9b799444 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -71,9 +71,6 @@ "ble_scanner_mode": "Bluetooth scanner mode" } } - }, - "abort": { - "ble_unsupported": "Bluetooth support requires firmware version {ble_min_version} or newer." } }, "selector": { @@ -163,6 +160,14 @@ "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." + }, + "deprecated_valve_switch": { + "title": "The switch entity for Shelly Gas Valve is deprecated", + "description": "The switch entity for Shelly Gas Valve is deprecated. A valve entity {entity} is available and should be used going forward. For this new valve entity you need to use {service} service." + }, + "deprecated_valve_switch_entity": { + "title": "Deprecated switch entity for Shelly Gas Valve detected in {info}", + "description": "Your Shelly Gas Valve entity `{entity}` is being used in `{info}`. A valve entity is available and should be used going forward.\n\nPlease adjust `{info}` to fix this issue." } } } diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 5a398182e4d..e5d91943a55 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -5,14 +5,22 @@ from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block -from aioshelly.const import MODEL_2, MODEL_25, MODEL_GAS +from aioshelly.const import MODEL_2, MODEL_25, MODEL_GAS, RPC_GENERATIONS -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import GAS_VALVE_OPEN_STATES, MODEL_WALL_DISPLAY +from .const import DOMAIN, GAS_VALVE_OPEN_STATES, MODEL_WALL_DISPLAY from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ( BlockEntityDescription, @@ -30,16 +38,18 @@ from .utils import ( ) -@dataclass +@dataclass(frozen=True) class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription): """Class to describe a BLOCK switch.""" +# This entity description is deprecated and will be removed in Home Assistant 2024.7.0. GAS_VALVE_SWITCH = BlockSwitchDescription( key="valve|valve", name="Valve", available=lambda block: block.valve not in ("failure", "checking"), removal_condition=lambda _, block: block.valve in ("not_connected", "unknown"), + entity_registry_enabled_default=False, ) @@ -49,7 +59,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches for device.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: return async_setup_rpc_entry(hass, config_entry, async_add_entities) return async_setup_block_entry(hass, config_entry, async_add_entities) @@ -137,7 +147,10 @@ def async_setup_rpc_entry( class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): - """Entity that controls a Gas Valve on Block based Shelly devices.""" + """Entity that controls a Gas Valve on Block based Shelly devices. + + This class is deprecated and will be removed in Home Assistant 2024.7.0. + """ entity_description: BlockSwitchDescription @@ -167,14 +180,61 @@ class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Open valve.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_valve_switch", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_valve_switch", + translation_placeholders={ + "entity": f"{VALVE_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", + "service": f"{VALVE_DOMAIN}.open_valve", + }, + ) self.control_result = await self.set_state(go="open") self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Close valve.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_valve_switch", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_valve_switche", + translation_placeholders={ + "entity": f"{VALVE_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", + "service": f"{VALVE_DOMAIN}.close_valve", + }, + ) self.control_result = await self.set_state(go="close") self.async_write_ha_state() + async def async_added_to_hass(self) -> None: + """Set up a listener when this entity is added to HA.""" + await super().async_added_to_hass() + + entity_automations = automations_with_entity(self.hass, self.entity_id) + entity_scripts = scripts_with_entity(self.hass, self.entity_id) + for item in entity_automations + entity_scripts: + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_valve_{self.entity_id}_{item}", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_valve_switch_entity", + translation_placeholders={ + "entity": f"{SWITCH_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", + "info": item, + }, + ) + @callback def _update_callback(self) -> None: """When device updates, clear control result that overrides state.""" diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index 9e52a292108..9e8b1505afe 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -6,6 +6,7 @@ from dataclasses import dataclass import logging from typing import Any, Final, cast +from aioshelly.const import RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.components.update import ( @@ -39,7 +40,7 @@ from .utils import get_device_entry_gen, get_release_url LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class RpcUpdateRequiredKeysMixin: """Class for RPC update required keys.""" @@ -47,7 +48,7 @@ class RpcUpdateRequiredKeysMixin: beta: bool -@dataclass +@dataclass(frozen=True) class RestUpdateRequiredKeysMixin: """Class for REST update required keys.""" @@ -55,14 +56,14 @@ class RestUpdateRequiredKeysMixin: beta: bool -@dataclass +@dataclass(frozen=True) class RpcUpdateDescription( RpcEntityDescription, UpdateEntityDescription, RpcUpdateRequiredKeysMixin ): """Class to describe a RPC update.""" -@dataclass +@dataclass(frozen=True) class RestUpdateDescription( RestEntityDescription, UpdateEntityDescription, RestUpdateRequiredKeysMixin ): @@ -119,7 +120,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up update entities for Shelly component.""" - if get_device_entry_gen(config_entry) == 2: + if get_device_entry_gen(config_entry) in RPC_GENERATIONS: if config_entry.data[CONF_SLEEP_PERIOD]: async_setup_entry_rpc( hass, diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index d4164c1dd7d..a43d9cb0bcb 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -7,12 +7,14 @@ from typing import Any, cast from aiohttp.web import Request, WebSocketResponse from aioshelly.block_device import COAP, Block, BlockDevice from aioshelly.const import ( + BLOCK_GENERATIONS, MODEL_1L, MODEL_DIMMER, MODEL_DIMMER_2, MODEL_EM3, MODEL_I3, MODEL_NAMES, + RPC_GENERATIONS, ) from aioshelly.rpc_device import RpcDevice, WsServer @@ -267,15 +269,6 @@ def get_block_device_sleep_period(settings: dict[str, Any]) -> int: return sleep_period * 60 # minutes to seconds -def get_rpc_device_sleep_period(config: dict[str, Any]) -> int: - """Return the device sleep period in seconds or 0 for non sleeping devices. - - sys.sleep.wakeup_period value is deprecated and not available in Shelly - firmware 1.0.0 or later. - """ - return cast(int, config["sys"].get("sleep", {}).get("wakeup_period", 0)) - - def get_rpc_device_wakeup_period(status: dict[str, Any]) -> int: """Return the device wakeup period in seconds or 0 for non sleeping devices.""" return cast(int, status["sys"].get("wakeup_period", 0)) @@ -293,7 +286,7 @@ def get_info_gen(info: dict[str, Any]) -> int: def get_model_name(info: dict[str, Any]) -> str: """Return the device model name.""" - if get_info_gen(info) == 2: + if get_info_gen(info) in RPC_GENERATIONS: return cast(str, MODEL_NAMES.get(info["model"], info["model"])) return cast(str, MODEL_NAMES.get(info["type"], info["type"])) @@ -431,4 +424,4 @@ def get_release_url(gen: int, model: str, beta: bool) -> str | None: if beta or model in DEVICES_WITHOUT_FIRMWARE_CHANGELOG: return None - return GEN1_RELEASE_URL if gen == 1 else GEN2_RELEASE_URL + return GEN1_RELEASE_URL if gen in BLOCK_GENERATIONS else GEN2_RELEASE_URL diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py new file mode 100644 index 00000000000..7bc4a9a5329 --- /dev/null +++ b/homeassistant/components/shelly/valve.py @@ -0,0 +1,122 @@ +"""Valve for Shelly.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +from aioshelly.block_device import Block +from aioshelly.const import BLOCK_GENERATIONS, MODEL_GAS + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import ShellyBlockCoordinator, get_entry_data +from .entity import ( + BlockEntityDescription, + ShellyBlockAttributeEntity, + async_setup_block_attribute_entities, +) +from .utils import get_device_entry_gen + + +@dataclass(kw_only=True, frozen=True) +class BlockValveDescription(BlockEntityDescription, ValveEntityDescription): + """Class to describe a BLOCK valve.""" + + +GAS_VALVE = BlockValveDescription( + key="valve|valve", + name="Valve", + available=lambda block: block.valve not in ("failure", "checking"), + removal_condition=lambda _, block: block.valve in ("not_connected", "unknown"), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up valves for device.""" + if get_device_entry_gen(config_entry) in BLOCK_GENERATIONS: + async_setup_block_entry(hass, config_entry, async_add_entities) + + +@callback +def async_setup_block_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up valve for device.""" + coordinator = get_entry_data(hass)[config_entry.entry_id].block + assert coordinator and coordinator.device.blocks + + if coordinator.model == MODEL_GAS: + async_setup_block_attribute_entities( + hass, + async_add_entities, + coordinator, + {("valve", "valve"): GAS_VALVE}, + BlockShellyValve, + ) + + +class BlockShellyValve(ShellyBlockAttributeEntity, ValveEntity): + """Entity that controls a valve on block based Shelly devices.""" + + entity_description: BlockValveDescription + _attr_device_class = ValveDeviceClass.GAS + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block, + attribute: str, + description: BlockValveDescription, + ) -> None: + """Initialize block valve.""" + super().__init__(coordinator, block, attribute, description) + self.control_result: dict[str, Any] | None = None + self._attr_is_closed = bool(self.attribute_value == "closed") + + @property + def is_closing(self) -> bool: + """Return if the valve is closing.""" + if self.control_result: + return cast(bool, self.control_result["state"] == "closing") + + return self.attribute_value == "closing" + + @property + def is_opening(self) -> bool: + """Return if the valve is opening.""" + if self.control_result: + return cast(bool, self.control_result["state"] == "opening") + + return self.attribute_value == "opening" + + async def async_open_valve(self, **kwargs: Any) -> None: + """Open valve.""" + self.control_result = await self.set_state(go="open") + self.async_write_ha_state() + + async def async_close_valve(self, **kwargs: Any) -> None: + """Close valve.""" + self.control_result = await self.set_state(go="close") + self.async_write_ha_state() + + @callback + def _update_callback(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + self._attr_is_closed = bool(self.attribute_value == "closed") + super()._update_callback() diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py index d89f376d662..2d959858067 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -1,6 +1,6 @@ """A shopping list todo platform.""" -from typing import Any, cast +from typing import cast from homeassistant.components.todo import ( TodoItem, @@ -55,11 +55,10 @@ class ShoppingTodoListEntity(TodoListEntity): async def async_update_todo_item(self, item: TodoItem) -> None: """Update an item to the To-do list.""" - data: dict[str, Any] = {} - if item.summary: - data["name"] = item.summary - if item.status: - data["complete"] = item.status == TodoItemStatus.COMPLETED + data = { + "name": item.summary, + "complete": item.status == TodoItemStatus.COMPLETED, + } try: await self._data.async_update(item.uid, data) except NoMatchingShoppingListItem as err: diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py index 149a0427ed0..e7850a5f9d2 100644 --- a/homeassistant/components/sia/alarm_control_panel.py +++ b/homeassistant/components/sia/alarm_control_panel.py @@ -29,7 +29,7 @@ from .sia_entity_base import SIABaseEntity, SIAEntityDescription _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SIAAlarmControlPanelEntityDescription( AlarmControlPanelEntityDescription, SIAEntityDescription, diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py index db0845473fd..f6e2533be93 100644 --- a/homeassistant/components/sia/binary_sensor.py +++ b/homeassistant/components/sia/binary_sensor.py @@ -32,7 +32,7 @@ from .sia_entity_base import SIABaseEntity, SIAEntityDescription _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SIABinarySensorEntityDescription( BinarySensorEntityDescription, SIAEntityDescription, diff --git a/homeassistant/components/sia/sia_entity_base.py b/homeassistant/components/sia/sia_entity_base.py index a947f9e177b..f6895cc48a9 100644 --- a/homeassistant/components/sia/sia_entity_base.py +++ b/homeassistant/components/sia/sia_entity_base.py @@ -35,14 +35,14 @@ from .utils import ( _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SIARequiredKeysMixin: """Required keys for SIA entities.""" code_consequences: dict[str, StateType | bool] -@dataclass +@dataclass(frozen=True) class SIAEntityDescription(EntityDescription, SIARequiredKeysMixin): """Entity Description for SIA entities.""" diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 7b57fa1fc32..772b6f9cbf6 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Iterable +from collections.abc import Callable, Coroutine, Iterable from datetime import timedelta from typing import Any, cast @@ -336,7 +336,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @callback - def extract_system(func: Callable) -> Callable: + def extract_system( + func: Callable[[ServiceCall, SystemType], Coroutine[Any, Any, None]], + ) -> Callable[[ServiceCall], Coroutine[Any, Any, None]]: """Define a decorator to get the correct system for a service call.""" async def wrapper(call: ServiceCall) -> None: diff --git a/homeassistant/components/simplisafe/button.py b/homeassistant/components/simplisafe/button.py index bd60c040f56..a11ddc04d64 100644 --- a/homeassistant/components/simplisafe/button.py +++ b/homeassistant/components/simplisafe/button.py @@ -19,14 +19,14 @@ from .const import DOMAIN from .typing import SystemType -@dataclass +@dataclass(frozen=True) class SimpliSafeButtonDescriptionMixin: """Define an entity description mixin for SimpliSafe buttons.""" push_action: Callable[[System], Awaitable] -@dataclass +@dataclass(frozen=True) class SimpliSafeButtonDescription( ButtonEntityDescription, SimpliSafeButtonDescriptionMixin ): diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index ac02201b928..29ad238ac00 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -1,10 +1,10 @@ """Component to interface with various sirens/chimes.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta +from functools import partial import logging -from typing import Any, TypedDict, cast, final +from typing import TYPE_CHECKING, Any, TypedDict, cast, final import voluptuous as vol @@ -16,24 +16,33 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from .const import ( # noqa: F401 + _DEPRECATED_SUPPORT_DURATION, + _DEPRECATED_SUPPORT_TONES, + _DEPRECATED_SUPPORT_TURN_OFF, + _DEPRECATED_SUPPORT_TURN_ON, + _DEPRECATED_SUPPORT_VOLUME_SET, ATTR_AVAILABLE_TONES, ATTR_DURATION, ATTR_TONE, ATTR_VOLUME_LEVEL, DOMAIN, - SUPPORT_DURATION, - SUPPORT_TONES, - SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, - SUPPORT_VOLUME_SET, SirenEntityFeature, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=60) @@ -44,6 +53,12 @@ TURN_ON_SCHEMA = { vol.Optional(ATTR_VOLUME_LEVEL): cv.small_float, } +# As we import deprecated constants from the const module, we need to add these two functions +# otherwise this module will be logged for using deprecated constants and not the custom component +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) + class SirenTurnOnServiceParameters(TypedDict, total=False): """Represent possible parameters to siren.turn_on service data dict type.""" @@ -149,14 +164,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class SirenEntityDescription(ToggleEntityDescription): +class SirenEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes siren entities.""" available_tones: list[int | str] | dict[int, str] | None = None -class SirenEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "available_tones", + "supported_features", +} + + +class SirenEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a siren device.""" _entity_component_unrecorded_attributes = frozenset({ATTR_AVAILABLE_TONES}) @@ -177,7 +197,7 @@ class SirenEntity(ToggleEntity): return None - @property + @cached_property def available_tones(self) -> list[int | str] | dict[int, str] | None: """Return a list of available tones. @@ -189,7 +209,12 @@ class SirenEntity(ToggleEntity): return self.entity_description.available_tones return None - @property + @cached_property def supported_features(self) -> SirenEntityFeature: """Return the list of supported features.""" - return self._attr_supported_features + features = self._attr_supported_features + if type(features) is int: # noqa: E721 + new_features = SirenEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features diff --git a/homeassistant/components/siren/const.py b/homeassistant/components/siren/const.py index 374b1d59e2a..50c3af61c8d 100644 --- a/homeassistant/components/siren/const.py +++ b/homeassistant/components/siren/const.py @@ -1,8 +1,15 @@ """Constants for the siren component.""" from enum import IntFlag +from functools import partial from typing import Final +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) + DOMAIN: Final = "siren" ATTR_TONE: Final = "tone" @@ -24,8 +31,22 @@ class SirenEntityFeature(IntFlag): # These constants are deprecated as of Home Assistant 2022.5 # Please use the SirenEntityFeature enum instead. -SUPPORT_TURN_ON: Final = 1 -SUPPORT_TURN_OFF: Final = 2 -SUPPORT_TONES: Final = 4 -SUPPORT_VOLUME_SET: Final = 8 -SUPPORT_DURATION: Final = 16 +_DEPRECATED_SUPPORT_TURN_ON: Final = DeprecatedConstantEnum( + SirenEntityFeature.TURN_ON, "2025.1" +) +_DEPRECATED_SUPPORT_TURN_OFF: Final = DeprecatedConstantEnum( + SirenEntityFeature.TURN_OFF, "2025.1" +) +_DEPRECATED_SUPPORT_TONES: Final = DeprecatedConstantEnum( + SirenEntityFeature.TONES, "2025.1" +) +_DEPRECATED_SUPPORT_VOLUME_SET: Final = DeprecatedConstantEnum( + SirenEntityFeature.VOLUME_SET, "2025.1" +) +_DEPRECATED_SUPPORT_DURATION: Final = DeprecatedConstantEnum( + SirenEntityFeature.DURATION, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index 130196a990d..7093c5cad20 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -22,14 +22,14 @@ from homeassistant.helpers.typing import StateType from .entity import DOMAIN, SkybellEntity -@dataclass +@dataclass(frozen=True) class SkybellSensorEntityDescriptionMixIn: """Mixin for Skybell sensor.""" value_fn: Callable[[SkybellDevice], StateType | datetime] -@dataclass +@dataclass(frozen=True) class SkybellSensorEntityDescription( SensorEntityDescription, SkybellSensorEntityDescriptionMixIn ): diff --git a/homeassistant/components/sleepiq/button.py b/homeassistant/components/sleepiq/button.py index cca9253d589..0d9a118d3c9 100644 --- a/homeassistant/components/sleepiq/button.py +++ b/homeassistant/components/sleepiq/button.py @@ -17,14 +17,14 @@ from .coordinator import SleepIQData from .entity import SleepIQEntity -@dataclass +@dataclass(frozen=True) class SleepIQButtonEntityDescriptionMixin: """Describes a SleepIQ Button entity.""" press_action: Callable[[SleepIQBed], Any] -@dataclass +@dataclass(frozen=True) class SleepIQButtonEntityDescription( ButtonEntityDescription, SleepIQButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/sleepiq/const.py b/homeassistant/components/sleepiq/const.py index 4eb6148f9b8..4243684cd52 100644 --- a/homeassistant/components/sleepiq/const.py +++ b/homeassistant/components/sleepiq/const.py @@ -11,12 +11,16 @@ ICON_OCCUPIED = "mdi:bed" IS_IN_BED = "is_in_bed" PRESSURE = "pressure" SLEEP_NUMBER = "sleep_number" +FOOT_WARMING_TIMER = "foot_warming_timer" +FOOT_WARMER = "foot_warmer" ENTITY_TYPES = { ACTUATOR: "Position", FIRMNESS: "Firmness", PRESSURE: "Pressure", IS_IN_BED: "Is In Bed", SLEEP_NUMBER: "SleepNumber", + FOOT_WARMING_TIMER: "Foot Warming Timer", + FOOT_WARMER: "Foot Warmer", } LEFT = "left" diff --git a/homeassistant/components/sleepiq/entity.py b/homeassistant/components/sleepiq/entity.py index 38d8eb32051..9a0342aa7ac 100644 --- a/homeassistant/components/sleepiq/entity.py +++ b/homeassistant/components/sleepiq/entity.py @@ -29,6 +29,14 @@ def device_from_bed(bed: SleepIQBed) -> DeviceInfo: ) +def sleeper_for_side(bed: SleepIQBed, side: str) -> SleepIQSleeper: + """Find the sleeper for a side or the first sleeper.""" + for sleeper in bed.sleepers: + if sleeper.side == side: + return sleeper + return bed.sleepers[0] + + class SleepIQEntity(Entity): """Implementation of a SleepIQ entity.""" diff --git a/homeassistant/components/sleepiq/manifest.json b/homeassistant/components/sleepiq/manifest.json index 874ae90ec4a..62bd3930c77 100644 --- a/homeassistant/components/sleepiq/manifest.json +++ b/homeassistant/components/sleepiq/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/sleepiq", "iot_class": "cloud_polling", "loggers": ["asyncsleepiq"], - "requirements": ["asyncsleepiq==1.3.7"] + "requirements": ["asyncsleepiq==1.4.1"] } diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 5523f931bd4..520e11bb331 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -5,19 +5,26 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, cast -from asyncsleepiq import SleepIQActuator, SleepIQBed, SleepIQSleeper +from asyncsleepiq import SleepIQActuator, SleepIQBed, SleepIQFootWarmer, SleepIQSleeper from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ACTUATOR, DOMAIN, ENTITY_TYPES, FIRMNESS, ICON_OCCUPIED +from .const import ( + ACTUATOR, + DOMAIN, + ENTITY_TYPES, + FIRMNESS, + FOOT_WARMING_TIMER, + ICON_OCCUPIED, +) from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator -from .entity import SleepIQBedEntity +from .entity import SleepIQBedEntity, sleeper_for_side -@dataclass +@dataclass(frozen=True) class SleepIQNumberEntityDescriptionMixin: """Mixin to describe a SleepIQ number entity.""" @@ -27,7 +34,7 @@ class SleepIQNumberEntityDescriptionMixin: get_unique_id_fn: Callable[[SleepIQBed, Any], str] -@dataclass +@dataclass(frozen=True) class SleepIQNumberEntityDescription( NumberEntityDescription, SleepIQNumberEntityDescriptionMixin ): @@ -69,6 +76,21 @@ def _get_sleeper_unique_id(bed: SleepIQBed, sleeper: SleepIQSleeper) -> str: return f"{sleeper.sleeper_id}_{FIRMNESS}" +async def _async_set_foot_warmer_time( + foot_warmer: SleepIQFootWarmer, time: int +) -> None: + foot_warmer.timer = time + + +def _get_foot_warming_name(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer) -> str: + sleeper = sleeper_for_side(bed, foot_warmer.side) + return f"SleepNumber {bed.name} {sleeper.name} {ENTITY_TYPES[FOOT_WARMING_TIMER]}" + + +def _get_foot_warming_unique_id(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer) -> str: + return f"{bed.id}_{foot_warmer.side.value}_{FOOT_WARMING_TIMER}" + + NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { FIRMNESS: SleepIQNumberEntityDescription( key=FIRMNESS, @@ -94,6 +116,18 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { get_name_fn=_get_actuator_name, get_unique_id_fn=_get_actuator_unique_id, ), + FOOT_WARMING_TIMER: SleepIQNumberEntityDescription( + key=FOOT_WARMING_TIMER, + native_min_value=30, + native_max_value=360, + native_step=30, + name=ENTITY_TYPES[FOOT_WARMING_TIMER], + icon="mdi:timer", + value_fn=lambda foot_warmer: foot_warmer.timer, + set_value_fn=_async_set_foot_warmer_time, + get_name_fn=_get_foot_warming_name, + get_unique_id_fn=_get_foot_warming_unique_id, + ), } @@ -125,6 +159,15 @@ async def async_setup_entry( NUMBER_DESCRIPTIONS[ACTUATOR], ) ) + for foot_warmer in bed.foundation.foot_warmers: + entities.append( + SleepIQNumberEntity( + data.data_coordinator, + bed, + foot_warmer, + NUMBER_DESCRIPTIONS[FOOT_WARMING_TIMER], + ) + ) async_add_entities(entities) @@ -148,6 +191,8 @@ class SleepIQNumberEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], Number self._attr_name = description.get_name_fn(bed, device) self._attr_unique_id = description.get_unique_id_fn(bed, device) + if description.icon: + self._attr_icon = description.icon super().__init__(coordinator, bed) diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index 1609dc2e116..df8d854c9da 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -1,16 +1,22 @@ """Support for SleepIQ foundation preset selection.""" from __future__ import annotations -from asyncsleepiq import Side, SleepIQBed, SleepIQPreset +from asyncsleepiq import ( + FootWarmingTemps, + Side, + SleepIQBed, + SleepIQFootWarmer, + SleepIQPreset, +) from homeassistant.components.select import SelectEntity 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 DOMAIN, FOOT_WARMER from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator -from .entity import SleepIQBedEntity +from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side async def async_setup_entry( @@ -20,11 +26,17 @@ async def async_setup_entry( ) -> None: """Set up the SleepIQ foundation preset select entities.""" data: SleepIQData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - SleepIQSelectEntity(data.data_coordinator, bed, preset) - for bed in data.client.beds.values() - for preset in bed.foundation.presets - ) + entities: list[SleepIQBedEntity] = [] + for bed in data.client.beds.values(): + for preset in bed.foundation.presets: + entities.append(SleepIQSelectEntity(data.data_coordinator, bed, preset)) + for foot_warmer in bed.foundation.foot_warmers: + entities.append( + SleepIQFootWarmingTempSelectEntity( + data.data_coordinator, bed, foot_warmer + ) + ) + async_add_entities(entities) class SleepIQSelectEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], SelectEntity): @@ -59,3 +71,46 @@ class SleepIQSelectEntity(SleepIQBedEntity[SleepIQDataUpdateCoordinator], Select await self.preset.set_preset(option) self._attr_current_option = option self.async_write_ha_state() + + +class SleepIQFootWarmingTempSelectEntity( + SleepIQSleeperEntity[SleepIQDataUpdateCoordinator], SelectEntity +): + """Representation of a SleepIQ foot warming temperature select entity.""" + + _attr_icon = "mdi:heat-wave" + _attr_options = [e.name.lower() for e in FootWarmingTemps] + _attr_translation_key = "foot_warmer_temp" + + def __init__( + self, + coordinator: SleepIQDataUpdateCoordinator, + bed: SleepIQBed, + foot_warmer: SleepIQFootWarmer, + ) -> None: + """Initialize the select entity.""" + self.foot_warmer = foot_warmer + sleeper = sleeper_for_side(bed, foot_warmer.side) + super().__init__(coordinator, bed, sleeper, FOOT_WARMER) + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + self._attr_current_option = FootWarmingTemps( + self.foot_warmer.temperature + ).name.lower() + + async def async_select_option(self, option: str) -> None: + """Change the current preset.""" + temperature = FootWarmingTemps[option.upper()] + timer = self.foot_warmer.timer or 120 + + if temperature == 0: + await self.foot_warmer.turn_off() + else: + await self.foot_warmer.turn_on(temperature, timer) + + self._attr_current_option = option + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index 7a9a4c58464..bdafbfb6c77 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -23,5 +23,17 @@ } } } + }, + "entity": { + "select": { + "foot_warmer_temp": { + "state": { + "off": "Off", + "low": "Low", + "medium": "Medium", + "high": "High" + } + } + } } } diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 82bc60936b3..ad6e5af963e 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -18,26 +18,26 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class SmappeeRequiredKeysMixin: """Mixin for required keys.""" sensor_id: str -@dataclass +@dataclass(frozen=True) class SmappeeSensorEntityDescription(SensorEntityDescription, SmappeeRequiredKeysMixin): """Describes Smappee sensor entity.""" -@dataclass +@dataclass(frozen=True) class SmappeePollingSensorEntityDescription(SmappeeSensorEntityDescription): """Describes Smappee sensor entity.""" local_polling: bool = False -@dataclass +@dataclass(frozen=True) class SmappeeVoltageSensorEntityDescription(SmappeeSensorEntityDescription): """Describes Smappee sensor entity.""" diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index ebf80e22909..6c814b781b2 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -12,10 +12,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index b2d4fbf17c4..9f1802e7327 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -23,11 +23,13 @@ from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, SMARTTUB_CONTROLL from .entity import SmartTubEntity PRESET_DAY = "day" +PRESET_READY = "ready" PRESET_MODES = { Spa.HeatMode.AUTO: PRESET_NONE, Spa.HeatMode.ECONOMY: PRESET_ECO, Spa.HeatMode.DAY: PRESET_DAY, + Spa.HeatMode.READY: PRESET_READY, } HEAT_MODES = {v: k for k, v in PRESET_MODES.items()} diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index cf4b49e6105..d3ba407fa40 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -14,10 +14,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import DOMAIN, SIGNAL_UPDATE_SMARTY diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 7ca31bae618..696b079fd5e 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -3,8 +3,9 @@ from __future__ import annotations import binascii import logging -import sys +from pysnmp.entity import config as cfg +from pysnmp.entity.rfc3413.oneliner import cmdgen import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -14,7 +15,6 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -26,11 +26,6 @@ from .const import ( DEFAULT_COMMUNITY, ) -if sys.version_info < (3, 12): - from pysnmp.entity import config as cfg - from pysnmp.entity.rfc3413.oneliner import cmdgen - - _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( @@ -46,10 +41,6 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> SnmpScanner | None: """Validate the configuration and return an SNMP scanner.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "SNMP is not supported on Python 3.12. Please use Python 3.11." - ) scanner = SnmpScanner(config[DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index 324a1e49366..2756b97157c 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/snmp", "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"], - "requirements": ["pysnmplib==5.0.21"] + "requirements": ["pysnmp-lextudio==5.0.31"] } diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 58cd12d611f..a5915183ad0 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -3,8 +3,20 @@ from __future__ import annotations from datetime import timedelta import logging -import sys +from pysnmp.error import PySnmpError +import pysnmp.hlapi.asyncio as hlapi +from pysnmp.hlapi.asyncio import ( + CommunityData, + ContextData, + ObjectIdentity, + ObjectType, + SnmpEngine, + Udp6TransportTarget, + UdpTransportTarget, + UsmUserData, + getCmd, +) import voluptuous as vol from homeassistant.components.sensor import CONF_STATE_CLASS, PLATFORM_SCHEMA @@ -21,7 +33,6 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template @@ -56,21 +67,6 @@ from .const import ( SNMP_VERSIONS, ) -if sys.version_info < (3, 12): - from pysnmp.error import PySnmpError - import pysnmp.hlapi.asyncio as hlapi - from pysnmp.hlapi.asyncio import ( - CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, - Udp6TransportTarget, - UdpTransportTarget, - UsmUserData, - getCmd, - ) - _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) @@ -115,10 +111,6 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the SNMP sensor.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "SNMP is not supported on Python 3.12. Please use Python 3.11." - ) host = config.get(CONF_HOST) port = config.get(CONF_PORT) community = config.get(CONF_COMMUNITY) diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index e94c6991601..d0fe393d550 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -2,9 +2,34 @@ from __future__ import annotations import logging -import sys from typing import Any +import pysnmp.hlapi.asyncio as hlapi +from pysnmp.hlapi.asyncio import ( + CommunityData, + ContextData, + ObjectIdentity, + ObjectType, + SnmpEngine, + UdpTransportTarget, + UsmUserData, + getCmd, + setCmd, +) +from pysnmp.proto.rfc1902 import ( + Counter32, + Counter64, + Gauge32, + Integer, + Integer32, + IpAddress, + Null, + ObjectIdentifier, + OctetString, + Opaque, + TimeTicks, + Unsigned32, +) import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity @@ -17,7 +42,6 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -43,34 +67,6 @@ from .const import ( SNMP_VERSIONS, ) -if sys.version_info < (3, 12): - import pysnmp.hlapi.asyncio as hlapi - from pysnmp.hlapi.asyncio import ( - CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, - UdpTransportTarget, - UsmUserData, - getCmd, - setCmd, - ) - from pysnmp.proto.rfc1902 import ( - Counter32, - Counter64, - Gauge32, - Integer, - Integer32, - IpAddress, - Null, - ObjectIdentifier, - OctetString, - Opaque, - TimeTicks, - Unsigned32, - ) - _LOGGER = logging.getLogger(__name__) CONF_COMMAND_OID = "command_oid" @@ -81,22 +77,21 @@ DEFAULT_COMMUNITY = "private" DEFAULT_PAYLOAD_OFF = 0 DEFAULT_PAYLOAD_ON = 1 -if sys.version_info < (3, 12): - MAP_SNMP_VARTYPES = { - "Counter32": Counter32, - "Counter64": Counter64, - "Gauge32": Gauge32, - "Integer32": Integer32, - "Integer": Integer, - "IpAddress": IpAddress, - "Null": Null, - # some work todo to support tuple ObjectIdentifier, this just supports str - "ObjectIdentifier": ObjectIdentifier, - "OctetString": OctetString, - "Opaque": Opaque, - "TimeTicks": TimeTicks, - "Unsigned32": Unsigned32, - } +MAP_SNMP_VARTYPES = { + "Counter32": Counter32, + "Counter64": Counter64, + "Gauge32": Gauge32, + "Integer32": Integer32, + "Integer": Integer, + "IpAddress": IpAddress, + "Null": Null, + # some work todo to support tuple ObjectIdentifier, this just supports str + "ObjectIdentifier": ObjectIdentifier, + "OctetString": OctetString, + "Opaque": Opaque, + "TimeTicks": TimeTicks, + "Unsigned32": Unsigned32, +} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -132,10 +127,6 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the SNMP switch.""" - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "SNMP is not supported on Python 3.12. Please use Python 3.11." - ) name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 5e298ae2a6f..bb82da5fc89 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -33,14 +33,14 @@ from .coordinator import ( ) -@dataclass +@dataclass(frozen=True) class SolarEdgeSensorEntityRequiredKeyMixin: """Sensor entity description with json_key for SolarEdge.""" json_key: str -@dataclass +@dataclass(frozen=True) class SolarEdgeSensorEntityDescription( SensorEntityDescription, SolarEdgeSensorEntityRequiredKeyMixin ): diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index d0efcd0ec9b..0475489a6f4 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -2,8 +2,7 @@ from __future__ import annotations from contextlib import suppress -from copy import copy -from dataclasses import dataclass +import dataclasses from datetime import timedelta import logging import statistics @@ -51,7 +50,7 @@ INVERTER_MODES = ( ) -@dataclass +@dataclasses.dataclass(frozen=True) class SolarEdgeLocalSensorEntityDescription(SensorEntityDescription): """Describes SolarEdge-local sensor entity.""" @@ -231,10 +230,11 @@ def setup_platform( data = SolarEdgeData(hass, api) # Changing inverter temperature unit. - inverter_temp_description = copy(SENSOR_TYPE_INVERTER_TEMPERATURE) + inverter_temp_description = SENSOR_TYPE_INVERTER_TEMPERATURE if status.inverters.primary.temperature.units.farenheit: - inverter_temp_description.native_unit_of_measurement = ( - UnitOfTemperature.FAHRENHEIT + inverter_temp_description = dataclasses.replace( + inverter_temp_description, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ) # Create entities diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py index cd8304a1198..a8025c7fc0f 100644 --- a/homeassistant/components/solarlog/sensor.py +++ b/homeassistant/components/solarlog/sensor.py @@ -26,7 +26,7 @@ from . import SolarlogData from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class SolarLogSensorEntityDescription(SensorEntityDescription): """Describes Solarlog sensor entity.""" diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index def44d382ce..5753d0d23ea 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -31,7 +31,7 @@ from .coordinator import SonarrDataT, SonarrDataUpdateCoordinator from .entity import SonarrEntity -@dataclass +@dataclass(frozen=True) class SonarrSensorEntityDescriptionMixIn(Generic[SonarrDataT]): """Mixin for Sonarr sensor.""" @@ -39,7 +39,7 @@ class SonarrSensorEntityDescriptionMixIn(Generic[SonarrDataT]): value_fn: Callable[[SonarrDataT], StateType] -@dataclass +@dataclass(frozen=True) class SonarrSensorEntityDescription( SensorEntityDescription, SonarrSensorEntityDescriptionMixIn[SonarrDataT] ): diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index e6b328cbcb0..c79856c58b6 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -290,6 +290,17 @@ class SonosDiscoveryManager: sub.callback = _async_subscription_succeeded # Hold lock to prevent concurrent subscription attempts await asyncio.sleep(ZGS_SUBSCRIPTION_TIMEOUT * 2) + try: + # Cancel this subscription as we create an autorenewing + # subscription when setting up the SonosSpeaker instance + await sub.unsubscribe() + except ClientError as ex: + # Will be rejected if already replaced by new subscription + _LOGGER.debug( + "Cleanup unsubscription from %s was rejected: %s", ip_address, ex + ) + except (OSError, Timeout) as ex: + _LOGGER.error("Cleanup unsubscription from %s failed: %s", ip_address, ex) async def _async_stop_event_listener(self, event: Event | None = None) -> None: for speaker in self.data.discovered.values(): diff --git a/homeassistant/components/sonos/diagnostics.py b/homeassistant/components/sonos/diagnostics.py index 96ffeb1df2a..21e440673d6 100644 --- a/homeassistant/components/sonos/diagnostics.py +++ b/homeassistant/components/sonos/diagnostics.py @@ -112,7 +112,7 @@ async def async_generate_speaker_info( payload: dict[str, Any] = {} def get_contents( - item: int | float | str | dict[str, Any] + item: int | float | str | dict[str, Any], ) -> int | float | str | dict[str, Any]: if isinstance(item, (int, float, str)): return item diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 5d36da862ca..0e1a1d7daa4 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.29.1", "sonos-websocket==0.1.2"], + "requirements": ["soco==0.30.0", "sonos-websocket==0.1.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index 375ed58035b..c74c5933ecf 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -21,6 +21,7 @@ LEVEL_TYPES = { "bass": (-10, 10), "balance": (-100, 100), "treble": (-10, 10), + "sub_crossover": (50, 110), "sub_gain": (-15, 15), "surround_level": (-15, 15), "music_surround_level": (-15, 15), diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index b73ca6a77e4..fea5b5de7de 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -154,6 +154,7 @@ class SonosSpeaker: self.dialog_level: bool | None = None self.night_mode: bool | None = None self.sub_enabled: bool | None = None + self.sub_crossover: int | None = None self.sub_gain: int | None = None self.surround_enabled: bool | None = None self.surround_mode: bool | None = None @@ -561,6 +562,7 @@ class SonosSpeaker: "audio_delay", "bass", "treble", + "sub_crossover", "sub_gain", "surround_level", "music_surround_level", diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index fb10167f1d0..6f45195c46b 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -36,6 +36,9 @@ "treble": { "name": "Treble" }, + "sub_crossover": { + "name": "Sub crossover frequency" + }, "sub_gain": { "name": "Sub gain" }, diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index af41c400e0b..53e80be0cc0 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -33,7 +33,7 @@ from .const import ( from .coordinator import SpeedTestDataCoordinator -@dataclass +@dataclass(frozen=True) class SpeedtestSensorEntityDescription(SensorEntityDescription): """Class describing Speedtest sensor entities.""" diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index 4658e19932c..a4768165c25 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import sqlparse import voluptuous as vol from homeassistant.components.recorder import CONF_DB_URL, get_instance @@ -38,9 +39,14 @@ _LOGGER = logging.getLogger(__name__) def validate_sql_select(value: str) -> str: """Validate that value is a SQL SELECT query.""" - if not value.lstrip().lower().startswith("select"): + if len(query := sqlparse.parse(value.lstrip().lstrip(";"))) > 1: + raise vol.Invalid("Multiple SQL queries are not supported") + if len(query) == 0 or (query_type := query[0].get_type()) == "UNKNOWN": + raise vol.Invalid("Invalid SQL query") + if query_type != "SELECT": + _LOGGER.debug("The SQL query %s is of type %s", query, query_type) raise vol.Invalid("Only SELECT queries allowed") - return value + return str(query[0]) QUERY_SCHEMA = vol.Schema( diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index e00b1f8e402..a697bdc51a7 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -6,8 +6,10 @@ from typing import Any import sqlalchemy from sqlalchemy.engine import Result -from sqlalchemy.exc import NoSuchColumnError, SQLAlchemyError +from sqlalchemy.exc import MultipleResultsFound, NoSuchColumnError, SQLAlchemyError from sqlalchemy.orm import Session, scoped_session, sessionmaker +import sqlparse +from sqlparse.exceptions import SQLParseError import voluptuous as vol from homeassistant import config_entries @@ -80,11 +82,16 @@ CONFIG_SCHEMA: vol.Schema = vol.Schema( ).extend(OPTIONS_SCHEMA.schema) -def validate_sql_select(value: str) -> str | None: +def validate_sql_select(value: str) -> str: """Validate that value is a SQL SELECT query.""" - if not value.lstrip().lower().startswith("select"): - raise ValueError("Incorrect Query") - return value + if len(query := sqlparse.parse(value.lstrip().lstrip(";"))) > 1: + raise MultipleResultsFound + if len(query) == 0 or (query_type := query[0].get_type()) == "UNKNOWN": + raise ValueError + if query_type != "SELECT": + _LOGGER.debug("The SQL query %s is of type %s", query, query_type) + raise SQLParseError + return str(query[0]) def validate_query(db_url: str, query: str, column: str) -> bool: @@ -148,7 +155,7 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): db_url_for_validation = None try: - validate_sql_select(query) + query = validate_sql_select(query) db_url_for_validation = resolve_db_url(self.hass, db_url) await self.hass.async_add_executor_job( validate_query, db_url_for_validation, query, column @@ -156,9 +163,14 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except NoSuchColumnError: errors["column"] = "column_invalid" description_placeholders = {"column": column} + except MultipleResultsFound: + errors["query"] = "multiple_queries" except SQLAlchemyError: errors["db_url"] = "db_url_invalid" - except ValueError: + except SQLParseError: + errors["query"] = "query_no_read_only" + except ValueError as err: + _LOGGER.debug("Invalid query: %s", err) errors["query"] = "query_invalid" options = { @@ -209,7 +221,7 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): name = self.options.get(CONF_NAME, self.config_entry.title) try: - validate_sql_select(query) + query = validate_sql_select(query) db_url_for_validation = resolve_db_url(self.hass, db_url) await self.hass.async_add_executor_job( validate_query, db_url_for_validation, query, column @@ -217,9 +229,14 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): except NoSuchColumnError: errors["column"] = "column_invalid" description_placeholders = {"column": column} + except MultipleResultsFound: + errors["query"] = "multiple_queries" except SQLAlchemyError: errors["db_url"] = "db_url_invalid" - except ValueError: + except SQLParseError: + errors["query"] = "query_no_read_only" + except ValueError as err: + _LOGGER.debug("Invalid query: %s", err) errors["query"] = "query_invalid" else: recorder_db = get_instance(self.hass).db_url diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index c63ba19e0ad..5ebd79b09a5 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.23"] + "requirements": ["SQLAlchemy==2.0.23", "sqlparse==0.4.4"] } diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index b4bb73d4b99..361585b8876 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -6,6 +6,8 @@ "error": { "db_url_invalid": "Database URL invalid", "query_invalid": "SQL Query invalid", + "query_no_read_only": "SQL query must be read-only", + "multiple_queries": "Multiple SQL queries are not supported", "column_invalid": "The column `{column}` is not returned by the query" }, "step": { @@ -61,6 +63,8 @@ "error": { "db_url_invalid": "[%key:component::sql::config::error::db_url_invalid%]", "query_invalid": "[%key:component::sql::config::error::query_invalid%]", + "query_no_read_only": "[%key:component::sql::config::error::query_no_read_only%]", + "multiple_queries": "[%key:component::sql::config::error::multiple_queries%]", "column_invalid": "[%key:component::sql::config::error::column_invalid%]" } }, diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py index 2c96046b97c..b155c7eddc0 100644 --- a/homeassistant/components/squeezebox/config_flow.py +++ b/homeassistant/components/squeezebox/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_registry import async_get -from .const import DEFAULT_PORT, DOMAIN +from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -49,9 +49,15 @@ def _base_schema(discovery_info=None): ) else: base_schema.update({vol.Required(CONF_PORT, default=DEFAULT_PORT): int}) + base_schema.update( - {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str} + { + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + vol.Optional(CONF_HTTPS, default=False): bool, + } ) + return vol.Schema(base_schema) @@ -105,6 +111,7 @@ class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data[CONF_PORT], data.get(CONF_USERNAME), data.get(CONF_PASSWORD), + https=data[CONF_HTTPS], ) try: diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index d8b67504397..38a9ef7668f 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -6,3 +6,4 @@ PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub" DISCOVERY_TASK = "discovery_task" DEFAULT_PORT = 9000 SQUEEZEBOX_SOURCE_STRINGS = ("source:", "wavin:", "spotify:") +CONF_HTTPS = "https" diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 43c2868dd69..83ca3ff1b00 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/squeezebox", "iot_class": "local_polling", "loggers": ["pysqueezebox"], - "requirements": ["pysqueezebox==0.6.3"] + "requirements": ["pysqueezebox==0.7.1"] } diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 03457c6a5c0..4e3d71eca24 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -52,6 +52,7 @@ from .browse_media import ( media_source_content_filter, ) from .const import ( + CONF_HTTPS, DISCOVERY_TASK, DOMAIN, KNOWN_PLAYERS, @@ -126,6 +127,7 @@ async def async_setup_entry( password = config.get(CONF_PASSWORD) host = config[CONF_HOST] port = config[CONF_PORT] + https = config.get(CONF_HTTPS, False) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN].setdefault(config_entry.entry_id, {}) @@ -134,7 +136,7 @@ async def async_setup_entry( session = async_get_clientsession(hass) _LOGGER.debug("Creating LMS object for %s", host) - lms = Server(session, host, port, username, password) + lms = Server(session, host, port, username, password, https=https) async def _discovery(now=None): """Discover squeezebox players by polling server.""" diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json index 87881e3414b..fd232851e8a 100644 --- a/homeassistant/components/squeezebox/strings.json +++ b/homeassistant/components/squeezebox/strings.json @@ -5,6 +5,9 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of your Logitech Media Server." } }, "edit": { @@ -13,7 +16,8 @@ "host": "[%key:common::config_flow::data::host%]", "port": "[%key:common::config_flow::data::port%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "https": "Connect over https (requires reverse proxy)" } } }, diff --git a/homeassistant/components/srp_energy/config_flow.py b/homeassistant/components/srp_energy/config_flow.py index c52574ff312..ac32e005e06 100644 --- a/homeassistant/components/srp_energy/config_flow.py +++ b/homeassistant/components/srp_energy/config_flow.py @@ -7,12 +7,12 @@ from srpenergy.client import SrpEnergyClient import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError -from .const import CONF_IS_TOU, DEFAULT_NAME, DOMAIN, LOGGER +from .const import CONF_IS_TOU, DOMAIN, LOGGER async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: @@ -40,46 +40,53 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle a flow initialized by the user.""" - errors = {} - default_title: str = DEFAULT_NAME - - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - if self.hass.config.location_name: - default_title = self.hass.config.location_name - - if user_input: - try: - await validate_input(self.hass, user_input) - except ValueError: - # Thrown when the account id is malformed - errors["base"] = "invalid_account" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - LOGGER.exception("Unexpected exception") - return self.async_abort(reason="unknown") - else: - return self.async_create_entry(title=default_title, data=user_input) - + @callback + def _show_form(self, errors: dict[str, Any]) -> FlowResult: + """Show the form to the user.""" + LOGGER.debug("Show Form") return self.async_show_form( step_id="user", data_schema=vol.Schema( { + vol.Required( + CONF_NAME, default=self.hass.config.location_name + ): str, vol.Required(CONF_ID): str, vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_IS_TOU, default=False): bool, } ), - errors=errors or {}, + errors=errors, ) + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + LOGGER.debug("Config entry") + errors: dict[str, str] = {} + if not user_input: + return self._show_form(errors) + + try: + await validate_input(self.hass, user_input) + except ValueError: + # Thrown when the account id is malformed + errors["base"] = "invalid_account" + return self._show_form(errors) + except InvalidAuth: + errors["base"] = "invalid_auth" + return self._show_form(errors) + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + await self.async_set_unique_id(user_input[CONF_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/srp_energy/const.py b/homeassistant/components/srp_energy/const.py index bace71aca55..b2ab05f43d5 100644 --- a/homeassistant/components/srp_energy/const.py +++ b/homeassistant/components/srp_energy/const.py @@ -11,3 +11,7 @@ CONF_IS_TOU = "is_tou" PHOENIX_TIME_ZONE = "America/Phoenix" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) + +DEVICE_CONFIG_URL = "https://www.srpnet.com/" +DEVICE_MANUFACTURER = "srpnet.com" +DEVICE_MODEL = "Service Api" diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 37aacf4ff25..9e8b8d08de9 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -11,10 +11,11 @@ from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import SRPEnergyDataUpdateCoordinator -from .const import DOMAIN +from .const import DEVICE_CONFIG_URL, DEVICE_MANUFACTURER, DEVICE_MODEL, DOMAIN async def async_setup_entry( @@ -37,18 +38,23 @@ class SrpEntity(CoordinatorEntity[SRPEnergyDataUpdateCoordinator], SensorEntity) _attr_translation_key = "energy_usage" def __init__( - self, coordinator: SRPEnergyDataUpdateCoordinator, config_entry: ConfigEntry + self, + coordinator: SRPEnergyDataUpdateCoordinator, + config_entry: ConfigEntry, ) -> None: """Initialize the SrpEntity class.""" super().__init__(coordinator) self._attr_unique_id = f"{config_entry.entry_id}_total_usage" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, config_entry.entry_id)}, - name="SRP Energy", + name=f"SRP Energy {config_entry.title}", entry_type=DeviceEntryType.SERVICE, + manufacturer=DEVICE_MANUFACTURER, + model=DEVICE_MODEL, + configuration_url=DEVICE_CONFIG_URL, ) @property - def native_value(self) -> float: + def native_value(self) -> StateType: """Return the state of the device.""" return self.coordinator.data diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json index fd963411198..35195ddb4f2 100644 --- a/homeassistant/components/srp_energy/strings.json +++ b/homeassistant/components/srp_energy/strings.json @@ -17,7 +17,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, "entity": { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index bf48b44e5dc..e6f18190c0b 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.36.2"] + "requirements": ["async-upnp-client==0.38.0"] } diff --git a/homeassistant/components/starline/account.py b/homeassistant/components/starline/account.py index f0dea666085..2940dcf0579 100644 --- a/homeassistant/components/starline/account.py +++ b/homeassistant/components/starline/account.py @@ -25,6 +25,12 @@ from .const import ( ) +def _parse_datetime(dt_str: str | None) -> str | None: + if dt_str is None or (parsed := dt_util.parse_datetime(dt_str)) is None: + return None + return parsed.replace(tzinfo=dt_util.UTC).isoformat() + + class StarlineAccount: """StarLine Account class.""" @@ -136,15 +142,14 @@ class StarlineAccount: model=device.typename, name=device.name, sw_version=device.fw_version, + configuration_url="https://starline-online.ru/", ) @staticmethod def gps_attrs(device: StarlineDevice) -> dict[str, Any]: """Attributes for device tracker.""" return { - "updated": dt_util.utc_from_timestamp(device.position["ts"]) - .replace(tzinfo=None) - .isoformat(), + "updated": dt_util.utc_from_timestamp(device.position["ts"]).isoformat(), "online": device.online, } @@ -154,7 +159,7 @@ class StarlineAccount: return { "operator": device.balance.get("operator"), "state": device.balance.get("state"), - "updated": device.balance.get("ts"), + "updated": _parse_datetime(device.balance.get("ts")), } @staticmethod diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index bef724392b7..c0fe56df71e 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -7,6 +7,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -18,7 +19,7 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="hbrake", translation_key="hand_brake", - device_class=BinarySensorDeviceClass.POWER, + icon="mdi:car-brake-parking", ), BinarySensorEntityDescription( key="hood", @@ -40,6 +41,24 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( translation_key="doors", device_class=BinarySensorDeviceClass.LOCK, ), + BinarySensorEntityDescription( + key="hfree", + translation_key="handsfree", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:hand-back-right", + ), + BinarySensorEntityDescription( + key="neutral", + translation_key="neutral", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:car-shift-pattern", + ), + BinarySensorEntityDescription( + key="arm_moving_pb", + translation_key="moving_ban", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:car-off", + ), ) diff --git a/homeassistant/components/starline/button.py b/homeassistant/components/starline/button.py new file mode 100644 index 00000000000..af6a05206e0 --- /dev/null +++ b/homeassistant/components/starline/button.py @@ -0,0 +1,57 @@ +"""Support for StarLine button.""" +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .account import StarlineAccount, StarlineDevice +from .const import DOMAIN +from .entity import StarlineEntity + +BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = ( + ButtonEntityDescription( + key="poke", + translation_key="horn", + icon="mdi:bullhorn-outline", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the StarLine button.""" + account: StarlineAccount = hass.data[DOMAIN][entry.entry_id] + entities = [] + for device in account.api.devices.values(): + if device.support_state: + for description in BUTTON_TYPES: + entities.append(StarlineButton(account, device, description)) + async_add_entities(entities) + + +class StarlineButton(StarlineEntity, ButtonEntity): + """Representation of a StarLine button.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + account: StarlineAccount, + device: StarlineDevice, + description: ButtonEntityDescription, + ) -> None: + """Initialize the button.""" + super().__init__(account, device, description.key) + self.entity_description = description + + @property + def available(self): + """Return True if entity is available.""" + return super().available and self._device.online + + def press(self): + """Press the button.""" + self._account.api.set_car_state(self._device.device_id, self._key, True) diff --git a/homeassistant/components/starline/const.py b/homeassistant/components/starline/const.py index be9656e70c9..06465c7b50e 100644 --- a/homeassistant/components/starline/const.py +++ b/homeassistant/components/starline/const.py @@ -7,10 +7,11 @@ _LOGGER = logging.getLogger(__package__) DOMAIN = "starline" PLATFORMS = [ - Platform.DEVICE_TRACKER, Platform.BINARY_SENSOR, - Platform.SENSOR, + Platform.BUTTON, + Platform.DEVICE_TRACKER, Platform.LOCK, + Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 4b787ae5212..603cceec222 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfElectricPotential, UnitOfLength, UnitOfTemperature, @@ -60,6 +61,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="errors", translation_key="errors", icon="mdi:alert-octagon", + entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="mileage", diff --git a/homeassistant/components/starline/strings.json b/homeassistant/components/starline/strings.json index 800fd3a65f3..9631dbf7479 100644 --- a/homeassistant/components/starline/strings.json +++ b/homeassistant/components/starline/strings.json @@ -54,6 +54,15 @@ }, "doors": { "name": "Doors" + }, + "handsfree": { + "name": "Handsfree" + }, + "neutral": { + "name": "Programmable neutral" + }, + "moving_ban": { + "name": "Moving ban" } }, "device_tracker": { @@ -104,7 +113,21 @@ }, "horn": { "name": "Horn" + }, + "service_mode": { + "name": "Service mode" } + }, + "button": { + "horn": { + "name": "Horn" + } + } + }, + "issues": { + "deprecated_horn_switch": { + "title": "The Starline Horn switch entity is being removed", + "description": "Using the Horn switch is now deprecated and will be removed in a future version of Home Assistant.\n\nPlease adjust any automations or scripts that use Horn switch entity to instead use the Horn button entity." } }, "services": { diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index ebe27e29e8c..ef24dd52c02 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -8,13 +8,14 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio 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, create_issue from .account import StarlineAccount, StarlineDevice from .const import DOMAIN from .entity import StarlineEntity -@dataclass +@dataclass(frozen=True) class StarlineRequiredKeysMixin: """Mixin for required keys.""" @@ -22,7 +23,7 @@ class StarlineRequiredKeysMixin: icon_off: str -@dataclass +@dataclass(frozen=True) class StarlineSwitchEntityDescription( SwitchEntityDescription, StarlineRequiredKeysMixin ): @@ -48,12 +49,19 @@ SWITCH_TYPES: tuple[StarlineSwitchEntityDescription, ...] = ( icon_on="mdi:access-point-network", icon_off="mdi:access-point-network-off", ), + # Deprecated and should be removed in 2024.8 StarlineSwitchEntityDescription( key="poke", translation_key="horn", icon_on="mdi:bullhorn-outline", icon_off="mdi:bullhorn-outline", ), + StarlineSwitchEntityDescription( + key="valet", + translation_key="service_mode", + icon_on="mdi:wrench-clock", + icon_off="mdi:car-wrench", + ), ) @@ -119,6 +127,16 @@ class StarlineSwitch(StarlineEntity, SwitchEntity): def turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" + if self._key == "poke": + create_issue( + self.hass, + DOMAIN, + "deprecated_horn_switch", + breaks_in_ha_version="2024.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_horn_switch", + ) self._account.api.set_car_state(self._device.device_id, self._key, True) def turn_off(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py index 87614460096..d346c19fec4 100644 --- a/homeassistant/components/starlink/binary_sensor.py +++ b/homeassistant/components/starlink/binary_sensor.py @@ -32,14 +32,14 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[StarlinkData], bool | None] -@dataclass +@dataclass(frozen=True) class StarlinkBinarySensorEntityDescription( BinarySensorEntityDescription, StarlinkBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/starlink/button.py b/homeassistant/components/starlink/button.py index 2df9d9b033b..daf3122a00d 100644 --- a/homeassistant/components/starlink/button.py +++ b/homeassistant/components/starlink/button.py @@ -31,14 +31,14 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkButtonEntityDescriptionMixin: """Mixin for required keys.""" press_fn: Callable[[StarlinkUpdateCoordinator], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class StarlinkButtonEntityDescription( ButtonEntityDescription, StarlinkButtonEntityDescriptionMixin ): diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py index eb832741f40..f260a7d1c32 100644 --- a/homeassistant/components/starlink/device_tracker.py +++ b/homeassistant/components/starlink/device_tracker.py @@ -26,7 +26,7 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkDeviceTrackerEntityDescriptionMixin: """Describes a Starlink device tracker.""" @@ -34,7 +34,7 @@ class StarlinkDeviceTrackerEntityDescriptionMixin: longitude_fn: Callable[[StarlinkData], float] -@dataclass +@dataclass(frozen=True) class StarlinkDeviceTrackerEntityDescription( EntityDescription, StarlinkDeviceTrackerEntityDescriptionMixin ): diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index ab76a8dffdd..d5116d49305 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -40,14 +40,14 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[StarlinkData], datetime | StateType] -@dataclass +@dataclass(frozen=True) class StarlinkSensorEntityDescription( SensorEntityDescription, StarlinkSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/starlink/switch.py b/homeassistant/components/starlink/switch.py index 31932fe9854..551afa8e73c 100644 --- a/homeassistant/components/starlink/switch.py +++ b/homeassistant/components/starlink/switch.py @@ -31,7 +31,7 @@ async def async_setup_entry( ) -@dataclass +@dataclass(frozen=True) class StarlinkSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -40,7 +40,7 @@ class StarlinkSwitchEntityDescriptionMixin: turn_off_fn: Callable[[StarlinkUpdateCoordinator], Awaitable[None]] -@dataclass +@dataclass(frozen=True) class StarlinkSwitchEntityDescription( SwitchEntityDescription, StarlinkSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/steamist/entity.py b/homeassistant/components/steamist/entity.py index 94b3d32eaa4..78340dab363 100644 --- a/homeassistant/components/steamist/entity.py +++ b/homeassistant/components/steamist/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from aiosteamist import SteamistStatus from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME +from homeassistant.const import CONF_HOST, CONF_MODEL from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription @@ -14,7 +14,9 @@ from .coordinator import SteamistDataUpdateCoordinator class SteamistEntity(CoordinatorEntity[SteamistDataUpdateCoordinator], Entity): - """Representation of an Steamist entity.""" + """Representation of a Steamist entity.""" + + _attr_has_entity_name = True def __init__( self, @@ -25,13 +27,10 @@ class SteamistEntity(CoordinatorEntity[SteamistDataUpdateCoordinator], Entity): """Initialize the entity.""" super().__init__(coordinator) self.entity_description = description - if coordinator.device_name: - self._attr_name = f"{coordinator.device_name} {description.name}" self._attr_unique_id = f"{entry.entry_id}_{description.key}" if entry.unique_id: # Only present if UDP broadcast works self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)}, - name=entry.data[CONF_NAME], manufacturer="Steamist", model=entry.data[CONF_MODEL], configuration_url=f"http://{entry.data[CONF_HOST]}", diff --git a/homeassistant/components/steamist/sensor.py b/homeassistant/components/steamist/sensor.py index 17cc0e8c272..dd51c485b4e 100644 --- a/homeassistant/components/steamist/sensor.py +++ b/homeassistant/components/steamist/sensor.py @@ -30,14 +30,14 @@ UNIT_MAPPINGS = { } -@dataclass +@dataclass(frozen=True) class SteamistSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[SteamistStatus], int | None] -@dataclass +@dataclass(frozen=True) class SteamistSensorEntityDescription( SensorEntityDescription, SteamistSensorEntityDescriptionMixin ): @@ -47,13 +47,13 @@ class SteamistSensorEntityDescription( SENSORS: tuple[SteamistSensorEntityDescription, ...] = ( SteamistSensorEntityDescription( key=_KEY_MINUTES_REMAIN, - name="Steam Minutes Remain", + translation_key="steam_minutes_remain", native_unit_of_measurement=UnitOfTime.MINUTES, value_fn=lambda status: status.minutes_remain, ), SteamistSensorEntityDescription( key=_KEY_TEMP, - name="Steam Temperature", + translation_key="steam_temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda status: status.temp, @@ -79,7 +79,7 @@ async def async_setup_entry( class SteamistSensorEntity(SteamistEntity, SensorEntity): - """Representation of an Steamist steam switch.""" + """Representation of a Steamist steam switch.""" entity_description: SteamistSensorEntityDescription diff --git a/homeassistant/components/steamist/strings.json b/homeassistant/components/steamist/strings.json index 8827df6a08a..7bc3685472a 100644 --- a/homeassistant/components/steamist/strings.json +++ b/homeassistant/components/steamist/strings.json @@ -28,5 +28,20 @@ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "not_steamist_device": "Not a steamist device" } + }, + "entity": { + "sensor": { + "steam_minutes_remain": { + "name": "Steam minutes remain" + }, + "steam_temperature": { + "name": "Steam temperature" + } + }, + "switch": { + "steam_active": { + "name": "Steam active" + } + } } } diff --git a/homeassistant/components/steamist/switch.py b/homeassistant/components/steamist/switch.py index af9e894b70d..a9a7526c560 100644 --- a/homeassistant/components/steamist/switch.py +++ b/homeassistant/components/steamist/switch.py @@ -13,7 +13,9 @@ from .coordinator import SteamistDataUpdateCoordinator from .entity import SteamistEntity ACTIVE_SWITCH = SwitchEntityDescription( - key="active", icon="mdi:pot-steam", name="Steam Active" + key="active", + icon="mdi:pot-steam", + translation_key="steam_active", ) @@ -30,7 +32,7 @@ async def async_setup_entry( class SteamistSwitchEntity(SteamistEntity, SwitchEntity): - """Representation of an Steamist steam switch.""" + """Representation of a Steamist steam switch.""" @property def is_on(self) -> bool: diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index c09f6040fed..986b5de8049 100644 --- a/homeassistant/components/streamlabswater/__init__.py +++ b/homeassistant/components/streamlabswater/__init__.py @@ -1,28 +1,31 @@ """Support for Streamlabs Water Monitor devices.""" -import logging -from streamlabswater import streamlabswater +from streamlabswater.streamlabswater import StreamlabsClient import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY, Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import discovery +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + ServiceCall, +) +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -DOMAIN = "streamlabswater" - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .coordinator import StreamlabsCoordinator ATTR_AWAY_MODE = "away_mode" SERVICE_SET_AWAY_MODE = "set_away_mode" AWAY_MODE_AWAY = "away" AWAY_MODE_HOME = "home" -PLATFORMS = [Platform.SENSOR, Platform.BINARY_SENSOR] - CONF_LOCATION_ID = "location_id" +ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=streamlabswater"} CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -36,52 +39,77 @@ CONFIG_SCHEMA = vol.Schema( ) SET_AWAY_MODE_SCHEMA = vol.Schema( - {vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME])} + { + vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]), + vol.Optional(CONF_LOCATION_ID): cv.string, + } ) +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the streamlabs water integration.""" - conf = config[DOMAIN] - api_key = conf.get(CONF_API_KEY) - location_id = conf.get(CONF_LOCATION_ID) + if DOMAIN not in config: + return True - client = streamlabswater.StreamlabsClient(api_key) - locations = client.get_locations().get("locations") - - if locations is None: - _LOGGER.error("Unable to retrieve locations. Verify API key") - return False - - if location_id is None: - location = locations[0] - location_id = location["locationId"] - _LOGGER.info( - "Streamlabs Water Monitor auto-detected location_id=%s", location_id + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: config[DOMAIN][CONF_API_KEY]}, + ) + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "already_configured" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "StreamLabs", + }, ) else: - location = next( - (loc for loc in locations if location_id == loc["locationId"]), None + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_${result['reason']}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_${result['reason']}", + translation_placeholders=ISSUE_PLACEHOLDER, ) - if location is None: - _LOGGER.error("Supplied location_id is invalid") - return False + return True - location_name = location["name"] - hass.data[DOMAIN] = { - "client": client, - "location_id": location_id, - "location_name": location_name, - } +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up StreamLabs from a config entry.""" - for platform in PLATFORMS: - discovery.load_platform(hass, platform, DOMAIN, {}, config) + api_key = entry.data[CONF_API_KEY] + client = StreamlabsClient(api_key) + coordinator = StreamlabsCoordinator(hass, client) + + 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) def set_away_mode(service: ServiceCall) -> None: """Set the StreamLabsWater Away Mode.""" away_mode = service.data.get(ATTR_AWAY_MODE) + location_id = ( + service.data.get(CONF_LOCATION_ID) or list(coordinator.data.values())[0] + ) client.update_location(location_id, away_mode) hass.services.register( @@ -89,3 +117,11 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: ) 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/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py index 43465fb99ae..d0ca500ded4 100644 --- a/homeassistant/components/streamlabswater/binary_sensor.py +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -1,80 +1,54 @@ """Support for Streamlabs Water Monitor Away Mode.""" from __future__ import annotations -from datetime import timedelta - from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN as STREAMLABSWATER_DOMAIN +from . import StreamlabsCoordinator +from .const import DOMAIN +from .coordinator import StreamlabsData -DEPENDS = ["streamlabswater"] - -MIN_TIME_BETWEEN_LOCATION_UPDATES = timedelta(seconds=60) - -ATTR_LOCATION_ID = "location_id" NAME_AWAY_MODE = "Water Away Mode" -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_devices: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the StreamLabsWater mode sensor.""" - client = hass.data[STREAMLABSWATER_DOMAIN]["client"] - location_id = hass.data[STREAMLABSWATER_DOMAIN]["location_id"] - location_name = hass.data[STREAMLABSWATER_DOMAIN]["location_name"] + """Set up Streamlabs water binary sensor from a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] - streamlabs_location_data = StreamlabsLocationData(location_id, client) - streamlabs_location_data.update() + entities = [] - add_devices([StreamlabsAwayMode(location_name, streamlabs_location_data)]) + for location_id in coordinator.data: + entities.append(StreamlabsAwayMode(coordinator, location_id)) + + async_add_entities(entities) -class StreamlabsLocationData: - """Track and query location data.""" - - def __init__(self, location_id, client): - """Initialize the location data.""" - self._location_id = location_id - self._client = client - self._is_away = None - - @Throttle(MIN_TIME_BETWEEN_LOCATION_UPDATES) - def update(self): - """Query and store location data.""" - location = self._client.get_location(self._location_id) - self._is_away = location["homeAway"] == "away" - - def is_away(self): - """Return whether away more is enabled.""" - return self._is_away - - -class StreamlabsAwayMode(BinarySensorEntity): +class StreamlabsAwayMode(CoordinatorEntity[StreamlabsCoordinator], BinarySensorEntity): """Monitor the away mode state.""" - def __init__(self, location_name, streamlabs_location_data): + def __init__(self, coordinator: StreamlabsCoordinator, location_id: str) -> None: """Initialize the away mode device.""" - self._location_name = location_name - self._streamlabs_location_data = streamlabs_location_data - self._is_away = None + super().__init__(coordinator) + self._location_id = location_id @property - def name(self): + def location_data(self) -> StreamlabsData: + """Returns the data object.""" + return self.coordinator.data[self._location_id] + + @property + def name(self) -> str: """Return the name for away mode.""" - return f"{self._location_name} {NAME_AWAY_MODE}" + return f"{self.location_data.name} {NAME_AWAY_MODE}" @property - def is_on(self): + def is_on(self) -> bool: """Return if away mode is on.""" - return self._streamlabs_location_data.is_away() - - def update(self) -> None: - """Retrieve the latest location data and away mode state.""" - self._streamlabs_location_data.update() + return self.location_data.is_away diff --git a/homeassistant/components/streamlabswater/config_flow.py b/homeassistant/components/streamlabswater/config_flow.py new file mode 100644 index 00000000000..5cede037d5a --- /dev/null +++ b/homeassistant/components/streamlabswater/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for StreamLabs integration.""" +from __future__ import annotations + +from typing import Any + +from streamlabswater.streamlabswater import StreamlabsClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN, LOGGER + + +async def validate_input(hass: HomeAssistant, api_key: str) -> None: + """Validate the user input allows us to connect.""" + client = StreamlabsClient(api_key) + response = await hass.async_add_executor_job(client.get_locations) + locations = response.get("locations") + + if locations is None: + raise CannotConnect + + +class StreamlabsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for StreamLabs.""" + + 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: + self._async_abort_entries_match(user_input) + try: + await validate_input(self.hass, user_input[CONF_API_KEY]) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title="Streamlabs", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Import the yaml config.""" + self._async_abort_entries_match(user_input) + try: + await validate_input(self.hass, user_input[CONF_API_KEY]) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + + return self.async_create_entry(title="Streamlabs", data=user_input) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/streamlabswater/const.py b/homeassistant/components/streamlabswater/const.py new file mode 100644 index 00000000000..ee407d376d4 --- /dev/null +++ b/homeassistant/components/streamlabswater/const.py @@ -0,0 +1,6 @@ +"""Constants for the StreamLabs integration.""" +import logging + +DOMAIN = "streamlabswater" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/streamlabswater/coordinator.py b/homeassistant/components/streamlabswater/coordinator.py new file mode 100644 index 00000000000..a11eced5a6e --- /dev/null +++ b/homeassistant/components/streamlabswater/coordinator.py @@ -0,0 +1,57 @@ +"""Coordinator for Streamlabs water integration.""" +from dataclasses import dataclass +from datetime import timedelta + +from streamlabswater.streamlabswater import StreamlabsClient + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import LOGGER + + +@dataclass(slots=True) +class StreamlabsData: + """Class to hold Streamlabs data.""" + + is_away: bool + name: str + daily_usage: float + monthly_usage: float + yearly_usage: float + + +class StreamlabsCoordinator(DataUpdateCoordinator[dict[str, StreamlabsData]]): + """Coordinator for Streamlabs.""" + + def __init__( + self, + hass: HomeAssistant, + client: StreamlabsClient, + ) -> None: + """Coordinator for Streamlabs.""" + super().__init__( + hass, + LOGGER, + name="Streamlabs", + update_interval=timedelta(seconds=60), + ) + self.client = client + + async def _async_update_data(self) -> dict[str, StreamlabsData]: + return await self.hass.async_add_executor_job(self._update_data) + + def _update_data(self) -> dict[str, StreamlabsData]: + locations = self.client.get_locations() + res = {} + for location in locations: + location_id = location["locationId"] + water_usage = self.client.get_water_usage_summary(location_id) + res[location_id] = StreamlabsData( + is_away=location["homeAway"] == "away", + name=location["name"], + daily_usage=round(water_usage["today"], 1), + monthly_usage=round(water_usage["thisMonth"], 1), + yearly_usage=round(water_usage["thisYear"], 1), + ) + return res diff --git a/homeassistant/components/streamlabswater/manifest.json b/homeassistant/components/streamlabswater/manifest.json index fae19ca3e7a..ec076bd52ec 100644 --- a/homeassistant/components/streamlabswater/manifest.json +++ b/homeassistant/components/streamlabswater/manifest.json @@ -2,6 +2,7 @@ "domain": "streamlabswater", "name": "StreamLabs", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/streamlabswater", "iot_class": "cloud_polling", "loggers": ["streamlabswater"], diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py index 42cf2bb588f..0b249b7c4e5 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -1,107 +1,69 @@ """Support for Streamlabs Water Monitor Usage.""" from __future__ import annotations -from datetime import timedelta - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfVolume from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DOMAIN as STREAMLABSWATER_DOMAIN - -DEPENDENCIES = ["streamlabswater"] - -WATER_ICON = "mdi:water" -MIN_TIME_BETWEEN_USAGE_UPDATES = timedelta(seconds=60) +from . import StreamlabsCoordinator +from .const import DOMAIN +from .coordinator import StreamlabsData NAME_DAILY_USAGE = "Daily Water" NAME_MONTHLY_USAGE = "Monthly Water" NAME_YEARLY_USAGE = "Yearly Water" -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_devices: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up water usage sensors.""" - client = hass.data[STREAMLABSWATER_DOMAIN]["client"] - location_id = hass.data[STREAMLABSWATER_DOMAIN]["location_id"] - location_name = hass.data[STREAMLABSWATER_DOMAIN]["location_name"] + """Set up Streamlabs water sensor from a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] - streamlabs_usage_data = StreamlabsUsageData(location_id, client) - streamlabs_usage_data.update() + entities = [] - add_devices( - [ - StreamLabsDailyUsage(location_name, streamlabs_usage_data), - StreamLabsMonthlyUsage(location_name, streamlabs_usage_data), - StreamLabsYearlyUsage(location_name, streamlabs_usage_data), - ] - ) + for location_id in coordinator.data.values(): + entities.extend( + [ + StreamLabsDailyUsage(coordinator, location_id), + StreamLabsMonthlyUsage(coordinator, location_id), + StreamLabsYearlyUsage(coordinator, location_id), + ] + ) + + async_add_entities(entities) -class StreamlabsUsageData: - """Track and query usage data.""" - - def __init__(self, location_id, client): - """Initialize the usage data.""" - self._location_id = location_id - self._client = client - self._today = None - self._this_month = None - self._this_year = None - - @Throttle(MIN_TIME_BETWEEN_USAGE_UPDATES) - def update(self): - """Query and store usage data.""" - water_usage = self._client.get_water_usage_summary(self._location_id) - self._today = round(water_usage["today"], 1) - self._this_month = round(water_usage["thisMonth"], 1) - self._this_year = round(water_usage["thisYear"], 1) - - def get_daily_usage(self): - """Return the day's usage.""" - return self._today - - def get_monthly_usage(self): - """Return the month's usage.""" - return self._this_month - - def get_yearly_usage(self): - """Return the year's usage.""" - return self._this_year - - -class StreamLabsDailyUsage(SensorEntity): +class StreamLabsDailyUsage(CoordinatorEntity[StreamlabsCoordinator], SensorEntity): """Monitors the daily water usage.""" _attr_device_class = SensorDeviceClass.WATER _attr_native_unit_of_measurement = UnitOfVolume.GALLONS - def __init__(self, location_name, streamlabs_usage_data): + def __init__(self, coordinator: StreamlabsCoordinator, location_id: str) -> None: """Initialize the daily water usage device.""" - self._location_name = location_name - self._streamlabs_usage_data = streamlabs_usage_data - self._state = None + super().__init__(coordinator) + self._location_id = location_id + + @property + def location_data(self) -> StreamlabsData: + """Returns the data object.""" + return self.coordinator.data[self._location_id] @property def name(self) -> str: """Return the name for daily usage.""" - return f"{self._location_name} {NAME_DAILY_USAGE}" + return f"{self.location_data.name} {NAME_DAILY_USAGE}" @property - def native_value(self): + def native_value(self) -> float: """Return the current daily usage.""" - return self._streamlabs_usage_data.get_daily_usage() - - def update(self) -> None: - """Retrieve the latest daily usage.""" - self._streamlabs_usage_data.update() + return self.location_data.daily_usage class StreamLabsMonthlyUsage(StreamLabsDailyUsage): @@ -110,12 +72,12 @@ class StreamLabsMonthlyUsage(StreamLabsDailyUsage): @property def name(self) -> str: """Return the name for monthly usage.""" - return f"{self._location_name} {NAME_MONTHLY_USAGE}" + return f"{self.location_data.name} {NAME_MONTHLY_USAGE}" @property - def native_value(self): + def native_value(self) -> float: """Return the current monthly usage.""" - return self._streamlabs_usage_data.get_monthly_usage() + return self.location_data.monthly_usage class StreamLabsYearlyUsage(StreamLabsDailyUsage): @@ -124,9 +86,9 @@ class StreamLabsYearlyUsage(StreamLabsDailyUsage): @property def name(self) -> str: """Return the name for yearly usage.""" - return f"{self._location_name} {NAME_YEARLY_USAGE}" + return f"{self.location_data.name} {NAME_YEARLY_USAGE}" @property - def native_value(self): + def native_value(self) -> float: """Return the current yearly usage.""" - return self._streamlabs_usage_data.get_yearly_usage() + return self.location_data.yearly_usage diff --git a/homeassistant/components/streamlabswater/services.yaml b/homeassistant/components/streamlabswater/services.yaml index 7504a911123..cd828fd3fed 100644 --- a/homeassistant/components/streamlabswater/services.yaml +++ b/homeassistant/components/streamlabswater/services.yaml @@ -7,3 +7,6 @@ set_away_mode: options: - "away" - "home" + location_id: + selector: + text: diff --git a/homeassistant/components/streamlabswater/strings.json b/homeassistant/components/streamlabswater/strings.json index 56b35ab1044..e6b5dd7465b 100644 --- a/homeassistant/components/streamlabswater/strings.json +++ b/homeassistant/components/streamlabswater/strings.json @@ -1,4 +1,20 @@ { + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, "services": { "set_away_mode": { "name": "Set away mode", @@ -7,8 +23,22 @@ "away_mode": { "name": "Away mode", "description": "Home or away." + }, + "location_id": { + "name": "Location ID", + "description": "The location ID of the Streamlabs Water Monitor." } } } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Streamlabs water YAML configuration import failed", + "description": "Configuring Streamlabs water using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to Streamlabs water works and restart Home Assistant to try again or remove the Streamlabs water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Streamlabs water YAML configuration import failed", + "description": "Configuring Streamlabs water using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the imported configuration is correct and remove the Streamlabs water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } } } diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index 862f59d5f6d..45f8ccefc68 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -6,8 +6,9 @@ from collections.abc import AsyncIterable, Coroutine import logging from typing import Any +from homeassistant.config import config_per_platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_prepare_setup_platform @@ -28,9 +29,6 @@ _LOGGER = logging.getLogger(__name__) @callback def async_default_provider(hass: HomeAssistant) -> str | None: """Return the domain of the default provider.""" - if "cloud" in hass.data[DATA_PROVIDERS]: - return "cloud" - return next(iter(hass.data[DATA_PROVIDERS]), None) diff --git a/homeassistant/components/subaru/__init__.py b/homeassistant/components/subaru/__init__.py index 091a281defc..8a22391284f 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -6,7 +6,13 @@ import time from subarulink import Controller as SubaruAPI, InvalidCredentials, SubaruException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.const import ( + CONF_COUNTRY, + CONF_DEVICE_ID, + CONF_PASSWORD, + CONF_PIN, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client @@ -14,7 +20,6 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - CONF_COUNTRY, CONF_UPDATE_ENABLED, COORDINATOR_NAME, DOMAIN, diff --git a/homeassistant/components/subaru/config_flow.py b/homeassistant/components/subaru/config_flow.py index 6d1d5015ed3..b21feab7843 100644 --- a/homeassistant/components/subaru/config_flow.py +++ b/homeassistant/components/subaru/config_flow.py @@ -15,12 +15,18 @@ from subarulink.const import COUNTRY_CAN, COUNTRY_USA import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.const import ( + CONF_COUNTRY, + CONF_DEVICE_ID, + CONF_PASSWORD, + CONF_PIN, + CONF_USERNAME, +) from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv -from .const import CONF_COUNTRY, CONF_UPDATE_ENABLED, DOMAIN +from .const import CONF_UPDATE_ENABLED, DOMAIN _LOGGER = logging.getLogger(__name__) CONF_CONTACT_METHOD = "contact_method" diff --git a/homeassistant/components/subaru/const.py b/homeassistant/components/subaru/const.py index 9c94ed35361..ab76c363f7e 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -7,7 +7,6 @@ DOMAIN = "subaru" FETCH_INTERVAL = 300 UPDATE_INTERVAL = 7200 CONF_UPDATE_ENABLED = "update_enabled" -CONF_COUNTRY = "country" # entry fields ENTRY_CONTROLLER = "controller" diff --git a/homeassistant/components/suez_water/__init__.py b/homeassistant/components/suez_water/__init__.py index a2d07a8d0a4..66c3981705c 100644 --- a/homeassistant/components/suez_water/__init__.py +++ b/homeassistant/components/suez_water/__init__.py @@ -1 +1,48 @@ -"""France Suez Water integration.""" +"""The Suez Water integration.""" +from __future__ import annotations + +from pysuez import SuezClient +from pysuez.client import PySuezError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import CONF_COUNTER_ID, DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Suez Water from a config entry.""" + + def get_client() -> SuezClient: + try: + client = SuezClient( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_COUNTER_ID], + provider=None, + ) + if not client.check_credentials(): + raise ConfigEntryError + return client + except PySuezError: + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {})[ + entry.entry_id + ] = await hass.async_add_executor_job(get_client) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/suez_water/config_flow.py b/homeassistant/components/suez_water/config_flow.py new file mode 100644 index 00000000000..ba288c90e34 --- /dev/null +++ b/homeassistant/components/suez_water/config_flow.py @@ -0,0 +1,99 @@ +"""Config flow for Suez Water integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pysuez import SuezClient +from pysuez.client import PySuezError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import CONF_COUNTER_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_COUNTER_ID): str, + } +) + + +def validate_input(data: dict[str, Any]) -> None: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + try: + client = SuezClient( + data[CONF_USERNAME], + data[CONF_PASSWORD], + data[CONF_COUNTER_ID], + provider=None, + ) + if not client.check_credentials(): + raise InvalidAuth + except PySuezError: + raise CannotConnect + + +class SuezWaterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Suez Water.""" + + 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: + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + try: + await self.hass.async_add_executor_job(validate_input, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Import the yaml config.""" + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + try: + await self.hass.async_add_executor_job(validate_input, user_input) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + except InvalidAuth: + return self.async_abort(reason="invalid_auth") + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/suez_water/const.py b/homeassistant/components/suez_water/const.py new file mode 100644 index 00000000000..7afc0d3ce3e --- /dev/null +++ b/homeassistant/components/suez_water/const.py @@ -0,0 +1,5 @@ +"""Constants for the Suez Water integration.""" + +DOMAIN = "suez_water" + +CONF_COUNTER_ID = "counter_id" diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json index 3da91c4aa52..4503d7a1119 100644 --- a/homeassistant/components/suez_water/manifest.json +++ b/homeassistant/components/suez_water/manifest.json @@ -2,6 +2,7 @@ "domain": "suez_water", "name": "Suez Water", "codeowners": ["@ooii"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/suez_water", "iot_class": "cloud_polling", "loggers": ["pysuez", "regex"], diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py index d0c1bba211e..4602df27748 100644 --- a/homeassistant/components/suez_water/sensor.py +++ b/homeassistant/components/suez_water/sensor.py @@ -13,18 +13,22 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfVolume -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType 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 +from .const import CONF_COUNTER_ID, DOMAIN + _LOGGER = logging.getLogger(__name__) +ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=suez_water"} SCAN_INTERVAL = timedelta(hours=12) -CONF_COUNTER_ID = "counter_id" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_USERNAME): cv.string, @@ -34,28 +38,58 @@ 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 sensor platform.""" - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - counter_id = config[CONF_COUNTER_ID] - try: - client = SuezClient(username, password, counter_id, provider=None) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "already_configured" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Suez Water", + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_${result['reason']}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_${result['reason']}", + translation_placeholders=ISSUE_PLACEHOLDER, + ) - if not client.check_credentials(): - _LOGGER.warning("Wrong username and/or password") - return - except PySuezError: - _LOGGER.warning("Unable to create Suez Client") - return - - add_entities([SuezSensor(client)], True) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Suez Water sensor from a config entry.""" + client = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SuezSensor(client)], True) class SuezSensor(SensorEntity): @@ -71,7 +105,7 @@ class SuezSensor(SensorEntity): self.client = client self._attr_extra_state_attributes = {} - def _fetch_data(self): + def _fetch_data(self) -> None: """Fetch latest data from Suez.""" try: self.client.update() diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json new file mode 100644 index 00000000000..09df3ead17f --- /dev/null +++ b/homeassistant/components/suez_water/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "counter_id": "Counter id" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_invalid_auth": { + "title": "The Suez water YAML configuration import failed", + "description": "Configuring Suez water using YAML is being removed but there was an authentication error importing your YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the Suez water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Suez water YAML configuration import failed", + "description": "Configuring Suez water using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to Suez water works and restart Home Assistant to try again or remove the Suez water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Suez water YAML configuration import failed", + "description": "Configuring Suez water using YAML is being removed but there was an unknown error when trying to import the YAML configuration.\n\nEnsure the imported configuration is correct and remove the Suez water YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + } + } +} diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 0f867f9b7c4..384e356fdd6 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -26,19 +26,14 @@ from .const import DOMAIN, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED ENTITY_ID_SENSOR_FORMAT = SENSOR_DOMAIN + ".sun_{}" -@dataclass -class SunEntityDescriptionMixin: - """Mixin for required Sun base description keys.""" +@dataclass(kw_only=True, frozen=True) +class SunSensorEntityDescription(SensorEntityDescription): + """Describes a Sun sensor entity.""" value_fn: Callable[[Sun], StateType | datetime] signal: str -@dataclass -class SunSensorEntityDescription(SensorEntityDescription, SunEntityDescriptionMixin): - """Describes Sun sensor entity.""" - - SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( SunSensorEntityDescription( key="next_dawn", diff --git a/homeassistant/components/sunweg/__init__.py b/homeassistant/components/sunweg/__init__.py new file mode 100644 index 00000000000..9da91ccda0f --- /dev/null +++ b/homeassistant/components/sunweg/__init__.py @@ -0,0 +1,196 @@ +"""The Sun WEG inverter sensor integration.""" +import datetime +import json +import logging + +from sunweg.api import APIHelper +from sunweg.plant import Plant + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import StateType, UndefinedType +from homeassistant.util import Throttle + +from .const import CONF_PLANT_ID, DOMAIN, PLATFORMS, DeviceType + +SCAN_INTERVAL = datetime.timedelta(minutes=5) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Load the saved entities.""" + api = APIHelper(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + if not await hass.async_add_executor_job(api.authenticate): + _LOGGER.error("Username or Password may be incorrect!") + return False + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SunWEGData( + api, entry.data[CONF_PLANT_ID] + ) + 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.""" + hass.data[DOMAIN].pop(entry.entry_id) + if len(hass.data[DOMAIN]) == 0: + hass.data.pop(DOMAIN) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +class SunWEGData: + """The class for handling data retrieval.""" + + def __init__( + self, + api: APIHelper, + plant_id: int, + ) -> None: + """Initialize the probe.""" + + self.api = api + self.plant_id = plant_id + self.data: Plant = None + self.previous_values: dict = {} + + @Throttle(SCAN_INTERVAL) + def update(self) -> None: + """Update probe data.""" + _LOGGER.debug("Updating data for plant %s", self.plant_id) + try: + self.data = self.api.plant(self.plant_id) + for inverter in self.data.inverters: + self.api.complete_inverter(inverter) + except json.decoder.JSONDecodeError: + _LOGGER.error("Unable to fetch data from SunWEG server") + _LOGGER.debug("Finished updating data for plant %s", self.plant_id) + + def get_api_value( + self, + variable: str, + device_type: DeviceType, + inverter_id: int = 0, + deep_name: str | None = None, + ): + """Retrieve from a Plant the desired variable value.""" + if device_type == DeviceType.TOTAL: + return self.data.__dict__.get(variable) + + inverter_list = [i for i in self.data.inverters if i.id == inverter_id] + if len(inverter_list) == 0: + return None + inverter = inverter_list[0] + + if device_type == DeviceType.INVERTER: + return inverter.__dict__.get(variable) + if device_type == DeviceType.PHASE: + for phase in inverter.phases: + if phase.name == deep_name: + return phase.__dict__.get(variable) + elif device_type == DeviceType.STRING: + for mppt in inverter.mppts: + for string in mppt.strings: + if string.name == deep_name: + return string.__dict__.get(variable) + return None + + def get_data( + self, + *, + api_variable_key: str, + api_variable_unit: str | None, + deep_name: str | None, + device_type: DeviceType, + inverter_id: int, + name: str | UndefinedType | None, + native_unit_of_measurement: str | None, + never_resets: bool, + previous_value_drop_threshold: float | None, + ) -> tuple[StateType | datetime.datetime, str | None]: + """Get the data.""" + _LOGGER.debug( + "Data request for: %s", + name, + ) + variable = api_variable_key + previous_unit = native_unit_of_measurement + api_value = self.get_api_value(variable, device_type, inverter_id, deep_name) + previous_value = self.previous_values.get(variable) + return_value = api_value + if api_variable_unit is not None: + native_unit_of_measurement = self.get_api_value( + api_variable_unit, + device_type, + inverter_id, + deep_name, + ) + + # If we have a 'drop threshold' specified, then check it and correct if needed + if ( + previous_value_drop_threshold is not None + and previous_value is not None + and api_value is not None + and previous_unit == native_unit_of_measurement + ): + _LOGGER.debug( + ( + "%s - Drop threshold specified (%s), checking for drop... API" + " Value: %s, Previous Value: %s" + ), + name, + previous_value_drop_threshold, + api_value, + previous_value, + ) + diff = float(api_value) - float(previous_value) + + # Check if the value has dropped (negative value i.e. < 0) and it has only + # dropped by a small amount, if so, use the previous value. + # Note - The energy dashboard takes care of drops within 10% + # of the current value, however if the value is low e.g. 0.2 + # and drops by 0.1 it classes as a reset. + if -(previous_value_drop_threshold) <= diff < 0: + _LOGGER.debug( + ( + "Diff is negative, but only by a small amount therefore not a" + " nightly reset, using previous value (%s) instead of api value" + " (%s)" + ), + previous_value, + api_value, + ) + return_value = previous_value + else: + _LOGGER.debug("%s - No drop detected, using API value", name) + + # Lifetime total values should always be increasing, they will never reset, + # however the API sometimes returns 0 values when the clock turns to 00:00 + # local time in that scenario we should just return the previous value + # Scenarios: + # 1 - System has a genuine 0 value when it it first commissioned: + # - will return 0 until a non-zero value is registered + # 2 - System has been running fine but temporarily resets to 0 briefly + # at midnight: + # - will return the previous value + # 3 - HA is restarted during the midnight 'outage' - Not handled: + # - Previous value will not exist meaning 0 will be returned + # - This is an edge case that would be better handled by looking + # up the previous value of the entity from the recorder + if never_resets and api_value == 0 and previous_value: + _LOGGER.debug( + ( + "API value is 0, but this value should never reset, returning" + " previous value (%s) instead" + ), + previous_value, + ) + return_value = previous_value + + self.previous_values[variable] = return_value + + return (return_value, native_unit_of_measurement) diff --git a/homeassistant/components/sunweg/config_flow.py b/homeassistant/components/sunweg/config_flow.py new file mode 100644 index 00000000000..cd24a4722e9 --- /dev/null +++ b/homeassistant/components/sunweg/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for Sun WEG integration.""" +from sunweg.api import APIHelper +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_PLANT_ID, DOMAIN + + +class SunWEGConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow class.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialise sun weg server flow.""" + self.api: APIHelper = None + self.data: dict = {} + + @callback + def _async_show_user_form(self, errors=None) -> FlowResult: + """Show the form to the user.""" + data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle the start of the config flow.""" + if not user_input: + return self._async_show_user_form() + + # Initialise the library with the username & password + self.api = APIHelper(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + login_response = await self.hass.async_add_executor_job(self.api.authenticate) + + if not login_response: + return self._async_show_user_form({"base": "invalid_auth"}) + + # Store authentication info + self.data = user_input + return await self.async_step_plant() + + async def async_step_plant(self, user_input=None) -> FlowResult: + """Handle adding a "plant" to Home Assistant.""" + plant_list = await self.hass.async_add_executor_job(self.api.listPlants) + + if len(plant_list) == 0: + return self.async_abort(reason="no_plants") + + plants = {plant.id: plant.name for plant in plant_list} + + if user_input is None and len(plant_list) > 1: + data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)}) + + return self.async_show_form(step_id="plant", data_schema=data_schema) + + if user_input is None and len(plant_list) == 1: + user_input = {CONF_PLANT_ID: plant_list[0].id} + + user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]] + await self.async_set_unique_id(user_input[CONF_PLANT_ID]) + self._abort_if_unique_id_configured() + self.data.update(user_input) + return self.async_create_entry(title=self.data[CONF_NAME], data=self.data) diff --git a/homeassistant/components/sunweg/const.py b/homeassistant/components/sunweg/const.py new file mode 100644 index 00000000000..e4b2b242abf --- /dev/null +++ b/homeassistant/components/sunweg/const.py @@ -0,0 +1,24 @@ +"""Define constants for the Sun WEG component.""" +from enum import Enum + +from homeassistant.const import Platform + + +class DeviceType(Enum): + """Device Type Enum.""" + + TOTAL = 1 + INVERTER = 2 + PHASE = 3 + STRING = 4 + + +CONF_PLANT_ID = "plant_id" + +DEFAULT_PLANT_ID = 0 + +DEFAULT_NAME = "Sun WEG" + +DOMAIN = "sunweg" + +PLATFORMS = [Platform.SENSOR] diff --git a/homeassistant/components/sunweg/manifest.json b/homeassistant/components/sunweg/manifest.json new file mode 100644 index 00000000000..de0b3406f05 --- /dev/null +++ b/homeassistant/components/sunweg/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sunweg", + "name": "Sun WEG", + "codeowners": ["@rokam"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sunweg/", + "iot_class": "cloud_polling", + "loggers": ["sunweg"], + "requirements": ["sunweg==2.0.3"] +} diff --git a/homeassistant/components/sunweg/sensor.py b/homeassistant/components/sunweg/sensor.py new file mode 100644 index 00000000000..42a3dc33d2b --- /dev/null +++ b/homeassistant/components/sunweg/sensor.py @@ -0,0 +1,177 @@ +"""Read status of SunWEG inverters.""" +from __future__ import annotations + +import logging +from types import MappingProxyType +from typing import Any + +from sunweg.api import APIHelper +from sunweg.device import Inverter +from sunweg.plant import Plant + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SunWEGData +from .const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN, DeviceType +from .sensor_types.inverter import INVERTER_SENSOR_TYPES +from .sensor_types.phase import PHASE_SENSOR_TYPES +from .sensor_types.sensor_entity_description import SunWEGSensorEntityDescription +from .sensor_types.string import STRING_SENSOR_TYPES +from .sensor_types.total import TOTAL_SENSOR_TYPES + +_LOGGER = logging.getLogger(__name__) + + +def get_device_list( + api: APIHelper, config: MappingProxyType[str, Any] +) -> tuple[list[Inverter], int]: + """Retrieve the device list for the selected plant.""" + plant_id = int(config[CONF_PLANT_ID]) + + if plant_id == DEFAULT_PLANT_ID: + plant_info: list[Plant] = api.listPlants() + plant_id = plant_info[0].id + + devices: list[Inverter] = [] + # Get a list of devices for specified plant to add sensors for. + for inverter in api.plant(plant_id).inverters: + api.complete_inverter(inverter) + devices.append(inverter) + return (devices, plant_id) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the SunWEG sensor.""" + name = config_entry.data[CONF_NAME] + + probe: SunWEGData = hass.data[DOMAIN][config_entry.entry_id] + + devices, plant_id = await hass.async_add_executor_job( + get_device_list, probe.api, config_entry.data + ) + + entities = [ + SunWEGInverter( + probe, + name=f"{name} Total", + unique_id=f"{plant_id}-{description.key}", + description=description, + device_type=DeviceType.TOTAL, + ) + for description in TOTAL_SENSOR_TYPES + ] + + # Add sensors for each device in the specified plant. + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name}", + unique_id=f"{device.sn}-{description.key}", + description=description, + device_type=DeviceType.INVERTER, + inverter_id=device.id, + ) + for device in devices + for description in INVERTER_SENSOR_TYPES + ] + ) + + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name} {phase.name}", + unique_id=f"{device.sn}-{phase.name}-{description.key}", + description=description, + inverter_id=device.id, + device_type=DeviceType.PHASE, + deep_name=phase.name, + ) + for device in devices + for phase in device.phases + for description in PHASE_SENSOR_TYPES + ] + ) + + entities.extend( + [ + SunWEGInverter( + probe, + name=f"{device.name} {string.name}", + unique_id=f"{device.sn}-{string.name}-{description.key}", + description=description, + inverter_id=device.id, + device_type=DeviceType.STRING, + deep_name=string.name, + ) + for device in devices + for mppt in device.mppts + for string in mppt.strings + for description in STRING_SENSOR_TYPES + ] + ) + + async_add_entities(entities, True) + + +class SunWEGInverter(SensorEntity): + """Representation of a SunWEG Sensor.""" + + entity_description: SunWEGSensorEntityDescription + + def __init__( + self, + probe: SunWEGData, + name: str, + unique_id: str, + description: SunWEGSensorEntityDescription, + device_type: DeviceType, + inverter_id: int = 0, + deep_name: str | None = None, + ) -> None: + """Initialize a sensor.""" + self.probe = probe + self.entity_description = description + self.device_type = device_type + self.inverter_id = inverter_id + self.deep_name = deep_name + + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = unique_id + self._attr_icon = ( + description.icon if description.icon is not None else "mdi:solar-power" + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(probe.plant_id))}, + manufacturer="SunWEG", + name=name, + ) + + def update(self) -> None: + """Get the latest data from the Sun WEG API and updates the state.""" + self.probe.update() + ( + self._attr_native_value, + self._attr_native_unit_of_measurement, + ) = self.probe.get_data( + api_variable_key=self.entity_description.api_variable_key, + api_variable_unit=self.entity_description.api_variable_unit, + deep_name=self.deep_name, + device_type=self.device_type, + inverter_id=self.inverter_id, + name=self.entity_description.name, + native_unit_of_measurement=self.native_unit_of_measurement, + never_resets=self.entity_description.never_resets, + previous_value_drop_threshold=self.entity_description.previous_value_drop_threshold, + ) diff --git a/homeassistant/components/sunweg/sensor_types/__init__.py b/homeassistant/components/sunweg/sensor_types/__init__.py new file mode 100644 index 00000000000..f370fddd16b --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/__init__.py @@ -0,0 +1 @@ +"""Sensor types for supported Sun WEG systems.""" diff --git a/homeassistant/components/sunweg/sensor_types/inverter.py b/homeassistant/components/sunweg/sensor_types/inverter.py new file mode 100644 index 00000000000..f406efb1a83 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/inverter.py @@ -0,0 +1,69 @@ +"""SunWEG Sensor definitions for the Inverter type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import ( + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, +) + +from .sensor_entity_description import SunWEGSensorEntityDescription + +INVERTER_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="inverter_energy_today", + name="Energy today", + api_variable_key="_today_energy", + api_variable_unit="_today_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_energy_total", + name="Lifetime energy output", + api_variable_key="_total_energy", + api_variable_unit="_total_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=1, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + SunWEGSensorEntityDescription( + key="inverter_frequency", + name="AC frequency", + api_variable_key="_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_current_wattage", + name="Output power", + api_variable_key="_power", + api_variable_unit="_power_metric", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_temperature", + name="Temperature", + api_variable_key="_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + icon="mdi:temperature-celsius", + suggested_display_precision=1, + ), + SunWEGSensorEntityDescription( + key="inverter_power_factor", + name="Power Factor", + api_variable_key="_power_factor", + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/phase.py b/homeassistant/components/sunweg/sensor_types/phase.py new file mode 100644 index 00000000000..ca6b9374e0d --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/phase.py @@ -0,0 +1,26 @@ +"""SunWEG Sensor definitions for the Phase type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential + +from .sensor_entity_description import SunWEGSensorEntityDescription + +PHASE_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="voltage", + name="Voltage", + api_variable_key="_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="amperage", + name="Amperage", + api_variable_key="_amperage", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py new file mode 100644 index 00000000000..a47818b694b --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/sensor_entity_description.py @@ -0,0 +1,23 @@ +"""Sensor Entity Description for the SunWEG integration.""" +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntityDescription + + +@dataclass(frozen=True) +class SunWEGRequiredKeysMixin: + """Mixin for required keys.""" + + api_variable_key: str + + +@dataclass(frozen=True) +class SunWEGSensorEntityDescription(SensorEntityDescription, SunWEGRequiredKeysMixin): + """Describes SunWEG sensor entity.""" + + api_variable_unit: str | None = None + previous_value_drop_threshold: float | None = None + never_resets: bool = False + icon: str | None = None diff --git a/homeassistant/components/sunweg/sensor_types/string.py b/homeassistant/components/sunweg/sensor_types/string.py new file mode 100644 index 00000000000..d3ee0a43c21 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/string.py @@ -0,0 +1,26 @@ +"""SunWEG Sensor definitions for the String type.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential + +from .sensor_entity_description import SunWEGSensorEntityDescription + +STRING_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="voltage", + name="Voltage", + api_variable_key="_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="amperage", + name="Amperage", + api_variable_key="_amperage", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=1, + ), +) diff --git a/homeassistant/components/sunweg/sensor_types/total.py b/homeassistant/components/sunweg/sensor_types/total.py new file mode 100644 index 00000000000..ed9d6171735 --- /dev/null +++ b/homeassistant/components/sunweg/sensor_types/total.py @@ -0,0 +1,54 @@ +"""SunWEG Sensor definitions for Totals.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.const import UnitOfEnergy, UnitOfPower + +from .sensor_entity_description import SunWEGSensorEntityDescription + +TOTAL_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = ( + SunWEGSensorEntityDescription( + key="total_money_total", + name="Money lifetime", + api_variable_key="_saving", + icon="mdi:cash", + native_unit_of_measurement="R$", + suggested_display_precision=2, + ), + SunWEGSensorEntityDescription( + key="total_energy_today", + name="Energy Today", + api_variable_key="_today_energy", + api_variable_unit="_today_energy_metric", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SunWEGSensorEntityDescription( + key="total_output_power", + name="Output Power", + api_variable_key="_total_power", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + SunWEGSensorEntityDescription( + key="total_energy_output", + name="Lifetime energy output", + api_variable_key="_total_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + never_resets=True, + ), + SunWEGSensorEntityDescription( + key="kwh_per_kwp", + name="kWh por kWp", + api_variable_key="_kwh_per_kwp", + ), + SunWEGSensorEntityDescription( + key="last_update", + name="Last Update", + api_variable_key="_last_update", + device_class=SensorDeviceClass.DATE, + ), +) diff --git a/homeassistant/components/sunweg/strings.json b/homeassistant/components/sunweg/strings.json new file mode 100644 index 00000000000..3a910e62940 --- /dev/null +++ b/homeassistant/components/sunweg/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "no_plants": "No plants have been found on this account" + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "plant": { + "data": { + "plant_id": "Plant" + }, + "title": "Select your plant" + }, + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "title": "Enter your Sun WEG information" + } + } + } +} diff --git a/homeassistant/components/swepco/__init__.py b/homeassistant/components/swepco/__init__.py new file mode 100644 index 00000000000..6a1bcc0209a --- /dev/null +++ b/homeassistant/components/swepco/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Southwestern Electric Power Company (SWEPCO).""" diff --git a/homeassistant/components/swepco/manifest.json b/homeassistant/components/swepco/manifest.json new file mode 100644 index 00000000000..115060b7e3f --- /dev/null +++ b/homeassistant/components/swepco/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "swepco", + "name": "Southwestern Electric Power Company (SWEPCO)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index c53cb1f6934..9e01a07416f 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -1 +1,67 @@ """The swiss_public_transport component.""" +import logging + +from opendata_transport import OpendataTransport +from opendata_transport.exceptions import ( + OpendataTransportConnectionError, + OpendataTransportError, +) + +from homeassistant import config_entries, core +from homeassistant.const import Platform +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_DESTINATION, CONF_START, DOMAIN +from .coordinator import SwissPublicTransportDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Set up Swiss public transport from a config entry.""" + config = entry.data + + start = config[CONF_START] + destination = config[CONF_DESTINATION] + + session = async_get_clientsession(hass) + opendata = OpendataTransport(start, destination, session) + + try: + await opendata.async_get_data() + except OpendataTransportConnectionError as e: + raise ConfigEntryNotReady( + f"Timeout while connecting for entry '{start} {destination}'" + ) from e + except OpendataTransportError as e: + _LOGGER.error( + "Setup failed for entry '%s %s', check at http://transport.opendata.ch/examples/stationboard.html if your station names are valid", + start, + destination, + ) + raise ConfigEntryError( + f"Setup failed for entry '{start} {destination}' with invalid data" + ) from e + + coordinator = SwissPublicTransportDataUpdateCoordinator(hass, opendata) + 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: core.HomeAssistant, entry: config_entries.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/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py new file mode 100644 index 00000000000..63eca1efe96 --- /dev/null +++ b/homeassistant/components/swiss_public_transport/config_flow.py @@ -0,0 +1,104 @@ +"""Config flow for swiss_public_transport.""" +import logging +from typing import Any + +from opendata_transport import OpendataTransport +from opendata_transport.exceptions import ( + OpendataTransportConnectionError, + OpendataTransportError, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import CONF_DESTINATION, CONF_START, DOMAIN, PLACEHOLDERS + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_START): cv.string, + vol.Required(CONF_DESTINATION): cv.string, + } +) + +_LOGGER = logging.getLogger(__name__) + + +class SwissPublicTransportConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Swiss public transport config flow.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Async user step to set up the connection.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match( + { + CONF_START: user_input[CONF_START], + CONF_DESTINATION: user_input[CONF_DESTINATION], + } + ) + + session = async_get_clientsession(self.hass) + opendata = OpendataTransport( + user_input[CONF_START], user_input[CONF_DESTINATION], session + ) + try: + await opendata.async_get_data() + except OpendataTransportConnectionError: + errors["base"] = "cannot_connect" + except OpendataTransportError: + errors["base"] = "bad_config" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unknown error") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=f"{user_input[CONF_START]} {user_input[CONF_DESTINATION]}", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + description_placeholders=PLACEHOLDERS, + ) + + async def async_step_import(self, import_input: dict[str, Any]) -> FlowResult: + """Async import step to set up the connection.""" + self._async_abort_entries_match( + { + CONF_START: import_input[CONF_START], + CONF_DESTINATION: import_input[CONF_DESTINATION], + } + ) + + session = async_get_clientsession(self.hass) + opendata = OpendataTransport( + import_input[CONF_START], import_input[CONF_DESTINATION], session + ) + try: + await opendata.async_get_data() + except OpendataTransportConnectionError: + return self.async_abort(reason="cannot_connect") + except OpendataTransportError: + return self.async_abort(reason="bad_config") + except Exception: # pylint: disable=broad-except + _LOGGER.error( + "Unknown error raised by python-opendata-transport for '%s %s', check at http://transport.opendata.ch/examples/stationboard.html if your station names and your parameters are valid", + import_input[CONF_START], + import_input[CONF_DESTINATION], + ) + return self.async_abort(reason="unknown") + + return self.async_create_entry( + title=import_input[CONF_NAME], + data=import_input, + ) diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py new file mode 100644 index 00000000000..6d9fb8bb960 --- /dev/null +++ b/homeassistant/components/swiss_public_transport/const.py @@ -0,0 +1,14 @@ +"""Constants for the swiss_public_transport integration.""" + +DOMAIN = "swiss_public_transport" + +CONF_DESTINATION = "to" +CONF_START = "from" + +DEFAULT_NAME = "Next Destination" + + +PLACEHOLDERS = { + "stationboard_url": "http://transport.opendata.ch/examples/stationboard.html", + "opendata_url": "http://transport.opendata.ch", +} diff --git a/homeassistant/components/swiss_public_transport/coordinator.py b/homeassistant/components/swiss_public_transport/coordinator.py new file mode 100644 index 00000000000..93b3312b099 --- /dev/null +++ b/homeassistant/components/swiss_public_transport/coordinator.py @@ -0,0 +1,81 @@ +"""DataUpdateCoordinator for the swiss_public_transport integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import TypedDict + +from opendata_transport import OpendataTransport +from opendata_transport.exceptions import OpendataTransportError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class DataConnection(TypedDict): + """A connection data class.""" + + departure: str + next_departure: str + next_on_departure: str + duration: str + platform: str + remaining_time: str + start: str + destination: str + train_number: str + transfers: str + delay: int + + +class SwissPublicTransportDataUpdateCoordinator(DataUpdateCoordinator[DataConnection]): + """A SwissPublicTransport Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, opendata: OpendataTransport) -> None: + """Initialize the SwissPublicTransport data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=90), + ) + self._opendata = opendata + + async def _async_update_data(self) -> DataConnection: + try: + await self._opendata.async_get_data() + except OpendataTransportError as e: + _LOGGER.warning( + "Unable to connect and retrieve data from transport.opendata.ch" + ) + raise UpdateFailed from e + + departure_time = dt_util.parse_datetime( + self._opendata.connections[0]["departure"] + ) + if departure_time: + remaining_time = departure_time - dt_util.as_local(dt_util.utcnow()) + else: + remaining_time = None + + return DataConnection( + departure=self._opendata.connections[0]["departure"], + next_departure=self._opendata.connections[1]["departure"], + next_on_departure=self._opendata.connections[2]["departure"], + train_number=self._opendata.connections[0]["number"], + platform=self._opendata.connections[0]["platform"], + transfers=self._opendata.connections[0]["transfers"], + duration=self._opendata.connections[0]["duration"], + start=self._opendata.from_name, + destination=self._opendata.to_name, + remaining_time=f"{remaining_time}", + delay=self._opendata.connections[0]["delay"], + ) diff --git a/homeassistant/components/swiss_public_transport/manifest.json b/homeassistant/components/swiss_public_transport/manifest.json index fd9908bffeb..6f8e603bbe7 100644 --- a/homeassistant/components/swiss_public_transport/manifest.json +++ b/homeassistant/components/swiss_public_transport/manifest.json @@ -1,9 +1,10 @@ { "domain": "swiss_public_transport", "name": "Swiss public transport", - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@miaucl"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/swiss_public_transport", "iot_class": "cloud_polling", "loggers": ["opendata_transport"], - "requirements": ["python-opendata-transport==0.3.0"] + "requirements": ["python-opendata-transport==0.4.0"] } diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py index 12007e1741c..5d4a6813d2d 100644 --- a/homeassistant/components/swiss_public_transport/sensor.py +++ b/homeassistant/components/swiss_public_transport/sensor.py @@ -3,39 +3,28 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import TYPE_CHECKING -from opendata_transport import OpendataTransport -from opendata_transport.exceptions import OpendataTransportError import voluptuous as vol +from homeassistant import config_entries, core from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, 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 -import homeassistant.util.dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_DESTINATION, CONF_START, DEFAULT_NAME, DOMAIN, PLACEHOLDERS +from .coordinator import SwissPublicTransportDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -ATTR_DEPARTURE_TIME1 = "next_departure" -ATTR_DEPARTURE_TIME2 = "next_on_departure" -ATTR_DURATION = "duration" -ATTR_PLATFORM = "platform" -ATTR_REMAINING_TIME = "remaining_time" -ATTR_START = "start" -ATTR_TARGET = "destination" -ATTR_TRAIN_NUMBER = "train_number" -ATTR_TRANSFERS = "transfers" -ATTR_DELAY = "delay" - -CONF_DESTINATION = "to" -CONF_START = "from" - -DEFAULT_NAME = "Next Departure" - - SCAN_INTERVAL = timedelta(seconds=90) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -47,89 +36,103 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +async def async_setup_entry( + hass: core.HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor from a config entry created in the integrations UI.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + unique_id = config_entry.unique_id + + if TYPE_CHECKING: + assert unique_id + + async_add_entities( + [SwissPublicTransportSensor(coordinator, unique_id)], + ) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Swiss public transport sensor.""" - - name = config.get(CONF_NAME) - start = config.get(CONF_START) - destination = config.get(CONF_DESTINATION) - - session = async_get_clientsession(hass) - opendata = OpendataTransport(start, destination, session) - - try: - await opendata.async_get_data() - except OpendataTransportError: - _LOGGER.error( - "Check at http://transport.opendata.ch/examples/stationboard.html " - "if your station names are valid" + """Set up the sensor platform.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + if ( + result["type"] == FlowResultType.CREATE_ENTRY + or result["reason"] == "already_configured" + ): + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Swiss public transport", + }, + ) + else: + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_${result['reason']}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_${result['reason']}", + translation_placeholders=PLACEHOLDERS, ) - return - - async_add_entities([SwissPublicTransportSensor(opendata, start, destination, name)]) -class SwissPublicTransportSensor(SensorEntity): - """Implementation of an Swiss public transport sensor.""" +class SwissPublicTransportSensor( + CoordinatorEntity[SwissPublicTransportDataUpdateCoordinator], SensorEntity +): + """Implementation of a Swiss public transport sensor.""" _attr_attribution = "Data provided by transport.opendata.ch" _attr_icon = "mdi:bus" + _attr_has_entity_name = True + _attr_translation_key = "departure" - def __init__(self, opendata, start, destination, name): + def __init__( + self, + coordinator: SwissPublicTransportDataUpdateCoordinator, + unique_id: str, + ) -> None: """Initialize the sensor.""" - self._opendata = opendata - self._name = name - self._from = start - self._to = destination - self._remaining_time = "" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return ( - self._opendata.connections[0]["departure"] - if self._opendata is not None - else None + super().__init__(coordinator) + self._attr_unique_id = f"{unique_id}_departure" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Opendata.ch", + entry_type=DeviceEntryType.SERVICE, ) - @property - def extra_state_attributes(self): - """Return the state attributes.""" - if self._opendata is None: - return - - self._remaining_time = dt_util.parse_datetime( - self._opendata.connections[0]["departure"] - ) - dt_util.as_local(dt_util.utcnow()) - - return { - ATTR_TRAIN_NUMBER: self._opendata.connections[0]["number"], - ATTR_PLATFORM: self._opendata.connections[0]["platform"], - ATTR_TRANSFERS: self._opendata.connections[0]["transfers"], - ATTR_DURATION: self._opendata.connections[0]["duration"], - ATTR_DEPARTURE_TIME1: self._opendata.connections[1]["departure"], - ATTR_DEPARTURE_TIME2: self._opendata.connections[2]["departure"], - ATTR_START: self._opendata.from_name, - ATTR_TARGET: self._opendata.to_name, - ATTR_REMAINING_TIME: f"{self._remaining_time}", - ATTR_DELAY: self._opendata.connections[0]["delay"], + @callback + def _handle_coordinator_update(self) -> None: + """Handle the state update and prepare the extra state attributes.""" + self._attr_extra_state_attributes = { + key: value + for key, value in self.coordinator.data.items() + if key not in {"departure"} } + return super()._handle_coordinator_update() - async def async_update(self) -> None: - """Get the latest data from opendata.ch and update the states.""" - - try: - if self._remaining_time.total_seconds() < 0: - await self._opendata.async_get_data() - except OpendataTransportError: - _LOGGER.error("Unable to retrieve data from transport.opendata.ch") + @property + def native_value(self) -> str: + """Return the state of the sensor.""" + return self.coordinator.data["departure"] diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json new file mode 100644 index 00000000000..6d0eb53ad11 --- /dev/null +++ b/homeassistant/components/swiss_public_transport/strings.json @@ -0,0 +1,46 @@ +{ + "config": { + "error": { + "cannot_connect": "Cannot connect to server", + "bad_config": "Request failed due to bad config: Check at [stationboard]({stationboard_url}) if your station names are valid", + "unknown": "An unknown error was raised by python-opendata-transport" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "Cannot connect to server", + "bad_config": "Request failed due to bad config: Check the [stationboard]({stationboard_url}) for valid stations.", + "unknown": "An unknown error was raised by python-opendata-transport" + }, + "step": { + "user": { + "data": { + "from": "Start station", + "to": "End station" + }, + "description": "Provide start and end station for your connection\n\nCheck the [stationboard]({stationboard_url}) for valid stations.", + "title": "Swiss Public Transport" + } + } + }, + "entity": { + "sensor": { + "departure": { + "name": "Departure" + } + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The swiss public transport YAML configuration import cannot connect to server", + "description": "Configuring swiss public transport using YAML is being removed but there was an connection error importing your YAML configuration.\n\nMake sure your home assistant can reach the [opendata server]({opendata_url}). In case the server is down, try again later." + }, + "deprecated_yaml_import_issue_bad_config": { + "title": "The swiss public transport YAML configuration import request failed due to bad config", + "description": "Configuring swiss public transport using YAML is being removed but there was bad config imported in your YAML configuration.\n\nCheck the [stationboard]({stationboard_url}) for valid stations." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The swiss public transport YAML configuration import failed with unknown error raised by python-opendata-transport", + "description": "Configuring swiss public transport using YAML is being removed but there was an unknown error importing your YAML configuration.\n\nCheck your configuration or have a look at the documentation of the integration." + } + } +} diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index bf3c3424142..a318f763fcb 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -1,10 +1,11 @@ """Component to interface with switches that can be controlled remotely.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta from enum import StrEnum +from functools import partial import logging +from typing import TYPE_CHECKING import voluptuous as vol @@ -20,6 +21,11 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -27,6 +33,11 @@ from homeassistant.loader import bind_hass from .const import DOMAIN +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -48,8 +59,16 @@ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(SwitchDeviceClass)) # DEVICE_CLASS* below are deprecated as of 2021.12 # use the SwitchDeviceClass enum instead. DEVICE_CLASSES = [cls.value for cls in SwitchDeviceClass] -DEVICE_CLASS_OUTLET = SwitchDeviceClass.OUTLET.value -DEVICE_CLASS_SWITCH = SwitchDeviceClass.SWITCH.value +_DEPRECATED_DEVICE_CLASS_OUTLET = DeprecatedConstantEnum( + SwitchDeviceClass.OUTLET, "2025.1" +) +_DEPRECATED_DEVICE_CLASS_SWITCH = DeprecatedConstantEnum( + SwitchDeviceClass.SWITCH, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) # mypy: disallow-any-generics @@ -89,20 +108,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class SwitchEntityDescription(ToggleEntityDescription): +class SwitchEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes switch entities.""" device_class: SwitchDeviceClass | None = None -class SwitchEntity(ToggleEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "device_class", +} + + +class SwitchEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for switch entities.""" entity_description: SwitchEntityDescription _attr_device_class: SwitchDeviceClass | None - @property + @cached_property def device_class(self) -> SwitchDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/switch_as_x/config_flow.py b/homeassistant/components/switch_as_x/config_flow.py index 8b6527eb49e..90f6b985893 100644 --- a/homeassistant/components/switch_as_x/config_flow.py +++ b/homeassistant/components/switch_as_x/config_flow.py @@ -22,6 +22,7 @@ TARGET_DOMAIN_OPTIONS = [ selector.SelectOptionDict(value=Platform.LIGHT, label="Light"), selector.SelectOptionDict(value=Platform.LOCK, label="Lock"), selector.SelectOptionDict(value=Platform.SIREN, label="Siren"), + selector.SelectOptionDict(value=Platform.VALVE, label="Valve"), ] CONFIG_FLOW = { diff --git a/homeassistant/components/switch_as_x/valve.py b/homeassistant/components/switch_as_x/valve.py new file mode 100644 index 00000000000..3a9fbc16247 --- /dev/null +++ b/homeassistant/components/switch_as_x/valve.py @@ -0,0 +1,91 @@ +"""Valve support for switch entities.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + ValveEntity, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, +) +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 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize Valve Switch config entry.""" + registry = er.async_get(hass) + entity_id = er.async_validate_entity_id( + registry, config_entry.options[CONF_ENTITY_ID] + ) + + async_add_entities( + [ + ValveSwitch( + hass, + config_entry.title, + VALVE_DOMAIN, + entity_id, + config_entry.entry_id, + ) + ] + ) + + +class ValveSwitch(BaseEntity, ValveEntity): + """Represents a Switch as a Valve.""" + + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_reports_position = False + + async def async_open_valve(self, **kwargs: Any) -> None: + """Open the valve.""" + await self.hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: self._switch_entity_id}, + blocking=True, + context=self._context, + ) + + async def async_close_valve(self, **kwargs: Any) -> None: + """Close valve.""" + await self.hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: self._switch_entity_id}, + blocking=True, + context=self._context, + ) + + @callback + def async_state_changed_listener( + self, event: EventType[EventStateChangedData] | None = None + ) -> None: + """Handle child updates.""" + super().async_state_changed_listener(event) + if ( + not self.available + or (state := self.hass.states.get(self._switch_entity_id)) is None + ): + return + + self._attr_is_closed = state.state != STATE_ON diff --git a/homeassistant/components/switchbee/strings.json b/homeassistant/components/switchbee/strings.json index 2abeee6dd7e..858bda35c0f 100644 --- a/homeassistant/components/switchbee/strings.json +++ b/homeassistant/components/switchbee/strings.json @@ -7,6 +7,9 @@ "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your SwitchBee device." } } }, diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 445920ad276..6bad3c25142 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -98,6 +98,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # connectable means we can make connections to the device connectable = switchbot_model in CONNECTABLE_SUPPORTED_MODEL_TYPES address: str = entry.data[CONF_ADDRESS] + + await switchbot.close_stale_connections_by_address(address) + ble_device = bluetooth.async_ble_device_from_address( hass, address.upper(), connectable ) @@ -106,7 +109,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Could not find Switchbot {sensor_type} with address {address}" ) - await switchbot.close_stale_connections(ble_device) cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice) if cls is switchbot.SwitchbotLock: try: diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index e835a2f4aca..d3d84d2cd48 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -39,5 +39,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot", "iot_class": "local_push", "loggers": ["switchbot"], - "requirements": ["PySwitchbot==0.40.1"] + "requirements": ["PySwitchbot==0.43.0"] } diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 9a4e4fbe196..1539c81331e 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "iot_class": "cloud_polling", "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==1.2.1"] + "requirements": ["switchbot-api==1.3.0"] } diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index 4303c885106..5a1b7c821d2 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -30,7 +30,7 @@ from .const import SIGNAL_DEVICE_ADD from .utils import get_breeze_remote_manager -@dataclass +@dataclass(frozen=True) class SwitcherThermostatButtonDescriptionMixin: """Mixin to describe a Switcher Thermostat Button entity.""" @@ -38,7 +38,7 @@ class SwitcherThermostatButtonDescriptionMixin: supported: Callable[[SwitcherBreezeRemote], bool] -@dataclass +@dataclass(frozen=True) class SwitcherThermostatButtonEntityDescription( ButtonEntityDescription, SwitcherThermostatButtonDescriptionMixin ): diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 1f335aee4b9..27c6b416cb4 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -27,7 +27,7 @@ from .entity import ( from .models import SynologyDSMData -@dataclass +@dataclass(frozen=True) class SynologyDSMBinarySensorEntityDescription( BinarySensorEntityDescription, SynologyDSMEntityDescription ): diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index d62f816b29e..0e737c48eb6 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -24,14 +24,14 @@ from .models import SynologyDSMData LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SynologyDSMbuttonDescriptionMixin: """Mixin to describe a Synology DSM button entity.""" press_action: Callable[[SynoApi], Callable[[], Coroutine[Any, Any, None]]] -@dataclass +@dataclass(frozen=True) class SynologyDSMbuttonDescription( ButtonEntityDescription, SynologyDSMbuttonDescriptionMixin ): diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index a2f08202319..187db9fbba8 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -35,7 +35,7 @@ from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SynologyDSMCameraEntityDescription( CameraEntityDescription, SynologyDSMEntityDescription ): diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 36eb37b7882..ef2fc3dc128 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -84,7 +84,7 @@ def _user_schema_with_defaults(user_input: dict[str, Any]) -> vol.Schema: def _ordered_shared_schema( - schema_input: dict[str, Any] + schema_input: dict[str, Any], ) -> dict[vol.Required | vol.Optional, Any]: return { vol.Required(CONF_USERNAME, default=schema_input.get(CONF_USERNAME, "")): str, diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index bb668e292cc..8d53284fee7 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -18,14 +18,14 @@ from .coordinator import ( _CoordinatorT = TypeVar("_CoordinatorT", bound=SynologyDSMUpdateCoordinator[Any]) -@dataclass +@dataclass(frozen=True) class SynologyDSMRequiredKeysMixin: """Mixin for required keys.""" api_key: str -@dataclass +@dataclass(frozen=True) class SynologyDSMEntityDescription(EntityDescription, SynologyDSMRequiredKeysMixin): """Generic Synology DSM entity description.""" diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 29298647326..76606303c93 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -39,7 +39,7 @@ from .entity import ( from .models import SynologyDSMData -@dataclass +@dataclass(frozen=True) class SynologyDSMSensorEntityDescription( SensorEntityDescription, SynologyDSMEntityDescription ): diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index f7ae9c9f238..4ed06119577 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -10,6 +10,9 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "The hostname or IP address of your Synology NAS." } }, "2sa": { diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index 074a423c53d..77dc854fa3a 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -22,7 +22,7 @@ from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class SynologyDSMSwitchEntityDescription( SwitchEntityDescription, SynologyDSMEntityDescription ): diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index c550b180553..c66fc3c3d73 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -19,7 +19,7 @@ from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription from .models import SynologyDSMData -@dataclass +@dataclass(frozen=True) class SynologyDSMUpdateEntityEntityDescription( UpdateEntityDescription, SynologyDSMEntityDescription ): diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index 511feeaf93c..1d36c673eb6 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -19,7 +19,7 @@ from .coordinator import SystemBridgeDataUpdateCoordinator from .entity import SystemBridgeEntity -@dataclass +@dataclass(frozen=True) class SystemBridgeBinarySensorEntityDescription(BinarySensorEntityDescription): """Class describing System Bridge binary sensor entities.""" diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index e3fd2c14654..35cc0e00809 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -42,7 +42,7 @@ ATTR_USED: Final = "used" PIXELS: Final = "px" -@dataclass +@dataclass(frozen=True) class SystemBridgeSensorEntityDescription(SensorEntityDescription): """Class describing System Bridge sensor entities.""" diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py index 5ab8ac9f930..69dbb1f7952 100644 --- a/homeassistant/components/systemmonitor/__init__.py +++ b/homeassistant/components/systemmonitor/__init__.py @@ -1 +1,25 @@ -"""The systemmonitor integration.""" +"""The System Monitor integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up System Monitor from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload System Monitor config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +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/systemmonitor/config_flow.py b/homeassistant/components/systemmonitor/config_flow.py new file mode 100644 index 00000000000..6d9787a39f5 --- /dev/null +++ b/homeassistant/components/systemmonitor/config_flow.py @@ -0,0 +1,143 @@ +"""Adds config flow for System Monitor.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import voluptuous as vol + +from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) +from homeassistant.util import slugify + +from .const import CONF_PROCESS, DOMAIN +from .util import get_all_running_processes + + +async def validate_sensor_setup( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate sensor input.""" + # Standard behavior is to merge the result with the options. + # In this case, we want to add a sub-item so we update the options directly. + sensors: dict[str, list] = handler.options.setdefault(SENSOR_DOMAIN, {}) + processes = sensors.setdefault(CONF_PROCESS, []) + previous_processes = processes.copy() + processes.clear() + processes.extend(user_input[CONF_PROCESS]) + + entity_registry = er.async_get(handler.parent_handler.hass) + for process in previous_processes: + if process not in processes and ( + entity_id := entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, slugify(f"process_{process}") + ) + ): + entity_registry.async_remove(entity_id) + + return {} + + +async def validate_import_sensor_setup( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate sensor input.""" + # Standard behavior is to merge the result with the options. + # In this case, we want to add a sub-item so we update the options directly. + sensors: dict[str, list] = handler.options.setdefault(SENSOR_DOMAIN, {}) + import_processes: list[str] = user_input["processes"] + processes = sensors.setdefault(CONF_PROCESS, []) + processes.extend(import_processes) + legacy_resources: list[str] = handler.options.setdefault("resources", []) + legacy_resources.extend(user_input["legacy_resources"]) + + async_create_issue( + handler.parent_handler.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + is_persistent=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "System Monitor", + }, + ) + return {} + + +async def get_sensor_setup_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Return process sensor setup schema.""" + hass = handler.parent_handler.hass + processes = list(await hass.async_add_executor_job(get_all_running_processes)) + return vol.Schema( + { + vol.Required(CONF_PROCESS): SelectSelector( + SelectSelectorConfig( + options=processes, + multiple=True, + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + sort=True, + ) + ) + } + ) + + +async def get_suggested_value(handler: SchemaCommonFlowHandler) -> dict[str, Any]: + """Return suggested values for sensor setup.""" + sensors: dict[str, list] = handler.options.get(SENSOR_DOMAIN, {}) + processes: list[str] = sensors.get(CONF_PROCESS, []) + return {CONF_PROCESS: processes} + + +CONFIG_FLOW = { + "user": SchemaFlowFormStep(schema=vol.Schema({})), + "import": SchemaFlowFormStep( + schema=vol.Schema({}), + validate_user_input=validate_import_sensor_setup, + ), +} +OPTIONS_FLOW = { + "init": SchemaFlowFormStep( + get_sensor_setup_schema, + suggested_values=get_suggested_value, + validate_user_input=validate_sensor_setup, + ) +} + + +class SystemMonitorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for System Monitor.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return "System Monitor" + + @callback + def async_create_entry(self, data: Mapping[str, Any], **kwargs: Any) -> FlowResult: + """Finish config flow and create a config entry.""" + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + return super().async_create_entry(data, **kwargs) diff --git a/homeassistant/components/systemmonitor/const.py b/homeassistant/components/systemmonitor/const.py new file mode 100644 index 00000000000..c92647f9c8e --- /dev/null +++ b/homeassistant/components/systemmonitor/const.py @@ -0,0 +1,17 @@ +"""Constants for System Monitor.""" + +DOMAIN = "systemmonitor" + +CONF_INDEX = "index" +CONF_PROCESS = "process" + +NETWORK_TYPES = [ + "network_in", + "network_out", + "throughput_network_in", + "throughput_network_out", + "packets_in", + "packets_out", + "ipv4_address", + "ipv6_address", +] diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index 3bcbc75d3b7..213fa9cf6be 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -1,9 +1,10 @@ { "domain": "systemmonitor", "name": "System Monitor", - "codeowners": [], + "codeowners": ["@gjohansson-ST"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil==5.9.6"] + "requirements": ["psutil==5.9.7"] } diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 4cfbdba4066..28929d07a7c 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -15,26 +15,29 @@ import psutil import voluptuous as vol from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_RESOURCES, - CONF_SCAN_INTERVAL, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, PERCENTAGE, STATE_OFF, STATE_ON, + EntityCategory, UnitOfDataRate, UnitOfInformation, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -46,6 +49,9 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import slugify import homeassistant.util.dt as dt_util +from .const import CONF_PROCESS, DOMAIN, NETWORK_TYPES +from .util import get_all_disk_mounts, get_all_network_interfaces + _LOGGER = logging.getLogger(__name__) CONF_ARG = "arg" @@ -64,7 +70,7 @@ SENSOR_TYPE_MANDATORY_ARG = 4 SIGNAL_SYSTEMMONITOR_UPDATE = "systemmonitor_update" -@dataclass +@dataclass(frozen=True) class SysMonitorSensorEntityDescription(SensorEntityDescription): """Description for System Monitor sensor entities.""" @@ -261,6 +267,17 @@ def check_required_arg(value: Any) -> Any: return value +def check_legacy_resource(resource: str, resources: set[str]) -> bool: + """Return True if legacy resource was configured.""" + # This function to check legacy resources can be removed + # once we are removing the import from YAML + if resource in resources: + _LOGGER.debug("Checking %s in %s returns True", resource, ", ".join(resources)) + return True + _LOGGER.debug("Checking %s in %s returns False", resource, ", ".join(resources)) + return False + + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_RESOURCES, default={CONF_TYPE: "disk_use"}): vol.All( @@ -334,39 +351,156 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the system monitor sensors.""" + processes = [ + resource[CONF_ARG] + for resource in config[CONF_RESOURCES] + if resource[CONF_TYPE] == "process" + ] + legacy_config: list[dict[str, str]] = config[CONF_RESOURCES] + resources = [] + for resource_conf in legacy_config: + if (_type := resource_conf[CONF_TYPE]).startswith("disk_"): + if (arg := resource_conf.get(CONF_ARG)) is None: + resources.append(f"{_type}_/") + continue + resources.append(f"{_type}_{arg}") + continue + resources.append(f"{_type}_{resource_conf.get(CONF_ARG, '')}") + _LOGGER.debug( + "Importing config with processes: %s, resources: %s", processes, resources + ) + + # With removal of the import also cleanup legacy_resources logic in setup_entry + # Also cleanup entry.options["resources"] which is only imported for legacy reasons + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={"processes": processes, "legacy_resources": resources}, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up System Montor sensors based on a config entry.""" entities = [] sensor_registry: dict[tuple[str, str], SensorData] = {} + legacy_resources: set[str] = set(entry.options.get("resources", [])) + loaded_resources: set[str] = set() + disk_arguments = await hass.async_add_executor_job(get_all_disk_mounts) + network_arguments = await hass.async_add_executor_job(get_all_network_interfaces) + cpu_temperature = await hass.async_add_executor_job(_read_cpu_temperature) - for resource in config[CONF_RESOURCES]: - type_ = resource[CONF_TYPE] - # Initialize the sensor argument if none was provided. - # For disk monitoring default to "/" (root) to prevent runtime errors, if argument was not specified. - if CONF_ARG not in resource: - argument = "" - if resource[CONF_TYPE].startswith("disk_"): - argument = "/" - else: - argument = resource[CONF_ARG] + _LOGGER.debug("Setup from options %s", entry.options) + + for _type, sensor_description in SENSOR_TYPES.items(): + if _type.startswith("disk_"): + for argument in disk_arguments: + sensor_registry[(_type, argument)] = SensorData( + argument, None, None, None, None + ) + is_enabled = check_legacy_resource( + f"{_type}_{argument}", legacy_resources + ) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + sensor_registry, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + continue + + if _type in NETWORK_TYPES: + for argument in network_arguments: + sensor_registry[(_type, argument)] = SensorData( + argument, None, None, None, None + ) + is_enabled = check_legacy_resource( + f"{_type}_{argument}", legacy_resources + ) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + sensor_registry, + sensor_description, + entry.entry_id, + argument, + is_enabled, + ) + ) + continue # Verify if we can retrieve CPU / processor temperatures. # If not, do not create the entity and add a warning to the log - if ( - type_ == "processor_temperature" - and await hass.async_add_executor_job(_read_cpu_temperature) is None - ): + if _type == "processor_temperature" and cpu_temperature is None: _LOGGER.warning("Cannot read CPU / processor temperature information") continue - sensor_registry[(type_, argument)] = SensorData( - argument, None, None, None, None - ) + if _type == "process": + _entry: dict[str, list] = entry.options.get(SENSOR_DOMAIN, {}) + for argument in _entry.get(CONF_PROCESS, []): + sensor_registry[(_type, argument)] = SensorData( + argument, None, None, None, None + ) + loaded_resources.add(f"{_type}_{argument}") + entities.append( + SystemMonitorSensor( + sensor_registry, + sensor_description, + entry.entry_id, + argument, + True, + ) + ) + continue + + sensor_registry[(_type, "")] = SensorData("", None, None, None, None) + is_enabled = check_legacy_resource(f"{_type}_", legacy_resources) + loaded_resources.add(f"{_type}_") entities.append( - SystemMonitorSensor(sensor_registry, SENSOR_TYPES[type_], argument) + SystemMonitorSensor( + sensor_registry, + sensor_description, + entry.entry_id, + "", + is_enabled, + ) ) - scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - await async_setup_sensor_registry_updates(hass, sensor_registry, scan_interval) + # Ensure legacy imported disk_* resources are loaded if they are not part + # of mount points automatically discovered + for resource in legacy_resources: + if resource.startswith("disk_"): + _LOGGER.debug( + "Check resource %s already loaded in %s", resource, loaded_resources + ) + if resource not in loaded_resources: + split_index = resource.rfind("_") + _type = resource[:split_index] + argument = resource[split_index + 1 :] + _LOGGER.debug("Loading legacy %s with argument %s", _type, argument) + sensor_registry[(_type, argument)] = SensorData( + argument, None, None, None, None + ) + entities.append( + SystemMonitorSensor( + sensor_registry, + SENSOR_TYPES[_type], + entry.entry_id, + argument, + True, + ) + ) + scan_interval = DEFAULT_SCAN_INTERVAL + await async_setup_sensor_registry_updates(hass, sensor_registry, scan_interval) async_add_entities(entities) @@ -433,12 +567,16 @@ class SystemMonitorSensor(SensorEntity): """Implementation of a system monitor sensor.""" should_poll = False + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, sensor_registry: dict[tuple[str, str], SensorData], sensor_description: SysMonitorSensorEntityDescription, + entry_id: str, argument: str = "", + legacy_enabled: bool = False, ) -> None: """Initialize the sensor.""" self.entity_description = sensor_description @@ -446,6 +584,13 @@ class SystemMonitorSensor(SensorEntity): self._attr_unique_id: str = slugify(f"{sensor_description.key}_{argument}") self._sensor_registry = sensor_registry self._argument: str = argument + self._attr_entity_registry_enabled_default = legacy_enabled + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="System Monitor", + name="System Monitor", + ) @property def native_value(self) -> str | datetime | None: diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json new file mode 100644 index 00000000000..88ecad4b107 --- /dev/null +++ b/homeassistant/components/systemmonitor/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + }, + "step": { + "user": { + "description": "Press submit for initial setup. On the created config entry, press configure to add sensors for selected processes" + } + } + }, + "options": { + "step": { + "init": { + "description": "Configure a monitoring sensor for a running process", + "data": { + "process": "Processes to add as sensor(s)" + }, + "data_description": { + "process": "Select a running process from the list or add a custom value. Multiple selections/custom values are supported" + } + } + } + } +} diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py new file mode 100644 index 00000000000..25b8aa2eb1d --- /dev/null +++ b/homeassistant/components/systemmonitor/util.py @@ -0,0 +1,50 @@ +"""Utils for System Monitor.""" + +import logging +import os + +import psutil + +_LOGGER = logging.getLogger(__name__) + + +def get_all_disk_mounts() -> set[str]: + """Return all disk mount points on system.""" + disks: set[str] = set() + for part in psutil.disk_partitions(all=True): + if os.name == "nt": + if "cdrom" in part.opts or part.fstype == "": + # skip cd-rom drives with no disk in it; they may raise + # ENOENT, pop-up a Windows GUI error for a non-ready + # partition or just hang. + continue + try: + usage = psutil.disk_usage(part.mountpoint) + except PermissionError: + _LOGGER.debug( + "No permission for running user to access %s", part.mountpoint + ) + continue + if usage.total > 0 and part.device != "": + disks.add(part.mountpoint) + _LOGGER.debug("Adding disks: %s", ", ".join(disks)) + return disks + + +def get_all_network_interfaces() -> set[str]: + """Return all network interfaces on system.""" + interfaces: set[str] = set() + for interface, _ in psutil.net_if_addrs().items(): + interfaces.add(interface) + _LOGGER.debug("Adding interfaces: %s", ", ".join(interfaces)) + return interfaces + + +def get_all_running_processes() -> set[str]: + """Return all running processes on system.""" + processes: set[str] = set() + for proc in psutil.process_iter(["name"]): + if proc.name() not in processes: + processes.add(proc.name()) + _LOGGER.debug("Running processes: %s", ", ".join(processes)) + return processes diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 1cd21634c8e..7f166ccf01a 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -26,9 +26,11 @@ from .const import ( DOMAIN, INSIDE_TEMPERATURE_MEASUREMENT, PRESET_AUTO, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, SIGNAL_TADO_UPDATE_RECEIVED, TEMP_OFFSET, UPDATE_LISTENER, + UPDATE_MOBILE_DEVICE_TRACK, UPDATE_TRACK, ) @@ -38,12 +40,14 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, + Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.WATER_HEATER, ] MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4) SCAN_INTERVAL = timedelta(minutes=5) +SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -85,12 +89,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: SCAN_INTERVAL, ) + update_mobile_devices = async_track_time_interval( + hass, + lambda now: tadoconnector.update_mobile_devices(), + SCAN_MOBILE_DEVICE_INTERVAL, + ) + update_listener = entry.add_update_listener(_async_update_listener) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { DATA: tadoconnector, UPDATE_TRACK: update_track, + UPDATE_MOBILE_DEVICE_TRACK: update_mobile_devices, UPDATE_LISTENER: update_listener, } @@ -127,6 +138,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id][UPDATE_TRACK]() hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER]() + hass.data[DOMAIN][entry.entry_id][UPDATE_MOBILE_DEVICE_TRACK]() if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) @@ -151,6 +163,7 @@ class TadoConnector: self.devices = None self.data = { "device": {}, + "mobile_device": {}, "weather": {}, "geofence": {}, "zone": {}, @@ -164,14 +177,17 @@ class TadoConnector: def setup(self): """Connect to Tado and fetch the zones.""" self.tado = Tado(self._username, self._password) - self.tado.setDebugging(True) # Load zones and devices - self.zones = self.tado.getZones() - self.devices = self.tado.getDevices() - tado_home = self.tado.getMe()["homes"][0] + self.zones = self.tado.get_zones() + self.devices = self.tado.get_devices() + tado_home = self.tado.get_me()["homes"][0] self.home_id = tado_home["id"] self.home_name = tado_home["name"] + def get_mobile_devices(self): + """Return the Tado mobile devices.""" + return self.tado.getMobileDevices() + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update the registered zones.""" @@ -179,9 +195,35 @@ class TadoConnector: self.update_zones() self.update_home() + def update_mobile_devices(self) -> None: + """Update the mobile devices.""" + try: + mobile_devices = self.get_mobile_devices() + except RuntimeError: + _LOGGER.error("Unable to connect to Tado while updating mobile devices") + return + + for mobile_device in mobile_devices: + self.data["mobile_device"][mobile_device["id"]] = mobile_device + + _LOGGER.debug( + "Dispatching update to %s mobile devices: %s", + self.home_id, + mobile_devices, + ) + dispatcher_send( + self.hass, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + ) + def update_devices(self): """Update the device data from Tado.""" - devices = self.tado.getDevices() + try: + devices = self.tado.get_devices() + except RuntimeError: + _LOGGER.error("Unable to connect to Tado while updating devices") + return + for device in devices: device_short_serial_no = device["shortSerialNo"] _LOGGER.debug("Updating device %s", device_short_serial_no) @@ -190,7 +232,7 @@ class TadoConnector: INSIDE_TEMPERATURE_MEASUREMENT in device["characteristics"]["capabilities"] ): - device[TEMP_OFFSET] = self.tado.getDeviceInfo( + device[TEMP_OFFSET] = self.tado.get_device_info( device_short_serial_no, TEMP_OFFSET ) except RuntimeError: @@ -218,7 +260,7 @@ class TadoConnector: def update_zones(self): """Update the zone data from Tado.""" try: - zone_states = self.tado.getZoneStates()["zoneStates"] + zone_states = self.tado.get_zone_states()["zoneStates"] except RuntimeError: _LOGGER.error("Unable to connect to Tado while updating zones") return @@ -230,7 +272,7 @@ class TadoConnector: """Update the internal data from Tado.""" _LOGGER.debug("Updating zone %s", zone_id) try: - data = self.tado.getZoneState(zone_id) + data = self.tado.get_zone_state(zone_id) except RuntimeError: _LOGGER.error("Unable to connect to Tado while updating zone %s", zone_id) return @@ -251,8 +293,8 @@ class TadoConnector: def update_home(self): """Update the home data from Tado.""" try: - self.data["weather"] = self.tado.getWeather() - self.data["geofence"] = self.tado.getHomeState() + self.data["weather"] = self.tado.get_weather() + self.data["geofence"] = self.tado.get_home_state() dispatcher_send( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "home", "data"), @@ -265,15 +307,15 @@ class TadoConnector: def get_capabilities(self, zone_id): """Return the capabilities of the devices.""" - return self.tado.getCapabilities(zone_id) + return self.tado.get_capabilities(zone_id) def get_auto_geofencing_supported(self): """Return whether the Tado Home supports auto geofencing.""" - return self.tado.getAutoGeofencingSupported() + return self.tado.get_auto_geofencing_supported() def reset_zone_overlay(self, zone_id): """Reset the zone back to the default operation.""" - self.tado.resetZoneOverlay(zone_id) + self.tado.reset_zone_overlay(zone_id) self.update_zone(zone_id) def set_presence( @@ -282,11 +324,11 @@ class TadoConnector: ): """Set the presence to home, away or auto.""" if presence == PRESET_AWAY: - self.tado.setAway() + self.tado.set_away() elif presence == PRESET_HOME: - self.tado.setHome() + self.tado.set_home() elif presence == PRESET_AUTO: - self.tado.setAuto() + self.tado.set_auto() # Update everything when changing modes self.update_zones() @@ -320,7 +362,7 @@ class TadoConnector: ) try: - self.tado.setZoneOverlay( + self.tado.set_zone_overlay( zone_id, overlay_mode, temperature, @@ -328,7 +370,7 @@ class TadoConnector: device_type, "ON", mode, - fanSpeed=fan_speed, + fan_speed=fan_speed, swing=swing, ) @@ -340,7 +382,7 @@ class TadoConnector: def set_zone_off(self, zone_id, overlay_mode, device_type="HEATING"): """Set a zone to off.""" try: - self.tado.setZoneOverlay( + self.tado.set_zone_overlay( zone_id, overlay_mode, None, None, device_type, "OFF" ) except RequestException as exc: @@ -351,6 +393,6 @@ class TadoConnector: def set_temperature_offset(self, device_id, offset): """Set temperature offset of device.""" try: - self.tado.setTempOffset(device_id, offset) + self.tado.set_temp_offset(device_id, offset) except RequestException as exc: _LOGGER.error("Could not set temperature offset: %s", exc) diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py index c5222112c02..0f7a1b2b307 100644 --- a/homeassistant/components/tado/binary_sensor.py +++ b/homeassistant/components/tado/binary_sensor.py @@ -32,14 +32,14 @@ from .entity import TadoDeviceEntity, TadoZoneEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class TadoBinarySensorEntityDescriptionMixin: """Mixin for required keys.""" state_fn: Callable[[Any], bool] -@dataclass +@dataclass(frozen=True) class TadoBinarySensorEntityDescription( BinarySensorEntityDescription, TadoBinarySensorEntityDescriptionMixin ): diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index a755622ea76..f9f4f80bde1 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from typing import Any +import PyTado from PyTado.interface import Tado import requests.exceptions import voluptuous as vol @@ -16,6 +17,7 @@ from homeassistant.data_entry_flow import FlowResult from .const import ( CONF_FALLBACK, + CONF_HOME_ID, CONST_OVERLAY_TADO_DEFAULT, CONST_OVERLAY_TADO_OPTIONS, DOMAIN, @@ -110,6 +112,47 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() return await self.async_step_user() + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + _LOGGER.debug("Importing Tado from configuration.yaml") + username = import_config[CONF_USERNAME] + password = import_config[CONF_PASSWORD] + imported_home_id = import_config[CONF_HOME_ID] + + self._async_abort_entries_match( + { + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_HOME_ID: imported_home_id, + } + ) + + try: + validate_result = await validate_input( + self.hass, + { + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + except exceptions.HomeAssistantError: + return self.async_abort(reason="import_failed") + except PyTado.exceptions.TadoWrongCredentialsException: + return self.async_abort(reason="import_failed_invalid_auth") + + home_id = validate_result[UNIQUE_ID] + await self.async_set_unique_id(home_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=import_config[CONF_USERNAME], + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_HOME_ID: home_id, + }, + ) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index d6ae50c33c1..c14906c3a89 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -36,8 +36,10 @@ TADO_HVAC_ACTION_TO_HA_HVAC_ACTION = { # Configuration CONF_FALLBACK = "fallback" +CONF_HOME_ID = "home_id" DATA = "data" UPDATE_TRACK = "update_track" +UPDATE_MOBILE_DEVICE_TRACK = "update_mobile_device_track" # Weather CONDITIONS_MAP = { @@ -177,6 +179,7 @@ TADO_TO_HA_SWING_MODE_MAP = { DOMAIN = "tado" SIGNAL_TADO_UPDATE_RECEIVED = "tado_update_received_{}_{}_{}" +SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED = "tado_mobile_device_update_received" UNIQUE_ID = "unique_id" DEFAULT_NAME = "Tado" diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 1365c9f23a3..9c50318639d 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -1,33 +1,31 @@ """Support for Tado Smart device trackers.""" from __future__ import annotations -import asyncio -from collections import namedtuple -from datetime import timedelta -from http import HTTPStatus import logging +from typing import Any -import aiohttp import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, DeviceScanner, + SourceType, + TrackerEntity, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_HOME, STATE_NOT_HOME +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv +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.helpers.typing import ConfigType -from homeassistant.util import Throttle + +from .const import CONF_HOME_ID, DATA, DOMAIN, SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED _LOGGER = logging.getLogger(__name__) -CONF_HOME_ID = "home_id" - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) - PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend( { vol.Required(CONF_USERNAME): cv.string, @@ -37,113 +35,168 @@ PLATFORM_SCHEMA = BASE_PLATFORM_SCHEMA.extend( ) -def get_scanner(hass: HomeAssistant, config: ConfigType) -> TadoDeviceScanner | None: - """Return a Tado scanner.""" - scanner = TadoDeviceScanner(hass, config[DOMAIN]) - return scanner if scanner.success_init else None +async def async_get_scanner( + hass: HomeAssistant, config: ConfigType +) -> DeviceScanner | None: + """Configure the Tado device scanner.""" + device_config = config["device_tracker"] + import_result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_USERNAME: device_config[CONF_USERNAME], + CONF_PASSWORD: device_config[CONF_PASSWORD], + CONF_HOME_ID: device_config.get(CONF_HOME_ID), + }, + ) + + translation_key = "deprecated_yaml_import_device_tracker" + if import_result.get("type") == FlowResultType.ABORT: + translation_key = "import_aborted" + if import_result.get("reason") == "import_failed": + translation_key = "import_failed" + if import_result.get("reason") == "import_failed_invalid_auth": + translation_key = "import_failed_invalid_auth" + + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_device_tracker", + breaks_in_ha_version="2024.7.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + ) + return None -Device = namedtuple("Device", ["mac", "name"]) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Tado device scannery entity.""" + _LOGGER.debug("Setting up Tado device scanner entity") + tado = hass.data[DOMAIN][entry.entry_id][DATA] + tracked: set = set() + + @callback + def update_devices() -> None: + """Update the values of the devices.""" + add_tracked_entities(hass, tado, async_add_entities, tracked) + + update_devices() + + entry.async_on_unload( + async_dispatcher_connect( + hass, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + update_devices, + ) + ) -class TadoDeviceScanner(DeviceScanner): - """Scanner for geofenced devices from Tado.""" - - def __init__(self, hass, config): - """Initialize the scanner.""" - self.hass = hass - self.last_results = [] - - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] - - # The Tado device tracker can work with or without a home_id - self.home_id = config[CONF_HOME_ID] if CONF_HOME_ID in config else None - - # If there's a home_id, we need a different API URL - if self.home_id is None: - self.tadoapiurl = "https://my.tado.com/api/v2/me" - else: - self.tadoapiurl = "https://my.tado.com/api/v2/homes/{home_id}/mobileDevices" - - # The API URL always needs a username and password - self.tadoapiurl += "?username={username}&password={password}" - - self.websession = None - - self.success_init = asyncio.run_coroutine_threadsafe( - self._async_update_info(), hass.loop - ).result() - - _LOGGER.info("Scanner initialized") - - async def async_scan_devices(self): - """Scan for devices and return a list containing found device ids.""" - await self._async_update_info() - return [device.mac for device in self.last_results] - - async def async_get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - filter_named = [ - result.name for result in self.last_results if result.mac == device - ] - - if filter_named: - return filter_named[0] - return None - - @Throttle(MIN_TIME_BETWEEN_SCANS) - async def _async_update_info(self): - """Query Tado for device marked as at home. - - Returns boolean if scanning successful. - """ - _LOGGER.debug("Requesting Tado") - - if self.websession is None: - self.websession = async_create_clientsession( - self.hass, cookie_jar=aiohttp.CookieJar(unsafe=True) - ) - - last_results = [] - - try: - async with asyncio.timeout(10): - # Format the URL here, so we can log the template URL if - # anything goes wrong without exposing username and password. - url = self.tadoapiurl.format( - home_id=self.home_id, username=self.username, password=self.password - ) - - response = await self.websession.get(url) - - if response.status != HTTPStatus.OK: - _LOGGER.warning("Error %d on %s", response.status, self.tadoapiurl) - return False - - tado_json = await response.json() - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Cannot load Tado data") - return False - - # Without a home_id, we fetched an URL where the mobile devices can be - # found under the mobileDevices key. - if "mobileDevices" in tado_json: - tado_json = tado_json["mobileDevices"] - - # Find devices that have geofencing enabled, and are currently at home. - for mobile_device in tado_json: - if mobile_device.get("location") and mobile_device["location"]["atHome"]: - device_id = mobile_device["id"] - device_name = mobile_device["name"] - last_results.append(Device(device_id, device_name)) - - self.last_results = last_results +@callback +def add_tracked_entities( + hass: HomeAssistant, + tado: Any, + async_add_entities: AddEntitiesCallback, + tracked: set[str], +) -> None: + """Add new tracker entities from Tado.""" + _LOGGER.debug("Fetching Tado devices from API") + new_tracked = [] + for device_key, device in tado.data["mobile_device"].items(): + if device_key in tracked: + continue _LOGGER.debug( - "Tado presence query successful, %d device(s) at home", - len(self.last_results), + "Adding Tado device %s with deviceID %s", device["name"], device_key + ) + new_tracked.append(TadoDeviceTrackerEntity(device_key, device["name"], tado)) + tracked.add(device_key) + + async_add_entities(new_tracked) + + +class TadoDeviceTrackerEntity(TrackerEntity): + """A Tado Device Tracker entity.""" + + _attr_should_poll = False + + def __init__( + self, + device_id: str, + device_name: str, + tado: Any, + ) -> None: + """Initialize a Tado Device Tracker entity.""" + super().__init__() + self._attr_unique_id = device_id + self._device_id = device_id + self._device_name = device_name + self._tado = tado + self._active = False + self._latitude = None + self._longitude = None + + @callback + def update_state(self) -> None: + """Update the Tado device.""" + _LOGGER.debug( + "Updating Tado mobile device: %s (ID: %s)", + self._device_name, + self._device_id, + ) + device = self._tado.data["mobile_device"][self._device_id] + + self._active = False + if device.get("location") is not None and device["location"]["atHome"]: + _LOGGER.debug("Tado device %s is at home", device["name"]) + self._active = True + else: + _LOGGER.debug("Tado device %s is not at home", device["name"]) + + @callback + def on_demand_update(self) -> None: + """Update state on demand.""" + self.update_state() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register state update callback.""" + _LOGGER.debug("Registering Tado device tracker entity") + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_TADO_MOBILE_DEVICE_UPDATE_RECEIVED, + self.on_demand_update, + ) ) - return True + self.update_state() + + @property + def name(self) -> str: + """Return the name of the device.""" + return self._device_name + + @property + def location_name(self) -> str: + """Return the state of the device.""" + return STATE_HOME if self._active else STATE_NOT_HOME + + @property + def latitude(self) -> None: + """Return latitude value of the device.""" + return None + + @property + def longitude(self) -> None: + """Return longitude value of the device.""" + return None + + @property + def source_type(self) -> SourceType: + """Return the source type.""" + return SourceType.GPS diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 62f7a377239..467697fc810 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -1,7 +1,7 @@ { "domain": "tado", "name": "Tado", - "codeowners": ["@michaelarnauts", "@chiefdragon"], + "codeowners": ["@michaelarnauts", "@chiefdragon", "@erwindouna"], "config_flow": true, "dhcp": [ { @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.15.0"] + "requirements": ["python-tado==0.17.0"] } diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index c665cc3c592..a9647c7e6e5 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -35,14 +35,14 @@ from .entity import TadoHomeEntity, TadoZoneEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class TadoSensorEntityDescriptionMixin: """Mixin for required keys.""" state_fn: Callable[[Any], StateType] -@dataclass +@dataclass(frozen=True) class TadoSensorEntityDescription( SensorEntityDescription, TadoSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 9858b7aa51b..d50d1490566 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -123,5 +123,23 @@ } } } + }, + "issues": { + "deprecated_yaml_import_device_tracker": { + "title": "Tado YAML device tracker configuration imported", + "description": "Configuring the Tado Device Tracker using YAML is being removed.\nRemove the YAML device tracker configuration and restart Home Assistant." + }, + "import_aborted": { + "title": "Import aborted", + "description": "Configuring the Tado Device Tracker using YAML is being removed.\n The import was aborted, due to an existing config entry being the same as the data being imported in the YAML. Remove the YAML device tracker configuration and restart Home Assistant. Please use the UI to configure Tado." + }, + "import_failed": { + "title": "Failed to import", + "description": "Failed to import the configuration for the Tado Device Tracker. Please use the UI to configure Tado. Don't forget to delete the YAML configuration." + }, + "import_failed_invalid_auth": { + "title": "Failed to import, invalid credentials", + "description": "Failed to import the configuration for the Tado Device Tracker, due to invalid credentials. Please fix the YAML configuration and restart Home Assistant. Alternatively you can use the UI to configure Tado. Don't forget to delete the YAML configuration, once the import is successful." + } } } diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py index ee1c682c559..00fa21279ea 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -20,7 +20,7 @@ from . import TailscaleEntity from .const import DOMAIN -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class TailscaleBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes a Tailscale binary sensor entity.""" diff --git a/homeassistant/components/tailscale/sensor.py b/homeassistant/components/tailscale/sensor.py index f5850848c8c..5d2e615945b 100644 --- a/homeassistant/components/tailscale/sensor.py +++ b/homeassistant/components/tailscale/sensor.py @@ -21,7 +21,7 @@ from . import TailscaleEntity from .const import DOMAIN -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class TailscaleSensorEntityDescription(SensorEntityDescription): """Describes a Tailscale sensor entity.""" diff --git a/homeassistant/components/tailwind/__init__.py b/homeassistant/components/tailwind/__init__.py new file mode 100644 index 00000000000..f4772050e5a --- /dev/null +++ b/homeassistant/components/tailwind/__init__.py @@ -0,0 +1,44 @@ +"""Integration for Tailwind devices.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator + +PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.BUTTON, Platform.NUMBER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Tailwind device from a config entry.""" + coordinator = TailwindDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + # Register the Tailwind device, since other entities will have it as a parent. + # This prevents a child device being created before the parent ending up + # with a missing via_device. + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, coordinator.data.device_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.data.mac_address)}, + manufacturer="Tailwind", + model=coordinator.data.product, + sw_version=coordinator.data.firmware_version, + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Tailwind config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + return unload_ok diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py new file mode 100644 index 00000000000..eaa0cbd1a08 --- /dev/null +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -0,0 +1,67 @@ +"""Binary sensor entity platform for Tailwind.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from gotailwind import TailwindDoor + +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 TailwindDataUpdateCoordinator +from .entity import TailwindDoorEntity + + +@dataclass(kw_only=True, frozen=True) +class TailwindDoorBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Tailwind door binary sensor entities.""" + + is_on_fn: Callable[[TailwindDoor], bool] + + +DESCRIPTIONS: tuple[TailwindDoorBinarySensorEntityDescription, ...] = ( + TailwindDoorBinarySensorEntityDescription( + key="locked_out", + translation_key="operational_problem", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + icon="mdi:garage-alert", + is_on_fn=lambda door: door.locked_out, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tailwind binary sensor based on a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailwindDoorBinarySensorEntity(coordinator, door_id, description) + for description in DESCRIPTIONS + for door_id in coordinator.data.doors + ) + + +class TailwindDoorBinarySensorEntity(TailwindDoorEntity, BinarySensorEntity): + """Representation of a Tailwind door binary sensor entity.""" + + entity_description: TailwindDoorBinarySensorEntityDescription + + @property + def is_on(self) -> bool | None: + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn( + self.coordinator.data.doors[self.door_id] + ) diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py new file mode 100644 index 00000000000..019b803901c --- /dev/null +++ b/homeassistant/components/tailwind/button.py @@ -0,0 +1,73 @@ +"""Button entity platform for Tailwind.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from gotailwind import Tailwind, TailwindError + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +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 DOMAIN +from .coordinator import TailwindDataUpdateCoordinator +from .entity import TailwindEntity + + +@dataclass(frozen=True, kw_only=True) +class TailwindButtonEntityDescription(ButtonEntityDescription): + """Class describing Tailwind button entities.""" + + press_fn: Callable[[Tailwind], Awaitable[Any]] + + +DESCRIPTIONS = [ + TailwindButtonEntityDescription( + key="identify", + device_class=ButtonDeviceClass.IDENTIFY, + entity_category=EntityCategory.CONFIG, + press_fn=lambda tailwind: tailwind.identify(), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tailwind button based on a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailwindButtonEntity( + coordinator, + description, + ) + for description in DESCRIPTIONS + ) + + +class TailwindButtonEntity(TailwindEntity, ButtonEntity): + """Representation of a Tailwind button entity.""" + + entity_description: TailwindButtonEntityDescription + + async def async_press(self) -> None: + """Trigger button press on the Tailwind device.""" + try: + await self.entity_description.press_fn(self.coordinator.tailwind) + except TailwindError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="communication_error", + ) from exc diff --git a/homeassistant/components/tailwind/config_flow.py b/homeassistant/components/tailwind/config_flow.py new file mode 100644 index 00000000000..97515f17f3f --- /dev/null +++ b/homeassistant/components/tailwind/config_flow.py @@ -0,0 +1,233 @@ +"""Config flow to configure the Tailwind integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from gotailwind import ( + MIN_REQUIRED_FIRMWARE_VERSION, + Tailwind, + TailwindAuthenticationError, + TailwindConnectionError, + TailwindUnsupportedFirmwareVersionError, + tailwind_device_id_to_mac_address, +) +import voluptuous as vol + +from homeassistant.components import zeroconf +from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN, LOGGER + +LOCAL_CONTROL_KEY_URL = ( + "https://web.gotailwind.com/client/integration/local-control-key" +) + + +class TailwindFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a Tailwind config flow.""" + + VERSION = 1 + + host: str + reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is not None: + try: + return await self._async_step_create_entry( + host=user_input[CONF_HOST], + token=user_input[CONF_TOKEN], + ) + except AbortFlow: + raise + except TailwindAuthenticationError: + errors[CONF_TOKEN] = "invalid_auth" + except TailwindConnectionError: + errors[CONF_HOST] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_HOST, default=user_input.get(CONF_HOST) + ): TextSelector(TextSelectorConfig(autocomplete="off")), + vol.Required(CONF_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + description_placeholders={"url": LOCAL_CONTROL_KEY_URL}, + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery of a Tailwind device.""" + if not (device_id := discovery_info.properties.get("device_id")): + return self.async_abort(reason="no_device_id") + + if ( + version := discovery_info.properties.get("SW ver") + ) and version < MIN_REQUIRED_FIRMWARE_VERSION: + return self.async_abort(reason="unsupported_firmware") + + await self.async_set_unique_id( + format_mac(tailwind_device_id_to_mac_address(device_id)) + ) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + + self.host = discovery_info.host + self.context.update( + { + "title_placeholders": { + "name": f"Tailwind {discovery_info.properties.get('product')}" + }, + "configuration_url": LOCAL_CONTROL_KEY_URL, + } + ) + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by zeroconf.""" + errors = {} + + if user_input is not None: + try: + return await self._async_step_create_entry( + host=self.host, + token=user_input[CONF_TOKEN], + ) + except TailwindAuthenticationError: + errors[CONF_TOKEN] = "invalid_auth" + except TailwindConnectionError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="zeroconf_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + description_placeholders={"url": LOCAL_CONTROL_KEY_URL}, + errors=errors, + ) + + async def async_step_reauth(self, _: Mapping[str, Any]) -> FlowResult: + """Handle initiation of re-authentication with a Tailwind device.""" + 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: + """Handle re-authentication with a Tailwind device.""" + errors = {} + + if user_input is not None and self.reauth_entry: + try: + return await self._async_step_create_entry( + host=self.reauth_entry.data[CONF_HOST], + token=user_input[CONF_TOKEN], + ) + except TailwindAuthenticationError: + errors[CONF_TOKEN] = "invalid_auth" + except TailwindConnectionError: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_TOKEN): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + description_placeholders={"url": LOCAL_CONTROL_KEY_URL}, + errors=errors, + ) + + async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: + """Handle dhcp discovery to update existing entries. + + This flow is triggered only by DHCP discovery of known devices. + """ + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + + # This situation should never happen, as Home Assistant will only + # send updates for existing entries. In case it does, we'll just + # abort the flow with an unknown error. + return self.async_abort(reason="unknown") + + async def _async_step_create_entry(self, *, host: str, token: str) -> FlowResult: + """Create entry.""" + tailwind = Tailwind( + host=host, token=token, session=async_get_clientsession(self.hass) + ) + + try: + status = await tailwind.status() + except TailwindUnsupportedFirmwareVersionError: + return self.async_abort(reason="unsupported_firmware") + + if self.reauth_entry: + self.hass.config_entries.async_update_entry( + self.reauth_entry, + data={CONF_HOST: host, CONF_TOKEN: token}, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + await self.async_set_unique_id( + format_mac(status.mac_address), raise_on_progress=False + ) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: host, + CONF_TOKEN: token, + } + ) + + return self.async_create_entry( + title=f"Tailwind {status.product}", + data={CONF_HOST: host, CONF_TOKEN: token}, + ) diff --git a/homeassistant/components/tailwind/const.py b/homeassistant/components/tailwind/const.py new file mode 100644 index 00000000000..99e5bb0f1bf --- /dev/null +++ b/homeassistant/components/tailwind/const.py @@ -0,0 +1,9 @@ +"""Constants for the Tailwind integration.""" +from __future__ import annotations + +import logging +from typing import Final + +DOMAIN: Final = "tailwind" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/tailwind/coordinator.py b/homeassistant/components/tailwind/coordinator.py new file mode 100644 index 00000000000..d918b093605 --- /dev/null +++ b/homeassistant/components/tailwind/coordinator.py @@ -0,0 +1,47 @@ +"""Data update coordinator for Tailwind.""" +from datetime import timedelta + +from gotailwind import ( + Tailwind, + TailwindAuthenticationError, + TailwindDeviceStatus, + TailwindError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +class TailwindDataUpdateCoordinator(DataUpdateCoordinator[TailwindDeviceStatus]): + """Class to manage fetching Tailwind data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + self.tailwind = Tailwind( + host=entry.data[CONF_HOST], + token=entry.data[CONF_TOKEN], + session=async_get_clientsession(hass), + ) + super().__init__( + hass, + LOGGER, + name=f"{DOMAIN}_{entry.data[CONF_HOST]}", + update_interval=timedelta(seconds=5), + ) + + async def _async_update_data(self) -> TailwindDeviceStatus: + """Fetch data from the Tailwind device.""" + try: + return await self.tailwind.status() + except TailwindAuthenticationError as err: + raise ConfigEntryAuthFailed from err + except TailwindError as err: + raise UpdateFailed(err) from err diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py new file mode 100644 index 00000000000..935fa01eee0 --- /dev/null +++ b/homeassistant/components/tailwind/cover.py @@ -0,0 +1,125 @@ +"""Cover entity platform for Tailwind.""" +from __future__ import annotations + +from typing import Any + +from gotailwind import ( + TailwindDoorDisabledError, + TailwindDoorLockedOutError, + TailwindDoorOperationCommand, + TailwindDoorState, + TailwindError, +) + +from homeassistant.components.cover import ( + CoverDeviceClass, + 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 .coordinator import TailwindDataUpdateCoordinator +from .entity import TailwindDoorEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tailwind cover based on a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailwindDoorCoverEntity(coordinator, door_id) + for door_id in coordinator.data.doors + ) + + +class TailwindDoorCoverEntity(TailwindDoorEntity, CoverEntity): + """Representation of a Tailwind door binary sensor entity.""" + + _attr_device_class = CoverDeviceClass.GARAGE + _attr_is_closing = False + _attr_is_opening = False + _attr_name = None + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + @property + def is_closed(self) -> bool: + """Return if the cover is closed or not.""" + return ( + self.coordinator.data.doors[self.door_id].state == TailwindDoorState.CLOSED + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the garage door. + + The Tailwind operating command will await the confirmation of the + door being opened before returning. + """ + self._attr_is_opening = True + self.async_write_ha_state() + try: + await self.coordinator.tailwind.operate( + door=self.coordinator.data.doors[self.door_id], + operation=TailwindDoorOperationCommand.OPEN, + ) + except TailwindDoorDisabledError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="door_disabled", + ) from exc + except TailwindDoorLockedOutError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="door_locked_out", + ) from exc + except TailwindError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="communication_error", + ) from exc + finally: + self._attr_is_opening = False + await self.coordinator.async_request_refresh() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the garage door. + + The Tailwind operating command will await the confirmation of the + door being closed before returning. + """ + self._attr_is_closing = True + self.async_write_ha_state() + try: + await self.coordinator.tailwind.operate( + door=self.coordinator.data.doors[self.door_id], + operation=TailwindDoorOperationCommand.CLOSE, + ) + except TailwindDoorDisabledError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="door_disabled", + ) from exc + except TailwindDoorLockedOutError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="door_locked_out", + ) from exc + except TailwindError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="communication_error", + ) from exc + self._attr_is_closing = False + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/tailwind/diagnostics.py b/homeassistant/components/tailwind/diagnostics.py new file mode 100644 index 00000000000..50c9b2266e2 --- /dev/null +++ b/homeassistant/components/tailwind/diagnostics.py @@ -0,0 +1,18 @@ +"""Diagnostics platform for Tailwind.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + return coordinator.data.to_dict() diff --git a/homeassistant/components/tailwind/entity.py b/homeassistant/components/tailwind/entity.py new file mode 100644 index 00000000000..843cc600582 --- /dev/null +++ b/homeassistant/components/tailwind/entity.py @@ -0,0 +1,64 @@ +"""Base entity for the Tailwind integration.""" +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator + + +class TailwindEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): + """Defines an Tailwind entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TailwindDataUpdateCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize an Tailwind entity.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{coordinator.data.device_id}-{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.device_id)}, + ) + + +class TailwindDoorEntity(CoordinatorEntity[TailwindDataUpdateCoordinator]): + """Defines an Tailwind door entity. + + These are the entities that belong to a specific garage door opener + that is connected via the Tailwind controller. + """ + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TailwindDataUpdateCoordinator, + door_id: str, + entity_description: EntityDescription | None = None, + ) -> None: + """Initialize an Tailwind door entity.""" + super().__init__(coordinator) + self.door_id = door_id + + self._attr_unique_id = f"{coordinator.data.device_id}-{door_id}" + if entity_description: + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.data.device_id}-{door_id}-{entity_description.key}" + ) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.data.device_id}-{door_id}")}, + via_device=(DOMAIN, coordinator.data.device_id), + name=f"Door {coordinator.data.doors[door_id].index+1}", + manufacturer="Tailwind", + model=coordinator.data.product, + sw_version=coordinator.data.firmware_version, + ) diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json new file mode 100644 index 00000000000..da115ab5603 --- /dev/null +++ b/homeassistant/components/tailwind/manifest.json @@ -0,0 +1,24 @@ +{ + "domain": "tailwind", + "name": "Tailwind", + "codeowners": ["@frenck"], + "config_flow": true, + "dhcp": [ + { + "registered_devices": true + } + ], + "documentation": "https://www.home-assistant.io/integrations/tailwind", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "platinum", + "requirements": ["gotailwind==0.2.2"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "properties": { + "vendor": "tailwind" + } + } + ] +} diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py new file mode 100644 index 00000000000..5853e5c2d30 --- /dev/null +++ b/homeassistant/components/tailwind/number.py @@ -0,0 +1,84 @@ +"""Number entity platform for Tailwind.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from gotailwind import Tailwind, TailwindDeviceStatus, TailwindError + +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.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TailwindDataUpdateCoordinator +from .entity import TailwindEntity + + +@dataclass(frozen=True, kw_only=True) +class TailwindNumberEntityDescription(NumberEntityDescription): + """Class describing Tailwind number entities.""" + + value_fn: Callable[[TailwindDeviceStatus], int] + set_value_fn: Callable[[Tailwind, float], Awaitable[Any]] + + +DESCRIPTIONS = [ + TailwindNumberEntityDescription( + key="brightness", + icon="mdi:led-on", + translation_key="brightness", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: data.led_brightness, + set_value_fn=lambda tailwind, brightness: tailwind.status_led( + brightness=int(brightness), + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Tailwind number based on a config entry.""" + coordinator: TailwindDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TailwindNumberEntity( + coordinator, + description, + ) + for description in DESCRIPTIONS + ) + + +class TailwindNumberEntity(TailwindEntity, NumberEntity): + """Representation of a Tailwind number entity.""" + + entity_description: TailwindNumberEntityDescription + + @property + def native_value(self) -> int | None: + """Return the number value.""" + return self.entity_description.value_fn(self.coordinator.data) + + async def async_set_native_value(self, value: float) -> None: + """Change to new number value.""" + try: + await self.entity_description.set_value_fn(self.coordinator.tailwind, value) + except TailwindError as exc: + raise HomeAssistantError( + str(exc), + translation_domain=DOMAIN, + translation_key="communication_error", + ) from exc + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/tailwind/strings.json b/homeassistant/components/tailwind/strings.json new file mode 100644 index 00000000000..7ff7fd439cc --- /dev/null +++ b/homeassistant/components/tailwind/strings.json @@ -0,0 +1,75 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "description": "Reauthenticate with your Tailwind garage door opener.\n\nTo do so, you will need to get your new local control key of your Tailwind device. For more details, see the description below the field down below.", + "data": { + "token": "[%key:component::tailwind::config::step::user::data::token%]" + }, + "data_description": { + "token": "[%key:component::tailwind::config::step::user::data_description::token%]" + } + }, + "user": { + "description": "Set up your Tailwind garage door opener to integrate with Home Assistant.\n\nTo do so, you will need to get the local control key and IP address of your Tailwind device. For more details, see the description below the fields down below.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "token": "Local control key token" + }, + "data_description": { + "host": "The hostname or IP address of your Tailwind device. You can find the IP address by going into the Tailwind app and selecting your Tailwind device's cog icon. The IP address is shown in the **Device Info** section.", + "token": "To find local control key token, browse to the [Tailwind web portal]({url}), log in with your Tailwind account, and select the [**Local Control Key**]({url}) tab. The 6-digit number shown is your local control key token." + } + }, + "zeroconf_confirm": { + "description": "Set up your discovered Tailwind garage door opener to integrate with Home Assistant.\n\nTo do so, you will need to get the local control key of your Tailwind device. For more details, see the description below the field down below.", + "data": { + "token": "[%key:component::tailwind::config::step::user::data::token%]" + }, + "data_description": { + "token": "[%key:component::tailwind::config::step::user::data_description::token%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_device_id": "The discovered Tailwind device did not provide a device ID.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unsupported_firmware": "The firmware of your Tailwind device is not supported. Please update your Tailwind device to the latest firmware version using the Tailwind app." + } + }, + "entity": { + "binary_sensor": { + "operational_problem": { + "name": "Operational problem", + "state": { + "off": "Operational", + "on": "Locked out" + } + } + }, + "number": { + "brightness": { + "name": "Status LED brightness" + } + } + }, + "exceptions": { + "communication_error": { + "message": "An error occurred while communicating with the Tailwind device." + }, + "door_disabled": { + "message": "The door is disabled and cannot be operated." + }, + "door_locked_out": { + "message": "The door is locked out and cannot be operated." + } + } +} diff --git a/homeassistant/components/tankerkoenig/diagnostics.py b/homeassistant/components/tankerkoenig/diagnostics.py new file mode 100644 index 00000000000..811ec07ef19 --- /dev/null +++ b/homeassistant/components/tankerkoenig/diagnostics.py @@ -0,0 +1,32 @@ +"""Diagnostics support for Tankerkoenig.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_UNIQUE_ID, +) +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TankerkoenigDataUpdateCoordinator + +TO_REDACT = {CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIQUE_ID} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + diag_data = { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "data": coordinator.data, + } + return diag_data diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index a64f4312de1..ca9de9df8de 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -43,14 +43,14 @@ def get_top_stats( return value -@dataclass +@dataclass(frozen=True) class TautulliSensorEntityMixin: """Mixin for Tautulli sensor.""" value_fn: Callable[[PyTautulliApiHomeStats, PyTautulliApiActivity, str], StateType] -@dataclass +@dataclass(frozen=True) class TautulliSensorEntityDescription( SensorEntityDescription, TautulliSensorEntityMixin ): @@ -151,14 +151,14 @@ SENSOR_TYPES: tuple[TautulliSensorEntityDescription, ...] = ( ) -@dataclass +@dataclass(frozen=True) class TautulliSessionSensorEntityMixin: """Mixin for Tautulli session sensor.""" value_fn: Callable[[PyTautulliApiSession], StateType] -@dataclass +@dataclass(frozen=True) class TautulliSessionSensorEntityDescription( SensorEntityDescription, TautulliSessionSensorEntityMixin ): diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 7d150e95977..1d71e055e2e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -55,6 +55,7 @@ ATTR_CALLBACK_QUERY_ID = "callback_query_id" ATTR_CAPTION = "caption" ATTR_CHAT_ID = "chat_id" ATTR_CHAT_INSTANCE = "chat_instance" +ATTR_DATE = "date" ATTR_DISABLE_NOTIF = "disable_notification" ATTR_DISABLE_WEB_PREV = "disable_web_page_preview" ATTR_EDITED_MSG = "edited_message" @@ -991,6 +992,7 @@ class BaseTelegramBotEntity: event_data: dict[str, Any] = { ATTR_MSGID: message.message_id, ATTR_CHAT_ID: message.chat.id, + ATTR_DATE: message.date, } if Filters.command.filter(message): # This is a command message - set event type to command and split data into command and args diff --git a/homeassistant/components/temper/manifest.json b/homeassistant/components/temper/manifest.json index 527a4b95b71..dbad8827877 100644 --- a/homeassistant/components/temper/manifest.json +++ b/homeassistant/components/temper/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/temper", "iot_class": "local_polling", "loggers": ["pyusb", "temperusb"], - "requirements": ["temperusb==1.6.0"] + "requirements": ["temperusb==1.6.1"] } diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 55a0e2fb72d..227109d59e2 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -94,9 +94,9 @@ class StateImageEntity(TemplateEntity, ImageEntity): @property def entity_picture(self) -> str | None: """Return entity picture.""" - # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 if self._entity_picture_template: - return TemplateEntity.entity_picture.fget(self) # type: ignore[attr-defined] + return TemplateEntity.entity_picture.__get__(self) + # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 return ImageEntity.entity_picture.fget(self) # type: ignore[attr-defined] @callback diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 8c3554c067e..f9c61850e58 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -430,14 +430,17 @@ class TemplateEntity(Entity): return try: - state, attrs = self._async_generate_attributes() - validate_state(state) + calculated_state = self._async_calculate_state() + validate_state(calculated_state.state) except Exception as err: # pylint: disable=broad-exception-caught self._preview_callback(None, None, None, str(err)) else: assert self._template_result_info self._preview_callback( - state, attrs, self._template_result_info.listeners, None + calculated_state.state, + calculated_state.attributes, + self._template_result_info.listeners, + None, ) @callback diff --git a/homeassistant/components/tesla_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py index 41403ab84f2..30e61dc7744 100644 --- a/homeassistant/components/tesla_wall_connector/__init__.py +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -152,7 +152,7 @@ class WallConnectorEntity(CoordinatorEntity): ) -@dataclass() +@dataclass(frozen=True) class WallConnectorLambdaValueGetterMixin: """Mixin with a function pointer for getting sensor value.""" diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py index e0a34460c8c..e9ac03c69e1 100644 --- a/homeassistant/components/tesla_wall_connector/binary_sensor.py +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -22,7 +22,7 @@ from .const import DOMAIN, WALLCONNECTOR_DATA_VITALS _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class WallConnectorBinarySensorDescription( BinarySensorEntityDescription, WallConnectorLambdaValueGetterMixin ): diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 0322830890a..1b9433eb696 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -30,7 +30,7 @@ from .const import DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITAL _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class WallConnectorSensorDescription( SensorEntityDescription, WallConnectorLambdaValueGetterMixin ): diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py new file mode 100644 index 00000000000..869cd46cf51 --- /dev/null +++ b/homeassistant/components/tessie/__init__.py @@ -0,0 +1,78 @@ +"""Tessie integration.""" +from http import HTTPStatus +import logging + +from aiohttp import ClientError, ClientResponseError +from tessie_api import get_state_of_all_vehicles + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .coordinator import TessieStateUpdateCoordinator +from .models import TessieVehicle + +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CLIMATE, + Platform.COVER, + Platform.DEVICE_TRACKER, + Platform.LOCK, + Platform.MEDIA_PLAYER, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.UPDATE, +] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Tessie config.""" + api_key = entry.data[CONF_ACCESS_TOKEN] + + try: + vehicles = await get_state_of_all_vehicles( + session=async_get_clientsession(hass), + api_key=api_key, + only_active=True, + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + raise ConfigEntryAuthFailed from e + _LOGGER.error("Setup failed, unable to connect to Tessie: %s", e) + return False + except ClientError as e: + raise ConfigEntryNotReady from e + + data = [ + TessieVehicle( + state_coordinator=TessieStateUpdateCoordinator( + hass, + api_key=api_key, + vin=vehicle["vin"], + data=vehicle["last_state"], + ) + ) + for vehicle in vehicles["results"] + if vehicle["last_state"] is not None + ] + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Tessie Config.""" + 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/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py new file mode 100644 index 00000000000..5edbb108568 --- /dev/null +++ b/homeassistant/components/tessie/binary_sensor.py @@ -0,0 +1,167 @@ +"""Binary Sensor platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +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, TessieStatus +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +@dataclass(frozen=True, kw_only=True) +class TessieBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Tessie binary sensor entity.""" + + is_on: Callable[..., bool] = lambda x: x + + +DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = ( + TessieBinarySensorEntityDescription( + key="state", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + is_on=lambda x: x == TessieStatus.ONLINE, + ), + TessieBinarySensorEntityDescription( + key="charge_state_battery_heater_on", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="charge_state_charging_state", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + is_on=lambda x: x == "Charging", + ), + TessieBinarySensorEntityDescription( + key="charge_state_preconditioning_enabled", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="charge_state_scheduled_charging_pending", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="charge_state_trip_charging", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_auto_seat_climate_left", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_auto_seat_climate_right", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_auto_steering_wheel_heat", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_cabin_overheat_protection", + device_class=BinarySensorDeviceClass.RUNNING, + is_on=lambda x: x == "On", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="climate_state_cabin_overheat_protection_actively_cooling", + device_class=BinarySensorDeviceClass.HEAT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_dashcam_state", + device_class=BinarySensorDeviceClass.RUNNING, + is_on=lambda x: x == "Recording", + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_is_user_present", + device_class=BinarySensorDeviceClass.PRESENCE, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_fl", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_fr", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_rl", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_tpms_soft_warning_rr", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_fd_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_fp_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_rd_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieBinarySensorEntityDescription( + key="vehicle_state_rp_window", + device_class=BinarySensorDeviceClass.WINDOW, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie binary sensor platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieBinarySensorEntity(vehicle.state_coordinator, description) + for vehicle in data + for description in DESCRIPTIONS + if description.key in vehicle.state_coordinator.data + ) + + +class TessieBinarySensorEntity(TessieEntity, BinarySensorEntity): + """Base class for Tessie binary sensors.""" + + entity_description: TessieBinarySensorEntityDescription + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + description: TessieBinarySensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + return self.entity_description.is_on(self._value) diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py new file mode 100644 index 00000000000..86065d389a4 --- /dev/null +++ b/homeassistant/components/tessie/button.py @@ -0,0 +1,82 @@ +"""Button platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from tessie_api import ( + boombox, + enable_keyless_driving, + flash_lights, + honk, + trigger_homelink, + wake, +) + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +@dataclass(frozen=True, kw_only=True) +class TessieButtonEntityDescription(ButtonEntityDescription): + """Describes a Tessie Button entity.""" + + func: Callable + + +DESCRIPTIONS: tuple[TessieButtonEntityDescription, ...] = ( + TessieButtonEntityDescription(key="wake", func=lambda: wake, icon="mdi:sleep-off"), + TessieButtonEntityDescription( + key="flash_lights", func=lambda: flash_lights, icon="mdi:flashlight" + ), + TessieButtonEntityDescription(key="honk", func=lambda: honk, icon="mdi:bullhorn"), + TessieButtonEntityDescription( + key="trigger_homelink", func=lambda: trigger_homelink, icon="mdi:garage" + ), + TessieButtonEntityDescription( + key="enable_keyless_driving", + func=lambda: enable_keyless_driving, + icon="mdi:car-key", + ), + TessieButtonEntityDescription( + key="boombox", func=lambda: boombox, icon="mdi:volume-high" + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Button platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieButtonEntity(vehicle.state_coordinator, description) + for vehicle in data + for description in DESCRIPTIONS + ) + + +class TessieButtonEntity(TessieEntity, ButtonEntity): + """Base class for Tessie Buttons.""" + + entity_description: TessieButtonEntityDescription + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + description: TessieButtonEntityDescription, + ) -> None: + """Initialize the Button.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + async def async_press(self) -> None: + """Press the button.""" + await self.run(self.entity_description.func()) diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py new file mode 100644 index 00000000000..8d27305cb0b --- /dev/null +++ b/homeassistant/components/tessie/climate.py @@ -0,0 +1,136 @@ +"""Climate platform for Tessie integration.""" +from __future__ import annotations + +from typing import Any + +from tessie_api import ( + set_climate_keeper_mode, + set_temperature, + start_climate_preconditioning, + stop_climate, +) + +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TessieClimateKeeper +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Climate platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieClimateEntity(vehicle.state_coordinator) for vehicle in data + ) + + +class TessieClimateEntity(TessieEntity, ClimateEntity): + """Vehicle Location Climate Class.""" + + _attr_precision = PRECISION_HALVES + _attr_min_temp = 15 + _attr_max_temp = 28 + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] + _attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE + ) + _attr_preset_modes: list = [ + TessieClimateKeeper.OFF, + TessieClimateKeeper.ON, + TessieClimateKeeper.DOG, + TessieClimateKeeper.CAMP, + ] + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + ) -> None: + """Initialize the Climate entity.""" + super().__init__(coordinator, "primary") + + @property + def hvac_mode(self) -> HVACMode | None: + """Return hvac operation ie. heat, cool mode.""" + if self.get("climate_state_is_climate_on"): + return HVACMode.HEAT_COOL + return HVACMode.OFF + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self.get("climate_state_inside_temp") + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + return self.get("climate_state_driver_temp_setting") + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self.get("climate_state_max_avail_temp", self._attr_max_temp) + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self.get("climate_state_min_avail_temp", self._attr_min_temp) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return self.get("climate_state_climate_keeper_mode") + + async def async_turn_on(self) -> None: + """Set the climate state to on.""" + await self.run(start_climate_preconditioning) + self.set(("climate_state_is_climate_on", True)) + + async def async_turn_off(self) -> None: + """Set the climate state to off.""" + await self.run(stop_climate) + self.set( + ("climate_state_is_climate_on", False), + ("climate_state_climate_keeper_mode", "off"), + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the climate temperature.""" + temp = kwargs[ATTR_TEMPERATURE] + await self.run(set_temperature, temperature=temp) + self.set(("climate_state_driver_temp_setting", temp)) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the climate mode and state.""" + if hvac_mode == HVACMode.OFF: + await self.async_turn_off() + else: + await self.async_turn_on() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the climate preset mode.""" + await self.run( + set_climate_keeper_mode, mode=self._attr_preset_modes.index(preset_mode) + ) + self.set( + ( + "climate_state_climate_keeper_mode", + preset_mode, + ), + ( + "climate_state_is_climate_on", + preset_mode != self._attr_preset_modes[0], + ), + ) diff --git a/homeassistant/components/tessie/config_flow.py b/homeassistant/components/tessie/config_flow.py new file mode 100644 index 00000000000..97d9d44af70 --- /dev/null +++ b/homeassistant/components/tessie/config_flow.py @@ -0,0 +1,107 @@ +"""Config Flow for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from http import HTTPStatus +from typing import Any + +from aiohttp import ClientConnectionError, ClientResponseError +from tessie_api import get_state_of_all_vehicles +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +TESSIE_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) +DESCRIPTION_PLACEHOLDERS = { + "url": "[my.tessie.com/settings/api](https://my.tessie.com/settings/api)" +} + + +class TessieConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Config Tessie API connection.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize.""" + self._reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: + """Get configuration from the user.""" + errors: dict[str, str] = {} + if user_input: + try: + await get_state_of_all_vehicles( + session=async_get_clientsession(self.hass), + api_key=user_input[CONF_ACCESS_TOKEN], + only_active=True, + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + else: + errors["base"] = "unknown" + except ClientConnectionError: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title="Tessie", + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=TESSIE_SCHEMA, + description_placeholders=DESCRIPTION_PLACEHOLDERS, + errors=errors, + ) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Handle 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: Mapping[str, Any] | None = None + ) -> FlowResult: + """Get update API Key from the user.""" + errors: dict[str, str] = {} + assert self._reauth_entry + if user_input: + try: + await get_state_of_all_vehicles( + session=async_get_clientsession(self.hass), + api_key=user_input[CONF_ACCESS_TOKEN], + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + errors[CONF_ACCESS_TOKEN] = "invalid_access_token" + else: + errors["base"] = "unknown" + except ClientConnectionError: + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input + ) + self.hass.async_create_task( + 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=TESSIE_SCHEMA, + description_placeholders=DESCRIPTION_PLACEHOLDERS, + errors=errors, + ) diff --git a/homeassistant/components/tessie/const.py b/homeassistant/components/tessie/const.py new file mode 100644 index 00000000000..2ba4e514579 --- /dev/null +++ b/homeassistant/components/tessie/const.py @@ -0,0 +1,55 @@ +"""Constants used by Tessie integration.""" +from __future__ import annotations + +from enum import IntEnum, StrEnum + +DOMAIN = "tessie" + +MODELS = { + "model3": "Model 3", + "modelx": "Model X", + "modely": "Model Y", + "models": "Model S", +} + + +class TessieStatus(StrEnum): + """Tessie status.""" + + ASLEEP = "asleep" + ONLINE = "online" + + +class TessieSeatHeaterOptions(StrEnum): + """Tessie seat heater options.""" + + OFF = "off" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class TessieClimateKeeper(StrEnum): + """Tessie Climate Keeper Modes.""" + + OFF = "off" + ON = "on" + DOG = "dog" + CAMP = "camp" + + +class TessieUpdateStatus(StrEnum): + """Tessie Update Statuses.""" + + AVAILABLE = "available" + DOWNLOADING = "downloading" + INSTALLING = "installing" + WIFI_WAIT = "downloading_wifi_wait" + SCHEDULED = "scheduled" + + +class TessieCoverStates(IntEnum): + """Tessie Cover states.""" + + CLOSED = 0 + OPEN = 1 diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py new file mode 100644 index 00000000000..c2f53da53bc --- /dev/null +++ b/homeassistant/components/tessie/coordinator.py @@ -0,0 +1,82 @@ +"""Tessie Data Coordinator.""" +from datetime import timedelta +from http import HTTPStatus +import logging +from typing import Any + +from aiohttp import ClientResponseError +from tessie_api import get_state + +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 + +from .const import TessieStatus + +# This matches the update interval Tessie performs server side +TESSIE_SYNC_INTERVAL = 10 + +_LOGGER = logging.getLogger(__name__) + + +class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching data from the Tessie API.""" + + def __init__( + self, + hass: HomeAssistant, + api_key: str, + vin: str, + data: dict[str, Any], + ) -> None: + """Initialize Tessie Data Update Coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Tessie", + update_interval=timedelta(seconds=TESSIE_SYNC_INTERVAL), + ) + self.api_key = api_key + self.vin = vin + self.session = async_get_clientsession(hass) + self.data = self._flatten(data) + self.did_first_update = False + + async def _async_update_data(self) -> dict[str, Any]: + """Update vehicle data using Tessie API.""" + try: + vehicle = await get_state( + session=self.session, + api_key=self.api_key, + vin=self.vin, + use_cache=self.did_first_update, + ) + except ClientResponseError as e: + if e.status == HTTPStatus.UNAUTHORIZED: + # Auth Token is no longer valid + raise ConfigEntryAuthFailed from e + raise e + + self.did_first_update = True + if vehicle["state"] == TessieStatus.ONLINE: + # Vehicle is online, all data is fresh + return self._flatten(vehicle) + + # Vehicle is asleep, only update state + self.data["state"] = vehicle["state"] + return self.data + + def _flatten( + self, data: dict[str, Any], parent: str | None = None + ) -> dict[str, Any]: + """Flatten the data structure.""" + result = {} + for key, value in data.items(): + if parent: + key = f"{parent}_{key}" + if isinstance(value, dict): + result.update(self._flatten(value, key)) + else: + result[key] = value + return result diff --git a/homeassistant/components/tessie/cover.py b/homeassistant/components/tessie/cover.py new file mode 100644 index 00000000000..6b4393fce1f --- /dev/null +++ b/homeassistant/components/tessie/cover.py @@ -0,0 +1,160 @@ +"""Cover platform for Tessie integration.""" +from __future__ import annotations + +from typing import Any + +from tessie_api import ( + close_charge_port, + close_windows, + open_close_rear_trunk, + open_front_trunk, + open_unlock_charge_port, + vent_windows, +) + +from homeassistant.components.cover import ( + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TessieCoverStates +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie sensor platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + klass(vehicle.state_coordinator) + for klass in ( + TessieWindowEntity, + TessieChargePortEntity, + TessieFrontTrunkEntity, + TessieRearTrunkEntity, + ) + for vehicle in data + ) + + +class TessieWindowEntity(TessieEntity, CoverEntity): + """Cover entity for current charge.""" + + _attr_device_class = CoverDeviceClass.WINDOW + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__(self, coordinator: TessieStateUpdateCoordinator) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "windows") + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed or not.""" + return ( + self.get("vehicle_state_fd_window") == TessieCoverStates.CLOSED + and self.get("vehicle_state_fp_window") == TessieCoverStates.CLOSED + and self.get("vehicle_state_rd_window") == TessieCoverStates.CLOSED + and self.get("vehicle_state_rp_window") == TessieCoverStates.CLOSED + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open windows.""" + await self.run(vent_windows) + self.set( + ("vehicle_state_fd_window", TessieCoverStates.OPEN), + ("vehicle_state_fp_window", TessieCoverStates.OPEN), + ("vehicle_state_rd_window", TessieCoverStates.OPEN), + ("vehicle_state_rp_window", TessieCoverStates.OPEN), + ) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close windows.""" + await self.run(close_windows) + self.set( + ("vehicle_state_fd_window", TessieCoverStates.CLOSED), + ("vehicle_state_fp_window", TessieCoverStates.CLOSED), + ("vehicle_state_rd_window", TessieCoverStates.CLOSED), + ("vehicle_state_rp_window", TessieCoverStates.CLOSED), + ) + + +class TessieChargePortEntity(TessieEntity, CoverEntity): + """Cover entity for the charge port.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__(self, coordinator: TessieStateUpdateCoordinator) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "charge_state_charge_port_door_open") + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed or not.""" + return not self._value + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open windows.""" + await self.run(open_unlock_charge_port) + self.set((self.key, True)) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close windows.""" + await self.run(close_charge_port) + self.set((self.key, False)) + + +class TessieFrontTrunkEntity(TessieEntity, CoverEntity): + """Cover entity for the charge port.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN + + def __init__(self, coordinator: TessieStateUpdateCoordinator) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "vehicle_state_ft") + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed or not.""" + return self._value == TessieCoverStates.CLOSED + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open front trunk.""" + await self.run(open_front_trunk) + self.set((self.key, TessieCoverStates.OPEN)) + + +class TessieRearTrunkEntity(TessieEntity, CoverEntity): + """Cover entity for the charge port.""" + + _attr_device_class = CoverDeviceClass.DOOR + _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + + def __init__(self, coordinator: TessieStateUpdateCoordinator) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "vehicle_state_rt") + + @property + def is_closed(self) -> bool | None: + """Return if the cover is closed or not.""" + return self._value == TessieCoverStates.CLOSED + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open rear trunk.""" + if self._value == TessieCoverStates.CLOSED: + await self.run(open_close_rear_trunk) + self.set((self.key, TessieCoverStates.OPEN)) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close rear trunk.""" + if self._value == TessieCoverStates.OPEN: + await self.run(open_close_rear_trunk) + self.set((self.key, TessieCoverStates.CLOSED)) diff --git a/homeassistant/components/tessie/device_tracker.py b/homeassistant/components/tessie/device_tracker.py new file mode 100644 index 00000000000..9b1ddfcfe4f --- /dev/null +++ b/homeassistant/components/tessie/device_tracker.py @@ -0,0 +1,85 @@ +"""Device Tracker platform for Tessie integration.""" +from __future__ import annotations + +from homeassistant.components.device_tracker import SourceType +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie device tracker platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + klass(vehicle.state_coordinator) + for klass in ( + TessieDeviceTrackerLocationEntity, + TessieDeviceTrackerRouteEntity, + ) + for vehicle in data + ) + + +class TessieDeviceTrackerEntity(TessieEntity, TrackerEntity): + """Base class for Tessie Tracker Entities.""" + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + ) -> None: + """Initialize the device tracker.""" + super().__init__(coordinator, self.key) + + @property + def source_type(self) -> SourceType | str: + """Return the source type of the device tracker.""" + return SourceType.GPS + + +class TessieDeviceTrackerLocationEntity(TessieDeviceTrackerEntity): + """Vehicle Location Device Tracker Class.""" + + key = "location" + + @property + def longitude(self) -> float | None: + """Return the longitude of the device tracker.""" + return self.get("drive_state_longitude") + + @property + def latitude(self) -> float | None: + """Return the latitude of the device tracker.""" + return self.get("drive_state_latitude") + + @property + def extra_state_attributes(self) -> dict[str, StateType] | None: + """Return device state attributes.""" + return { + "heading": self.get("drive_state_heading"), + "speed": self.get("drive_state_speed"), + } + + +class TessieDeviceTrackerRouteEntity(TessieDeviceTrackerEntity): + """Vehicle Navigation Device Tracker Class.""" + + key = "route" + + @property + def longitude(self) -> float | None: + """Return the longitude of the device tracker.""" + return self.get("drive_state_active_route_longitude") + + @property + def latitude(self) -> float | None: + """Return the latitude of the device tracker.""" + return self.get("drive_state_active_route_latitude") diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py new file mode 100644 index 00000000000..be80caf50cb --- /dev/null +++ b/homeassistant/components/tessie/entity.py @@ -0,0 +1,76 @@ +"""Tessie parent entity class.""" + +from collections.abc import Awaitable, Callable +from typing import Any + +from aiohttp import ClientResponseError + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MODELS +from .coordinator import TessieStateUpdateCoordinator + + +class TessieEntity(CoordinatorEntity[TessieStateUpdateCoordinator]): + """Parent class for Tessie Entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + key: str, + ) -> None: + """Initialize common aspects of a Tessie entity.""" + super().__init__(coordinator) + self.vin = coordinator.vin + self.key = key + + car_type = coordinator.data["vehicle_config_car_type"] + + self._attr_translation_key = key + self._attr_unique_id = f"{self.vin}-{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.vin)}, + manufacturer="Tesla", + configuration_url="https://my.tessie.com/", + name=coordinator.data["display_name"], + model=MODELS.get(car_type, car_type), + sw_version=coordinator.data["vehicle_state_car_version"], + hw_version=coordinator.data["vehicle_config_driver_assist"], + ) + + @property + def _value(self) -> Any: + """Return value from coordinator data.""" + return self.coordinator.data[self.key] + + def get(self, key: str | None = None, default: Any | None = None) -> Any: + """Return a specific value from coordinator data.""" + return self.coordinator.data.get(key or self.key, default) + + async def run( + self, func: Callable[..., Awaitable[dict[str, bool | str]]], **kargs: Any + ) -> None: + """Run a tessie_api function and handle exceptions.""" + try: + response = await func( + session=self.coordinator.session, + vin=self.vin, + api_key=self.coordinator.api_key, + **kargs, + ) + except ClientResponseError as e: + raise HomeAssistantError from e + if response["result"] is False: + raise HomeAssistantError( + response.get("reason", "An unknown issue occurred") + ) + + def set(self, *args: Any) -> None: + """Set a value in coordinator data.""" + for key, value in args: + self.coordinator.data[key] = value + self.async_write_ha_state() diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py new file mode 100644 index 00000000000..e8fb8930bbc --- /dev/null +++ b/homeassistant/components/tessie/lock.py @@ -0,0 +1,50 @@ +"""Lock platform for Tessie integration.""" +from __future__ import annotations + +from typing import Any + +from tessie_api import lock, unlock + +from homeassistant.components.lock import LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie sensor platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities(TessieLockEntity(vehicle.state_coordinator) for vehicle in data) + + +class TessieLockEntity(TessieEntity, LockEntity): + """Lock entity for current charge.""" + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, "vehicle_state_locked") + + @property + def is_locked(self) -> bool | None: + """Return the state of the Lock.""" + return self._value + + async def async_lock(self, **kwargs: Any) -> None: + """Set new value.""" + await self.run(lock) + self.set((self.key, True)) + + async def async_unlock(self, **kwargs: Any) -> None: + """Set new value.""" + await self.run(unlock) + self.set((self.key, False)) diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json new file mode 100644 index 00000000000..52fc8dd5be1 --- /dev/null +++ b/homeassistant/components/tessie/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tessie", + "name": "Tessie", + "codeowners": ["@Bre77"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tessie", + "iot_class": "cloud_polling", + "loggers": ["tessie"], + "requirements": ["tessie-api==0.0.9"] +} diff --git a/homeassistant/components/tessie/media_player.py b/homeassistant/components/tessie/media_player.py new file mode 100644 index 00000000000..c4392e1de1d --- /dev/null +++ b/homeassistant/components/tessie/media_player.py @@ -0,0 +1,108 @@ +"""Media Player platform for Tessie integration.""" +from __future__ import annotations + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerState, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + +STATES = { + "Playing": MediaPlayerState.PLAYING, + "Paused": MediaPlayerState.PAUSED, + "Stopped": MediaPlayerState.IDLE, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Media platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities(TessieMediaEntity(vehicle.state_coordinator) for vehicle in data) + + +class TessieMediaEntity(TessieEntity, MediaPlayerEntity): + """Vehicle Location Media Class.""" + + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + ) -> None: + """Initialize the media player entity.""" + super().__init__(coordinator, "media") + + @property + def state(self) -> MediaPlayerState: + """State of the player.""" + return STATES.get( + self.get("vehicle_state_media_info_media_playback_status"), + MediaPlayerState.OFF, + ) + + @property + def volume_level(self) -> float: + """Volume level of the media player (0..1).""" + return self.get("vehicle_state_media_info_audio_volume", 0) / self.get( + "vehicle_state_media_info_audio_volume_max", 10.333333 + ) + + @property + def media_duration(self) -> int | None: + """Duration of current playing media in seconds.""" + if duration := self.get("vehicle_state_media_info_now_playing_duration"): + return duration / 1000 + return None + + @property + def media_position(self) -> int | None: + """Position of current playing media in seconds.""" + # Return media position only when a media duration is > 0 + if self.get("vehicle_state_media_info_now_playing_duration"): + return self.get("vehicle_state_media_info_now_playing_elapsed") / 1000 + return None + + @property + def media_title(self) -> str | None: + """Title of current playing media.""" + if title := self.get("vehicle_state_media_info_now_playing_title"): + return title + return None + + @property + def media_artist(self) -> str | None: + """Artist of current playing media, music track only.""" + if artist := self.get("vehicle_state_media_info_now_playing_artist"): + return artist + return None + + @property + def media_album_name(self) -> str | None: + """Album name of current playing media, music track only.""" + if album := self.get("vehicle_state_media_info_now_playing_album"): + return album + return None + + @property + def media_playlist(self) -> str | None: + """Title of Playlist currently playing.""" + if playlist := self.get("vehicle_state_media_info_now_playing_station"): + return playlist + return None + + @property + def source(self) -> str | None: + """Name of the current input source.""" + if source := self.get("vehicle_state_media_info_now_playing_source"): + return source + return None diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py new file mode 100644 index 00000000000..32466a6b2ac --- /dev/null +++ b/homeassistant/components/tessie/models.py @@ -0,0 +1,13 @@ +"""The Tessie integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from .coordinator import TessieStateUpdateCoordinator + + +@dataclass +class TessieVehicle: + """Data for the Tessie integration.""" + + state_coordinator: TessieStateUpdateCoordinator diff --git a/homeassistant/components/tessie/number.py b/homeassistant/components/tessie/number.py new file mode 100644 index 00000000000..ada088f1bd2 --- /dev/null +++ b/homeassistant/components/tessie/number.py @@ -0,0 +1,137 @@ +"""Number platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from tessie_api import set_charge_limit, set_charging_amps, set_speed_limit + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + PRECISION_WHOLE, + UnitOfElectricCurrent, + UnitOfSpeed, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +@dataclass(frozen=True, kw_only=True) +class TessieNumberEntityDescription(NumberEntityDescription): + """Describes Tessie Number entity.""" + + func: Callable + arg: str + native_min_value: float + native_max_value: float + min_key: str | None = None + max_key: str + + +DESCRIPTIONS: tuple[TessieNumberEntityDescription, ...] = ( + TessieNumberEntityDescription( + key="charge_state_charge_current_request", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=32, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=NumberDeviceClass.CURRENT, + max_key="charge_state_charge_current_request_max", + func=lambda: set_charging_amps, + arg="amps", + ), + TessieNumberEntityDescription( + key="charge_state_charge_limit_soc", + native_step=PRECISION_WHOLE, + native_min_value=50, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + min_key="charge_state_charge_limit_soc_min", + max_key="charge_state_charge_limit_soc_max", + func=lambda: set_charge_limit, + arg="percent", + ), + TessieNumberEntityDescription( + key="vehicle_state_speed_limit_mode_current_limit_mph", + native_step=PRECISION_WHOLE, + native_min_value=50, + native_max_value=120, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=NumberDeviceClass.SPEED, + mode=NumberMode.BOX, + min_key="vehicle_state_speed_limit_mode_min_limit_mph", + max_key="vehicle_state_speed_limit_mode_max_limit_mph", + func=lambda: set_speed_limit, + arg="mph", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie sensor platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieNumberEntity(vehicle.state_coordinator, description) + for vehicle in data + for description in DESCRIPTIONS + if description.key in vehicle.state_coordinator.data + ) + + +class TessieNumberEntity(TessieEntity, NumberEntity): + """Number entity for current charge.""" + + entity_description: TessieNumberEntityDescription + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + description: TessieNumberEntityDescription, + ) -> None: + """Initialize the Number entity.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def native_value(self) -> float | None: + """Return the value reported by the number.""" + return self._value + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + if self.entity_description.min_key: + return self.get( + self.entity_description.min_key, + self.entity_description.native_min_value, + ) + return self.entity_description.native_min_value + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + return self.get( + self.entity_description.max_key, self.entity_description.native_max_value + ) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self.run( + self.entity_description.func(), **{self.entity_description.arg: value} + ) + self.set((self.key, value)) diff --git a/homeassistant/components/tessie/select.py b/homeassistant/components/tessie/select.py new file mode 100644 index 00000000000..03436b44cfc --- /dev/null +++ b/homeassistant/components/tessie/select.py @@ -0,0 +1,58 @@ +"""Select platform for Tessie integration.""" +from __future__ import annotations + +from tessie_api import set_seat_heat + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TessieSeatHeaterOptions +from .entity import TessieEntity + +SEAT_HEATERS = { + "climate_state_seat_heater_left": "front_left", + "climate_state_seat_heater_right": "front_right", + "climate_state_seat_heater_rear_left": "rear_left", + "climate_state_seat_heater_rear_center": "rear_center", + "climate_state_seat_heater_rear_right": "rear_right", + "climate_state_seat_heater_third_row_left": "third_row_left", + "climate_state_seat_heater_third_row_right": "third_row_right", +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie select platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieSeatHeaterSelectEntity(vehicle.state_coordinator, key) + for vehicle in data + for key in SEAT_HEATERS + if key in vehicle.state_coordinator.data + ) + + +class TessieSeatHeaterSelectEntity(TessieEntity, SelectEntity): + """Select entity for current charge.""" + + _attr_options = [ + TessieSeatHeaterOptions.OFF, + TessieSeatHeaterOptions.LOW, + TessieSeatHeaterOptions.MEDIUM, + TessieSeatHeaterOptions.HIGH, + ] + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + return self._attr_options[self._value] + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + level = self._attr_options.index(option) + await self.run(set_seat_heat, seat=SEAT_HEATERS[self.key], level=level) + self.set((self.key, level)) diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py new file mode 100644 index 00000000000..aaf37e51d61 --- /dev/null +++ b/homeassistant/components/tessie/sensor.py @@ -0,0 +1,218 @@ +"""Sensor platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfLength, + UnitOfPower, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import DOMAIN +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +@dataclass(frozen=True, kw_only=True) +class TessieSensorEntityDescription(SensorEntityDescription): + """Describes Tessie Sensor entity.""" + + value_fn: Callable[[StateType], StateType] = lambda x: x + + +DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = ( + TessieSensorEntityDescription( + key="charge_state_usable_battery_level", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + TessieSensorEntityDescription( + key="charge_state_charge_energy_added", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="charge_state_charger_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + ), + TessieSensorEntityDescription( + key="charge_state_charger_voltage", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state_charger_actual_current", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state_charge_rate", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="charge_state_battery_range", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="drive_state_speed", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, + device_class=SensorDeviceClass.SPEED, + ), + TessieSensorEntityDescription( + key="drive_state_power", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="drive_state_shift_state", + icon="mdi:car-shift-pattern", + options=["p", "d", "r", "n"], + device_class=SensorDeviceClass.ENUM, + value_fn=lambda x: x.lower() if isinstance(x, str) else x, + ), + TessieSensorEntityDescription( + key="vehicle_state_odometer", + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfLength.MILES, + device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=0, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state_tpms_pressure_fl", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state_tpms_pressure_fr", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state_tpms_pressure_rl", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="vehicle_state_tpms_pressure_rr", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPressure.BAR, + suggested_unit_of_measurement=UnitOfPressure.PSI, + device_class=SensorDeviceClass.PRESSURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="climate_state_inside_temp", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="climate_state_outside_temp", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + ), + TessieSensorEntityDescription( + key="climate_state_driver_temp_setting", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TessieSensorEntityDescription( + key="climate_state_passenger_temp_setting", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=1, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie sensor platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieSensorEntity(vehicle.state_coordinator, description) + for vehicle in data + for description in DESCRIPTIONS + if description.key in vehicle.state_coordinator.data + ) + + +class TessieSensorEntity(TessieEntity, SensorEntity): + """Base class for Tessie metric sensors.""" + + entity_description: TessieSensorEntityDescription + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + description: TessieSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._value) diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json new file mode 100644 index 00000000000..7cf511c125c --- /dev/null +++ b/homeassistant/components/tessie/strings.json @@ -0,0 +1,319 @@ +{ + "config": { + "error": { + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "description": "Enter your access token from {url}." + }, + "reauth_confirm": { + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + }, + "description": "[%key:component::tessie::config::step::user::description%]", + "title": "[%key:common::config_flow::title::reauth%]" + } + } + }, + "entity": { + "device_tracker": { + "location": { + "name": "Location", + "state_attributes": { + "heading": { + "name": "Heading" + }, + "speed": { + "name": "Speed" + } + } + }, + "route": { + "name": "Route" + } + }, + "climate": { + "primary": { + "name": "[%key:component::climate::title%]", + "state_attributes": { + "preset_mode": { + "state": { + "off": "Normal", + "on": "Keep mode", + "dog": "Dog mode", + "camp": "Camp mode" + } + } + } + } + }, + "lock": { + "vehicle_state_locked": { + "name": "[%key:component::lock::title%]" + } + }, + "media_player": { + "media": { + "name": "[%key:component::media_player::title%]" + } + }, + "sensor": { + "charge_state_usable_battery_level": { + "name": "Battery level" + }, + "charge_state_charge_energy_added": { + "name": "Charge energy added" + }, + "charge_state_charger_power": { + "name": "Charger power" + }, + "charge_state_charger_voltage": { + "name": "Charger voltage" + }, + "charge_state_charger_actual_current": { + "name": "Charger current" + }, + "charge_state_charge_rate": { + "name": "Charge rate" + }, + "charge_state_battery_range": { + "name": "Battery range" + }, + "drive_state_speed": { + "name": "Speed" + }, + "drive_state_power": { + "name": "Power" + }, + "drive_state_shift_state": { + "name": "Shift state", + "state": { + "p": "Park", + "d": "Drive", + "r": "Reverse", + "n": "Neutral" + } + }, + "vehicle_state_odometer": { + "name": "Odometer" + }, + "vehicle_state_tpms_pressure_fl": { + "name": "Tire pressure front left" + }, + "vehicle_state_tpms_pressure_fr": { + "name": "Tire pressure front right" + }, + "vehicle_state_tpms_pressure_rl": { + "name": "Tire pressure rear left" + }, + "vehicle_state_tpms_pressure_rr": { + "name": "Tire pressure rear right" + }, + "climate_state_inside_temp": { + "name": "Inside temperature" + }, + "climate_state_outside_temp": { + "name": "Outside temperature" + }, + "climate_state_driver_temp_setting": { + "name": "Driver temperature setting" + }, + "climate_state_passenger_temp_setting": { + "name": "Passenger temperature setting" + } + }, + "cover": { + "windows": { + "name": "Vent windows" + }, + "charge_state_charge_port_door_open": { + "name": "Charge port door" + }, + "vehicle_state_ft": { "name": "Frunk" }, + "vehicle_state_rt": { "name": "Trunk" } + }, + "select": { + "climate_state_seat_heater_left": { + "name": "Seat heater left", + "state": { + "off": "[%key:common::state::off%]", + "low": "Low", + "medium": "Medium", + "high": "High" + } + }, + "climate_state_seat_heater_right": { + "name": "Seat heater right", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_rear_left": { + "name": "Seat heater rear left", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_rear_center": { + "name": "Seat heater rear center", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_rear_right": { + "name": "Seat heater rear right", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_third_row_left": { + "name": "Seat heater third row left", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + }, + "climate_state_seat_heater_third_row_right": { + "name": "Seat heater third row right", + "state": { + "off": "[%key:common::state::off%]", + "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]", + "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]", + "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]" + } + } + }, + "binary_sensor": { + "state": { + "name": "Status" + }, + "charge_state_battery_heater_on": { + "name": "Battery heater" + }, + "charge_state_charge_enable_request": { + "name": "Charge enable request" + }, + "charge_state_charge_port_door_open": { + "name": "Charge port door" + }, + "charge_state_charging_state": { + "name": "Charging" + }, + "charge_state_preconditioning_enabled": { + "name": "Preconditioning enabled" + }, + "charge_state_scheduled_charging_pending": { + "name": "Scheduled charging pending" + }, + "charge_state_trip_charging": { + "name": "Trip charging" + }, + "climate_state_auto_seat_climate_left": { + "name": "Auto seat climate left" + }, + "climate_state_auto_seat_climate_right": { + "name": "Auto seat climate right" + }, + "climate_state_auto_steering_wheel_heater": { + "name": "Auto steering wheel heater" + }, + "climate_state_cabin_overheat_protection": { + "name": "Cabin overheat protection" + }, + "climate_state_cabin_overheat_protection_actively_cooling": { + "name": "Cabin overheat protection actively cooling" + }, + "vehicle_state_dashcam_state": { + "name": "Dashcam" + }, + "vehicle_state_is_user_present": { + "name": "User present" + }, + "vehicle_state_tpms_soft_warning_fl": { + "name": "Tire pressure warning front left" + }, + "vehicle_state_tpms_soft_warning_fr": { + "name": "Tire pressure warning front right" + }, + "vehicle_state_tpms_soft_warning_rl": { + "name": "Tire pressure warning rear left" + }, + "vehicle_state_tpms_soft_warning_rr": { + "name": "Tire pressure warning rear right" + }, + "vehicle_state_fd_window": { + "name": "Front driver window" + }, + "vehicle_state_fp_window": { + "name": "Front passenger window" + }, + "vehicle_state_rd_window": { + "name": "Rear driver window" + }, + "vehicle_state_rp_window": { + "name": "Rear passenger window" + } + }, + "button": { + "wake": { "name": "Wake" }, + "flash_lights": { "name": "Flash lights" }, + "honk": { "name": "Honk horn" }, + "trigger_homelink": { "name": "Homelink" }, + "enable_keyless_driving": { "name": "Keyless driving" }, + "boombox": { "name": "Play fart" } + }, + "switch": { + "charge_state_charge_enable_request": { + "name": "Charge" + }, + "climate_state_defrost_mode": { + "name": "Defrost mode" + }, + "vehicle_state_sentry_mode": { + "name": "Sentry mode" + }, + "vehicle_state_valet_mode": { + "name": "Valet mode" + }, + "climate_state_steering_wheel_heater": { + "name": "Steering wheel heater" + } + }, + "number": { + "charge_state_charge_current_request": { + "name": "Charge current" + }, + "charge_state_charge_limit_soc": { + "name": "Charge limit" + }, + "vehicle_state_speed_limit_mode_current_limit_mph": { + "name": "Speed limit" + } + }, + "update": { + "update": { + "name": "[%key:component::update::title%]" + } + } + } +} diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py new file mode 100644 index 00000000000..595c44e11be --- /dev/null +++ b/homeassistant/components/tessie/switch.py @@ -0,0 +1,121 @@ +"""Switch platform for Tessie integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from tessie_api import ( + disable_sentry_mode, + disable_valet_mode, + enable_sentry_mode, + enable_valet_mode, + start_charging, + start_defrost, + start_steering_wheel_heater, + stop_charging, + stop_defrost, + stop_steering_wheel_heater, +) + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +@dataclass(frozen=True, kw_only=True) +class TessieSwitchEntityDescription(SwitchEntityDescription): + """Describes Tessie Switch entity.""" + + on_func: Callable + off_func: Callable + + +DESCRIPTIONS: tuple[TessieSwitchEntityDescription, ...] = ( + TessieSwitchEntityDescription( + key="charge_state_charge_enable_request", + on_func=lambda: start_charging, + off_func=lambda: stop_charging, + icon="mdi:ev-station", + ), + TessieSwitchEntityDescription( + key="climate_state_defrost_mode", + on_func=lambda: start_defrost, + off_func=lambda: stop_defrost, + icon="mdi:snowflake", + ), + TessieSwitchEntityDescription( + key="vehicle_state_sentry_mode", + on_func=lambda: enable_sentry_mode, + off_func=lambda: disable_sentry_mode, + icon="mdi:shield-car", + ), + TessieSwitchEntityDescription( + key="vehicle_state_valet_mode", + on_func=lambda: enable_valet_mode, + off_func=lambda: disable_valet_mode, + icon="mdi:car-key", + ), + TessieSwitchEntityDescription( + key="climate_state_steering_wheel_heater", + on_func=lambda: start_steering_wheel_heater, + off_func=lambda: stop_steering_wheel_heater, + icon="mdi:steering", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Switch platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + [ + TessieSwitchEntity(vehicle.state_coordinator, description) + for vehicle in data + for description in DESCRIPTIONS + if description.key in vehicle.state_coordinator.data + ] + ) + + +class TessieSwitchEntity(TessieEntity, SwitchEntity): + """Base class for Tessie Switch.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + entity_description: TessieSwitchEntityDescription + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + description: TessieSwitchEntityDescription, + ) -> None: + """Initialize the Switch.""" + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return the state of the Switch.""" + return self._value + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the Switch.""" + await self.run(self.entity_description.on_func()) + self.set((self.entity_description.key, True)) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the Switch.""" + await self.run(self.entity_description.off_func()) + self.set((self.entity_description.key, False)) diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py new file mode 100644 index 00000000000..1d2fb59c492 --- /dev/null +++ b/homeassistant/components/tessie/update.py @@ -0,0 +1,87 @@ +"""Update platform for Tessie integration.""" +from __future__ import annotations + +from typing import Any + +from tessie_api import schedule_software_update + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TessieUpdateStatus +from .coordinator import TessieStateUpdateCoordinator +from .entity import TessieEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Tessie Update platform from a config entry.""" + data = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + TessieUpdateEntity(vehicle.state_coordinator) for vehicle in data + ) + + +class TessieUpdateEntity(TessieEntity, UpdateEntity): + """Tessie Updates entity.""" + + _attr_supported_features = UpdateEntityFeature.PROGRESS + + def __init__( + self, + coordinator: TessieStateUpdateCoordinator, + ) -> None: + """Initialize the Update.""" + super().__init__(coordinator, "update") + + @property + def supported_features(self) -> UpdateEntityFeature: + """Flag supported features.""" + if self.get("vehicle_state_software_update_status") in ( + TessieUpdateStatus.AVAILABLE, + TessieUpdateStatus.SCHEDULED, + ): + return self._attr_supported_features | UpdateEntityFeature.INSTALL + return self._attr_supported_features + + @property + def installed_version(self) -> str: + """Return the current app version.""" + # Discard build from version number + return self.coordinator.data["vehicle_state_car_version"].split(" ")[0] + + @property + def latest_version(self) -> str | None: + """Return the latest version.""" + if self.get("vehicle_state_software_update_status") in ( + TessieUpdateStatus.AVAILABLE, + TessieUpdateStatus.SCHEDULED, + TessieUpdateStatus.INSTALLING, + TessieUpdateStatus.DOWNLOADING, + TessieUpdateStatus.WIFI_WAIT, + ): + return self.get("vehicle_state_software_update_version") + return self.installed_version + + @property + def in_progress(self) -> bool | int | None: + """Update installation progress.""" + if ( + self.get("vehicle_state_software_update_status") + == TessieUpdateStatus.INSTALLING + ): + return self.get("vehicle_state_software_update_install_perc") + return False + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + await self.run(schedule_software_update, in_seconds=0) + self.set( + ("vehicle_state_software_update_status", TessieUpdateStatus.INSTALLING) + ) diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index acc5f62a0cc..89fad759f8b 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta from enum import StrEnum import logging import re -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -33,6 +33,11 @@ from .const import ( SERVICE_SET_VALUE, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -98,8 +103,7 @@ class TextMode(StrEnum): TEXT = "text" -@dataclass -class TextEntityDescription(EntityDescription): +class TextEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes text entities.""" native_min: int = 0 @@ -108,7 +112,16 @@ class TextEntityDescription(EntityDescription): pattern: str | None = None -class TextEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "mode", + "native_value", + "native_min", + "native_max", + "pattern", +} + + +class TextEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Text entity.""" _entity_component_unrecorded_attributes = frozenset( @@ -157,7 +170,7 @@ class TextEntity(Entity): ) return self.native_value - @property + @cached_property def mode(self) -> TextMode: """Return the mode of the entity.""" if hasattr(self, "_attr_mode"): @@ -166,7 +179,7 @@ class TextEntity(Entity): return self.entity_description.mode return TextMode.TEXT - @property + @cached_property def native_min(self) -> int: """Return the minimum length of the value.""" if hasattr(self, "_attr_native_min"): @@ -181,7 +194,7 @@ class TextEntity(Entity): """Return the minimum length of the value.""" return max(self.native_min, 0) - @property + @cached_property def native_max(self) -> int: """Return the maximum length of the value.""" if hasattr(self, "_attr_native_max"): @@ -207,7 +220,7 @@ class TextEntity(Entity): self.__pattern_cmp = re.compile(self.pattern) return self.__pattern_cmp - @property + @cached_property def pattern(self) -> str | None: """Return the regex pattern that the value must match.""" if hasattr(self, "_attr_pattern"): @@ -216,7 +229,7 @@ class TextEntity(Entity): return self.entity_description.pattern return None - @property + @cached_property def native_value(self) -> str | None: """Return the value reported by the text.""" return self._attr_native_value diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json index 772c565e9d2..29443acaa3d 100644 --- a/homeassistant/components/thermobeacon/manifest.json +++ b/homeassistant/components/thermobeacon/manifest.json @@ -42,5 +42,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermobeacon", "iot_class": "local_push", - "requirements": ["thermobeacon-ble==0.6.0"] + "requirements": ["thermobeacon-ble==0.6.2"] } diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 3395353b7bf..0f2997986cb 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -60,11 +60,7 @@ def async_discovery_data_from_service( except UnicodeDecodeError: return None - # Service properties are always bytes if they are set from the network. - # For legacy backwards compatibility zeroconf allows properties to be set - # as strings but we never do that so we can safely cast here. - service_properties = cast(dict[bytes, bytes | None], service.properties) - + service_properties = service.properties border_agent_id = service_properties.get(b"id") model_name = try_decode(service_properties.get(b"mn")) network_name = try_decode(service_properties.get(b"nn")) @@ -121,10 +117,7 @@ def async_read_zeroconf_cache(aiozc: AsyncZeroconf) -> list[ThreadRouterDiscover # data is not fully in the cache, so ignore for now continue - # Service properties are always bytes if they are set from the network. - # For legacy backwards compatibility zeroconf allows properties to be set - # as strings but we never do that so we can safely cast here. - service_properties = cast(dict[bytes, bytes | None], info.properties) + service_properties = info.properties if not (xa := service_properties.get(b"xa")): _LOGGER.debug("Ignoring record without xa %s", info) @@ -189,10 +182,7 @@ class ThreadRouterDiscovery: return _LOGGER.debug("_add_update_service %s %s", name, service) - # Service properties are always bytes if they are set from the network. - # For legacy backwards compatibility zeroconf allows properties to be set - # as strings but we never do that so we can safely cast here. - service_properties = cast(dict[bytes, bytes | None], service.properties) + service_properties = service.properties # We need xa and xp, bail out if either is missing if not (xa := service_properties.get(b"xa")): diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 26d40191fb9..387c42f0852 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -1,10 +1,9 @@ """Component to allow setting time as platforms.""" from __future__ import annotations -from dataclasses import dataclass from datetime import time, timedelta import logging -from typing import final +from typing import TYPE_CHECKING, final import voluptuous as vol @@ -22,6 +21,12 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, SERVICE_SET_VALUE +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -62,12 +67,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class TimeEntityDescription(EntityDescription): +class TimeEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes time entities.""" -class TimeEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = {"native_value"} + + +class TimeEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Representation of a Time entity.""" entity_description: TimeEntityDescription @@ -75,13 +82,13 @@ class TimeEntity(Entity): _attr_device_class: None = None _attr_state: None = None - @property + @cached_property @final def device_class(self) -> None: """Return the device class for the entity.""" return None - @property + @cached_property @final def state_attributes(self) -> None: """Return the state attributes.""" @@ -95,7 +102,7 @@ class TimeEntity(Entity): return None return self.native_value.isoformat() - @property + @cached_property def native_value(self) -> time | None: """Return the value reported by the time.""" return self._attr_native_value diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index c0e0303d76e..afcb8e28f74 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Iterable import dataclasses import datetime import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -19,7 +19,7 @@ from homeassistant.core import ( SupportsResponse, callback, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -41,6 +41,12 @@ from .const import ( TodoListEntityFeature, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = datetime.timedelta(seconds=60) @@ -68,19 +74,19 @@ class TodoItemFieldDescription: TODO_ITEM_FIELDS = [ TodoItemFieldDescription( service_field=ATTR_DUE_DATE, - validation=cv.date, + validation=vol.Any(cv.date, None), todo_item_field=ATTR_DUE, required_feature=TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, ), TodoItemFieldDescription( service_field=ATTR_DUE_DATETIME, - validation=vol.All(cv.datetime, dt_util.as_local), + validation=vol.Any(vol.All(cv.datetime, dt_util.as_local), None), todo_item_field=ATTR_DUE, required_feature=TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, ), TodoItemFieldDescription( service_field=ATTR_DESCRIPTION, - validation=cv.string, + validation=vol.Any(cv.string, None), todo_item_field=ATTR_DESCRIPTION, required_feature=TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, ), @@ -100,8 +106,11 @@ def _validate_supported_features( if desc.service_field not in call_data: continue if not supported_features or not supported_features & desc.required_feature: - raise ValueError( - f"Entity does not support setting field '{desc.service_field}'" + raise ServiceValidationError( + f"Entity does not support setting field '{desc.service_field}'", + translation_domain=DOMAIN, + translation_key="update_field_not_supported", + translation_placeholders={"service_field": desc.service_field}, ) @@ -226,7 +235,12 @@ class TodoItem: """ -class TodoListEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "todo_items", +} + + +class TodoListEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """An entity that represents a To-do list.""" _attr_todo_items: list[TodoItem] | None = None @@ -240,7 +254,7 @@ class TodoListEntity(Entity): return None return sum([item.status == TodoItemStatus.NEEDS_ACTION for item in items]) - @property + @cached_property def todo_items(self) -> list[TodoItem] | None: """Return the To-do items in the To-do list.""" return self._attr_todo_items @@ -470,22 +484,31 @@ async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> item = call.data["item"] found = _find_by_uid_or_summary(item, entity.todo_items) if not found: - raise ValueError(f"Unable to find To-do item '{item}'") + raise ServiceValidationError( + f"Unable to find To-do item '{item}'", + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"item": item}, + ) _validate_supported_features(entity.supported_features, call.data) - await entity.async_update_todo_item( - item=TodoItem( - uid=found.uid, - summary=call.data.get("rename"), - status=call.data.get("status"), - **{ - desc.todo_item_field: call.data[desc.service_field] - for desc in TODO_ITEM_FIELDS - if desc.service_field in call.data - }, - ) + # Perform a partial update on the existing entity based on the fields + # present in the update. This allows explicitly clearing any of the + # extended fields present and set to None. + updated_data = dataclasses.asdict(found) + if summary := call.data.get("rename"): + updated_data["summary"] = summary + if status := call.data.get("status"): + updated_data["status"] = status + updated_data.update( + { + desc.todo_item_field: call.data[desc.service_field] + for desc in TODO_ITEM_FIELDS + if desc.service_field in call.data + } ) + await entity.async_update_todo_item(item=TodoItem(**updated_data)) async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: @@ -494,7 +517,12 @@ async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> for item in call.data.get("item", []): found = _find_by_uid_or_summary(item, entity.todo_items) if not found or not found.uid: - raise ValueError(f"Unable to find To-do item '{item}") + raise ServiceValidationError( + f"Unable to find To-do item '{item}'", + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"item": item}, + ) uids.append(found.uid) await entity.async_delete_todo_items(uids=uids) diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 8ecc9e0ec86..07f91e12e22 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -26,14 +26,23 @@ add_item: selector: text: due_date: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DUE_DATE_ON_ITEM example: "2023-11-17" selector: date: due_datetime: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM example: "2023-11-17 13:30:00" selector: datetime: description: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM example: "A more complete description of the to-do item than that provided by the summary." selector: text: @@ -62,14 +71,23 @@ update_item: - needs_action - completed due_date: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DUE_DATE_ON_ITEM example: "2023-11-17" selector: date: due_datetime: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM example: "2023-11-17 13:30:00" selector: datetime: description: + filter: + supported_features: + - todo.TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM example: "A more complete description of the to-do item than that provided by the summary." selector: text: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 3da921a8f47..5ef7a5fe35b 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -90,5 +90,13 @@ "completed": "Completed" } } + }, + "exceptions": { + "item_not_found": { + "message": "Unable to find To-do item: {item}" + }, + "update_field_not_supported": { + "message": "Entity does not support setting field: {service_field}" + } } } diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py index 6231a6878ae..5067e98642e 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -34,19 +34,20 @@ async def async_setup_entry( def _task_api_data(item: TodoItem) -> dict[str, Any]: """Convert a TodoItem to the set of add or update arguments.""" - item_data: dict[str, Any] = {} - if summary := item.summary: - item_data["content"] = summary + item_data: dict[str, Any] = { + "content": item.summary, + # Description needs to be empty string to be cleared + "description": item.description or "", + } if due := item.due: if isinstance(due, datetime.datetime): - item_data["due"] = { - "date": due.date().isoformat(), - "datetime": due.isoformat(), - } + item_data["due_datetime"] = due.isoformat() else: - item_data["due"] = {"date": due.isoformat()} - if description := item.description: - item_data["description"] = description + item_data["due_date"] = due.isoformat() + else: + # Special flag "no date" clears the due date/datetime. + # See https://developer.todoist.com/rest/v2/#update-a-task for more. + item_data["due_string"] = "no date" return item_data @@ -128,10 +129,16 @@ class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntit if update_data := _task_api_data(item): await self.coordinator.api.update_task(task_id=uid, **update_data) if item.status is not None: - if item.status == TodoItemStatus.COMPLETED: - await self.coordinator.api.close_task(task_id=uid) - else: - await self.coordinator.api.reopen_task(task_id=uid) + # Only update status if changed + for existing_item in self._attr_todo_items or (): + if existing_item.uid != item.uid: + continue + + if item.status != existing_item.status: + if item.status == TodoItemStatus.COMPLETED: + await self.coordinator.api.close_task(task_id=uid) + else: + await self.coordinator.api.reopen_task(task_id=uid) await self.coordinator.async_refresh() async def async_delete_todo_items(self, uids: list[str]) -> None: diff --git a/homeassistant/components/tolo/number.py b/homeassistant/components/tolo/number.py index 3e07392c336..b31b5102394 100644 --- a/homeassistant/components/tolo/number.py +++ b/homeassistant/components/tolo/number.py @@ -18,7 +18,7 @@ from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator from .const import DOMAIN, FAN_TIMER_MAX, POWER_TIMER_MAX, SALT_BATH_TIMER_MAX -@dataclass +@dataclass(frozen=True) class ToloNumberEntityDescriptionBase: """Required values when describing TOLO Number entities.""" @@ -26,7 +26,7 @@ class ToloNumberEntityDescriptionBase: setter: Callable[[ToloClient, int | None], Any] -@dataclass +@dataclass(frozen=True) class ToloNumberEntityDescription( NumberEntityDescription, ToloNumberEntityDescriptionBase ): diff --git a/homeassistant/components/tolo/sensor.py b/homeassistant/components/tolo/sensor.py index 2ff901939ae..ec57612a99f 100644 --- a/homeassistant/components/tolo/sensor.py +++ b/homeassistant/components/tolo/sensor.py @@ -26,7 +26,7 @@ from . import ToloSaunaCoordinatorEntity, ToloSaunaUpdateCoordinator from .const import DOMAIN -@dataclass +@dataclass(frozen=True) class ToloSensorEntityDescriptionBase: """Required values when describing TOLO Sensor entities.""" @@ -34,7 +34,7 @@ class ToloSensorEntityDescriptionBase: availability_checker: Callable[[SettingsInfo, StatusInfo], bool] | None -@dataclass +@dataclass(frozen=True) class ToloSensorEntityDescription( SensorEntityDescription, ToloSensorEntityDescriptionBase ): diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 947bbf6fd2f..6b285378e7e 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -70,7 +70,7 @@ from .const import ( ) -@dataclass +@dataclass(frozen=True) class TomorrowioSensorEntityDescription(SensorEntityDescription): """Describes a Tomorrow.io sensor entity.""" @@ -92,8 +92,9 @@ class TomorrowioSensorEntityDescription(SensorEntityDescription): ) if self.value_map is not None: - self.device_class = SensorDeviceClass.ENUM - self.options = [item.name.lower() for item in self.value_map] + options = [item.name.lower() for item in self.value_map] + object.__setattr__(self, "device_class", SensorDeviceClass.ENUM) + object.__setattr__(self, "options", options) # From https://cfpub.epa.gov/ncer_abstracts/index.cfm/fuseaction/display.files/fileID/14285 diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py index e632915edf7..6edc656df06 100644 --- a/homeassistant/components/toon/binary_sensor.py +++ b/homeassistant/components/toon/binary_sensor.py @@ -91,14 +91,14 @@ class ToonBoilerModuleBinarySensor(ToonBinarySensor, ToonBoilerModuleDeviceEntit """Defines a Boiler module binary sensor.""" -@dataclass +@dataclass(frozen=True) class ToonBinarySensorRequiredKeysMixin(ToonRequiredKeysMixin): """Mixin for binary sensor required keys.""" cls: type[ToonBinarySensor] -@dataclass +@dataclass(frozen=True) class ToonBinarySensorEntityDescription( BinarySensorEntityDescription, ToonBinarySensorRequiredKeysMixin ): diff --git a/homeassistant/components/toon/models.py b/homeassistant/components/toon/models.py index 75e3ddb0370..44986b02143 100644 --- a/homeassistant/components/toon/models.py +++ b/homeassistant/components/toon/models.py @@ -151,7 +151,7 @@ class ToonBoilerDeviceEntity(ToonEntity): ) -@dataclass +@dataclass(frozen=True) class ToonRequiredKeysMixin: """Mixin for required keys.""" diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 90dd466045c..7ff9d2b67f7 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -114,14 +114,14 @@ class ToonDisplayDeviceSensor(ToonSensor, ToonDisplayDeviceEntity): """Defines a Display sensor.""" -@dataclass +@dataclass(frozen=True) class ToonSensorRequiredKeysMixin(ToonRequiredKeysMixin): """Mixin for sensor required keys.""" cls: type[ToonSensor] -@dataclass +@dataclass(frozen=True) class ToonSensorEntityDescription(SensorEntityDescription, ToonSensorRequiredKeysMixin): """Describes Toon sensor entity.""" diff --git a/homeassistant/components/toon/switch.py b/homeassistant/components/toon/switch.py index bf283b203c7..8dddb657df0 100644 --- a/homeassistant/components/toon/switch.py +++ b/homeassistant/components/toon/switch.py @@ -94,14 +94,14 @@ class ToonHolidayModeSwitch(ToonSwitch, ToonDisplayDeviceEntity): ) -@dataclass +@dataclass(frozen=True) class ToonSwitchRequiredKeysMixin(ToonRequiredKeysMixin): """Mixin for switch required keys.""" cls: type[ToonSwitch] -@dataclass +@dataclass(frozen=True) class ToonSwitchEntityDescription(SwitchEntityDescription, ToonSwitchRequiredKeysMixin): """Describes Toon switch entity.""" diff --git a/homeassistant/components/totalconnect/const.py b/homeassistant/components/totalconnect/const.py index 5012a303b69..1e98adaaa70 100644 --- a/homeassistant/components/totalconnect/const.py +++ b/homeassistant/components/totalconnect/const.py @@ -2,7 +2,6 @@ DOMAIN = "totalconnect" CONF_USERCODES = "usercodes" -CONF_LOCATION = "location" AUTO_BYPASS = "auto_bypass_low_battery" # Most TotalConnect alarms will work passing '-1' as usercode diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index b1cd323a36a..22b5741fceb 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -13,7 +13,6 @@ ATTR_TODAY_ENERGY_KWH: Final = "today_energy_kwh" ATTR_TOTAL_ENERGY_KWH: Final = "total_energy_kwh" CONF_DIMMER: Final = "dimmer" -CONF_DISCOVERY: Final = "discovery" CONF_LIGHT: Final = "light" CONF_STRIP: Final = "strip" CONF_SWITCH: Final = "switch" diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index afb341b47ed..2df9a856083 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -18,7 +18,7 @@ _P = ParamSpec("_P") def async_refresh_after( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Define a wrapper to refresh after.""" diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index db7e6ff355e..8e77c68a880 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -182,6 +182,16 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): self._attr_unique_id = legacy_device_id(device) else: self._attr_unique_id = device.mac.replace(":", "").upper() + modes: set[ColorMode] = set() + if device.is_variable_color_temp: + modes.add(ColorMode.COLOR_TEMP) + if device.is_color: + modes.add(ColorMode.HS) + if device.is_dimmable: + modes.add(ColorMode.BRIGHTNESS) + if not modes: + modes.add(ColorMode.ONOFF) + self._attr_supported_color_modes = modes @callback def _async_extract_brightness_transition( @@ -267,22 +277,6 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): hue, saturation, _ = self.device.hsv return hue, saturation - @property - def supported_color_modes(self) -> set[ColorMode]: - """Return list of available color modes.""" - modes: set[ColorMode] = set() - if self.device.is_variable_color_temp: - modes.add(ColorMode.COLOR_TEMP) - if self.device.is_color: - modes.add(ColorMode.HS) - if self.device.is_dimmable: - modes.add(ColorMode.BRIGHTNESS) - - if not modes: - modes.add(ColorMode.ONOFF) - - return modes - @property def color_mode(self) -> ColorMode: """Return the active color mode.""" @@ -300,11 +294,7 @@ class TPLinkSmartLightStrip(TPLinkSmartBulb): """Representation of a TPLink Smart Light Strip.""" device: SmartLightStrip - - @property - def supported_features(self) -> LightEntityFeature: - """Flag supported features.""" - return super().supported_features | LightEntityFeature.EFFECT + _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT @property def effect_list(self) -> list[str] | None: diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index 46909f39dfe..4fd957c2d8f 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -35,7 +35,7 @@ from .coordinator import TPLinkDataUpdateCoordinator from .entity import CoordinatedTPLinkEntity -@dataclass +@dataclass(frozen=True) class TPLinkSensorEntityDescription(SensorEntityDescription): """Describes TPLink sensor entity.""" diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py index 49eda4f8d09..ab9dad88e06 100644 --- a/homeassistant/components/tractive/sensor.py +++ b/homeassistant/components/tractive/sensor.py @@ -43,14 +43,14 @@ from .const import ( from .entity import TractiveEntity -@dataclass +@dataclass(frozen=True) class TractiveRequiredKeysMixin: """Mixin for required keys.""" signal_prefix: str -@dataclass +@dataclass(frozen=True) class TractiveSensorEntityDescription( SensorEntityDescription, TractiveRequiredKeysMixin ): diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 58c82bd6514..b77c35e6904 100644 --- a/homeassistant/components/tractive/switch.py +++ b/homeassistant/components/tractive/switch.py @@ -28,14 +28,14 @@ from .entity import TractiveEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class TractiveRequiredKeysMixin: """Mixin for required keys.""" method: Literal["async_set_buzzer", "async_set_led", "async_set_live_tracking"] -@dataclass +@dataclass(frozen=True) class TractiveSwitchEntityDescription( SwitchEntityDescription, TractiveRequiredKeysMixin ): diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index 416eb175d31..abb35df62aa 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -19,7 +19,7 @@ from .coordinator import TradfriDeviceDataUpdateCoordinator def handle_error( - func: Callable[[Command | list[Command]], Any] + func: Callable[[Command | list[Command]], Any], ) -> Callable[[Command | list[Command]], Coroutine[Any, Any, None]]: """Handle tradfri api call error.""" diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 383eec8a8fb..7f04b8aff03 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -37,14 +37,14 @@ from .const import ( from .coordinator import TradfriDeviceDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class TradfriSensorEntityDescriptionMixin: """Mixin for required keys.""" value: Callable[[Device], Any | None] -@dataclass +@dataclass(frozen=True) class TradfriSensorEntityDescription( SensorEntityDescription, TradfriSensorEntityDescriptionMixin, diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index 3ac3ce35882..f0f758272f7 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -6,12 +6,12 @@ import logging from pytrafikverket.trafikverket_camera import TrafikverketCamera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_ID +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_LOCATION from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_LOCATION, DOMAIN, PLATFORMS +from .const import DOMAIN, PLATFORMS from .coordinator import TVDataUpdateCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/homeassistant/components/trafikverket_camera/binary_sensor.py b/homeassistant/components/trafikverket_camera/binary_sensor.py index c9da5bd5d8a..b725f6d2f95 100644 --- a/homeassistant/components/trafikverket_camera/binary_sensor.py +++ b/homeassistant/components/trafikverket_camera/binary_sensor.py @@ -19,14 +19,14 @@ from .entity import TrafikverketCameraNonCameraEntity PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Trafikverket Camera base description keys.""" value_fn: Callable[[CameraData], bool | None] -@dataclass +@dataclass(frozen=True) class TVCameraSensorEntityDescription( BinarySensorEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index 7572855b7d4..a5257455e7a 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -14,12 +14,12 @@ from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY, CONF_ID +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_LOCATION from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import TextSelector -from .const import CONF_LOCATION, DOMAIN +from .const import DOMAIN class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -90,7 +90,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=vol.Schema( { - vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_API_KEY): TextSelector(), } ), errors=errors, @@ -123,8 +123,8 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_LOCATION): cv.string, + vol.Required(CONF_API_KEY): TextSelector(), + vol.Required(CONF_LOCATION): TextSelector(), } ), errors=errors, diff --git a/homeassistant/components/trafikverket_camera/const.py b/homeassistant/components/trafikverket_camera/const.py index ff40d1bbc91..728ba9f7bd5 100644 --- a/homeassistant/components/trafikverket_camera/const.py +++ b/homeassistant/components/trafikverket_camera/const.py @@ -2,7 +2,6 @@ from homeassistant.const import Platform DOMAIN = "trafikverket_camera" -CONF_LOCATION = "location" PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR] ATTRIBUTION = "Data provided by Trafikverket" diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index 31eb911e24d..d7631ada680 100644 --- a/homeassistant/components/trafikverket_camera/manifest.json +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_camera", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.9.1"] + "requirements": ["pytrafikverket==0.3.9.2"] } diff --git a/homeassistant/components/trafikverket_camera/sensor.py b/homeassistant/components/trafikverket_camera/sensor.py index 96231bba755..678c703307c 100644 --- a/homeassistant/components/trafikverket_camera/sensor.py +++ b/homeassistant/components/trafikverket_camera/sensor.py @@ -23,14 +23,14 @@ from .entity import TrafikverketCameraNonCameraEntity PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class DeviceBaseEntityDescriptionMixin: """Mixin for required Trafikverket Camera base description keys.""" value_fn: Callable[[CameraData], StateType | datetime] -@dataclass +@dataclass(frozen=True) class TVCameraSensorEntityDescription( SensorEntityDescription, DeviceBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json index 651225934cd..35dbbb1f540 100644 --- a/homeassistant/components/trafikverket_camera/strings.json +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -15,6 +15,9 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "location": "[%key:common::config_flow::data::location%]" + }, + "data_description": { + "location": "Equal or part of name, description or camera id" } } } diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 7f750c26c57..e1c86038986 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.9.1"] + "requirements": ["pytrafikverket==0.3.9.2"] } diff --git a/homeassistant/components/trafikverket_ferry/sensor.py b/homeassistant/components/trafikverket_ferry/sensor.py index a673f624a47..cd0682c12bc 100644 --- a/homeassistant/components/trafikverket_ferry/sensor.py +++ b/homeassistant/components/trafikverket_ferry/sensor.py @@ -32,7 +32,7 @@ ICON = "mdi:ferry" SCAN_INTERVAL = timedelta(minutes=5) -@dataclass +@dataclass(frozen=True) class TrafikverketRequiredKeysMixin: """Mixin for required keys.""" @@ -40,7 +40,7 @@ class TrafikverketRequiredKeysMixin: info_fn: Callable[[dict[str, Any]], StateType | list] | None -@dataclass +@dataclass(frozen=True) class TrafikverketSensorEntityDescription( SensorEntityDescription, TrafikverketRequiredKeysMixin ): diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py index 91a7e9f07b2..d5402e44ec6 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -39,6 +39,8 @@ class TrainData: other_info: str | None deviation: str | None product_filter: str | None + departure_time_next: datetime | None + departure_time_next_next: datetime | None _LOGGER = logging.getLogger(__name__) @@ -91,6 +93,7 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): when = dt_util.now() state: TrainStop | None = None + states: list[TrainStop] | None = None if self._time: departure_day = next_departuredate(self._weekdays) when = datetime.combine( @@ -104,8 +107,12 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): self.from_station, self.to_station, when, self._filter_product ) else: - state = await self._train_api.async_get_next_train_stop( - self.from_station, self.to_station, when, self._filter_product + states = await self._train_api.async_get_next_train_stops( + self.from_station, + self.to_station, + when, + self._filter_product, + number_of_stops=3, ) except InvalidAuthentication as error: raise ConfigEntryAuthFailed from error @@ -117,6 +124,20 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): f"Train departure {when} encountered a problem: {error}" ) from error + depart_next = None + depart_next_next = None + if not state and states: + state = states[0] + depart_next = ( + states[1].advertised_time_at_location if len(states) > 1 else None + ) + depart_next_next = ( + states[2].advertised_time_at_location if len(states) > 2 else None + ) + + if not state: + raise UpdateFailed("Could not find any departures") + departure_time = state.advertised_time_at_location if state.estimated_time_at_location: departure_time = state.estimated_time_at_location @@ -125,7 +146,7 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): delay_time = state.get_delay_time() - states = TrainData( + return TrainData( departure_time=_get_as_utc(departure_time), departure_state=state.get_state().value, cancelled=state.canceled, @@ -136,6 +157,6 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): other_info=_get_as_joined(state.other_information), deviation=_get_as_joined(state.deviations), product_filter=self._filter_product, + departure_time_next=_get_as_utc(depart_next), + departure_time_next_next=_get_as_utc(depart_next_next), ) - - return states diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index b68a56b3793..83dd0e726ee 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.9.1"] + "requirements": ["pytrafikverket==0.3.9.2"] } diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index a5e76299b61..68865a64cb5 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -25,14 +25,14 @@ from .coordinator import TrainData, TVDataUpdateCoordinator ATTR_PRODUCT_FILTER = "product_filter" -@dataclass +@dataclass(frozen=True) class TrafikverketRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[TrainData], StateType | datetime] -@dataclass +@dataclass(frozen=True) class TrafikverketSensorEntityDescription( SensorEntityDescription, TrafikverketRequiredKeysMixin ): @@ -105,6 +105,20 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( icon="mdi:alert", value_fn=lambda data: data.deviation, ), + TrafikverketSensorEntityDescription( + key="departure_time_next", + translation_key="departure_time_next", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.departure_time_next, + ), + TrafikverketSensorEntityDescription( + key="departure_time_next_next", + translation_key="departure_time_next_next", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.departure_time_next_next, + ), ) diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index a2c286867b2..89542211a92 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -69,6 +69,22 @@ } } }, + "departure_time_next": { + "name": "Departure time next", + "state_attributes": { + "product_filter": { + "name": "[%key:component::trafikverket_train::entity::sensor::departure_time::state_attributes::product_filter::name%]" + } + } + }, + "departure_time_next_next": { + "name": "Departure time next after", + "state_attributes": { + "product_filter": { + "name": "[%key:component::trafikverket_train::entity::sensor::departure_time::state_attributes::product_filter::name%]" + } + } + }, "departure_state": { "name": "Departure state", "state": { diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index bd4b2b99b6a..1f27346b3a8 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.9.1"] + "requirements": ["pytrafikverket==0.3.9.2"] } diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 607a230fbbe..9c025237187 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -42,14 +42,14 @@ PRECIPITATION_TYPE = [ ] -@dataclass +@dataclass(frozen=True) class TrafikverketRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[WeatherStationInfo], StateType | datetime] -@dataclass +@dataclass(frozen=True) class TrafikverketSensorEntityDescription( SensorEntityDescription, TrafikverketRequiredKeysMixin ): diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 20f4fc95c87..87bcb87da9a 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -1,7 +1,9 @@ """Support for monitoring the Transmission BitTorrent client API.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress +from dataclasses import dataclass from typing import Any from transmission_rpc.torrent import Torrent @@ -16,6 +18,7 @@ from homeassistant.const import STATE_IDLE, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -28,23 +31,103 @@ from .const import ( ) from .coordinator import TransmissionDataUpdateCoordinator -SPEED_SENSORS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription(key="download", translation_key="download_speed"), - SensorEntityDescription(key="upload", translation_key="upload_speed"), -) +MODES: dict[str, list[str] | None] = { + "started_torrents": ["downloading"], + "completed_torrents": ["seeding"], + "paused_torrents": ["stopped"], + "active_torrents": [ + "seeding", + "downloading", + ], + "total_torrents": None, +} -STATUS_SENSORS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription(key="status", translation_key="transmission_status"), -) -TORRENT_SENSORS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription(key="active_torrents", translation_key="active_torrents"), - SensorEntityDescription(key="paused_torrents", translation_key="paused_torrents"), - SensorEntityDescription(key="total_torrents", translation_key="total_torrents"), - SensorEntityDescription( - key="completed_torrents", translation_key="completed_torrents" +@dataclass(frozen=True, kw_only=True) +class TransmissionSensorEntityDescription(SensorEntityDescription): + """Entity description class for Transmission sensors.""" + + val_func: Callable[[TransmissionDataUpdateCoordinator], StateType] + extra_state_attr_func: Callable[[Any], dict[str, str]] | None = None + + +SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = ( + TransmissionSensorEntityDescription( + key="download", + translation_key="download_speed", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + val_func=lambda coordinator: float(coordinator.data.download_speed), + ), + TransmissionSensorEntityDescription( + key="upload", + translation_key="upload_speed", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + val_func=lambda coordinator: float(coordinator.data.upload_speed), + ), + TransmissionSensorEntityDescription( + key="status", + translation_key="transmission_status", + device_class=SensorDeviceClass.ENUM, + options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING], + val_func=lambda coordinator: get_state( + coordinator.data.upload_speed, coordinator.data.download_speed + ), + ), + TransmissionSensorEntityDescription( + key="active_torrents", + translation_key="active_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: coordinator.data.active_torrent_count, + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="active_torrents" + ), + ), + TransmissionSensorEntityDescription( + key="paused_torrents", + translation_key="paused_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: coordinator.data.paused_torrent_count, + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="paused_torrents" + ), + ), + TransmissionSensorEntityDescription( + key="total_torrents", + translation_key="total_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: coordinator.data.torrent_count, + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="total_torrents" + ), + ), + TransmissionSensorEntityDescription( + key="completed_torrents", + translation_key="completed_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: len( + _filter_torrents(coordinator.torrents, MODES["completed_torrents"]) + ), + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="completed_torrents" + ), + ), + TransmissionSensorEntityDescription( + key="started_torrents", + translation_key="started_torrents", + native_unit_of_measurement="torrents", + val_func=lambda coordinator: len( + _filter_torrents(coordinator.torrents, MODES["started_torrents"]) + ), + extra_state_attr_func=lambda coordinator: _torrents_info_attr( + coordinator=coordinator, key="started_torrents" + ), ), - SensorEntityDescription(key="started_torrents", translation_key="started_torrents"), ) @@ -59,22 +142,9 @@ async def async_setup_entry( config_entry.entry_id ] - entities: list[TransmissionSensor] = [] - - entities = [ - TransmissionSpeedSensor(coordinator, description) - for description in SPEED_SENSORS - ] - entities += [ - TransmissionStatusSensor(coordinator, description) - for description in STATUS_SENSORS - ] - entities += [ - TransmissionTorrentsSensor(coordinator, description) - for description in TORRENT_SENSORS - ] - - async_add_entities(entities) + async_add_entities( + TransmissionSensor(coordinator, description) for description in SENSOR_TYPES + ) class TransmissionSensor( @@ -82,12 +152,13 @@ class TransmissionSensor( ): """A base class for all Transmission sensors.""" + entity_description: TransmissionSensorEntityDescription _attr_has_entity_name = True def __init__( self, coordinator: TransmissionDataUpdateCoordinator, - entity_description: SensorEntityDescription, + entity_description: TransmissionSensorEntityDescription, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) @@ -101,85 +172,28 @@ class TransmissionSensor( manufacturer="Transmission", ) - -class TransmissionSpeedSensor(TransmissionSensor): - """Representation of a Transmission speed sensor.""" - - _attr_device_class = SensorDeviceClass.DATA_RATE - _attr_native_unit_of_measurement = UnitOfDataRate.BYTES_PER_SECOND - _attr_suggested_display_precision = 2 - _attr_suggested_unit_of_measurement = UnitOfDataRate.MEGABYTES_PER_SECOND + @property + def native_value(self) -> StateType: + """Return the value of the sensor.""" + return self.entity_description.val_func(self.coordinator) @property - def native_value(self) -> float: - """Return the speed of the sensor.""" - data = self.coordinator.data - return ( - float(data.download_speed) - if self.entity_description.key == "download" - else float(data.upload_speed) - ) - - -class TransmissionStatusSensor(TransmissionSensor): - """Representation of a Transmission status sensor.""" - - _attr_device_class = SensorDeviceClass.ENUM - _attr_options = [STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING] - - @property - def native_value(self) -> str: - """Return the value of the status sensor.""" - upload = self.coordinator.data.upload_speed - download = self.coordinator.data.download_speed - if upload > 0 and download > 0: - return STATE_UP_DOWN - if upload > 0 and download == 0: - return STATE_SEEDING - if upload == 0 and download > 0: - return STATE_DOWNLOADING - return STATE_IDLE - - -class TransmissionTorrentsSensor(TransmissionSensor): - """Representation of a Transmission torrents sensor.""" - - MODES: dict[str, list[str] | None] = { - "started_torrents": ["downloading"], - "completed_torrents": ["seeding"], - "paused_torrents": ["stopped"], - "active_torrents": [ - "seeding", - "downloading", - ], - "total_torrents": None, - } - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return "Torrents" - - @property - def extra_state_attributes(self) -> dict[str, Any]: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes, if any.""" - info = _torrents_info( - torrents=self.coordinator.torrents, - order=self.coordinator.order, - limit=self.coordinator.limit, - statuses=self.MODES[self.entity_description.key], - ) - return { - STATE_ATTR_TORRENT_INFO: info, - } + if attr_func := self.entity_description.extra_state_attr_func: + return attr_func(self.coordinator) + return None - @property - def native_value(self) -> int: - """Return the count of the sensor.""" - torrents = _filter_torrents( - self.coordinator.torrents, statuses=self.MODES[self.entity_description.key] - ) - return len(torrents) + +def get_state(upload: int, download: int) -> str: + """Get current download/upload state.""" + if upload > 0 and download > 0: + return STATE_UP_DOWN + if upload > 0 and download == 0: + return STATE_SEEDING + if upload == 0 and download > 0: + return STATE_DOWNLOADING + return STATE_IDLE def _filter_torrents( @@ -192,13 +206,13 @@ def _filter_torrents( ] -def _torrents_info( - torrents: list[Torrent], order: str, limit: int, statuses: list[str] | None = None +def _torrents_info_attr( + coordinator: TransmissionDataUpdateCoordinator, key: str ) -> dict[str, Any]: infos = {} - torrents = _filter_torrents(torrents, statuses) - torrents = SUPPORTED_ORDER_MODES[order](torrents) - for torrent in torrents[:limit]: + torrents = _filter_torrents(coordinator.torrents, MODES[key]) + torrents = SUPPORTED_ORDER_MODES[coordinator.order](torrents) + for torrent in torrents[: coordinator.limit]: info = infos[torrent.name] = { "added_date": torrent.added_date, "percent_done": f"{torrent.percent_done * 100:.2f}", @@ -207,4 +221,4 @@ def _torrents_info( } with suppress(ValueError): info["eta"] = str(torrent.eta) - return infos + return {STATE_ATTR_TORRENT_INFO: infos} diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index fecda94fbf8..643b2f0ba70 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -17,7 +17,7 @@ from .coordinator import TransmissionDataUpdateCoordinator _LOGGING = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class TransmissionSwitchEntityDescriptionMixin: """Mixin for required keys.""" @@ -26,7 +26,7 @@ class TransmissionSwitchEntityDescriptionMixin: off_func: Callable[[TransmissionDataUpdateCoordinator], None] -@dataclass +@dataclass(frozen=True) class TransmissionSwitchEntityDescription( SwitchEntityDescription, TransmissionSwitchEntityDescriptionMixin ): diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index b583f424da1..91d50bcc928 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -1,5 +1,27 @@ """A sensor that monitors trends in other components.""" +from __future__ import annotations +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform +from homeassistant.core import HomeAssistant PLATFORMS = [Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Trend from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) + + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an Trend options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 2d00f35202c..c86fb65e966 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -17,6 +17,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, @@ -52,23 +53,43 @@ from .const import ( CONF_INVERT, CONF_MAX_SAMPLES, CONF_MIN_GRADIENT, + CONF_MIN_SAMPLES, CONF_SAMPLE_DURATION, + DEFAULT_MAX_SAMPLES, + DEFAULT_MIN_GRADIENT, + DEFAULT_MIN_SAMPLES, + DEFAULT_SAMPLE_DURATION, DOMAIN, ) _LOGGER = logging.getLogger(__name__) -SENSOR_SCHEMA = vol.Schema( - { - vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Optional(CONF_ATTRIBUTE): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_INVERT, default=False): cv.boolean, - vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, - vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), - vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, - } + +def _validate_min_max(data: dict[str, Any]) -> dict[str, Any]: + if ( + CONF_MIN_SAMPLES in data + and CONF_MAX_SAMPLES in data + and data[CONF_MAX_SAMPLES] < data[CONF_MIN_SAMPLES] + ): + raise vol.Invalid("min_samples must be smaller than or equal to max_samples") + return data + + +SENSOR_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_ATTRIBUTE): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_INVERT, default=False): cv.boolean, + vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int, + vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), + vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, + vol.Optional(CONF_MIN_SAMPLES, default=2): cv.positive_int, + } + ), + _validate_min_max, ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -85,37 +106,52 @@ async def async_setup_platform( """Set up the trend sensors.""" await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - sensors = [] - - for device_id, device_config in config[CONF_SENSORS].items(): - entity_id = device_config[ATTR_ENTITY_ID] - attribute = device_config.get(CONF_ATTRIBUTE) - device_class = device_config.get(CONF_DEVICE_CLASS) - friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device_id) - invert = device_config[CONF_INVERT] - max_samples = device_config[CONF_MAX_SAMPLES] - min_gradient = device_config[CONF_MIN_GRADIENT] - sample_duration = device_config[CONF_SAMPLE_DURATION] - - sensors.append( + entities = [] + for sensor_name, sensor_config in config[CONF_SENSORS].items(): + entities.append( SensorTrend( - hass, - device_id, - friendly_name, - entity_id, - attribute, - device_class, - invert, - max_samples, - min_gradient, - sample_duration, + name=sensor_config.get(CONF_FRIENDLY_NAME, sensor_name), + entity_id=sensor_config[CONF_ENTITY_ID], + attribute=sensor_config.get(CONF_ATTRIBUTE), + invert=sensor_config[CONF_INVERT], + sample_duration=sensor_config[CONF_SAMPLE_DURATION], + min_gradient=sensor_config[CONF_MIN_GRADIENT], + min_samples=sensor_config[CONF_MIN_SAMPLES], + max_samples=sensor_config[CONF_MAX_SAMPLES], + device_class=sensor_config.get(CONF_DEVICE_CLASS), + sensor_entity_id=generate_entity_id( + ENTITY_ID_FORMAT, sensor_name, hass=hass + ), ) ) - if not sensors: - _LOGGER.error("No sensors added") - return - async_add_entities(sensors) + async_add_entities(entities) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up trend sensor from config entry.""" + + async_add_entities( + [ + SensorTrend( + name=entry.title, + entity_id=entry.options[CONF_ENTITY_ID], + attribute=entry.options.get(CONF_ATTRIBUTE), + invert=entry.options[CONF_INVERT], + sample_duration=entry.options.get( + CONF_SAMPLE_DURATION, DEFAULT_SAMPLE_DURATION + ), + min_gradient=entry.options.get(CONF_MIN_GRADIENT, DEFAULT_MIN_GRADIENT), + min_samples=entry.options.get(CONF_MIN_SAMPLES, DEFAULT_MIN_SAMPLES), + max_samples=entry.options.get(CONF_MAX_SAMPLES, DEFAULT_MAX_SAMPLES), + unique_id=entry.entry_id, + ) + ] + ) class SensorTrend(BinarySensorEntity, RestoreEntity): @@ -127,28 +163,33 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): def __init__( self, - hass: HomeAssistant, - device_id: str, - friendly_name: str, + name: str, entity_id: str, - attribute: str, - device_class: BinarySensorDeviceClass, + attribute: str | None, invert: bool, - max_samples: int, - min_gradient: float, sample_duration: int, + min_gradient: float, + min_samples: int, + max_samples: int, + unique_id: str | None = None, + device_class: BinarySensorDeviceClass | None = None, + sensor_entity_id: str | None = None, ) -> None: """Initialize the sensor.""" - self._hass = hass - self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) - self._attr_name = friendly_name - self._attr_device_class = device_class self._entity_id = entity_id self._attribute = attribute self._invert = invert self._sample_duration = sample_duration self._min_gradient = min_gradient - self.samples: deque = deque(maxlen=max_samples) + self._min_samples = min_samples + self.samples: deque = deque(maxlen=int(max_samples)) + + self._attr_name = name + self._attr_device_class = device_class + self._attr_unique_id = unique_id + + if sensor_entity_id: + self.entity_id = sensor_entity_id @property def is_on(self) -> bool | None: @@ -210,7 +251,7 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): while self.samples and self.samples[0][0] < cutoff: self.samples.popleft() - if len(self.samples) < 2: + if len(self.samples) < self._min_samples: return # Calculate gradient of linear trend diff --git a/homeassistant/components/trend/config_flow.py b/homeassistant/components/trend/config_flow.py new file mode 100644 index 00000000000..457522dca82 --- /dev/null +++ b/homeassistant/components/trend/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for Trend integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_NAME, UnitOfTime +from homeassistant.helpers import selector +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, +) + +from .const import ( + CONF_INVERT, + CONF_MAX_SAMPLES, + CONF_MIN_GRADIENT, + CONF_MIN_SAMPLES, + CONF_SAMPLE_DURATION, + DEFAULT_MAX_SAMPLES, + DEFAULT_MIN_GRADIENT, + DEFAULT_MIN_SAMPLES, + DEFAULT_SAMPLE_DURATION, + DOMAIN, +) + + +async def get_base_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Get base options schema.""" + return vol.Schema( + { + vol.Optional(CONF_ATTRIBUTE): selector.AttributeSelector( + selector.AttributeSelectorConfig( + entity_id=handler.options[CONF_ENTITY_ID] + ) + ), + vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(), + } + ) + + +async def get_extended_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: + """Get extended options schema.""" + return (await get_base_options_schema(handler)).extend( + { + vol.Optional( + CONF_MAX_SAMPLES, default=DEFAULT_MAX_SAMPLES + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=2, + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Optional( + CONF_MIN_SAMPLES, default=DEFAULT_MIN_SAMPLES + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=2, + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Optional( + CONF_MIN_GRADIENT, default=DEFAULT_MIN_GRADIENT + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + step="any", + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Optional( + CONF_SAMPLE_DURATION, default=DEFAULT_SAMPLE_DURATION + ): selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + mode=selector.NumberSelectorMode.BOX, + unit_of_measurement=UnitOfTime.SECONDS, + ), + ), + } + ) + + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): selector.TextSelector(), + vol.Required(CONF_ENTITY_ID): selector.EntitySelector( + selector.EntitySelectorConfig(domain=SENSOR_DOMAIN, multiple=False), + ), + } +) + + +class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config or options flow for Trend.""" + + config_flow = { + "user": SchemaFlowFormStep(schema=CONFIG_SCHEMA, next_step="settings"), + "settings": SchemaFlowFormStep(get_base_options_schema), + } + options_flow = { + "init": SchemaFlowFormStep(get_extended_options_schema), + } + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/trend/const.py b/homeassistant/components/trend/const.py index 6787dc08445..838056bfc4d 100644 --- a/homeassistant/components/trend/const.py +++ b/homeassistant/components/trend/const.py @@ -12,3 +12,9 @@ CONF_INVERT = "invert" CONF_MAX_SAMPLES = "max_samples" CONF_MIN_GRADIENT = "min_gradient" CONF_SAMPLE_DURATION = "sample_duration" +CONF_MIN_SAMPLES = "min_samples" + +DEFAULT_MAX_SAMPLES = 2 +DEFAULT_MIN_SAMPLES = 2 +DEFAULT_MIN_GRADIENT = 0.0 +DEFAULT_SAMPLE_DURATION = 0 diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 0adbf623346..110bab99e52 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -2,7 +2,9 @@ "domain": "trend", "name": "Trend", "codeowners": ["@jpbede"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/trend", + "integration_type": "helper", "iot_class": "calculated", "quality_scale": "internal", "requirements": ["numpy==1.26.0"] diff --git a/homeassistant/components/trend/strings.json b/homeassistant/components/trend/strings.json index 6af231bb4c5..2fe0b35ee3c 100644 --- a/homeassistant/components/trend/strings.json +++ b/homeassistant/components/trend/strings.json @@ -4,5 +4,43 @@ "name": "[%key:common::action::reload%]", "description": "Reloads trend sensors from the YAML-configuration." } + }, + "config": { + "step": { + "user": { + "title": "Trend helper", + "description": "The trend helper allows you to create a sensor which show the trend of a numeric state or a state attribute from another entity.", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "entity_id": "Entity that this sensor tracks" + } + }, + "settings": { + "data": { + "attribute": "Attribute of entity that this sensor tracks", + "invert": "Invert the result" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "[%key:component::trend::config::step::settings::data::attribute%]", + "invert": "[%key:component::trend::config::step::settings::data::invert%]", + "max_samples": "Maximum number of stored samples", + "min_samples": "Minimum number of stored samples", + "min_gradient": "Minimum rate at which the value must be changing", + "sample_duration": "Duration in seconds to store samples for" + }, + "data_description": { + "max_samples": "The maximum number of samples to store. If the number of samples exceeds this value, the oldest samples will be discarded.", + "min_samples": "The minimum number of samples that must be collected before the gradient can be calculated.", + "min_gradient": "The minimum rate at which the observed value must be changing for this sensor to switch on. The gradient is measured in sensor units per second.", + "sample_duration": "The duration in seconds to store samples for. Samples older than this value will be discarded." + } + } + } } } diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 38715825875..9a44382e851 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -545,7 +545,7 @@ class SpeechManager: self.providers[engine] = provider self.hass.config.components.add( - PLATFORM_FORMAT.format(domain=engine, platform=DOMAIN) + PLATFORM_FORMAT.format(domain=DOMAIN, platform=engine) ) @callback diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 4734c3f22d1..05be2e284e3 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -6,7 +6,7 @@ from collections.abc import Coroutine, Mapping from functools import partial import logging from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -18,6 +18,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MediaType, ) +from homeassistant.config import config_per_platform from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DESCRIPTION, @@ -25,12 +26,12 @@ from homeassistant.const import ( CONF_PLATFORM, ) from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_per_platform, discovery +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_set_service_schema from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_prepare_setup_platform -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .const import ( ATTR_CACHE, @@ -103,8 +104,8 @@ async def async_setup_legacy( # Load service descriptions from tts/services.yaml services_yaml = Path(__file__).parent / "services.yaml" - services_dict = cast( - dict, await hass.async_add_executor_job(load_yaml, str(services_yaml)) + services_dict = await hass.async_add_executor_job( + load_yaml_dict, str(services_yaml) ) async def async_setup_platform( diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 276d21f3821..ee084b77ef1 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -15,6 +15,7 @@ from tuya_iot import ( ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -25,10 +26,7 @@ from .const import ( CONF_ACCESS_SECRET, CONF_APP_TYPE, CONF_AUTH_TYPE, - CONF_COUNTRY_CODE, CONF_ENDPOINT, - CONF_PASSWORD, - CONF_USERNAME, DOMAIN, LOGGER, PLATFORMS, diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index c57a37365ed..8e934ae6593 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -21,7 +21,7 @@ from .base import TuyaEntity from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode -@dataclass +@dataclass(frozen=True) class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): """Describes a Tuya binary sensor.""" diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 6b3b84ba349..b8c66c5cc35 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -39,14 +39,14 @@ TUYA_HVAC_TO_HA = { } -@dataclass +@dataclass(frozen=True) class TuyaClimateSensorDescriptionMixin: """Define an entity description mixin for climate entities.""" switch_only_hvac_mode: HVACMode -@dataclass +@dataclass(frozen=True) class TuyaClimateEntityDescription( ClimateEntityDescription, TuyaClimateSensorDescriptionMixin ): diff --git a/homeassistant/components/tuya/config_flow.py b/homeassistant/components/tuya/config_flow.py index bf2c54a6158..f933ac84519 100644 --- a/homeassistant/components/tuya/config_flow.py +++ b/homeassistant/components/tuya/config_flow.py @@ -7,16 +7,14 @@ from tuya_iot import AuthType, TuyaOpenAPI import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME from .const import ( CONF_ACCESS_ID, CONF_ACCESS_SECRET, CONF_APP_TYPE, CONF_AUTH_TYPE, - CONF_COUNTRY_CODE, CONF_ENDPOINT, - CONF_PASSWORD, - CONF_USERNAME, DOMAIN, LOGGER, SMARTLIFE_APP, diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 19faa76a191..4cdca8f3904 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -36,9 +36,6 @@ CONF_PROJECT_TYPE = "tuya_project_type" CONF_ENDPOINT = "endpoint" CONF_ACCESS_ID = "access_id" CONF_ACCESS_SECRET = "access_secret" -CONF_USERNAME = "username" -CONF_PASSWORD = "password" -CONF_COUNTRY_CODE = "country_code" CONF_APP_TYPE = "tuya_app_type" TUYA_DISCOVERY_NEW = "tuya_discovery_new" diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index da9f7d29eb2..46bd0721ccb 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -24,7 +24,7 @@ from .base import IntegerTypeData, TuyaEntity from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType -@dataclass +@dataclass(frozen=True) class TuyaCoverEntityDescription(CoverEntityDescription): """Describe an Tuya cover entity.""" diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index 454416970ea..adac97174b9 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -9,20 +9,14 @@ from tuya_iot import TuyaDevice from homeassistant.components.diagnostics import REDACTED from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_COUNTRY_CODE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.util import dt as dt_util from . import HomeAssistantTuyaData -from .const import ( - CONF_APP_TYPE, - CONF_AUTH_TYPE, - CONF_COUNTRY_CODE, - CONF_ENDPOINT, - DOMAIN, - DPCode, -) +from .const import CONF_APP_TYPE, CONF_AUTH_TYPE, CONF_ENDPOINT, DOMAIN, DPCode async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 6d09ba4314c..a8008ced953 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -21,7 +21,7 @@ from .base import IntegerTypeData, TuyaEntity from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType -@dataclass +@dataclass(frozen=True) class TuyaHumidifierEntityDescription(HumidifierEntityDescription): """Describe an Tuya (de)humidifier entity.""" diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index b4396f617cd..8e98e8d6a41 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -49,7 +49,7 @@ DEFAULT_COLOR_TYPE_DATA_V2 = ColorTypeData( ) -@dataclass +@dataclass(frozen=True) class TuyaLightEntityDescription(LightEntityDescription): """Describe an Tuya light entity.""" diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 4bf8808f5f1..62b59cb8ed9 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -38,7 +38,7 @@ from .const import ( ) -@dataclass +@dataclass(frozen=True) class TuyaSensorEntityDescription(SensorEntityDescription): """Describes Tuya sensor entity.""" diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py index 1278f6523a5..32b4de47de4 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -21,7 +21,7 @@ from .const import DOMAIN from .entity import TwenteMilieuEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class TwenteMilieuSensorDescription(SensorEntityDescription): """Describe an Twente Milieu sensor.""" diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py index 897bfaf4e20..d57a56f489b 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -6,12 +6,12 @@ from aiohttp import ClientError from ttls.client import Twinkly from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SW_VERSION, Platform +from homeassistant.const import ATTR_SW_VERSION, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ATTR_VERSION, CONF_HOST, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN +from .const import ATTR_VERSION, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN PLATFORMS = [Platform.LIGHT] diff --git a/homeassistant/components/twinkly/config_flow.py b/homeassistant/components/twinkly/config_flow.py index eab44dba591..e37e0fd6170 100644 --- a/homeassistant/components/twinkly/config_flow.py +++ b/homeassistant/components/twinkly/config_flow.py @@ -11,10 +11,10 @@ from voluptuous import Required, Schema from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp -from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_ID, CONF_NAME, DEV_ID, DEV_MODEL, DEV_NAME, DOMAIN +from .const import DEV_ID, DEV_MODEL, DEV_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/twinkly/const.py b/homeassistant/components/twinkly/const.py index 2158e4aae07..f33024ed156 100644 --- a/homeassistant/components/twinkly/const.py +++ b/homeassistant/components/twinkly/const.py @@ -2,11 +2,6 @@ DOMAIN = "twinkly" -# Keys of the config entry -CONF_ID = "id" -CONF_HOST = "host" -CONF_NAME = "name" - # Strongly named HA attributes keys ATTR_HOST = "host" ATTR_VERSION = "version" diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 6d0b31b06ed..c4301936088 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -19,16 +19,19 @@ from homeassistant.components.light import ( LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SW_VERSION, CONF_MODEL +from homeassistant.const import ( + ATTR_SW_VERSION, + CONF_HOST, + CONF_ID, + CONF_MODEL, + CONF_NAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - CONF_HOST, - CONF_ID, - CONF_NAME, DATA_CLIENT, DATA_DEVICE_INFO, DEV_LED_PROFILE, diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index af7ab5852ab..c77a1f01447 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -57,14 +57,14 @@ async def async_power_cycle_port_control_fn( await api.request(DevicePowerCyclePortRequest.create(mac, int(index))) -@dataclass +@dataclass(frozen=True) class UnifiButtonEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" control_fn: Callable[[aiounifi.Controller, str], Coroutine[Any, Any, None]] -@dataclass +@dataclass(frozen=True) class UnifiButtonEntityDescription( ButtonEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT], diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index c78313f66e2..2b16895a9a8 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -2,6 +2,8 @@ import logging +from aiounifi.models.device import DeviceState + from homeassistant.const import Platform LOGGER = logging.getLogger(__package__) @@ -46,3 +48,19 @@ ATTR_MANUFACTURER = "Ubiquiti Networks" BLOCK_SWITCH = "block" DPI_SWITCH = "dpi" OUTLET_SWITCH = "outlet" + +DEVICE_STATES = { + DeviceState.DISCONNECTED: "Disconnected", + DeviceState.CONNECTED: "Connected", + DeviceState.PENDING: "Pending", + DeviceState.FIRMWARE_MISMATCH: "Firmware Mismatch", + DeviceState.UPGRADING: "Upgrading", + DeviceState.PROVISIONING: "Provisioning", + DeviceState.HEARTBEAT_MISSED: "Heartbeat Missed", + DeviceState.ADOPTING: "Adopting", + DeviceState.DELETING: "Deleting", + DeviceState.INFORM_ERROR: "Inform Error", + DeviceState.ADOPTION_FALIED: "Adoption Failed", + DeviceState.ISOLATED: "Isolated", + DeviceState.UNKNOWN: "Unknown", +} diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 1be52b97974..88667d8e811 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -137,7 +137,7 @@ def async_device_heartbeat_timedelta_fn( return timedelta(seconds=device.next_interval + 60) -@dataclass +@dataclass(frozen=True) class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, ApiItemT]): """Device tracker local functions.""" @@ -147,7 +147,7 @@ class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, ApiItemT]): hostname_fn: Callable[[aiounifi.Controller, str], str | None] -@dataclass +@dataclass(frozen=True) class UnifiTrackerEntityDescription( UnifiEntityDescription[HandlerT, ApiItemT], UnifiEntityTrackerDescriptionMixin[HandlerT, ApiItemT], diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 28a7b557b16..08dda12c11d 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -93,7 +93,7 @@ def async_client_device_info_fn(controller: UniFiController, obj_id: str) -> Dev ) -@dataclass +@dataclass(frozen=True) class UnifiDescription(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -110,7 +110,7 @@ class UnifiDescription(Generic[HandlerT, ApiItemT]): unique_id_fn: Callable[[UniFiController, str], str] -@dataclass +@dataclass(frozen=True) class UnifiEntityDescription(EntityDescription, UnifiDescription[HandlerT, ApiItemT]): """UniFi Entity Description.""" diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 2318702f0d1..a4fb8d5eb33 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -36,7 +36,7 @@ def async_wlan_qr_code_image_fn(controller: UniFiController, wlan: Wlan) -> byte return controller.api.wlans.generate_wlan_qr_code(wlan) -@dataclass +@dataclass(frozen=True) class UnifiImageEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -44,7 +44,7 @@ class UnifiImageEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): value_fn: Callable[[ApiItemT], str | None] -@dataclass +@dataclass(frozen=True) class UnifiImageEntityDescription( ImageEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT], diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 7d4717d3fff..4a43a65d5bb 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==67"], + "requirements": ["aiounifi==68"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 4d5cf49b5c9..c7b851a8fbb 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -36,6 +36,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util +from .const import DEVICE_STATES from .controller import UniFiController from .entity import ( HandlerT, @@ -131,14 +132,20 @@ def async_device_outlet_supported_fn(controller: UniFiController, obj_id: str) - return controller.api.devices[obj_id].outlet_ac_power_budget is not None -@dataclass +@dataclass(frozen=True) class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" value_fn: Callable[[UniFiController, ApiItemT], datetime | float | str | None] -@dataclass +@callback +def async_device_state_value_fn(controller: UniFiController, device: Device) -> str: + """Retrieve the state of the device.""" + return DEVICE_STATES[device.state] + + +@dataclass(frozen=True) class UnifiSensorEntityDescription( SensorEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT], @@ -343,6 +350,25 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( unique_id_fn=lambda controller, obj_id: f"device_temperature-{obj_id}", value_fn=lambda ctrlr, device: device.general_temperature, ), + UnifiSensorEntityDescription[Devices, Device]( + key="Device State", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda device: "State", + 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: f"device_state-{obj_id}", + value_fn=async_device_state_value_fn, + options=list(DEVICE_STATES.values()), + ), ) diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 1e9ec8b14c8..371676f4786 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -180,7 +180,7 @@ async def async_wlan_control_fn( await controller.api.request(WlanEnableRequest.create(obj_id, target)) -@dataclass +@dataclass(frozen=True) class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -188,7 +188,7 @@ class UnifiSwitchEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): is_on_fn: Callable[[UniFiController, ApiItemT], bool] -@dataclass +@dataclass(frozen=True) class UnifiSwitchEntityDescription( SwitchEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT], diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index 65b26736cf1..a0d2da328a2 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -40,7 +40,7 @@ async def async_device_control_fn(api: aiounifi.Controller, obj_id: str) -> None await api.request(DeviceUpgradeRequest.create(obj_id)) -@dataclass +@dataclass(frozen=True) class UnifiUpdateEntityDescriptionMixin(Generic[_HandlerT, _DataT]): """Validate and load entities from different UniFi handlers.""" @@ -48,7 +48,7 @@ class UnifiUpdateEntityDescriptionMixin(Generic[_HandlerT, _DataT]): state_fn: Callable[[aiounifi.Controller, _DataT], bool] -@dataclass +@dataclass(frozen=True) class UnifiUpdateEntityDescription( UpdateEntityDescription, UnifiEntityDescription[_HandlerT, _DataT], diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py index 13ebd0e33e5..77ce5d80cf9 100644 --- a/homeassistant/components/unifi_direct/device_tracker.py +++ b/homeassistant/components/unifi_direct/device_tracker.py @@ -1,10 +1,10 @@ """Support for Unifi AP direct access.""" from __future__ import annotations -import json import logging +from typing import Any -from pexpect import exceptions, pxssh +from unifi_ap import UniFiAP, UniFiAPConnectionException, UniFiAPDataException import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -20,9 +20,6 @@ from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) DEFAULT_SSH_PORT = 22 -UNIFI_COMMAND = 'mca-dump | tr -d "\n"' -UNIFI_SSID_TABLE = "vap_table" -UNIFI_CLIENT_TABLE = "sta_table" PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { @@ -37,104 +34,43 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> UnifiDeviceScanner | None: """Validate the configuration and return a Unifi direct scanner.""" scanner = UnifiDeviceScanner(config[DOMAIN]) - if not scanner.connected: - return None - return scanner + return scanner if scanner.update_clients() else None class UnifiDeviceScanner(DeviceScanner): """Class which queries Unifi wireless access point.""" - def __init__(self, config): + def __init__(self, config: ConfigType) -> None: """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config[CONF_PASSWORD] - self.port = config[CONF_PORT] - self.ssh = None - self.connected = False - self.last_results = {} - self._connect() - - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - result = _response_to_json(self._get_update()) - if result: - self.last_results = result - return self.last_results.keys() - - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - hostname = next( - ( - value.get("hostname") - for key, value in self.last_results.items() - if key.upper() == device.upper() - ), - None, + self.clients: dict[str, dict[str, Any]] = {} + self.ap = UniFiAP( + target=config[CONF_HOST], + username=config[CONF_USERNAME], + password=config[CONF_PASSWORD], + port=config[CONF_PORT], ) - if hostname is not None: - hostname = str(hostname) - return hostname - def _connect(self): - """Connect to the Unifi AP SSH server.""" + def scan_devices(self) -> list[str]: + """Scan for new devices and return a list with found device IDs.""" + self.update_clients() + return list(self.clients) - self.ssh = pxssh.pxssh(options={"HostKeyAlgorithms": "ssh-rsa"}) + def get_device_name(self, device: str) -> str | None: + """Return the name of the given device or None if we don't know.""" + client_info = self.clients.get(device) + if client_info: + return client_info.get("hostname") + return None + + def update_clients(self) -> bool: + """Update the client info from AP.""" try: - self.ssh.login( - self.host, self.username, password=self.password, port=self.port - ) - self.connected = True - except exceptions.EOF: - _LOGGER.error("Connection refused. SSH enabled?") - self._disconnect() + self.clients = self.ap.get_clients() + except UniFiAPConnectionException: + _LOGGER.error("Failed to connect to accesspoint") + return False + except UniFiAPDataException: + _LOGGER.error("Failed to get proper response from accesspoint") + return False - def _disconnect(self): - """Disconnect the current SSH connection.""" - try: - self.ssh.logout() - except Exception: # pylint: disable=broad-except - pass - finally: - self.ssh = None - - self.connected = False - - def _get_update(self): - try: - if not self.connected: - self._connect() - # If we still aren't connected at this point - # don't try to send anything to the AP. - if not self.connected: - return None - self.ssh.sendline(UNIFI_COMMAND) - self.ssh.prompt() - return self.ssh.before - except pxssh.ExceptionPxssh as err: - _LOGGER.error("Unexpected SSH error: %s", str(err)) - self._disconnect() - return None - except (AssertionError, exceptions.EOF) as err: - _LOGGER.error("Connection to AP unavailable: %s", str(err)) - self._disconnect() - return None - - -def _response_to_json(response): - try: - json_response = json.loads(str(response)[31:-1].replace("\\", "")) - _LOGGER.debug(str(json_response)) - ssid_table = json_response.get(UNIFI_SSID_TABLE) - active_clients = {} - - for ssid in ssid_table: - client_table = ssid.get(UNIFI_CLIENT_TABLE) - for client in client_table: - active_clients[client.get("mac")] = client - - return active_clients - except (ValueError, TypeError): - _LOGGER.error("Failed to decode response from AP") - return {} + return True diff --git a/homeassistant/components/unifi_direct/manifest.json b/homeassistant/components/unifi_direct/manifest.json index 68a1396727f..8ca8ef27bb2 100644 --- a/homeassistant/components/unifi_direct/manifest.json +++ b/homeassistant/components/unifi_direct/manifest.json @@ -1,9 +1,9 @@ { "domain": "unifi_direct", "name": "UniFi AP", - "codeowners": [], + "codeowners": ["@tofuSCHNITZEL"], "documentation": "https://www.home-assistant.io/integrations/unifi_direct", "iot_class": "local_polling", - "loggers": ["pexpect", "ptyprocess"], - "requirements": ["pexpect==4.6.0"] + "loggers": ["unifi_ap"], + "requirements": ["unifi_ap==0.0.1"] } diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 8f8bcab8ede..1104ecb98e1 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -1,8 +1,7 @@ """Component providing binary sensors for UniFi Protect.""" from __future__ import annotations -from copy import copy -from dataclasses import dataclass +import dataclasses import logging from pyunifiprotect.data import ( @@ -43,14 +42,14 @@ _LOGGER = logging.getLogger(__name__) _KEY_DOOR = "door" -@dataclass +@dataclasses.dataclass(frozen=True) class ProtectBinaryEntityDescription( ProtectRequiredKeysMixin, BinarySensorEntityDescription ): """Describes UniFi Protect Binary Sensor entity.""" -@dataclass +@dataclasses.dataclass(frozen=True) class ProtectBinaryEventEntityDescription( ProtectEventMixin, BinarySensorEntityDescription ): @@ -561,9 +560,11 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): self._attr_is_on = entity_description.get_ufp_value(updated_device) # UP Sense can be any of the 3 contact sensor device classes if entity_description.key == _KEY_DOOR and isinstance(updated_device, Sensor): - entity_description.device_class = MOUNT_DEVICE_CLASS_MAP.get( + self._attr_device_class = MOUNT_DEVICE_CLASS_MAP.get( updated_device.mount_type, BinarySensorDeviceClass.DOOR ) + else: + self._attr_device_class = self.entity_description.device_class class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): @@ -584,9 +585,11 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): # backwards compat with old unique IDs index = self._disk.slot - 1 - description = copy(description) - description.key = f"{description.key}_{index}" - description.name = f"{disk.type} {disk.slot}" + description = dataclasses.replace( + description, + key=f"{description.key}_{index}", + name=f"{disk.type} {disk.slot}", + ) super().__init__(data, device, description) @callback @@ -640,4 +643,15 @@ class ProtectEventBinarySensor(EventEntityMixin, BinarySensorEntity): or self._attr_extra_state_attributes != previous_extra_state_attributes or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", + device.name, + device.mac, + previous_is_on, + previous_available, + previous_extra_state_attributes, + self._attr_is_on, + self._attr_available, + self._attr_extra_state_attributes, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index bc93c156866..b69fbb95970 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -28,7 +28,7 @@ from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ProtectButtonEntityDescription( ProtectSetableKeysMixin[T], ButtonEntityDescription ): @@ -206,4 +206,11 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity): previous_available = self._attr_available self._async_update_device_from_protect(device) if self._attr_available != previous_available: + _LOGGER.debug( + "Updating state [%s (%s)] %s -> %s", + device.name, + device.mac, + previous_available, + self._attr_available, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 73d05f1be1d..8b8ec80c5ba 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -228,6 +228,8 @@ class ProtectData: # trigger updates for camera that the event references elif isinstance(obj, Event): # type: ignore[unreachable] + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("event WS msg: %s", obj.dict()) if obj.type in SMART_EVENTS: if obj.camera is not None: if obj.end is None: diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index cd38f50bf6d..2fbf8f31071 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.22.3", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.22.5", "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 df5ea40d4a9..b2376277e6f 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -133,6 +133,17 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): or self._attr_volume_level != previous_volume_level or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", + device.name, + device.mac, + previous_state, + previous_available, + previous_volume_level, + self._attr_state, + self._attr_available, + self._attr_volume_level, + ) self.async_write_ha_state() async def async_set_volume_level(self, volume: float) -> None: diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index c250a021340..7f5612a72a8 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -36,7 +36,7 @@ class PermRequired(int, Enum): DELETE = 3 -@dataclass +@dataclass(frozen=True) class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): """Mixin for required keys.""" @@ -52,9 +52,11 @@ class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): 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) + object.__setattr__( + self, "ufp_required_field", split_tuple(self.ufp_required_field) + ) + object.__setattr__(self, "ufp_value", split_tuple(self.ufp_value)) + object.__setattr__(self, "ufp_enabled", split_tuple(self.ufp_enabled)) def get_ufp_value(self, obj: T) -> Any: """Return value from UniFi Protect device.""" @@ -99,7 +101,7 @@ class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): return bool(get_nested_attr(obj, ufp_required_field)) -@dataclass +@dataclass(frozen=True) class ProtectEventMixin(ProtectRequiredKeysMixin[T]): """Mixin for events.""" @@ -125,7 +127,7 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): return value -@dataclass +@dataclass(frozen=True) class ProtectSetableKeysMixin(ProtectRequiredKeysMixin[T]): """Mixin for settable values.""" diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 08bc9f38527..c02753a9401 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +import logging from pyunifiprotect.data import ( Camera, @@ -25,8 +26,10 @@ from .entity import ProtectDeviceEntity, async_all_device_entities from .models import PermRequired, ProtectSetableKeysMixin, T from .utils import async_dispatch_id as _ufpd +_LOGGER = logging.getLogger(__name__) -@dataclass + +@dataclass(frozen=True) class NumberKeysMixin: """Mixin for required keys.""" @@ -35,7 +38,7 @@ class NumberKeysMixin: ufp_step: int | float -@dataclass +@dataclass(frozen=True) class ProtectNumberEntityDescription( ProtectSetableKeysMixin[T], NumberEntityDescription, NumberKeysMixin ): @@ -285,4 +288,13 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity): self._attr_native_value != previous_value or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s) -> %s (%s)", + device.name, + device.mac, + previous_value, + previous_available, + self._attr_native_value, + self._attr_available, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 7605be17fc9..dfc3be2d4a1 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -92,7 +92,7 @@ DEVICE_RECORDING_MODES = [ DEVICE_CLASS_LCD_MESSAGE: Final = "unifiprotect__lcd_message" -@dataclass +@dataclass(frozen=True) class ProtectSelectEntityDescription( ProtectSetableKeysMixin[T], SelectEntityDescription ): @@ -420,4 +420,15 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): or self._attr_options != previous_options or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s, %s) -> %s (%s, %s)", + device.name, + device.mac, + previous_option, + previous_available, + previous_options, + self._attr_current_option, + self._attr_available, + self._attr_options, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 756da49eb4d..3e2bd6ee858 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -54,7 +54,7 @@ _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" -@dataclass +@dataclass(frozen=True) class ProtectSensorEntityDescription( ProtectRequiredKeysMixin[T], SensorEntityDescription ): @@ -71,7 +71,7 @@ class ProtectSensorEntityDescription( return value -@dataclass +@dataclass(frozen=True) class ProtectSensorEventEntityDescription( ProtectEventMixin[T], SensorEntityDescription ): @@ -730,6 +730,15 @@ class ProtectDeviceSensor(ProtectDeviceEntity, SensorEntity): self._attr_native_value != previous_value or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s) -> %s (%s)", + device.name, + device.mac, + previous_value, + previous_available, + self._attr_native_value, + self._attr_available, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index f1e6185b010..d8a3fc1c5bc 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +import logging from typing import Any from pyunifiprotect.data import ( @@ -27,11 +28,12 @@ from .entity import ProtectDeviceEntity, ProtectNVREntity, async_all_device_enti from .models import PermRequired, ProtectSetableKeysMixin, T from .utils import async_dispatch_id as _ufpd +_LOGGER = logging.getLogger(__name__) ATTR_PREV_MIC = "prev_mic_level" ATTR_PREV_RECORD = "prev_record_mode" -@dataclass +@dataclass(frozen=True) class ProtectSwitchEntityDescription( ProtectSetableKeysMixin[T], SwitchEntityDescription ): @@ -209,6 +211,16 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_set_method="set_smoke_detection", ufp_perm=PermRequired.WRITE, ), + ProtectSwitchEntityDescription( + key="color_night_vision", + name="Color Night Vision", + icon="mdi:light-flood-down", + entity_category=EntityCategory.CONFIG, + ufp_required_field="has_color_night_vision", + ufp_value="isp_settings.is_color_night_vision_enabled", + ufp_set_method="set_color_night_vision", + ufp_perm=PermRequired.WRITE, + ), ) PRIVACY_MODE_SWITCH = ProtectSwitchEntityDescription[Camera]( @@ -448,6 +460,15 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity): self._attr_is_on != previous_is_on or self._attr_available != previous_available ): + _LOGGER.debug( + "Updating state [%s (%s)] %s (%s) -> %s (%s)", + device.name, + device.mac, + previous_is_on, + previous_available, + self._attr_is_on, + self._attr_available, + ) self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py index c39f7895231..de777121ff5 100644 --- a/homeassistant/components/unifiprotect/text.py +++ b/homeassistant/components/unifiprotect/text.py @@ -24,7 +24,7 @@ from .models import PermRequired, ProtectSetableKeysMixin, T from .utils import async_dispatch_id as _ufpd -@dataclass +@dataclass(frozen=True) class ProtectTextEntityDescription(ProtectSetableKeysMixin[T], TextEntityDescription): """Describes UniFi Protect Text entity.""" diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index c9496ce8f7b..40431332aaf 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -1,12 +1,11 @@ """Component to allow for providing device or service updates.""" 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 typing import TYPE_CHECKING, Any, Final, final from awesomeversion import AwesomeVersion, AwesomeVersionCompareException import voluptuous as vol @@ -21,7 +20,7 @@ from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity import ABCCachedProperties, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -43,6 +42,11 @@ from .const import ( UpdateEntityFeature, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + SCAN_INTERVAL = timedelta(minutes=15) ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" @@ -136,7 +140,7 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None # If version is specified, but not supported by the entity. if ( version is not None - and not entity.supported_features & UpdateEntityFeature.SPECIFIC_VERSION + and UpdateEntityFeature.SPECIFIC_VERSION not in entity.supported_features ): raise HomeAssistantError( f"Installing a specific version is not supported for {entity.entity_id}" @@ -145,7 +149,7 @@ async def async_install(entity: UpdateEntity, service_call: ServiceCall) -> None # If backup is requested, but not supported by the entity. if ( backup := service_call.data[ATTR_BACKUP] - ) and not entity.supported_features & UpdateEntityFeature.BACKUP: + ) and UpdateEntityFeature.BACKUP not in entity.supported_features: raise HomeAssistantError(f"Backup is not supported for {entity.entity_id}") # Update is already in progress. @@ -175,8 +179,7 @@ async def async_clear_skipped(entity: UpdateEntity, service_call: ServiceCall) - await entity.async_clear_skipped() -@dataclass -class UpdateEntityDescription(EntityDescription): +class UpdateEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes update entities.""" device_class: UpdateDeviceClass | None = None @@ -189,7 +192,24 @@ def _version_is_newer(latest_version: str, installed_version: str) -> bool: return AwesomeVersion(latest_version) > installed_version -class UpdateEntity(RestoreEntity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "auto_update", + "installed_version", + "device_class", + "in_progress", + "latest_version", + "release_summary", + "release_url", + "supported_features", + "title", +} + + +class UpdateEntity( + RestoreEntity, + metaclass=ABCCachedProperties, + cached_properties=CACHED_PROPERTIES_WITH_ATTR_, +): """Representation of an update entity.""" _entity_component_unrecorded_attributes = frozenset( @@ -210,12 +230,12 @@ class UpdateEntity(RestoreEntity): __skipped_version: str | None = None __in_progress: bool = False - @property + @cached_property def auto_update(self) -> bool: """Indicate if the device or service has auto update enabled.""" return self._attr_auto_update - @property + @cached_property def installed_version(self) -> str | None: """Version installed and in use.""" return self._attr_installed_version @@ -227,7 +247,7 @@ class UpdateEntity(RestoreEntity): """ return self.device_class is not None - @property + @cached_property def device_class(self) -> UpdateDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -243,7 +263,7 @@ class UpdateEntity(RestoreEntity): return self._attr_entity_category if hasattr(self, "entity_description"): return self.entity_description.entity_category - if self.supported_features & UpdateEntityFeature.INSTALL: + if UpdateEntityFeature.INSTALL in self.supported_features_compat: return EntityCategory.CONFIG return EntityCategory.DIAGNOSTIC @@ -258,7 +278,7 @@ class UpdateEntity(RestoreEntity): f"https://brands.home-assistant.io/_/{self.platform.platform_name}/icon.png" ) - @property + @cached_property def in_progress(self) -> bool | int | None: """Update installation progress. @@ -269,12 +289,12 @@ class UpdateEntity(RestoreEntity): """ return self._attr_in_progress - @property + @cached_property def latest_version(self) -> str | None: """Latest version available for install.""" return self._attr_latest_version - @property + @cached_property def release_summary(self) -> str | None: """Summary of the release notes or changelog. @@ -283,17 +303,17 @@ class UpdateEntity(RestoreEntity): """ return self._attr_release_summary - @property + @cached_property def release_url(self) -> str | None: """URL to the full release notes of the latest version available.""" return self._attr_release_url - @property + @cached_property def supported_features(self) -> UpdateEntityFeature: """Flag supported features.""" return self._attr_supported_features - @property + @cached_property def title(self) -> str | None: """Title of the software. @@ -302,6 +322,19 @@ class UpdateEntity(RestoreEntity): """ return self._attr_title + @property + def supported_features_compat(self) -> UpdateEntityFeature: + """Return the supported features as UpdateEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = UpdateEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + @final async def async_skip(self) -> None: """Skip the current offered version to update.""" @@ -388,7 +421,7 @@ class UpdateEntity(RestoreEntity): # If entity supports progress, return the in_progress value. # Otherwise, we use the internal progress value. - if self.supported_features & UpdateEntityFeature.PROGRESS: + if UpdateEntityFeature.PROGRESS in self.supported_features_compat: in_progress = self.in_progress else: in_progress = self.__in_progress @@ -424,7 +457,7 @@ class UpdateEntity(RestoreEntity): Handles setting the in_progress state in case the entity doesn't support it natively. """ - if not self.supported_features & UpdateEntityFeature.PROGRESS: + if UpdateEntityFeature.PROGRESS not in self.supported_features_compat: self.__in_progress = True self.async_write_ha_state() @@ -470,7 +503,7 @@ async def websocket_release_notes( ) return - if not entity.supported_features & UpdateEntityFeature.RELEASE_NOTES: + if UpdateEntityFeature.RELEASE_NOTES not in entity.supported_features_compat: connection.send_error( msg["id"], websocket_api.const.ERR_NOT_SUPPORTED, diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py index 0ab8962077b..676b9588ddb 100644 --- a/homeassistant/components/upnp/binary_sensor.py +++ b/homeassistant/components/upnp/binary_sensor.py @@ -18,7 +18,7 @@ from .const import DOMAIN, LOGGER, WAN_STATUS from .entity import UpnpEntity, UpnpEntityDescription -@dataclass +@dataclass(frozen=True) class UpnpBinarySensorEntityDescription( UpnpEntityDescription, BinarySensorEntityDescription ): diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index 93f551bea37..2f52a5d008f 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -157,7 +157,7 @@ class Device: _LOGGER.debug("Getting data for device: %s", self) igd_state = await self._igd_device.async_get_traffic_and_status_data() status_info = igd_state.status_info - if status_info is not None and not isinstance(status_info, Exception): + if status_info is not None and not isinstance(status_info, BaseException): wan_status = status_info.connection_status router_uptime = status_info.uptime else: @@ -165,7 +165,7 @@ class Device: router_uptime = None def get_value(value: Any) -> Any: - if value is None or isinstance(value, Exception): + if value is None or isinstance(value, BaseException): return None return value diff --git a/homeassistant/components/upnp/entity.py b/homeassistant/components/upnp/entity.py index e53d89018fb..504602372f7 100644 --- a/homeassistant/components/upnp/entity.py +++ b/homeassistant/components/upnp/entity.py @@ -10,7 +10,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import UpnpDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class UpnpEntityDescription(EntityDescription): """UPnP entity description.""" @@ -19,7 +19,7 @@ class UpnpEntityDescription(EntityDescription): def __post_init__(self): """Post initialize.""" - self.value_key = self.value_key or self.key + object.__setattr__(self, "value_key", self.value_key or self.key) class UpnpEntity(CoordinatorEntity[UpnpDataUpdateCoordinator]): diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 25f83e0dbf5..4b6badb0d3c 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.36.2", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.38.0", "getmac==0.9.4"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index 46d748f6939..e493118f58e 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -41,7 +41,7 @@ from .coordinator import UpnpDataUpdateCoordinator from .entity import UpnpEntity, UpnpEntityDescription -@dataclass +@dataclass(frozen=True) class UpnpSensorEntityDescription(UpnpEntityDescription, SensorEntityDescription): """A class that describes a sensor UPnP entities.""" diff --git a/homeassistant/components/v2c/binary_sensor.py b/homeassistant/components/v2c/binary_sensor.py index 7776a3398c7..b30c632174a 100644 --- a/homeassistant/components/v2c/binary_sensor.py +++ b/homeassistant/components/v2c/binary_sensor.py @@ -20,14 +20,14 @@ from .coordinator import V2CUpdateCoordinator from .entity import V2CBaseEntity -@dataclass +@dataclass(frozen=True) class V2CRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[Trydan], bool] -@dataclass +@dataclass(frozen=True) class V2CBinarySensorEntityDescription( BinarySensorEntityDescription, V2CRequiredKeysMixin ): diff --git a/homeassistant/components/v2c/number.py b/homeassistant/components/v2c/number.py index 0f2551818a2..dd20b0de787 100644 --- a/homeassistant/components/v2c/number.py +++ b/homeassistant/components/v2c/number.py @@ -24,7 +24,7 @@ MIN_INTENSITY = 6 MAX_INTENSITY = 32 -@dataclass +@dataclass(frozen=True) class V2CSettingsRequiredKeysMixin: """Mixin for required keys.""" @@ -32,7 +32,7 @@ class V2CSettingsRequiredKeysMixin: update_fn: Callable[[Trydan, int], Coroutine[Any, Any, None]] -@dataclass +@dataclass(frozen=True) class V2CSettingsNumberEntityDescription( NumberEntityDescription, V2CSettingsRequiredKeysMixin ): diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py index 0c860943922..0aa727fa408 100644 --- a/homeassistant/components/v2c/sensor.py +++ b/homeassistant/components/v2c/sensor.py @@ -25,14 +25,14 @@ from .entity import V2CBaseEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class V2CRequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[TrydanData], float] -@dataclass +@dataclass(frozen=True) class V2CSensorEntityDescription(SensorEntityDescription, V2CRequiredKeysMixin): """Describes an EVSE Power sensor entity.""" @@ -41,6 +41,7 @@ TRYDAN_SENSORS = ( V2CSensorEntityDescription( key="charge_power", translation_key="charge_power", + icon="mdi:ev-station", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, @@ -49,6 +50,7 @@ TRYDAN_SENSORS = ( V2CSensorEntityDescription( key="charge_energy", translation_key="charge_energy", + icon="mdi:ev-station", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, @@ -57,6 +59,7 @@ TRYDAN_SENSORS = ( V2CSensorEntityDescription( key="charge_time", translation_key="charge_time", + icon="mdi:timer", native_unit_of_measurement=UnitOfTime.SECONDS, state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.DURATION, @@ -65,6 +68,7 @@ TRYDAN_SENSORS = ( V2CSensorEntityDescription( key="house_power", translation_key="house_power", + icon="mdi:home-lightning-bolt", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, @@ -73,6 +77,7 @@ TRYDAN_SENSORS = ( V2CSensorEntityDescription( key="fv_power", translation_key="fv_power", + icon="mdi:solar-power-variant", native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, @@ -99,7 +104,6 @@ class V2CSensorBaseEntity(V2CBaseEntity, SensorEntity): """Defines a base v2c sensor entity.""" entity_description: V2CSensorEntityDescription - _attr_icon = "mdi:ev-station" def __init__( self, diff --git a/homeassistant/components/v2c/strings.json b/homeassistant/components/v2c/strings.json index bf19fe5188e..a60b61831fd 100644 --- a/homeassistant/components/v2c/strings.json +++ b/homeassistant/components/v2c/strings.json @@ -52,6 +52,18 @@ "switch": { "paused": { "name": "Pause session" + }, + "locked": { + "name": "Lock EVSE" + }, + "timer": { + "name": "Charge point timer" + }, + "dynamic": { + "name": "Dynamic intensity modulation" + }, + "pause_dynamic": { + "name": "Pause dynamic control modulation" } } } diff --git a/homeassistant/components/v2c/switch.py b/homeassistant/components/v2c/switch.py index 4e56e72dcbf..a8b4728c66d 100644 --- a/homeassistant/components/v2c/switch.py +++ b/homeassistant/components/v2c/switch.py @@ -7,7 +7,13 @@ import logging from typing import Any from pytrydan import Trydan, TrydanData -from pytrydan.models.trydan import PauseState +from pytrydan.models.trydan import ( + ChargePointTimerState, + DynamicState, + LockState, + PauseDynamicState, + PauseState, +) from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -21,7 +27,7 @@ from .entity import V2CBaseEntity _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class V2CRequiredKeysMixin: """Mixin for required keys.""" @@ -30,7 +36,7 @@ class V2CRequiredKeysMixin: turn_off_fn: Callable[[Trydan], Coroutine[Any, Any, Any]] -@dataclass +@dataclass(frozen=True) class V2CSwitchEntityDescription(SwitchEntityDescription, V2CRequiredKeysMixin): """Describes a V2C EVSE switch entity.""" @@ -44,6 +50,39 @@ TRYDAN_SWITCHES = ( turn_on_fn=lambda evse: evse.pause(), turn_off_fn=lambda evse: evse.resume(), ), + V2CSwitchEntityDescription( + key="locked", + translation_key="locked", + icon="mdi:lock", + value_fn=lambda evse_data: evse_data.locked == LockState.ENABLED, + turn_on_fn=lambda evse: evse.lock(), + turn_off_fn=lambda evse: evse.unlock(), + ), + V2CSwitchEntityDescription( + key="timer", + translation_key="timer", + icon="mdi:timer", + value_fn=lambda evse_data: evse_data.timer == ChargePointTimerState.TIMER_ON, + turn_on_fn=lambda evse: evse.timer(), + turn_off_fn=lambda evse: evse.timer_disable(), + ), + V2CSwitchEntityDescription( + key="dynamic", + translation_key="dynamic", + icon="mdi:gauge", + value_fn=lambda evse_data: evse_data.dynamic == DynamicState.ENABLED, + turn_on_fn=lambda evse: evse.dynamic(), + turn_off_fn=lambda evse: evse.dynamic_disable(), + ), + V2CSwitchEntityDescription( + key="pause_dynamic", + translation_key="pause_dynamic", + icon="mdi:pause", + value_fn=lambda evse_data: evse_data.pause_dynamic + == PauseDynamicState.NOT_MODULATING, + turn_on_fn=lambda evse: evse.pause_dynamic(), + turn_off_fn=lambda evse: evse.resume_dynamic(), + ), ) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index c0680913df6..9a10da23824 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -3,12 +3,11 @@ from __future__ import annotations import asyncio from collections.abc import Mapping -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag from functools import partial import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -46,6 +45,11 @@ from homeassistant.loader import ( bind_hass, ) +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + _LOGGER = logging.getLogger(__name__) DOMAIN = "vacuum" @@ -226,7 +230,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -class _BaseVacuum(Entity): +BASE_CACHED_PROPERTIES_WITH_ATTR_ = { + "supported_features", + "battery_level", + "battery_icon", + "fan_speed", + "fan_speed_list", +} + + +class _BaseVacuum(Entity, cached_properties=BASE_CACHED_PROPERTIES_WITH_ATTR_): """Representation of a base vacuum. Contains common properties and functions for all vacuum devices. @@ -240,27 +253,40 @@ class _BaseVacuum(Entity): _attr_fan_speed_list: list[str] _attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0) - @property + @cached_property def supported_features(self) -> VacuumEntityFeature: """Flag vacuum cleaner features that are supported.""" return self._attr_supported_features @property + def supported_features_compat(self) -> VacuumEntityFeature: + """Return the supported features as VacuumEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = VacuumEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + + @cached_property def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" return self._attr_battery_level - @property + @cached_property def battery_icon(self) -> str: """Return the battery icon for the vacuum cleaner.""" return self._attr_battery_icon - @property + @cached_property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" return self._attr_fan_speed - @property + @cached_property def fan_speed_list(self) -> list[str]: """Get the list of available fan speed steps of the vacuum cleaner.""" return self._attr_fan_speed_list @@ -268,7 +294,7 @@ class _BaseVacuum(Entity): @property def capability_attributes(self) -> Mapping[str, Any] | None: """Return capability attributes.""" - if self.supported_features & VacuumEntityFeature.FAN_SPEED: + if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} return None @@ -276,12 +302,13 @@ class _BaseVacuum(Entity): def state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" data: dict[str, Any] = {} + supported_features = self.supported_features_compat - if self.supported_features & VacuumEntityFeature.BATTERY: + if VacuumEntityFeature.BATTERY in supported_features: data[ATTR_BATTERY_LEVEL] = self.battery_level data[ATTR_BATTERY_ICON] = self.battery_icon - if self.supported_features & VacuumEntityFeature.FAN_SPEED: + if VacuumEntityFeature.FAN_SPEED in supported_features: data[ATTR_FAN_SPEED] = self.fan_speed return data @@ -367,12 +394,18 @@ class _BaseVacuum(Entity): ) -@dataclass -class VacuumEntityDescription(ToggleEntityDescription): +class VacuumEntityDescription(ToggleEntityDescription, frozen_or_thawed=True): """A class that describes vacuum entities.""" -class VacuumEntity(_BaseVacuum, ToggleEntity): +VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { + "status", +} + + +class VacuumEntity( + _BaseVacuum, ToggleEntity, cached_properties=VACUUM_CACHED_PROPERTIES_WITH_ATTR_ +): """Representation of a vacuum cleaner robot.""" @callback @@ -430,7 +463,7 @@ class VacuumEntity(_BaseVacuum, ToggleEntity): entity_description: VacuumEntityDescription _attr_status: str | None = None - @property + @cached_property def status(self) -> str | None: """Return the status of the vacuum cleaner.""" return self._attr_status @@ -451,7 +484,7 @@ class VacuumEntity(_BaseVacuum, ToggleEntity): """Return the state attributes of the vacuum cleaner.""" data = super().state_attributes - if self.supported_features & VacuumEntityFeature.STATUS: + if VacuumEntityFeature.STATUS in self.supported_features_compat: data[ATTR_STATUS] = self.status return data @@ -490,18 +523,24 @@ class VacuumEntity(_BaseVacuum, ToggleEntity): await self.hass.async_add_executor_job(partial(self.start_pause, **kwargs)) -@dataclass -class StateVacuumEntityDescription(EntityDescription): +class StateVacuumEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes vacuum entities.""" -class StateVacuumEntity(_BaseVacuum): +STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ = { + "state", +} + + +class StateVacuumEntity( + _BaseVacuum, cached_properties=STATE_VACUUM_CACHED_PROPERTIES_WITH_ATTR_ +): """Representation of a vacuum cleaner robot that supports states.""" entity_description: StateVacuumEntityDescription _attr_state: str | None = None - @property + @cached_property def state(self) -> str | None: """Return the state of the vacuum cleaner.""" return self._attr_state diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index aab35b42077..25f3822bd35 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -14,6 +14,14 @@ turn_off: supported_features: - vacuum.VacuumEntityFeature.TURN_OFF +toggle: + target: + entity: + domain: vacuum + supported_features: + - vacuum.VacuumEntityFeature.TURN_OFF + - vacuum.VacuumEntityFeature.TURN_ON + stop: target: entity: diff --git a/homeassistant/components/vacuum/significant_change.py b/homeassistant/components/vacuum/significant_change.py new file mode 100644 index 00000000000..5699050c7cb --- /dev/null +++ b/homeassistant/components/vacuum/significant_change.py @@ -0,0 +1,59 @@ +"""Helper to test significant Vacuum state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_BATTERY_LEVEL, + ATTR_FAN_SPEED, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + if attr_name != ATTR_BATTERY_LEVEL: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if check_absolute_change(old_attr_value, new_attr_value, 1.0): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 3c018fc1a89..15ba2076060 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -48,6 +48,10 @@ "name": "[%key:common::action::turn_off%]", "description": "Stops the current cleaning task and returns to its dock." }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles the vacuum cleaner on/off." + }, "stop": { "name": "[%key:common::action::stop%]", "description": "Stops the current cleaning task." diff --git a/homeassistant/components/vallox/binary_sensor.py b/homeassistant/components/vallox/binary_sensor.py index 05085c24424..00c25897d1c 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -41,14 +41,14 @@ class ValloxBinarySensorEntity(ValloxEntity, BinarySensorEntity): return self.coordinator.data.get_metric(self.entity_description.metric_key) == 1 -@dataclass +@dataclass(frozen=True) class ValloxMetricKeyMixin: """Dataclass to allow defining metric_key without a default value.""" metric_key: str -@dataclass +@dataclass(frozen=True) class ValloxBinarySensorEntityDescription( BinarySensorEntityDescription, ValloxMetricKeyMixin ): diff --git a/homeassistant/components/vallox/number.py b/homeassistant/components/vallox/number.py index ce43ca9c3fb..fa5dfff4a6d 100644 --- a/homeassistant/components/vallox/number.py +++ b/homeassistant/components/vallox/number.py @@ -60,14 +60,14 @@ class ValloxNumberEntity(ValloxEntity, NumberEntity): await self.coordinator.async_request_refresh() -@dataclass +@dataclass(frozen=True) class ValloxMetricMixin: """Holds Vallox metric key.""" metric_key: str -@dataclass +@dataclass(frozen=True) class ValloxNumberEntityDescription(NumberEntityDescription, ValloxMetricMixin): """Describes Vallox number entity.""" diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index ee0e1e43204..af5994b66d9 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -125,7 +125,7 @@ class ValloxCellStateSensor(ValloxSensorEntity): return VALLOX_CELL_STATE_TO_STR.get(super_native_value) -@dataclass +@dataclass(frozen=True) class ValloxSensorEntityDescription(SensorEntityDescription): """Describes Vallox sensor entity.""" diff --git a/homeassistant/components/vallox/switch.py b/homeassistant/components/vallox/switch.py index 194659d40cd..8e7835e0bd7 100644 --- a/homeassistant/components/vallox/switch.py +++ b/homeassistant/components/vallox/switch.py @@ -63,14 +63,14 @@ class ValloxSwitchEntity(ValloxEntity, SwitchEntity): await self.coordinator.async_request_refresh() -@dataclass +@dataclass(frozen=True) class ValloxMetricKeyMixin: """Dataclass to allow defining metric_key without a default value.""" metric_key: str -@dataclass +@dataclass(frozen=True) class ValloxSwitchEntityDescription(SwitchEntityDescription, ValloxMetricKeyMixin): """Describes Vallox switch entity.""" diff --git a/homeassistant/components/valve/__init__.py b/homeassistant/components/valve/__init__.py new file mode 100644 index 00000000000..9521d597303 --- /dev/null +++ b/homeassistant/components/valve/__init__.py @@ -0,0 +1,270 @@ +"""Support for Valve devices.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from enum import IntFlag, StrEnum +import logging +from typing import Any, final + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_SET_VALVE_POSITION, + SERVICE_STOP_VALVE, + SERVICE_TOGGLE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "valve" +SCAN_INTERVAL = timedelta(seconds=15) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + + +class ValveDeviceClass(StrEnum): + """Device class for valve.""" + + # Refer to the valve dev docs for device class descriptions + WATER = "water" + GAS = "gas" + + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(ValveDeviceClass)) + + +# mypy: disallow-any-generics +class ValveEntityFeature(IntFlag): + """Supported features of the valve entity.""" + + OPEN = 1 + CLOSE = 2 + SET_POSITION = 4 + STOP = 8 + + +ATTR_CURRENT_POSITION = "current_position" +ATTR_POSITION = "position" + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Track states and offer events for valves.""" + component = hass.data[DOMAIN] = EntityComponent[ValveEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_OPEN_VALVE, {}, "async_handle_open_valve", [ValveEntityFeature.OPEN] + ) + + component.async_register_entity_service( + SERVICE_CLOSE_VALVE, {}, "async_handle_close_valve", [ValveEntityFeature.CLOSE] + ) + + component.async_register_entity_service( + SERVICE_SET_VALVE_POSITION, + { + vol.Required(ATTR_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + }, + "async_set_valve_position", + [ValveEntityFeature.SET_POSITION], + ) + + component.async_register_entity_service( + SERVICE_STOP_VALVE, {}, "async_stop_valve", [ValveEntityFeature.STOP] + ) + + component.async_register_entity_service( + SERVICE_TOGGLE, + {}, + "async_toggle", + [ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE], + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[ValveEntity] = 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[ValveEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass(frozen=True, kw_only=True) +class ValveEntityDescription(EntityDescription): + """A class that describes valve entities.""" + + device_class: ValveDeviceClass | None = None + reports_position: bool = False + + +class ValveEntity(Entity): + """Base class for valve entities.""" + + entity_description: ValveEntityDescription + _attr_current_valve_position: int | None = None + _attr_device_class: ValveDeviceClass | None + _attr_is_closed: bool | None = None + _attr_is_closing: bool | None = None + _attr_is_opening: bool | None = None + _attr_reports_position: bool + _attr_supported_features: ValveEntityFeature = ValveEntityFeature(0) + + __is_last_toggle_direction_open = True + + @property + def reports_position(self) -> bool: + """Return True if entity reports position, False otherwise.""" + if hasattr(self, "_attr_reports_position"): + return self._attr_reports_position + if hasattr(self, "entity_description"): + return self.entity_description.reports_position + raise ValueError(f"'reports_position' not set for {self.entity_id}.") + + @property + def current_valve_position(self) -> int | None: + """Return current position of valve. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._attr_current_valve_position + + @property + def device_class(self) -> ValveDeviceClass | 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 + @final + def state(self) -> str | None: + """Return the state of the valve.""" + reports_position = self.reports_position + if self.is_opening: + self.__is_last_toggle_direction_open = True + return STATE_OPENING + if self.is_closing: + self.__is_last_toggle_direction_open = False + return STATE_CLOSING + if reports_position is True: + if (current_valve_position := self.current_valve_position) is None: + return None + position_zero = current_valve_position == 0 + return STATE_CLOSED if position_zero else STATE_OPEN + if (closed := self.is_closed) is None: + return None + return STATE_CLOSED if closed else STATE_OPEN + + @final + @property + def state_attributes(self) -> dict[str, Any]: + """Return the state attributes.""" + + return {ATTR_CURRENT_POSITION: self.current_valve_position} + + @property + def supported_features(self) -> ValveEntityFeature: + """Flag supported features.""" + return self._attr_supported_features + + @property + def is_opening(self) -> bool | None: + """Return if the valve is opening or not.""" + return self._attr_is_opening + + @property + def is_closing(self) -> bool | None: + """Return if the valve is closing or not.""" + return self._attr_is_closing + + @property + def is_closed(self) -> bool | None: + """Return if the valve is closed or not.""" + return self._attr_is_closed + + def open_valve(self) -> None: + """Open the valve.""" + raise NotImplementedError() + + async def async_open_valve(self) -> None: + """Open the valve.""" + await self.hass.async_add_executor_job(self.open_valve) + + @final + async def async_handle_open_valve(self) -> None: + """Open the valve.""" + if self.supported_features & ValveEntityFeature.SET_POSITION: + return await self.async_set_valve_position(100) + await self.async_open_valve() + + def close_valve(self) -> None: + """Close valve.""" + raise NotImplementedError() + + async def async_close_valve(self) -> None: + """Close valve.""" + await self.hass.async_add_executor_job(self.close_valve) + + @final + async def async_handle_close_valve(self) -> None: + """Close the valve.""" + if self.supported_features & ValveEntityFeature.SET_POSITION: + return await self.async_set_valve_position(0) + await self.async_close_valve() + + async def async_toggle(self) -> None: + """Toggle the entity.""" + if self.supported_features & ValveEntityFeature.STOP and ( + self.is_closing or self.is_opening + ): + return await self.async_stop_valve() + if self.is_closed: + return await self.async_handle_open_valve() + if self.__is_last_toggle_direction_open: + return await self.async_handle_close_valve() + return await self.async_handle_open_valve() + + def set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + raise NotImplementedError() + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + await self.hass.async_add_executor_job(self.set_valve_position, position) + + def stop_valve(self) -> None: + """Stop the valve.""" + raise NotImplementedError() + + async def async_stop_valve(self) -> None: + """Stop the valve.""" + await self.hass.async_add_executor_job(self.stop_valve) diff --git a/homeassistant/components/valve/manifest.json b/homeassistant/components/valve/manifest.json new file mode 100644 index 00000000000..28563f0976c --- /dev/null +++ b/homeassistant/components/valve/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "valve", + "name": "Valve", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/valve", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/valve/services.yaml b/homeassistant/components/valve/services.yaml new file mode 100644 index 00000000000..936599818f1 --- /dev/null +++ b/homeassistant/components/valve/services.yaml @@ -0,0 +1,45 @@ +# Describes the format for available valve services + +open_valve: + target: + entity: + domain: valve + supported_features: + - valve.ValveEntityFeature.OPEN + +close_valve: + target: + entity: + domain: valve + supported_features: + - valve.ValveEntityFeature.CLOSE + +toggle: + target: + entity: + domain: valve + supported_features: + - - valve.ValveEntityFeature.CLOSE + - valve.ValveEntityFeature.OPEN + +set_valve_position: + target: + entity: + domain: valve + supported_features: + - valve.ValveEntityFeature.SET_POSITION + fields: + position: + required: true + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" + +stop_valve: + target: + entity: + domain: valve + supported_features: + - valve.ValveEntityFeature.STOP diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json new file mode 100644 index 00000000000..b86ec371b34 --- /dev/null +++ b/homeassistant/components/valve/strings.json @@ -0,0 +1,54 @@ +{ + "title": "Valve", + "entity_component": { + "_": { + "name": "[%key:component::valve::title%]", + "state": { + "open": "[%key:common::state::open%]", + "opening": "Opening", + "closed": "[%key:common::state::closed%]", + "closing": "Closing", + "stopped": "Stopped" + }, + "state_attributes": { + "current_position": { + "name": "Position" + } + } + }, + "water": { + "name": "Water" + }, + "gas": { + "name": "Gas" + } + }, + "services": { + "open_valve": { + "name": "[%key:common::action::open%]", + "description": "Opens a valve." + }, + "close_valve": { + "name": "[%key:common::action::close%]", + "description": "Closes a valve." + }, + "toggle": { + "name": "[%key:common::action::toggle%]", + "description": "Toggles a valve open/closed." + }, + "set_valve_position": { + "name": "Set position", + "description": "Moves a valve to a specific position.", + "fields": { + "position": { + "name": "Position", + "description": "Target position." + } + } + }, + "stop_valve": { + "name": "[%key:common::action::stop%]", + "description": "Stops the valve movement." + } + } +} diff --git a/homeassistant/components/velbus/diagnostics.py b/homeassistant/components/velbus/diagnostics.py index f6015abd1f8..5b991fa35fb 100644 --- a/homeassistant/components/velbus/diagnostics.py +++ b/homeassistant/components/velbus/diagnostics.py @@ -48,7 +48,7 @@ def _build_module_diagnostics_info(module: VelbusModule) -> dict[str, Any]: def _build_channels_diagnostics_info( - channels: dict[str, VelbusChannel] + channels: dict[str, VelbusChannel], ) -> dict[str, Any]: """Build diagnostics info for all channels.""" data: dict[str, Any] = {} diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index 45220e1a9b4..1a99f796eb2 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -48,7 +48,7 @@ _P = ParamSpec("_P") def api_call( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Catch command exceptions.""" diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index b43ee39ed4e..d6a5f540c06 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -1,7 +1,7 @@ """Support for VELUX KLF 200 devices.""" import logging -from pyvlx import OpeningDevice, PyVLX, PyVLXException +from pyvlx import Node, PyVLX, PyVLXException import voluptuous as vol from homeassistant.const import ( @@ -90,7 +90,7 @@ class VeluxEntity(Entity): _attr_should_poll = False - def __init__(self, node: OpeningDevice) -> None: + def __init__(self, node: Node) -> None: """Initialize the Velux device.""" self.node = node self._attr_unique_id = node.serial_number diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 48c09a2b3c2..c8fb2aafb96 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -1,7 +1,7 @@ """Support for Velux covers.""" from __future__ import annotations -from typing import Any +from typing import Any, cast from pyvlx import OpeningDevice, Position from pyvlx.opening_device import Awning, Blind, GarageDoor, Gate, RollerShutter, Window @@ -40,6 +40,7 @@ class VeluxCover(VeluxEntity, CoverEntity): """Representation of a Velux cover.""" _is_blind = False + node: OpeningDevice def __init__(self, node: OpeningDevice) -> None: """Initialize VeluxCover.""" @@ -86,7 +87,7 @@ class VeluxCover(VeluxEntity, CoverEntity): def current_cover_tilt_position(self) -> int | None: """Return the current position of the cover.""" if self._is_blind: - return 100 - self.node.orientation.position_percent + return 100 - cast(Blind, self.node).orientation.position_percent return None @property @@ -116,20 +117,20 @@ class VeluxCover(VeluxEntity, CoverEntity): async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close cover tilt.""" - await self.node.close_orientation(wait_for_completion=False) + await cast(Blind, self.node).close_orientation(wait_for_completion=False) async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open cover tilt.""" - await self.node.open_orientation(wait_for_completion=False) + await cast(Blind, self.node).open_orientation(wait_for_completion=False) async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop cover tilt.""" - await self.node.stop_orientation(wait_for_completion=False) + await cast(Blind, self.node).stop_orientation(wait_for_completion=False) async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move cover tilt to a specific position.""" position_percent = 100 - kwargs[ATTR_TILT_POSITION] orientation = Position(position_percent=position_percent) - await self.node.set_orientation( + await cast(Blind, self.node).set_orientation( orientation=orientation, wait_for_completion=False ) diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py index a600aceedd2..a6d63436ecf 100644 --- a/homeassistant/components/velux/light.py +++ b/homeassistant/components/velux/light.py @@ -35,6 +35,8 @@ class VeluxLight(VeluxEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_color_mode = ColorMode.BRIGHTNESS + node: LighteningDevice + @property def brightness(self): """Return the current brightness.""" diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index 0495ff80a43..901034aa387 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/velux", "iot_class": "local_polling", "loggers": ["pyvlx"], - "requirements": ["pyvlx==0.2.20"] + "requirements": ["pyvlx==0.2.21"] } diff --git a/homeassistant/components/venstar/sensor.py b/homeassistant/components/venstar/sensor.py index 7125dfd4540..1e31fb9407b 100644 --- a/homeassistant/components/venstar/sensor.py +++ b/homeassistant/components/venstar/sensor.py @@ -65,7 +65,7 @@ SCHEDULE_PARTS: dict[int, str] = { } -@dataclass +@dataclass(frozen=True) class VenstarSensorTypeMixin: """Mixin for sensor required keys.""" @@ -74,7 +74,7 @@ class VenstarSensorTypeMixin: uom_fn: Callable[[Any], str | None] -@dataclass +@dataclass(frozen=True) class VenstarSensorEntityDescription(SensorEntityDescription, VenstarSensorTypeMixin): """Base description of a Sensor entity.""" diff --git a/homeassistant/components/vera/config_flow.py b/homeassistant/components/vera/config_flow.py index c300f599faa..00b45e00b11 100644 --- a/homeassistant/components/vera/config_flow.py +++ b/homeassistant/components/vera/config_flow.py @@ -44,7 +44,7 @@ def new_options(lights: list[int], exclude: list[int]) -> dict[str, list[int]]: def options_schema( - options: Mapping[str, Any] | None = None + options: Mapping[str, Any] | None = None, ) -> dict[vol.Optional, type[str]]: """Return options schema.""" options = options or {} diff --git a/homeassistant/components/version/__init__.py b/homeassistant/components/version/__init__.py index 878ed3d0138..f05c2147449 100644 --- a/homeassistant/components/version/__init__.py +++ b/homeassistant/components/version/__init__.py @@ -44,6 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: image=entry.data[CONF_IMAGE], board=BOARD_MAP[board], channel=entry.data[CONF_CHANNEL].lower(), + timeout=30, ), ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index a0e5b9da52e..b2fd090e781 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -18,9 +18,21 @@ DEV_TYPE_TO_HA = { "ESWL01": "switch", "ESWL03": "switch", "ESO15-TB": "outlet", + "LV-PUR131S": "fan", + "Core200S": "fan", + "Core300S": "fan", + "Core400S": "fan", + "Core600S": "fan", + "Vital200S": "fan", + "Vital100S": "fan", + "ESD16": "walldimmer", + "ESWD16": "walldimmer", + "ESL100": "bulb-dimmable", + "ESL100CW": "bulb-tunable-white", } SKU_TO_BASE_DEVICE = { + # Air Purifiers "LV-PUR131S": "LV-PUR131S", "LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S "Core200S": "Core200S", diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 326e7daf12c..f0d4d02a9a3 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -11,26 +11,16 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .common import VeSyncDevice -from .const import DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS +from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS _LOGGER = logging.getLogger(__name__) -DEV_TYPE_TO_HA = { - "LV-PUR131S": "fan", - "Core200S": "fan", - "Core300S": "fan", - "Core400S": "fan", - "Core600S": "fan", - "Vital200S": "fan", - "Vital100S": "fan", -} - FAN_MODE_AUTO = "auto" FAN_MODE_SLEEP = "sleep" FAN_MODE_PET = "pet" diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index e6cc979e808..040e9d5696d 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -14,17 +14,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .common import VeSyncDevice -from .const import DOMAIN, VS_DISCOVERY, VS_LIGHTS +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_LIGHTS _LOGGER = logging.getLogger(__name__) -DEV_TYPE_TO_HA = { - "ESD16": "walldimmer", - "ESWD16": "walldimmer", - "ESL100": "bulb-dimmable", - "ESL100CW": "bulb-tunable-white", -} - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index fb892acfd4f..ff3f56dd184 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -1,7 +1,7 @@ { "domain": "vesync", "name": "VeSync", - "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey"], + "codeowners": ["@markperdue", "@webdjoe", "@thegardenmonkey", "@cdnninja"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index 4277460c3ea..97a557ef49f 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -35,14 +35,14 @@ from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_ _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class VeSyncSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], StateType] -@dataclass +@dataclass(frozen=True) class VeSyncSensorEntityDescription( SensorEntityDescription, VeSyncSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 76de3a8a7ac..603a42bae41 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -37,14 +37,14 @@ _LOGGER = logging.getLogger(__name__) _TOKEN_FILENAME = "vicare_token.save" -@dataclass() +@dataclass(frozen=True) class ViCareRequiredKeysMixin: """Mixin for required keys.""" - value_getter: Callable[[Device], bool] + value_getter: Callable[[Device], Any] -@dataclass() +@dataclass(frozen=True) class ViCareRequiredKeysMixinWithSet(ViCareRequiredKeysMixin): """Mixin for required keys with setter.""" diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 525099e7d4e..f3cf585b470 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -1,6 +1,7 @@ """Viessmann ViCare sensor device.""" from __future__ import annotations +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass import logging @@ -34,12 +35,14 @@ from .utils import get_burners, get_circuits, get_compressors, is_supported _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ViCareBinarySensorEntityDescription( BinarySensorEntityDescription, ViCareRequiredKeysMixin ): """Describes ViCare binary sensor entity.""" + value_getter: Callable[[PyViCareDevice], bool] + CIRCUIT_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 374d98b3397..8f11fdf0ac5 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -28,7 +28,7 @@ from .utils import is_supported _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ViCareButtonEntityDescription( ButtonEntityDescription, ViCareRequiredKeysMixinWithSet ): diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index c14f940ffe6..2bb0a19924e 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -311,8 +311,12 @@ class ViCareClimate(ViCareEntity, ClimateEntity): ) _LOGGER.debug("Current preset %s", self._current_program) - if self._current_program and self._current_program != VICARE_PROGRAM_NORMAL: - # We can't deactivate "normal" + if self._current_program and self._current_program not in [ + VICARE_PROGRAM_NORMAL, + VICARE_PROGRAM_REDUCED, + VICARE_PROGRAM_STANDBY, + ]: + # We can't deactivate "normal", "reduced" or "standby" _LOGGER.debug("deactivating %s", self._current_program) try: self._circuit.deactivateProgram(self._current_program) @@ -326,8 +330,12 @@ class ViCareClimate(ViCareEntity, ClimateEntity): ) from err _LOGGER.debug("Setting preset to %s / %s", preset_mode, target_program) - if target_program != VICARE_PROGRAM_NORMAL: - # And we can't explicitly activate "normal", either + if target_program not in [ + VICARE_PROGRAM_NORMAL, + VICARE_PROGRAM_REDUCED, + VICARE_PROGRAM_STANDBY, + ]: + # And we can't explicitly activate "normal", "reduced" or "standby", either _LOGGER.debug("activating %s", target_program) try: self._circuit.activateProgram(target_program) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index cbde6242082..97c4b91022d 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.29.0"] + "requirements": ["PyViCare==2.32.0"] } diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py index 5511f2a5294..d4dd0437b04 100644 --- a/homeassistant/components/vicare/number.py +++ b/homeassistant/components/vicare/number.py @@ -19,7 +19,11 @@ from PyViCare.PyViCareUtils import ( ) from requests.exceptions import ConnectionError as RequestConnectionError -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant @@ -33,10 +37,11 @@ from .utils import get_circuits, is_supported _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class ViCareNumberEntityDescription(NumberEntityDescription, ViCareRequiredKeysMixin): """Describes ViCare number entity.""" + value_getter: Callable[[PyViCareDevice], float] value_setter: Callable[[PyViCareDevice, float], Any] | None = None min_value_getter: Callable[[PyViCareDevice], float | None] | None = None max_value_getter: Callable[[PyViCareDevice], float | None] | None = None @@ -49,6 +54,7 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( translation_key="heating_curve_shift", icon="mdi:plus-minus-variant", entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_getter=lambda api: api.getHeatingCurveShift(), value_setter=lambda api, shift: ( @@ -77,6 +83,42 @@ CIRCUIT_ENTITY_DESCRIPTIONS: tuple[ViCareNumberEntityDescription, ...] = ( native_max_value=3.5, native_step=0.1, ), + ViCareNumberEntityDescription( + key="normal_temperature", + translation_key="normal_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("normal"), + value_setter=lambda api, value: api.setProgramTemperature("normal", value), + min_value_getter=lambda api: api.getProgramMinTemperature("normal"), + max_value_getter=lambda api: api.getProgramMaxTemperature("normal"), + stepping_getter=lambda api: api.getProgramStepping("normal"), + ), + ViCareNumberEntityDescription( + key="reduced_temperature", + translation_key="reduced_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("reduced"), + value_setter=lambda api, value: api.setProgramTemperature("reduced", value), + min_value_getter=lambda api: api.getProgramMinTemperature("reduced"), + max_value_getter=lambda api: api.getProgramMaxTemperature("reduced"), + stepping_getter=lambda api: api.getProgramStepping("reduced"), + ), + ViCareNumberEntityDescription( + key="comfort_temperature", + translation_key="comfort_temperature", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDesiredTemperatureForProgram("comfort"), + value_setter=lambda api, value: api.setProgramTemperature("comfort", value), + min_value_getter=lambda api: api.getProgramMinTemperature("comfort"), + max_value_getter=lambda api: api.getProgramMaxTemperature("comfort"), + stepping_getter=lambda api: api.getProgramStepping("comfort"), + ), ) @@ -149,6 +191,7 @@ class ViCareNumber(ViCareEntity, NumberEntity): self._attr_native_value = self.entity_description.value_getter( self._api ) + if min_value := _get_value( self.entity_description.min_value_getter, self._api ): diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 875d8790c52..142e3cbabfa 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -58,7 +58,7 @@ VICARE_UNIT_TO_DEVICE_CLASS = { } -@dataclass +@dataclass(frozen=True) class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysMixin): """Describes ViCare sensor entity.""" diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 47ee60b2ea8..6c08215a9c1 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -80,9 +80,6 @@ }, "comfort_temperature": { "name": "Comfort temperature" - }, - "eco_temperature": { - "name": "Eco temperature" } }, "sensor": { diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py index 5b3fb38337f..a084eee383b 100644 --- a/homeassistant/components/vicare/utils.py +++ b/homeassistant/components/vicare/utils.py @@ -21,13 +21,12 @@ def is_supported( try: entity_description.value_getter(vicare_device) _LOGGER.debug("Found entity %s", name) + return True except PyViCareNotSupportedFeatureError: - _LOGGER.info("Feature not supported %s", name) - return False + _LOGGER.debug("Feature not supported %s", name) except AttributeError as error: - _LOGGER.debug("Attribute Error %s: %s", name, error) - return False - return True + _LOGGER.debug("Feature not supported %s: %s", name, error) + return False def get_burners(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: @@ -36,6 +35,8 @@ def get_burners(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]: return device.burners except PyViCareNotSupportedFeatureError: _LOGGER.debug("No burners found") + except AttributeError as error: + _LOGGER.debug("No burners found: %s", error) return [] @@ -45,6 +46,8 @@ def get_circuits(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent] return device.circuits except PyViCareNotSupportedFeatureError: _LOGGER.debug("No circuits found") + except AttributeError as error: + _LOGGER.debug("No circuits found: %s", error) return [] @@ -54,4 +57,6 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone return device.compressors except PyViCareNotSupportedFeatureError: _LOGGER.debug("No compressors found") + except AttributeError as error: + _LOGGER.debug("No compressors found: %s", error) return [] diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py index 511e25bbfba..c72edf1b7db 100644 --- a/homeassistant/components/vilfo/sensor.py +++ b/homeassistant/components/vilfo/sensor.py @@ -24,14 +24,14 @@ from .const import ( ) -@dataclass +@dataclass(frozen=True) class VilfoRequiredKeysMixin: """Mixin for required keys.""" api_key: str -@dataclass +@dataclass(frozen=True) class VilfoSensorEntityDescription(SensorEntityDescription, VilfoRequiredKeysMixin): """Describes Vilfo sensor entity.""" diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index ef1df676a2d..b84676776f5 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -45,7 +45,7 @@ async def async_setup_entry( def catch_vlc_errors( - func: Callable[Concatenate[_VlcDeviceT, _P], Awaitable[None]] + func: Callable[Concatenate[_VlcDeviceT, _P], Awaitable[None]], ) -> Callable[Concatenate[_VlcDeviceT, _P], Coroutine[Any, Any, None]]: """Catch VLC errors.""" diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py index 7f93f8023ef..3840af3d593 100644 --- a/homeassistant/components/vodafone_station/button.py +++ b/homeassistant/components/vodafone_station/button.py @@ -20,7 +20,7 @@ from .const import _LOGGER, DOMAIN from .coordinator import VodafoneStationRouter -@dataclass +@dataclass(frozen=True) class VodafoneStationBaseEntityDescriptionMixin: """Mixin to describe a Button entity.""" @@ -28,7 +28,7 @@ class VodafoneStationBaseEntityDescriptionMixin: is_suitable: Callable[[dict], bool] -@dataclass +@dataclass(frozen=True) class VodafoneStationEntityDescription( ButtonEntityDescription, VodafoneStationBaseEntityDescriptionMixin ): diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index 8d9cb444fc9..b383c2d193a 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -24,7 +24,7 @@ from .coordinator import VodafoneStationRouter NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] -@dataclass +@dataclass(frozen=True) class VodafoneStationBaseEntityDescription: """Vodafone Station entity base description.""" @@ -34,7 +34,7 @@ class VodafoneStationBaseEntityDescription: is_suitable: Callable[[dict], bool] = lambda val: True -@dataclass +@dataclass(frozen=True, kw_only=True) class VodafoneStationEntityDescription( VodafoneStationBaseEntityDescription, SensorEntityDescription ): diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index b3c5a9b4910..96d66bb4395 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -68,7 +68,7 @@ _P = ParamSpec("_P") def _require_authentication( - func: Callable[Concatenate[_WallboxCoordinatorT, _P], Any] + func: Callable[Concatenate[_WallboxCoordinatorT, _P], Any], ) -> Callable[Concatenate[_WallboxCoordinatorT, _P], Any]: """Authenticate with decorator using Wallbox API.""" diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index b47eb14d58a..76cf8316959 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -38,7 +38,7 @@ def min_charging_current_value(coordinator: WallboxCoordinator) -> float: return 6 -@dataclass +@dataclass(frozen=True) class WallboxNumberEntityDescriptionMixin: """Load entities from different handlers.""" @@ -47,7 +47,7 @@ class WallboxNumberEntityDescriptionMixin: set_value_fn: Callable[[WallboxCoordinator], Callable[[float], Awaitable[None]]] -@dataclass +@dataclass(frozen=True) class WallboxNumberEntityDescription( NumberEntityDescription, WallboxNumberEntityDescriptionMixin ): diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 4a1cf365bb1..5a825722d53 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -51,7 +51,7 @@ UPDATE_INTERVAL = 30 _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class WallboxSensorEntityDescription(SensorEntityDescription): """Describes Wallbox sensor entity.""" diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index d94a2e19f67..43be729e10f 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -153,7 +153,7 @@ async def async_setup_platform( ) -@dataclass +@dataclass(frozen=True) class WAQIMixin: """Mixin for required keys.""" @@ -161,7 +161,7 @@ class WAQIMixin: value_fn: Callable[[WAQIAirQuality], StateType] -@dataclass +@dataclass(frozen=True) class WAQISensorEntityDescription(SensorEntityDescription, WAQIMixin): """Describes WAQI sensor entity.""" diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 9e796092f6a..f2744416900 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass from datetime import timedelta from enum import IntFlag import functools as ft import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, final import voluptuous as vol @@ -29,12 +28,23 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) +from homeassistant.helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import TemperatureConverter +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + DEFAULT_MIN_TEMP = 110 DEFAULT_MAX_TEMP = 140 @@ -66,9 +76,19 @@ class WaterHeaterEntityFeature(IntFlag): # These SUPPORT_* constants are deprecated as of Home Assistant 2022.5. # Please use the WaterHeaterEntityFeature enum instead. -SUPPORT_TARGET_TEMPERATURE = 1 -SUPPORT_OPERATION_MODE = 2 -SUPPORT_AWAY_MODE = 4 +_DEPRECATED_SUPPORT_TARGET_TEMPERATURE = DeprecatedConstantEnum( + WaterHeaterEntityFeature.TARGET_TEMPERATURE, "2025.1" +) +_DEPRECATED_SUPPORT_OPERATION_MODE = DeprecatedConstantEnum( + WaterHeaterEntityFeature.OPERATION_MODE, "2025.1" +) +_DEPRECATED_SUPPORT_AWAY_MODE = DeprecatedConstantEnum( + WaterHeaterEntityFeature.AWAY_MODE, "2025.1" +) + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = ft.partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = ft.partial(dir_with_deprecated_constants, module_globals=globals()) ATTR_MAX_TEMP = "max_temp" ATTR_MIN_TEMP = "min_temp" @@ -156,12 +176,23 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class WaterHeaterEntityEntityDescription(EntityDescription): +class WaterHeaterEntityEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes water heater entities.""" -class WaterHeaterEntity(Entity): +CACHED_PROPERTIES_WITH_ATTR_ = { + "temperature_unit", + "current_operation", + "operation_list", + "current_temperature", + "target_temperature", + "target_temperature_high", + "target_temperature_low", + "is_away_mode_on", +} + + +class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Base class for water heater entities.""" _entity_component_unrecorded_attributes = frozenset( @@ -210,7 +241,7 @@ class WaterHeaterEntity(Entity): ), } - if self.supported_features & WaterHeaterEntityFeature.OPERATION_MODE: + if WaterHeaterEntityFeature.OPERATION_MODE in self.supported_features: data[ATTR_OPERATION_LIST] = self.operation_list return data @@ -246,51 +277,53 @@ class WaterHeaterEntity(Entity): ), } - if self.supported_features & WaterHeaterEntityFeature.OPERATION_MODE: + supported_features = self.supported_features + + if WaterHeaterEntityFeature.OPERATION_MODE in supported_features: data[ATTR_OPERATION_MODE] = self.current_operation - if self.supported_features & WaterHeaterEntityFeature.AWAY_MODE: + if WaterHeaterEntityFeature.AWAY_MODE in supported_features: is_away = self.is_away_mode_on data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF return data - @property + @cached_property def temperature_unit(self) -> str: """Return the unit of measurement used by the platform.""" return self._attr_temperature_unit - @property + @cached_property def current_operation(self) -> str | None: """Return current operation ie. eco, electric, performance, ...""" return self._attr_current_operation - @property + @cached_property def operation_list(self) -> list[str] | None: """Return the list of available operation modes.""" return self._attr_operation_list - @property + @cached_property def current_temperature(self) -> float | None: """Return the current temperature.""" return self._attr_current_temperature - @property + @cached_property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._attr_target_temperature - @property + @cached_property def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach.""" return self._attr_target_temperature_high - @property + @cached_property def target_temperature_low(self) -> float | None: """Return the lowbound target temperature we try to reach.""" return self._attr_target_temperature_low - @property + @cached_property def is_away_mode_on(self) -> bool | None: """Return true if away mode is on.""" return self._attr_is_away_mode_on @@ -368,6 +401,19 @@ class WaterHeaterEntity(Entity): """Return the list of supported features.""" return self._attr_supported_features + @property + def supported_features_compat(self) -> WaterHeaterEntityFeature: + """Return the supported features as WaterHeaterEntityFeature. + + Remove this compatibility shim in 2025.1 or later. + """ + features = self.supported_features + if type(features) is int: # noqa: E721 + new_features = WaterHeaterEntityFeature(features) + self._report_deprecated_supported_features_values(new_features) + return new_features + return features + async def async_service_away_mode( entity: WaterHeaterEntity, service: ServiceCall diff --git a/homeassistant/components/water_heater/significant_change.py b/homeassistant/components/water_heater/significant_change.py new file mode 100644 index 00000000000..bacb0232ee3 --- /dev/null +++ b/homeassistant/components/water_heater/significant_change.py @@ -0,0 +1,77 @@ +"""Helper to test significant Water Heater state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from . import ( + ATTR_AWAY_MODE, + ATTR_CURRENT_TEMPERATURE, + ATTR_OPERATION_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +) + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_CURRENT_TEMPERATURE, + ATTR_TEMPERATURE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_OPERATION_MODE, + ATTR_AWAY_MODE, +} + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + ha_unit = hass.config.units.temperature_unit + + for attr_name in changed_attrs: + if attr_name in [ATTR_OPERATION_MODE, ATTR_AWAY_MODE]: + return True + + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if ha_unit == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + if check_absolute_change(old_attr_value, new_attr_value, absolute_change): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/watttime/config_flow.py b/homeassistant/components/watttime/config_flow.py index 4f4206da6ec..12601c0af83 100644 --- a/homeassistant/components/watttime/config_flow.py +++ b/homeassistant/components/watttime/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_PASSWORD, + CONF_SHOW_ON_MAP, CONF_USERNAME, ) from homeassistant.core import callback @@ -23,7 +24,6 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import ( CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, - CONF_SHOW_ON_MAP, DOMAIN, LOGGER, ) diff --git a/homeassistant/components/watttime/const.py b/homeassistant/components/watttime/const.py index 5bb8cb50d40..ce2731e7832 100644 --- a/homeassistant/components/watttime/const.py +++ b/homeassistant/components/watttime/const.py @@ -7,4 +7,3 @@ LOGGER = logging.getLogger(__package__) CONF_BALANCING_AUTHORITY = "balancing_authority" CONF_BALANCING_AUTHORITY_ABBREV = "balancing_authority_abbreviation" -CONF_SHOW_ON_MAP = "show_on_map" diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py index 2a0e21ecf4c..ca5b0d06fa2 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -10,7 +10,13 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, PERCENTAGE, UnitOfMass +from homeassistant.const import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_SHOW_ON_MAP, + PERCENTAGE, + UnitOfMass, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -20,12 +26,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import ( - CONF_BALANCING_AUTHORITY, - CONF_BALANCING_AUTHORITY_ABBREV, - CONF_SHOW_ON_MAP, - DOMAIN, -) +from .const import CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, DOMAIN ATTR_BALANCING_AUTHORITY = "balancing_authority" diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 3d9eccd9425..bdc8ae4d514 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -5,11 +5,11 @@ import abc import asyncio from collections.abc import Callable, Iterable from contextlib import suppress -from dataclasses import dataclass from datetime import timedelta from functools import partial import logging from typing import ( + TYPE_CHECKING, Any, Final, Generic, @@ -45,7 +45,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity import ABCCachedProperties, Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform import homeassistant.helpers.issue_registry as ir @@ -85,6 +85,12 @@ from .const import ( # noqa: F401 ) from .websocket_api import async_setup as async_setup_ws_api +if TYPE_CHECKING: + from functools import cached_property +else: + from homeassistant.backports.functools import cached_property + + _LOGGER = logging.getLogger(__name__) ATTR_CONDITION_CLASS = "condition_class" @@ -251,15 +257,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass -class WeatherEntityDescription(EntityDescription): +class WeatherEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes weather entities.""" -class PostInitMeta(abc.ABCMeta): +class PostInitMeta(ABCCachedProperties): """Meta class which calls __post_init__ after __new__ and __init__.""" - def __call__(cls, *args: Any, **kwargs: Any) -> Any: + def __call__(cls, *args: Any, **kwargs: Any) -> Any: # noqa: N805 ruff bug, ruff does not understand this is a metaclass """Create an instance.""" instance: PostInit = super().__call__(*args, **kwargs) instance.__post_init__(*args, **kwargs) @@ -274,7 +279,29 @@ class PostInit(metaclass=PostInitMeta): """Finish initializing.""" -class WeatherEntity(Entity, PostInit): +CACHED_PROPERTIES_WITH_ATTR_ = { + "native_apparent_temperature", + "native_temperature", + "native_temperature_unit", + "native_dew_point", + "native_pressure", + "native_pressure_unit", + "humidity", + "native_wind_gust_speed", + "native_wind_speed", + "native_wind_speed_unit", + "wind_bearing", + "ozone", + "cloud_coverage", + "uv_index", + "native_visibility", + "native_visibility_unit", + "native_precipitation_unit", + "condition", +} + + +class WeatherEntity(Entity, PostInit, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ABC for weather data.""" _entity_component_unrecorded_attributes = frozenset({ATTR_FORECAST}) @@ -400,22 +427,22 @@ class WeatherEntity(Entity, PostInit): return self.async_registry_entry_updated() - @property + @cached_property def native_apparent_temperature(self) -> float | None: """Return the apparent temperature in native units.""" - return self._attr_native_temperature + return self._attr_native_apparent_temperature - @property + @cached_property def native_temperature(self) -> float | None: """Return the temperature in native units.""" return self._attr_native_temperature - @property + @cached_property def native_temperature_unit(self) -> str | None: """Return the native unit of measurement for temperature.""" return self._attr_native_temperature_unit - @property + @cached_property def native_dew_point(self) -> float | None: """Return the dew point temperature in native units.""" return self._attr_native_dew_point @@ -443,12 +470,12 @@ class WeatherEntity(Entity, PostInit): return self._default_temperature_unit - @property + @cached_property def native_pressure(self) -> float | None: """Return the pressure in native units.""" return self._attr_native_pressure - @property + @cached_property def native_pressure_unit(self) -> str | None: """Return the native unit of measurement for pressure.""" return self._attr_native_pressure_unit @@ -478,22 +505,22 @@ class WeatherEntity(Entity, PostInit): return self._default_pressure_unit - @property + @cached_property def humidity(self) -> float | None: """Return the humidity in native units.""" return self._attr_humidity - @property + @cached_property def native_wind_gust_speed(self) -> float | None: """Return the wind gust speed in native units.""" return self._attr_native_wind_gust_speed - @property + @cached_property def native_wind_speed(self) -> float | None: """Return the wind speed in native units.""" return self._attr_native_wind_speed - @property + @cached_property def native_wind_speed_unit(self) -> str | None: """Return the native unit of measurement for wind speed.""" return self._attr_native_wind_speed_unit @@ -523,32 +550,32 @@ class WeatherEntity(Entity, PostInit): return self._default_wind_speed_unit - @property + @cached_property def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" return self._attr_wind_bearing - @property + @cached_property def ozone(self) -> float | None: """Return the ozone level.""" return self._attr_ozone - @property + @cached_property def cloud_coverage(self) -> float | None: """Return the Cloud coverage in %.""" return self._attr_cloud_coverage - @property + @cached_property def uv_index(self) -> float | None: """Return the UV index.""" return self._attr_uv_index - @property + @cached_property def native_visibility(self) -> float | None: """Return the visibility in native units.""" return self._attr_native_visibility - @property + @cached_property def native_visibility_unit(self) -> str | None: """Return the native unit of measurement for visibility.""" return self._attr_native_visibility_unit @@ -605,7 +632,7 @@ class WeatherEntity(Entity, PostInit): """Return the hourly forecast in native units.""" raise NotImplementedError - @property + @cached_property def native_precipitation_unit(self) -> str | None: """Return the native unit of measurement for accumulated precipitation.""" return self._attr_native_precipitation_unit @@ -972,7 +999,7 @@ class WeatherEntity(Entity, PostInit): """Return the current state.""" return self.condition - @property + @cached_property def condition(self) -> str | None: """Return the current condition.""" return self._attr_condition diff --git a/homeassistant/components/weather/significant_change.py b/homeassistant/components/weather/significant_change.py new file mode 100644 index 00000000000..87e1246ce85 --- /dev/null +++ b/homeassistant/components/weather/significant_change.py @@ -0,0 +1,170 @@ +"""Helper to test significant Weather state changes.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.significant_change import ( + check_absolute_change, + check_valid_float, +) + +from .const import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, +) + +SIGNIFICANT_ATTRIBUTES: set[str] = { + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, +} + +VALID_CARDINAL_DIRECTIONS: list[str] = [ + "n", + "nne", + "ne", + "ene", + "e", + "ese", + "se", + "sse", + "s", + "ssw", + "sw", + "wsw", + "w", + "wnw", + "nw", + "nnw", +] + + +def _cardinal_to_degrees(value: str | int | float | None) -> int | float | None: + """Translate a cardinal direction into azimuth angle (degrees).""" + if not isinstance(value, str): + return value + + try: + return float(360 / 16 * VALID_CARDINAL_DIRECTIONS.index(value.lower())) + except ValueError: + return None + + +@callback +def async_check_significant_change( + hass: HomeAssistant, + old_state: str, + old_attrs: dict, + new_state: str, + new_attrs: dict, + **kwargs: Any, +) -> bool | None: + """Test if state significantly changed.""" + # state changes are always significant + if old_state != new_state: + return True + + old_attrs_s = set( + {k: v for k, v in old_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + new_attrs_s = set( + {k: v for k, v in new_attrs.items() if k in SIGNIFICANT_ATTRIBUTES}.items() + ) + changed_attrs: set[str] = {item[0] for item in old_attrs_s ^ new_attrs_s} + + for attr_name in changed_attrs: + old_attr_value = old_attrs.get(attr_name) + new_attr_value = new_attrs.get(attr_name) + absolute_change: float | None = None + if attr_name == ATTR_WEATHER_WIND_BEARING: + old_attr_value = _cardinal_to_degrees(old_attr_value) + new_attr_value = _cardinal_to_degrees(new_attr_value) + + if new_attr_value is None or not check_valid_float(new_attr_value): + # New attribute value is invalid, ignore it + continue + + if old_attr_value is None or not check_valid_float(old_attr_value): + # Old attribute value was invalid, we should report again + return True + + if attr_name in ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_TEMPERATURE, + ): + if ( + unit := new_attrs.get(ATTR_WEATHER_TEMPERATURE_UNIT) + ) is not None and unit == UnitOfTemperature.FAHRENHEIT: + absolute_change = 1.0 + else: + absolute_change = 0.5 + + if attr_name in ( + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ): + if ( + unit := new_attrs.get(ATTR_WEATHER_WIND_SPEED_UNIT) + ) is None or unit in ( + UnitOfSpeed.KILOMETERS_PER_HOUR, + UnitOfSpeed.MILES_PER_HOUR, # 1km/h = 0.62mi/s + UnitOfSpeed.FEET_PER_SECOND, # 1km/h = 0.91ft/s + ): + absolute_change = 1.0 + elif unit == UnitOfSpeed.METERS_PER_SECOND: # 1km/h = 0.277m/s + absolute_change = 0.5 + + if attr_name in ( + ATTR_WEATHER_CLOUD_COVERAGE, # range 0-100% + ATTR_WEATHER_HUMIDITY, # range 0-100% + ATTR_WEATHER_OZONE, # range ~20-100ppm + ATTR_WEATHER_VISIBILITY, # range 0-240km (150mi) + ATTR_WEATHER_WIND_BEARING, # range 0-359° + ): + absolute_change = 1.0 + + if attr_name == ATTR_WEATHER_UV_INDEX: # range 1-11 + absolute_change = 0.1 + + if attr_name == ATTR_WEATHER_PRESSURE: # local variation of around 100 hpa + if (unit := new_attrs.get(ATTR_WEATHER_PRESSURE_UNIT)) is None or unit in ( + UnitOfPressure.HPA, + UnitOfPressure.MBAR, # 1hPa = 1mbar + UnitOfPressure.MMHG, # 1hPa = 0.75mmHg + ): + absolute_change = 1.0 + elif unit == UnitOfPressure.INHG: # 1hPa = 0.03inHg + absolute_change = 0.05 + + # check for significant attribute value change + if absolute_change is not None: + if check_absolute_change(old_attr_value, new_attr_value, absolute_change): + return True + + # no significant attribute change detected + return False diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index f3e5b8744e6..bbdd79e1533 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -46,7 +46,7 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from .const import DOMAIN, LOGGER, format_dispatch_call -@dataclass +@dataclass(frozen=True) class WeatherFlowSensorRequiredKeysMixin: """Mixin for required keys.""" @@ -60,7 +60,7 @@ def precipitation_raw_conversion_fn(raw_data: Enum): return raw_data.name.lower() -@dataclass +@dataclass(frozen=True) class WeatherFlowSensorEntityDescription( SensorEntityDescription, WeatherFlowSensorRequiredKeysMixin ): diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index a918ce0f850..824c85781ea 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -37,7 +37,7 @@ class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): hass=hass, logger=LOGGER, name=DOMAIN, - update_interval=timedelta(minutes=15), + update_interval=timedelta(minutes=5), ) async def update_supported_data_sets(self): diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 61bef8c693c..f12b1c08c60 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -84,7 +84,7 @@ _P = ParamSpec("_P") def cmd( - func: Callable[Concatenate[_T, _P], Awaitable[None]] + func: Callable[Concatenate[_T, _P], Awaitable[None]], ) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: """Catch command exceptions.""" diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index cb90b46e182..dfd04aa001a 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -650,7 +650,7 @@ async def handle_render_template( def _serialize_entity_sources( - entity_infos: dict[str, entity.EntityInfo] + entity_infos: dict[str, entity.EntityInfo], ) -> dict[str, Any]: """Prepare a websocket response from a dict of entity sources.""" return { diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index e1c8655c196..39abdba6e82 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -14,10 +14,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from . import async_wemo_dispatcher_connect from .const import SERVICE_RESET_FILTER_LIFE, SERVICE_SET_HUMIDITY diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py index 2547dc0ad0d..ecb0c16055c 100644 --- a/homeassistant/components/wemo/sensor.py +++ b/homeassistant/components/wemo/sensor.py @@ -22,7 +22,7 @@ from .entity import WemoEntity from .wemo_device import DeviceCoordinator -@dataclass +@dataclass(frozen=True) class AttributeSensorDescription(SensorEntityDescription): """SensorEntityDescription for WeMo AttributeSensor entities.""" diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index c3cad90e045..227c0e9f653 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -89,14 +89,14 @@ def washer_state(washer: WasherDryer) -> str | None: return MACHINE_STATE.get(machine_state, None) -@dataclass +@dataclass(frozen=True) class WhirlpoolSensorEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable -@dataclass +@dataclass(frozen=True) class WhirlpoolSensorEntityDescription( SensorEntityDescription, WhirlpoolSensorEntityDescriptionMixin ): diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index 0116f542a3c..7118701a868 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -27,7 +27,7 @@ from homeassistant.util import dt as dt_util from .const import ATTR_EXPIRES, ATTR_NAME_SERVERS, ATTR_REGISTRAR, ATTR_UPDATED, DOMAIN -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WhoisSensorEntityDescription(SensorEntityDescription): """Describes a Whois sensor entity.""" diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index f95337dbaf4..cfbdb6bdc92 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -128,7 +128,9 @@ class WirelessTagPlatform: self.api.start_monitoring(push_callback) -def async_migrate_unique_id(hass: HomeAssistant, tag: SensorTag, domain: str, key: str): +def async_migrate_unique_id( + hass: HomeAssistant, tag: SensorTag, domain: str, key: str +) -> None: """Migrate old unique id to new one with use of tag's uuid.""" registry = er.async_get(hass) new_unique_id = f"{tag.uuid}_{key}" diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 36ac9ea7d73..de053d6a894 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -58,7 +58,7 @@ from .coordinator import ( from .entity import WithingsEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WithingsMeasurementSensorEntityDescription(SensorEntityDescription): """Immutable class for describing withings data.""" @@ -233,10 +233,37 @@ MEASUREMENT_SENSORS: dict[ translation_key="vascular_age", entity_registry_enabled_default=False, ), + MeasurementType.VISCERAL_FAT: WithingsMeasurementSensorEntityDescription( + key="visceral_fat", + measurement_type=MeasurementType.VISCERAL_FAT, + translation_key="visceral_fat_index", + entity_registry_enabled_default=False, + ), + MeasurementType.ELECTRODERMAL_ACTIVITY_FEET: WithingsMeasurementSensorEntityDescription( + key="electrodermal_activity_feet", + measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_FEET, + translation_key="electrodermal_activity_feet", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + MeasurementType.ELECTRODERMAL_ACTIVITY_LEFT_FOOT: WithingsMeasurementSensorEntityDescription( + key="electrodermal_activity_left_foot", + measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_LEFT_FOOT, + translation_key="electrodermal_activity_left_foot", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + MeasurementType.ELECTRODERMAL_ACTIVITY_RIGHT_FOOT: WithingsMeasurementSensorEntityDescription( + key="electrodermal_activity_right_foot", + measurement_type=MeasurementType.ELECTRODERMAL_ACTIVITY_RIGHT_FOOT, + translation_key="electrodermal_activity_right_foot", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), } -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WithingsSleepSensorEntityDescription(SensorEntityDescription): """Immutable class for describing withings data.""" @@ -396,7 +423,7 @@ SLEEP_SENSORS = [ ] -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WithingsActivitySensorEntityDescription(SensorEntityDescription): """Immutable class for describing withings data.""" @@ -494,7 +521,7 @@ SLEEP_GOAL = "sleep" WEIGHT_GOAL = "weight" -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WithingsGoalsSensorEntityDescription(SensorEntityDescription): """Immutable class for describing withings data.""" @@ -531,7 +558,7 @@ GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { } -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WithingsWorkoutSensorEntityDescription(SensorEntityDescription): """Immutable class for describing withings data.""" diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index ffbbd9acc2b..a142dd23eac 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -92,6 +92,18 @@ "vascular_age": { "name": "Vascular age" }, + "visceral_fat_index": { + "name": "Visceral fat index" + }, + "electrodermal_activity_feet": { + "name": "Electrodermal activity feet" + }, + "electrodermal_activity_left_foot": { + "name": "Electrodermal activity left foot" + }, + "electrodermal_activity_right_foot": { + "name": "Electrodermal activity right foot" + }, "breathing_disturbances_intensity": { "name": "Breathing disturbances intensity" }, diff --git a/homeassistant/components/wiz/number.py b/homeassistant/components/wiz/number.py index 76c4b197534..91436674d7f 100644 --- a/homeassistant/components/wiz/number.py +++ b/homeassistant/components/wiz/number.py @@ -22,7 +22,7 @@ from .entity import WizEntity from .models import WizData -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WizNumberEntityDescription(NumberEntityDescription): """Class to describe a WiZ number entity.""" diff --git a/homeassistant/components/wled/helpers.py b/homeassistant/components/wled/helpers.py index 85dcf9ca800..b4b5ee4c892 100644 --- a/homeassistant/components/wled/helpers.py +++ b/homeassistant/components/wled/helpers.py @@ -15,7 +15,7 @@ _P = ParamSpec("_P") def wled_exception_handler( - func: Callable[Concatenate[_WLEDEntityT, _P], Coroutine[Any, Any, Any]] + func: Callable[Concatenate[_WLEDEntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_WLEDEntityT, _P], Coroutine[Any, Any, None]]: """Decorate WLED calls to handle WLED exceptions. diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index 9ab5554a6b7..0fa7d464722 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -39,7 +39,7 @@ async def async_setup_entry( update_segments() -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WLEDNumberEntityDescription(NumberEntityDescription): """Class describing WLED number entities.""" diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 64cc3dc2812..709edaf424f 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -31,7 +31,7 @@ from .coordinator import WLEDDataUpdateCoordinator from .models import WLEDEntity -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class WLEDSensorEntityDescription(SensorEntityDescription): """Describes WLED sensor entity.""" diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 455f5d4618a..3000570731b 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -4,12 +4,12 @@ from __future__ import annotations from holidays import HolidayBase, country_holidays, list_supported_countries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LANGUAGE +from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import CONF_COUNTRY, CONF_PROVINCE, DOMAIN, PLATFORMS +from .const import CONF_PROVINCE, DOMAIN, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 2d1030c6b92..bda3a576563 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LANGUAGE, CONF_NAME +from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -21,12 +21,12 @@ from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, ) -from homeassistant.util import dt as dt_util +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.util import dt as dt_util, slugify from .const import ( ALLOWED_DAYS, CONF_ADD_HOLIDAYS, - CONF_COUNTRY, CONF_EXCLUDES, CONF_OFFSET, CONF_PROVINCE, @@ -123,6 +123,25 @@ async def async_setup_entry( LOGGER.debug("Removed %s by name '%s'", holiday, remove_holiday) except KeyError as unmatched: LOGGER.warning("No holiday found matching %s", unmatched) + async_create_issue( + hass, + DOMAIN, + f"bad_named_holiday-{entry.entry_id}-{slugify(remove_holiday)}", + is_fixable=True, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="bad_named_holiday", + translation_placeholders={ + CONF_COUNTRY: country if country else "-", + "title": entry.title, + CONF_REMOVE_HOLIDAYS: remove_holiday, + }, + data={ + "entry_id": entry.entry_id, + "country": country, + "named_holiday": remove_holiday, + }, + ) LOGGER.debug("Found the following holidays for your configuration:") for holiday_date, name in sorted(obj_holidays.items()): diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 348bb0c2fba..859d3710ca4 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigFlow, OptionsFlowWithConfigEntry, ) -from homeassistant.const import CONF_LANGUAGE, CONF_NAME +from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError @@ -33,7 +33,6 @@ from homeassistant.util import dt as dt_util from .const import ( ALLOWED_DAYS, CONF_ADD_HOLIDAYS, - CONF_COUNTRY, CONF_EXCLUDES, CONF_OFFSET, CONF_PROVINCE, @@ -155,6 +154,14 @@ DATA_SCHEMA_SETUP = vol.Schema( DATA_SCHEMA_OPT = vol.Schema( { + vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): SelectSelector( + SelectSelectorConfig( + options=ALLOWED_DAYS, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="days", + ) + ), vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES): SelectSelector( SelectSelectorConfig( options=ALLOWED_DAYS, @@ -166,14 +173,6 @@ DATA_SCHEMA_OPT = vol.Schema( vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): NumberSelector( NumberSelectorConfig(min=-10, max=10, step=1, mode=NumberSelectorMode.BOX) ), - vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): SelectSelector( - SelectSelectorConfig( - options=ALLOWED_DAYS, - multiple=True, - mode=SelectSelectorMode.DROPDOWN, - translation_key="days", - ) - ), vol.Optional(CONF_ADD_HOLIDAYS, default=[]): SelectSelector( SelectSelectorConfig( options=[], diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py index 20905fb9892..ad9375830dd 100644 --- a/homeassistant/components/workday/const.py +++ b/homeassistant/components/workday/const.py @@ -12,7 +12,6 @@ ALLOWED_DAYS = WEEKDAYS + ["holiday"] DOMAIN = "workday" PLATFORMS = [Platform.BINARY_SENSOR] -CONF_COUNTRY = "country" CONF_PROVINCE = "province" CONF_WORKDAYS = "workdays" CONF_EXCLUDES = "excludes" diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index c7c993e70d0..ae7c42c1868 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.36"] + "requirements": ["holidays==0.39"] } diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py index fbed179763e..905434f76ac 100644 --- a/homeassistant/components/workday/repairs.py +++ b/homeassistant/components/workday/repairs.py @@ -18,7 +18,8 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .const import CONF_PROVINCE +from .config_flow import validate_custom_dates +from .const import CONF_PROVINCE, CONF_REMOVE_HOLIDAYS class CountryFixFlow(RepairsFlow): @@ -108,6 +109,76 @@ class CountryFixFlow(RepairsFlow): ) +class HolidayFixFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__( + self, entry: ConfigEntry, country: str | None, named_holiday: str + ) -> None: + """Create flow.""" + self.entry = entry + self.country: str | None = country + self.named_holiday: str = named_holiday + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_named_holiday() + + async def async_step_named_holiday( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the options step of a fix flow.""" + errors: dict[str, str] = {} + if user_input: + options = dict(self.entry.options) + new_options = {**options, **user_input} + try: + await self.hass.async_add_executor_job( + validate_custom_dates, new_options + ) + except Exception: # pylint: disable=broad-except + errors["remove_holidays"] = "remove_holiday_error" + else: + self.hass.config_entries.async_update_entry( + self.entry, options=new_options + ) + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_create_entry(data={}) + + remove_holidays = self.entry.options[CONF_REMOVE_HOLIDAYS] + removed_named_holiday = [ + value for value in remove_holidays if value != self.named_holiday + ] + new_schema = self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Optional(CONF_REMOVE_HOLIDAYS, default=[]): SelectSelector( + SelectSelectorConfig( + options=[], + multiple=True, + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + {CONF_REMOVE_HOLIDAYS: removed_named_holiday}, + ) + return self.async_show_form( + step_id="named_holiday", + data_schema=new_schema, + description_placeholders={ + CONF_COUNTRY: self.country if self.country else "-", + CONF_REMOVE_HOLIDAYS: self.named_holiday, + "title": self.entry.title, + }, + errors=errors, + ) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, @@ -119,6 +190,10 @@ async def async_create_fix_flow( entry_id = cast(str, entry_id) entry = hass.config_entries.async_get_entry(entry_id) + if data and (holiday := data.get("named_holiday")) and entry: + # Bad named holiday in configuration + return HolidayFixFlow(entry, data.get("country"), holiday) + if data and entry: # Country or province does not exist return CountryFixFlow(entry, data.get("country")) diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index 20e7cd26fd6..bbb76676f96 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -23,13 +23,13 @@ "language": "Language for named holidays" }, "data_description": { - "excludes": "List of workdays to exclude", - "days_offset": "Days offset", - "workdays": "List of workdays", + "excludes": "List of workdays to exclude, notice the keyword `holiday` and read the documentation on how to use it correctly", + "days_offset": "Days offset from current day", + "workdays": "List of working days", "add_holidays": "Add custom holidays as YYYY-MM-DD or as range using `,` as separator", "remove_holidays": "Remove holidays as YYYY-MM-DD, as range using `,` as separator or by using partial of name", - "province": "State, Territory, Province, Region of Country", - "language": "Choose the language you want to configure named holidays after" + "province": "State, territory, province or region of country", + "language": "Language to use when configuring named holiday exclusions" } } }, @@ -132,6 +132,26 @@ } } } + }, + "bad_named_holiday": { + "title": "Configured named holiday {remove_holidays} for {title} does not exist", + "fix_flow": { + "step": { + "named_holiday": { + "title": "[%key:component::workday::issues::bad_named_holiday::title%]", + "description": "Remove named holiday `{remove_holidays}` as it can't be found in country {country}.", + "data": { + "remove_holidays": "[%key:component::workday::config::step::options::data::remove_holidays%]" + }, + "data_description": { + "remove_holidays": "[%key:component::workday::config::step::options::data_description::remove_holidays%]" + } + } + }, + "error": { + "remove_holiday_error": "[%key:component::workday::config::error::remove_holiday_error%]" + } + } } }, "entity": { diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index 2cc9b7050a0..88e490d6dc9 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -17,7 +17,12 @@ from .satellite import WyomingSatellite _LOGGER = logging.getLogger(__name__) -SATELLITE_PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SWITCH] +SATELLITE_PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SELECT, + Platform.SWITCH, + Platform.NUMBER, +] __all__ = [ "ATTR_SPEAKER", diff --git a/homeassistant/components/wyoming/devices.py b/homeassistant/components/wyoming/devices.py index 47a5cdc7eb8..6865669fbf0 100644 --- a/homeassistant/components/wyoming/devices.py +++ b/homeassistant/components/wyoming/devices.py @@ -19,10 +19,14 @@ class SatelliteDevice: is_active: bool = False is_muted: bool = False pipeline_name: str | None = None + noise_suppression_level: int = 0 + auto_gain: int = 0 + volume_multiplier: float = 1.0 _is_active_listener: Callable[[], None] | None = None _is_muted_listener: Callable[[], None] | None = None _pipeline_listener: Callable[[], None] | None = None + _audio_settings_listener: Callable[[], None] | None = None @callback def set_is_active(self, active: bool) -> None: @@ -48,6 +52,30 @@ class SatelliteDevice: if self._pipeline_listener is not None: self._pipeline_listener() + @callback + def set_noise_suppression_level(self, noise_suppression_level: int) -> None: + """Set noise suppression level.""" + if noise_suppression_level != self.noise_suppression_level: + self.noise_suppression_level = noise_suppression_level + if self._audio_settings_listener is not None: + self._audio_settings_listener() + + @callback + def set_auto_gain(self, auto_gain: int) -> None: + """Set auto gain amount.""" + if auto_gain != self.auto_gain: + self.auto_gain = auto_gain + if self._audio_settings_listener is not None: + self._audio_settings_listener() + + @callback + def set_volume_multiplier(self, volume_multiplier: float) -> None: + """Set auto gain amount.""" + if volume_multiplier != self.volume_multiplier: + self.volume_multiplier = volume_multiplier + if self._audio_settings_listener is not None: + self._audio_settings_listener() + @callback def set_is_active_listener(self, is_active_listener: Callable[[], None]) -> None: """Listen for updates to is_active.""" @@ -63,6 +91,13 @@ class SatelliteDevice: """Listen for updates to pipeline.""" self._pipeline_listener = pipeline_listener + @callback + def set_audio_settings_listener( + self, audio_settings_listener: Callable[[], None] + ) -> None: + """Listen for updates to audio settings.""" + self._audio_settings_listener = audio_settings_listener + def get_assist_in_progress_entity_id(self, hass: HomeAssistant) -> str | None: """Return entity id for assist in progress binary sensor.""" ent_reg = er.async_get(hass) @@ -83,3 +118,24 @@ class SatelliteDevice: return ent_reg.async_get_entity_id( "select", DOMAIN, f"{self.satellite_id}-pipeline" ) + + def get_noise_suppression_level_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for noise suppression select.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "select", DOMAIN, f"{self.satellite_id}-noise_suppression_level" + ) + + def get_auto_gain_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for auto gain amount.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "number", DOMAIN, f"{self.satellite_id}-auto_gain" + ) + + def get_volume_multiplier_entity_id(self, hass: HomeAssistant) -> str | None: + """Return entity id for microphone volume multiplier.""" + ent_reg = er.async_get(hass) + return ent_reg.async_get_entity_id( + "number", DOMAIN, f"{self.satellite_id}-volume_multiplier" + ) diff --git a/homeassistant/components/wyoming/number.py b/homeassistant/components/wyoming/number.py new file mode 100644 index 00000000000..5e769eeb06d --- /dev/null +++ b/homeassistant/components/wyoming/number.py @@ -0,0 +1,102 @@ +"""Number entities for Wyoming integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from homeassistant.components.number import NumberEntityDescription, RestoreNumber +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 .entity import WyomingSatelliteEntity + +if TYPE_CHECKING: + from .models import DomainDataItem + +_MAX_AUTO_GAIN: Final = 31 +_MIN_VOLUME_MULTIPLIER: Final = 0.1 +_MAX_VOLUME_MULTIPLIER: Final = 10.0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Wyoming number entities.""" + item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] + + # Setup is only forwarded for satellites + assert item.satellite is not None + + device = item.satellite.device + async_add_entities( + [ + WyomingSatelliteAutoGainNumber(device), + WyomingSatelliteVolumeMultiplierNumber(device), + ] + ) + + +class WyomingSatelliteAutoGainNumber(WyomingSatelliteEntity, RestoreNumber): + """Entity to represent auto gain amount.""" + + entity_description = NumberEntityDescription( + key="auto_gain", + translation_key="auto_gain", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_native_min_value = 0 + _attr_native_max_value = _MAX_AUTO_GAIN + _attr_native_value = 0 + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state is not None: + await self.async_set_native_value(float(state.state)) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + auto_gain = int(max(0, min(_MAX_AUTO_GAIN, value))) + self._attr_native_value = auto_gain + self.async_write_ha_state() + self._device.set_auto_gain(auto_gain) + + +class WyomingSatelliteVolumeMultiplierNumber(WyomingSatelliteEntity, RestoreNumber): + """Entity to represent microphone volume multiplier.""" + + entity_description = NumberEntityDescription( + key="volume_multiplier", + translation_key="volume_multiplier", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_native_min_value = _MIN_VOLUME_MULTIPLIER + _attr_native_max_value = _MAX_VOLUME_MULTIPLIER + _attr_native_step = 0.1 + _attr_native_value = 1.0 + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + last_number_data = await self.async_get_last_number_data() + if (last_number_data is not None) and ( + last_number_data.native_value is not None + ): + await self.async_set_native_value(last_number_data.native_value) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + self._attr_native_value = float( + max(_MIN_VOLUME_MULTIPLIER, min(_MAX_VOLUME_MULTIPLIER, value)) + ) + self.async_write_ha_state() + self._device.set_volume_multiplier(self._attr_native_value) diff --git a/homeassistant/components/wyoming/satellite.py b/homeassistant/components/wyoming/satellite.py index 16240cb625b..78f57ff4b01 100644 --- a/homeassistant/components/wyoming/satellite.py +++ b/homeassistant/components/wyoming/satellite.py @@ -60,6 +60,7 @@ class WyomingSatellite: self.device.set_is_muted_listener(self._muted_changed) self.device.set_pipeline_listener(self._pipeline_changed) + self.device.set_audio_settings_listener(self._audio_settings_changed) async def run(self) -> None: """Run and maintain a connection to satellite.""" @@ -135,6 +136,12 @@ class WyomingSatellite: # Cancel any running pipeline self._audio_queue.put_nowait(None) + def _audio_settings_changed(self) -> None: + """Run when device audio settings.""" + + # Cancel any running pipeline + self._audio_queue.put_nowait(None) + async def _run_once(self) -> None: """Run pipelines until an error occurs.""" self.device.set_is_active(False) @@ -227,6 +234,11 @@ class WyomingSatellite: end_stage=end_stage, tts_audio_output="wav", pipeline_id=pipeline_id, + audio_settings=assist_pipeline.AudioSettings( + noise_suppression_level=self.device.noise_suppression_level, + auto_gain_dbfs=self.device.auto_gain, + volume_multiplier=self.device.volume_multiplier, + ), device_id=self.device.device_id, ) ) diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py index 2929ae79fa0..c04bad4bef8 100644 --- a/homeassistant/components/wyoming/select.py +++ b/homeassistant/components/wyoming/select.py @@ -1,12 +1,15 @@ -"""Select entities for VoIP integration.""" +"""Select entities for Wyoming integration.""" from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from homeassistant.components.assist_pipeline.select import AssistPipelineSelect +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.helpers import restore_state from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -16,19 +19,34 @@ from .entity import WyomingSatelliteEntity if TYPE_CHECKING: from .models import DomainDataItem +_NOISE_SUPPRESSION_LEVEL: Final = { + "off": 0, + "low": 1, + "medium": 2, + "high": 3, + "max": 4, +} +_DEFAULT_NOISE_SUPPRESSION_LEVEL: Final = "off" + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up VoIP switch entities.""" + """Set up Wyoming select entities.""" item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id] # Setup is only forwarded for satellites assert item.satellite is not None - async_add_entities([WyomingSatellitePipelineSelect(hass, item.satellite.device)]) + device = item.satellite.device + async_add_entities( + [ + WyomingSatellitePipelineSelect(hass, device), + WyomingSatelliteNoiseSuppressionLevelSelect(device), + ] + ) class WyomingSatellitePipelineSelect(WyomingSatelliteEntity, AssistPipelineSelect): @@ -45,3 +63,32 @@ class WyomingSatellitePipelineSelect(WyomingSatelliteEntity, AssistPipelineSelec """Select an option.""" await super().async_select_option(option) self.device.set_pipeline_name(option) + + +class WyomingSatelliteNoiseSuppressionLevelSelect( + WyomingSatelliteEntity, SelectEntity, restore_state.RestoreEntity +): + """Entity to represent noise suppression level setting.""" + + entity_description = SelectEntityDescription( + key="noise_suppression_level", + translation_key="noise_suppression_level", + entity_category=EntityCategory.CONFIG, + ) + _attr_should_poll = False + _attr_current_option = _DEFAULT_NOISE_SUPPRESSION_LEVEL + _attr_options = list(_NOISE_SUPPRESSION_LEVEL.keys()) + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + + state = await self.async_get_last_state() + if state is not None and state.state in self.options: + self._attr_current_option = state.state + + async def async_select_option(self, option: str) -> None: + """Select an option.""" + self._attr_current_option = option + self.async_write_ha_state() + self._device.set_noise_suppression_level(_NOISE_SUPPRESSION_LEVEL[option]) diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json index c7ae63e7b95..f2768e45eb8 100644 --- a/homeassistant/components/wyoming/strings.json +++ b/homeassistant/components/wyoming/strings.json @@ -37,14 +37,29 @@ "preferred": "[%key:component::assist_pipeline::entity::select::pipeline::state::preferred%]" } }, - "noise_suppression": { - "name": "Noise suppression" + "noise_suppression_level": { + "name": "Noise suppression level", + "state": { + "off": "Off", + "low": "Low", + "medium": "Medium", + "high": "High", + "max": "Max" + } } }, "switch": { "mute": { "name": "Mute" } + }, + "number": { + "auto_gain": { + "name": "Auto gain" + }, + "volume_multiplier": { + "name": "Mic volume" + } } } } diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 3c316fd3f47..716d4a04fa7 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -35,7 +35,7 @@ from miio import ( from miio.gateway.gateway import GatewayException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN, Platform +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -43,7 +43,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ( ATTR_AVAILABLE, - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 30fcaa5152a..f9248ba5ff3 100644 --- a/homeassistant/components/xiaomi_miio/air_quality.py +++ b/homeassistant/components/xiaomi_miio/air_quality.py @@ -6,12 +6,11 @@ from miio import AirQualityMonitor, AirQualityMonitorCGDN1, DeviceException from homeassistant.components.air_quality import AirQualityEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, MODEL_AIRQUALITYMONITOR_B1, MODEL_AIRQUALITYMONITOR_CGDN1, diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py index 051ac2ab778..e1b06175493 100644 --- a/homeassistant/components/xiaomi_miio/binary_sensor.py +++ b/homeassistant/components/xiaomi_miio/binary_sensor.py @@ -11,13 +11,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODEL, EntityCategory +from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import VacuumCoordinatorDataAttributes from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, KEY_COORDINATOR, @@ -45,7 +44,7 @@ ATTR_WATER_BOX_ATTACHED = "is_water_box_attached" ATTR_WATER_SHORTAGE = "is_water_shortage" -@dataclass +@dataclass(frozen=True) class XiaomiMiioBinarySensorDescription(BinarySensorEntityDescription): """A class that describes binary sensor entities.""" diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index e5e11b85e58..4ebbf34f295 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -37,7 +37,7 @@ ATTR_RESET_VACUUM_FILTER = "reset_vacuum_filter" ATTR_RESET_VACUUM_SENSOR_DIRTY = "reset_vacuum_sensor_dirty" -@dataclass +@dataclass(frozen=True) class XiaomiMiioButtonDescription(ButtonEntityDescription): """A class that describes button entities.""" diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index 70e6fb5c0b6..02e88c6b14e 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.device_registry import format_mac @@ -23,10 +23,8 @@ from .const import ( CONF_CLOUD_PASSWORD, CONF_CLOUD_SUBDEVICES, CONF_CLOUD_USERNAME, - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, - CONF_MAC, CONF_MANUAL, DEFAULT_CLOUD_COUNTRY, DOMAIN, diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 6621e41e7aa..ef9668dbee4 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -18,8 +18,6 @@ DOMAIN = "xiaomi_miio" # Config flow CONF_FLOW_TYPE = "config_flow_device" CONF_GATEWAY = "gateway" -CONF_DEVICE = "device" -CONF_MAC = "mac" CONF_CLOUD_USERNAME = "cloud_username" CONF_CLOUD_PASSWORD = "cloud_password" CONF_CLOUD_COUNTRY = "cloud_country" diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py index da860c7045e..0c87f74a7e6 100644 --- a/homeassistant/components/xiaomi_miio/device.py +++ b/homeassistant/components/xiaomi_miio/device.py @@ -8,7 +8,7 @@ from typing import Any, TypeVar from construct.core import ChecksumError from miio import Device, DeviceException -from homeassistant.const import ATTR_CONNECTIONS, CONF_MODEL +from homeassistant.const import ATTR_CONNECTIONS, CONF_MAC, CONF_MODEL from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -17,7 +17,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import CONF_MAC, DOMAIN, AuthException, SetupException +from .const import DOMAIN, AuthException, SetupException _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 9be019ed724..30383426210 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -30,7 +30,7 @@ import voluptuous as vol from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, CONF_MODEL +from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -40,7 +40,6 @@ from homeassistant.util.percentage import ( ) from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, FEATURE_FLAGS_AIRFRESH, diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py index 0438b606efd..f2660bef68a 100644 --- a/homeassistant/components/xiaomi_miio/humidifier.py +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -20,13 +20,12 @@ from homeassistant.components.humidifier import ( HumidifierEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODE, CONF_MODEL +from homeassistant.const import ATTR_MODE, CONF_DEVICE, CONF_MODEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import percentage_to_ranged_value from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, KEY_COORDINATOR, diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index 1fc032b5c36..8d198ae2a8f 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -33,7 +33,13 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE, + CONF_HOST, + CONF_MODEL, + CONF_TOKEN, +) from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceInfo @@ -41,7 +47,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import color, dt as dt_util from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index a8346caa894..2660a1b2be1 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -13,6 +13,7 @@ from homeassistant.components.number import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE, CONF_MODEL, DEGREE, REVOLUTIONS_PER_MINUTE, @@ -25,7 +26,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, FEATURE_FLAGS_AIRFRESH, @@ -108,14 +108,14 @@ ATTR_OSCILLATION_ANGLE = "angle" ATTR_VOLUME = "volume" -@dataclass +@dataclass(frozen=True) class XiaomiMiioNumberMixin: """A class that describes number entities.""" method: str -@dataclass +@dataclass(frozen=True) class XiaomiMiioNumberDescription(NumberEntityDescription, XiaomiMiioNumberMixin): """A class that describes number entities.""" diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py index 74ce36ca57a..b70dab1921a 100644 --- a/homeassistant/components/xiaomi_miio/select.py +++ b/homeassistant/components/xiaomi_miio/select.py @@ -29,12 +29,11 @@ from miio.integrations.humidifier.zhimi.airhumidifier_miot import ( from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODEL, EntityCategory +from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, KEY_COORDINATOR, @@ -72,7 +71,7 @@ ATTR_MODE = "mode" _LOGGER = logging.getLogger(__name__) -@dataclass +@dataclass(frozen=True) class XiaomiMiioSelectDescription(SelectEntityDescription): """A class that describes select entities.""" diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 17d60e1a952..a8435d6a8a1 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -28,6 +28,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN, @@ -48,7 +49,6 @@ from homeassistant.util import dt as dt_util from . import VacuumCoordinatorDataAttributes from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, @@ -150,7 +150,7 @@ ATTR_CONSUMABLE_STATUS_FILTER_LEFT = "filter_left" ATTR_CONSUMABLE_STATUS_SENSOR_DIRTY_LEFT = "sensor_dirty_left" -@dataclass +@dataclass(frozen=True) class XiaomiMiioSensorDescription(SensorEntityDescription): """Class that holds device specific info for a xiaomi aqara or humidifier sensor.""" diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index 9bba9f61123..68714f1a6ff 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -21,6 +21,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, ATTR_TEMPERATURE, + CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN, @@ -31,7 +32,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, @@ -219,7 +219,7 @@ MODEL_TO_FEATURES_MAP = { } -@dataclass +@dataclass(frozen=True) class XiaomiMiioSwitchRequiredKeyMixin: """A class that describes switch entities.""" @@ -228,7 +228,7 @@ class XiaomiMiioSwitchRequiredKeyMixin: method_off: str -@dataclass +@dataclass(frozen=True) class XiaomiMiioSwitchDescription( SwitchEntityDescription, XiaomiMiioSwitchRequiredKeyMixin ): diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 34a7b949646..73e2e54b62f 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -19,6 +19,7 @@ from homeassistant.components.vacuum import ( VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -27,7 +28,6 @@ from homeassistant.util.dt import as_utc from . import VacuumCoordinatorData from .const import ( - CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, KEY_COORDINATOR, diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index 7ced3487269..31851ad3ceb 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -83,7 +83,13 @@ class YaleAlarmDevice(YaleAlarmEntity, AlarmControlPanelEntity): except YALE_ALL_ERRORS as error: raise HomeAssistantError( f"Could not set alarm for {self.coordinator.entry.data[CONF_NAME]}:" - f" {error}" + f" {error}", + translation_domain=DOMAIN, + translation_key="set_alarm", + translation_placeholders={ + "name": self.coordinator.entry.data[CONF_NAME], + "error": str(error), + }, ) from error if alarm_state: @@ -91,7 +97,9 @@ class YaleAlarmDevice(YaleAlarmEntity, AlarmControlPanelEntity): self.async_write_ha_state() return raise HomeAssistantError( - "Could not change alarm check system ready for arming." + "Could not change alarm, check system ready for arming", + translation_domain=DOMAIN, + translation_key="could_not_change_alarm", ) @property diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 50d7b28c52b..c5a9bb79ba8 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -79,14 +79,24 @@ class YaleDoorlock(YaleEntity, LockEntity): ) except YALE_ALL_ERRORS as error: raise HomeAssistantError( - f"Could not set lock for {self.lock_name}: {error}" + f"Could not set lock for {self.lock_name}: {error}", + translation_domain=DOMAIN, + translation_key="set_lock", + translation_placeholders={ + "name": self.lock_name, + "error": str(error), + }, ) from error if lock_state: self.coordinator.data["lock_map"][self._attr_unique_id] = command self.async_write_ha_state() return - raise HomeAssistantError("Could not set lock, check system ready for lock.") + raise HomeAssistantError( + "Could not set lock, check system ready for lock", + translation_domain=DOMAIN, + translation_key="could_not_change_lock", + ) @property def is_locked(self) -> bool | None: diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index a51d151d7d9..a698da20d8d 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -56,5 +56,19 @@ "name": "Panic button" } } + }, + "exceptions": { + "set_alarm": { + "message": "Could not set alarm for {name}: {error}" + }, + "could_not_change_alarm": { + "message": "Could not change alarm, check system ready for arming" + }, + "set_lock": { + "message": "Could not set lock for {name}: {error}" + }, + "could_not_change_lock": { + "message": "Could not set lock, check system ready for lock" + } } } diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index 11516015b6c..b5683777c24 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -10,6 +10,7 @@ from yalexs_ble import ( LockState, PushLock, YaleXSBLEError, + close_stale_connections_by_address, local_name_is_unique, ) @@ -47,6 +48,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: id_ = local_name if has_unique_local_name else address push_lock.set_name(f"{entry.title} ({id_})") + # Ensure any lingering connections are closed since the device may not be + # advertising when its connected to another client which will prevent us + # from setting the device and setup will fail. + await close_stale_connections_by_address(address) + @callback def _async_update_ble( service_info: bluetooth.BluetoothServiceInfoBleak, diff --git a/homeassistant/components/yalexs_ble/lock.py b/homeassistant/components/yalexs_ble/lock.py index d457784a038..f6fa1917d7e 100644 --- a/homeassistant/components/yalexs_ble/lock.py +++ b/homeassistant/components/yalexs_ble/lock.py @@ -40,17 +40,19 @@ class YaleXSBLELock(YALEXSBLEEntity, LockEntity): self._attr_is_unlocking = False self._attr_is_jammed = False lock_state = new_state.lock - if lock_state == LockStatus.LOCKED: + if lock_state is LockStatus.LOCKED: self._attr_is_locked = True - elif lock_state == LockStatus.LOCKING: + elif lock_state is LockStatus.LOCKING: self._attr_is_locking = True - elif lock_state == LockStatus.UNLOCKING: + elif lock_state is LockStatus.UNLOCKING: self._attr_is_unlocking = True elif lock_state in ( LockStatus.UNKNOWN_01, LockStatus.UNKNOWN_06, ): self._attr_is_jammed = True + elif lock_state is LockStatus.UNKNOWN: + self._attr_is_locked = None super()._async_update_state(new_state, lock_info, connection_info) async def async_unlock(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index be388ec563c..dcd7e57ce1f 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.3.2"] + "requirements": ["yalexs-ble==2.4.0"] } diff --git a/homeassistant/components/yalexs_ble/sensor.py b/homeassistant/components/yalexs_ble/sensor.py index 9d702ff52eb..da698d1b501 100644 --- a/homeassistant/components/yalexs_ble/sensor.py +++ b/homeassistant/components/yalexs_ble/sensor.py @@ -27,14 +27,14 @@ from .entity import YALEXSBLEEntity from .models import YaleXSBLEData -@dataclass +@dataclass(frozen=True) class YaleXSBLERequiredKeysMixin: """Mixin for required keys.""" value_fn: Callable[[LockState, LockInfo, ConnectionInfo], int | float | None] -@dataclass +@dataclass(frozen=True) class YaleXSBLESensorEntityDescription( SensorEntityDescription, YaleXSBLERequiredKeysMixin ): diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index b3bc0c30bf4..4881d8c576d 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.14", "async-upnp-client==0.36.2"], + "requirements": ["yeelight==0.7.14", "async-upnp-client==0.38.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index e65896cdd42..0650cc3a203 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -28,7 +28,7 @@ from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity -@dataclass +@dataclass(frozen=True) class YoLinkBinarySensorEntityDescription(BinarySensorEntityDescription): """YoLink BinarySensorEntityDescription.""" diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 2fc4a2b0725..ace13353341 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -48,21 +48,13 @@ from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity -@dataclass -class YoLinkSensorEntityDescriptionMixin: - """Mixin for device type.""" - - exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True - - -@dataclass -class YoLinkSensorEntityDescription( - YoLinkSensorEntityDescriptionMixin, SensorEntityDescription -): +@dataclass(frozen=True, kw_only=True) +class YoLinkSensorEntityDescription(SensorEntityDescription): """YoLink SensorEntityDescription.""" - value: Callable = lambda state: state + exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True should_update_entity: Callable = lambda state: True + value: Callable = lambda state: state SENSOR_DEVICE_TYPE = [ diff --git a/homeassistant/components/yolink/siren.py b/homeassistant/components/yolink/siren.py index 81c2b46a840..4a35e9506e9 100644 --- a/homeassistant/components/yolink/siren.py +++ b/homeassistant/components/yolink/siren.py @@ -23,7 +23,7 @@ from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity -@dataclass +@dataclass(frozen=True) class YoLinkSirenEntityDescription(SirenEntityDescription): """YoLink SirenEntityDescription.""" diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index 018fcb84988..69a958ba6d1 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -29,7 +29,7 @@ from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity -@dataclass +@dataclass(frozen=True) class YoLinkSwitchEntityDescription(SwitchEntityDescription): """YoLink SwitchEntityDescription.""" diff --git a/homeassistant/components/youtube/const.py b/homeassistant/components/youtube/const.py index 7404cd04665..63c4480c007 100644 --- a/homeassistant/components/youtube/const.py +++ b/homeassistant/components/youtube/const.py @@ -7,7 +7,6 @@ MANUFACTURER = "Google, Inc." CHANNEL_CREATION_HELP_URL = "https://support.google.com/youtube/answer/1646861" CONF_CHANNELS = "channels" -CONF_ID = "id" CONF_UPLOAD_PLAYLIST = "upload_playlist_id" COORDINATOR = "coordinator" AUTH = "auth" diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index 99cd3ecf095..d037a8c3c4b 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -26,7 +26,7 @@ from .const import ( from .entity import YouTubeChannelEntity -@dataclass +@dataclass(frozen=True) class YouTubeMixin: """Mixin for required keys.""" @@ -36,7 +36,7 @@ class YouTubeMixin: attributes_fn: Callable[[Any], dict[str, Any] | None] | None -@dataclass +@dataclass(frozen=True) class YouTubeSensorEntityDescription(SensorEntityDescription, YouTubeMixin): """Describes YouTube sensor entity.""" diff --git a/homeassistant/components/zamg/const.py b/homeassistant/components/zamg/const.py index e1733600f59..ea1e91d6149 100644 --- a/homeassistant/components/zamg/const.py +++ b/homeassistant/components/zamg/const.py @@ -14,13 +14,11 @@ LOGGER = logging.getLogger(__package__) ATTR_STATION = "station" ATTR_UPDATED = "updated" -ATTRIBUTION = "Data provided by ZAMG" +ATTRIBUTION = "Data provided by GeoSphere Austria" CONF_STATION_ID = "station_id" -DEFAULT_NAME = "zamg" - -MANUFACTURER_URL = "https://www.zamg.ac.at" +MANUFACTURER_URL = "https://www.geosphere.at" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) VIENNA_TIME_ZONE = dt_util.get_time_zone("Europe/Vienna") diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index f83e38002b8..e7fe584c767 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -1,6 +1,6 @@ { "domain": "zamg", - "name": "Zentralanstalt f\u00fcr Meteorologie und Geodynamik (ZAMG)", + "name": "GeoSphere Austria", "codeowners": ["@killer0071234"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zamg", diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 31275dd908d..adc07212a5f 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -37,14 +37,14 @@ from .const import ( from .coordinator import ZamgDataUpdateCoordinator -@dataclass +@dataclass(frozen=True) class ZamgRequiredKeysMixin: """Mixin for required keys.""" para_name: str -@dataclass +@dataclass(frozen=True) class ZamgSensorEntityDescription(SensorEntityDescription, ZamgRequiredKeysMixin): """Describes Zamg sensor entity.""" @@ -202,7 +202,7 @@ class ZamgSensor(CoordinatorEntity, SensorEntity): identifiers={(DOMAIN, station_id)}, manufacturer=ATTRIBUTION, configuration_url=MANUFACTURER_URL, - name=coordinator.name, + name=name, ) coordinator.api_fields = API_FIELDS diff --git a/homeassistant/components/zamg/strings.json b/homeassistant/components/zamg/strings.json index a92e7aa605e..6ffc489bdf5 100644 --- a/homeassistant/components/zamg/strings.json +++ b/homeassistant/components/zamg/strings.json @@ -3,7 +3,7 @@ "flow_title": "{name}", "step": { "user": { - "description": "Set up ZAMG to integrate with Home Assistant.", + "description": "Set up GeoSphere Austria to integrate with Home Assistant.", "data": { "station_id": "Station ID (Defaults to nearest station)" } @@ -11,7 +11,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "station_not_found": "Station ID not found at zamg" + "station_not_found": "Station ID not found at GeoSphere Austria" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index 98e08106dca..e855bde29d8 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -43,14 +43,14 @@ class ZamgWeather(CoordinatorEntity, WeatherEntity): """Initialise the platform with a data instance and station name.""" super().__init__(coordinator) self._attr_unique_id = station_id - self._attr_name = f"ZAMG {name}" + self._attr_name = name self.station_id = f"{station_id}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, station_id)}, manufacturer=ATTRIBUTION, configuration_url=MANUFACTURER_URL, - name=coordinator.name, + name=name, ) @property diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index bf0984d3989..e12a7599d4d 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -20,7 +20,7 @@ from zeroconf import ( IPVersion, ServiceStateChange, ) -from zeroconf.asyncio import AsyncServiceInfo +from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo from homeassistant import config_entries from homeassistant.components import network @@ -33,13 +33,14 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import ( HomeKitDiscoveredIntegration, + ZeroconfMatcher, async_get_homekit, async_get_zeroconf, bind_hass, ) from homeassistant.setup import async_when_setup_or_start -from .models import HaAsyncServiceBrowser, HaAsyncZeroconf, HaZeroconf +from .models import HaAsyncZeroconf, HaZeroconf from .usage import install_multiple_zeroconf_catcher _LOGGER = logging.getLogger(__name__) @@ -54,9 +55,6 @@ HOMEKIT_TYPES = [ ] _HOMEKIT_MODEL_SPLITS = (None, " ", "-") -# Top level keys we support matching against in properties that are always matched in -# lower case. ex: ZeroconfServiceInfo.name -LOWER_MATCH_ATTRS = {"name"} CONF_DEFAULT_INTERFACE = "default_interface" CONF_IPV6 = "ipv6" @@ -74,6 +72,8 @@ MAX_PROPERTY_VALUE_LEN = 230 # Dns label max length MAX_NAME_LEN = 63 +ATTR_DOMAIN: Final = "domain" +ATTR_NAME: Final = "name" ATTR_PROPERTIES: Final = "properties" # Attributes for ZeroconfServiceInfo[ATTR_PROPERTIES] @@ -128,12 +128,12 @@ class ZeroconfServiceInfo(BaseServiceInfo): @property def host(self) -> str: """Return the host.""" - return _stringify_ip_address(self.ip_address) + return str(self.ip_address) @property def addresses(self) -> list[str]: """Return the addresses.""" - return [_stringify_ip_address(ip_address) for ip_address in self.ip_addresses] + return [str(ip_address) for ip_address in self.ip_addresses] @bind_hass @@ -227,7 +227,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: zeroconf_types, homekit_model_lookup, homekit_model_matchers, - ipv6, ) await discovery.async_setup() @@ -249,7 +248,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: def _build_homekit_model_lookups( - homekit_models: dict[str, HomeKitDiscoveredIntegration] + homekit_models: dict[str, HomeKitDiscoveredIntegration], ) -> tuple[ dict[str, HomeKitDiscoveredIntegration], dict[re.Pattern, HomeKitDiscoveredIntegration], @@ -320,30 +319,13 @@ async def _async_register_hass_zc_service( await aio_zc.async_register_service(info, allow_name_change=True) -def _match_against_data( - matcher: dict[str, str | dict[str, str]], match_data: dict[str, str] -) -> bool: - """Check a matcher to ensure all values in match_data match.""" - for key in LOWER_MATCH_ATTRS: - if key not in matcher: - continue - if key not in match_data: - return False - match_val = matcher[key] - if TYPE_CHECKING: - assert isinstance(match_val, str) - - if not _memorized_fnmatch(match_data[key], match_val): - return False - return True - - -def _match_against_props(matcher: dict[str, str], props: dict[str, str]) -> bool: +def _match_against_props(matcher: dict[str, str], props: dict[str, str | None]) -> bool: """Check a matcher to ensure all values in props.""" return not any( key for key in matcher - if key not in props or not _memorized_fnmatch(props[key].lower(), matcher[key]) + if key not in props + or not _memorized_fnmatch((props[key] or "").lower(), matcher[key]) ) @@ -365,10 +347,9 @@ class ZeroconfDiscovery: self, hass: HomeAssistant, zeroconf: HaZeroconf, - zeroconf_types: dict[str, list[dict[str, str | dict[str, str]]]], + zeroconf_types: dict[str, list[ZeroconfMatcher]], homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], - ipv6: bool, ) -> None: """Init discovery.""" self.hass = hass @@ -376,10 +357,7 @@ class ZeroconfDiscovery: self.zeroconf_types = zeroconf_types self.homekit_model_lookups = homekit_model_lookups self.homekit_model_matchers = homekit_model_matchers - - self.ipv6 = ipv6 - - self.async_service_browser: HaAsyncServiceBrowser | None = None + self.async_service_browser: AsyncServiceBrowser | None = None async def async_setup(self) -> None: """Start discovery.""" @@ -391,8 +369,8 @@ class ZeroconfDiscovery: if hk_type not in self.zeroconf_types: types.append(hk_type) _LOGGER.debug("Starting Zeroconf browser for: %s", types) - self.async_service_browser = HaAsyncServiceBrowser( - self.ipv6, self.zeroconf, types, handlers=[self.async_service_update] + self.async_service_browser = AsyncServiceBrowser( + self.zeroconf, types, handlers=[self.async_service_update] ) async def async_stop(self) -> None: @@ -467,7 +445,7 @@ class ZeroconfDiscovery: _LOGGER.debug("Failed to get addresses for device %s", name) return _LOGGER.debug("Discovered new device %s %s", name, info) - props: dict[str, str] = info.properties + props: dict[str, str | None] = info.properties domain = None # If we can handle it as a HomeKit discovery, we do that here. @@ -500,27 +478,23 @@ class ZeroconfDiscovery: # discover it, we can stop here. return - match_data: dict[str, str] = {} - for key in LOWER_MATCH_ATTRS: - attr_value: str = getattr(info, key) - match_data[key] = attr_value.lower() + if not (matchers := self.zeroconf_types.get(service_type)): + return # Not all homekit types are currently used for discovery # so not all service type exist in zeroconf_types - for matcher in self.zeroconf_types.get(service_type, []): + for matcher in matchers: if len(matcher) > 1: - if not _match_against_data(matcher, match_data): + if ATTR_NAME in matcher and not _memorized_fnmatch( + info.name.lower(), matcher[ATTR_NAME] + ): + continue + if ATTR_PROPERTIES in matcher and not _match_against_props( + matcher[ATTR_PROPERTIES], props + ): continue - if ATTR_PROPERTIES in matcher: - matcher_props = matcher[ATTR_PROPERTIES] - if TYPE_CHECKING: - assert isinstance(matcher_props, dict) - if not _match_against_props(matcher_props, props): - continue - matcher_domain = matcher["domain"] - if TYPE_CHECKING: - assert isinstance(matcher_domain, str) + matcher_domain = matcher[ATTR_DOMAIN] context = { "source": config_entries.SOURCE_ZEROCONF, } @@ -563,10 +537,6 @@ def async_get_homekit_discovery( return None -# matches to the cache in zeroconf itself -_stringify_ip_address = lru_cache(maxsize=256)(str) - - def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: """Return prepared info from mDNS entries.""" # See https://ietf.org/rfc/rfc6763.html#section-6.4 and @@ -586,19 +556,10 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: if not ip_address: return None - # Service properties are always bytes if they are set from the network. - # For legacy backwards compatibility zeroconf allows properties to be set - # as strings but we never do that so we can safely cast here. - service_properties = cast(dict[bytes, bytes | None], service.properties) - - properties: dict[str, Any] = { - k.decode("ascii", "replace"): None - if v is None - else v.decode("utf-8", "replace") - for k, v in service_properties.items() - } - - assert service.server is not None, "server cannot be none if there are addresses" + if TYPE_CHECKING: + assert ( + service.server is not None + ), "server cannot be none if there are addresses" return ZeroconfServiceInfo( ip_address=ip_address, ip_addresses=ip_addresses, @@ -606,7 +567,7 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: hostname=service.server, type=service.type, name=service.name, - properties=properties, + properties=service.decoded_properties, ) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index d78f33f0d91..aecc88968f3 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.128.5"] + "requirements": ["zeroconf==0.131.0"] } diff --git a/homeassistant/components/zeroconf/models.py b/homeassistant/components/zeroconf/models.py index ffa5e1a2ecf..7393e699b51 100644 --- a/homeassistant/components/zeroconf/models.py +++ b/homeassistant/components/zeroconf/models.py @@ -1,11 +1,7 @@ """Models for Zeroconf.""" -from typing import Any - -from zeroconf import DNSAddress, DNSRecord, Zeroconf -from zeroconf.asyncio import AsyncServiceBrowser, AsyncZeroconf - -TYPE_AAAA = 28 +from zeroconf import Zeroconf +from zeroconf.asyncio import AsyncZeroconf class HaZeroconf(Zeroconf): @@ -24,22 +20,3 @@ class HaAsyncZeroconf(AsyncZeroconf): """Fake method to avoid integrations closing it.""" ha_async_close = AsyncZeroconf.async_close - - -class HaAsyncServiceBrowser(AsyncServiceBrowser): - """ServiceBrowser that only consumes DNSPointer records.""" - - def __init__(self, ipv6: bool, *args: Any, **kwargs: Any) -> None: - """Create service browser that filters ipv6 if it is disabled.""" - self.ipv6 = ipv6 - super().__init__(*args, **kwargs) - - def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None: - """Pre-Filter AAAA records if IPv6 is not enabled.""" - if ( - not self.ipv6 - and isinstance(record, DNSAddress) - and record.type == TYPE_AAAA - ): - return - super().update_record(zc, now, record) diff --git a/homeassistant/components/zeversolar/sensor.py b/homeassistant/components/zeversolar/sensor.py index ee9aa5531c8..9e2333a1e24 100644 --- a/homeassistant/components/zeversolar/sensor.py +++ b/homeassistant/components/zeversolar/sensor.py @@ -22,14 +22,14 @@ from .coordinator import ZeversolarCoordinator from .entity import ZeversolarEntity -@dataclass +@dataclass(frozen=True) class ZeversolarEntityDescriptionMixin: """Mixin for required keys.""" value_fn: Callable[[zeversolar.ZeverSolarData], zeversolar.kWh | zeversolar.Watt] -@dataclass +@dataclass(frozen=True) class ZeversolarEntityDescription( SensorEntityDescription, ZeversolarEntityDescriptionMixin ): diff --git a/homeassistant/components/zha/core/cluster_handlers/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py index dad3ee5eb4d..5e41785a6d8 100644 --- a/homeassistant/components/zha/core/cluster_handlers/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -110,6 +110,7 @@ class ThermostatClusterHandler(ClusterHandler): "max_heat_setpoint_limit": True, "min_cool_setpoint_limit": True, "min_heat_setpoint_limit": True, + "local_temperature_calibration": True, } @property diff --git a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py index 99c1e954a0e..57f1e2ee304 100644 --- a/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py +++ b/homeassistant/components/zha/core/cluster_handlers/manufacturerspecific.py @@ -5,8 +5,9 @@ import logging from typing import TYPE_CHECKING, Any from zhaquirks.inovelli.types import AllLEDEffectType, SingleLEDEffectType -from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER +from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, XIAOMI_AQARA_VIBRATION_AQ1 import zigpy.zcl +from zigpy.zcl.clusters.closures import DoorLock from homeassistant.core import callback @@ -24,6 +25,7 @@ from ..const import ( UNKNOWN, ) from . import AttrReportConfig, ClientClusterHandler, ClusterHandler +from .general import MultistateInput if TYPE_CHECKING: from ..endpoint import Endpoint @@ -147,6 +149,17 @@ class OppleRemote(ClusterHandler): "buzzer": True, "linkage_alarm": True, } + elif self.cluster.endpoint.model == "lumi.magnet.ac01": + self.ZCL_INIT_ATTRS = { + "detection_distance": True, + } + elif self.cluster.endpoint.model == "lumi.switch.acn047": + self.ZCL_INIT_ATTRS = { + "switch_mode": True, + "switch_type": True, + "startup_on_off": True, + "decoupled_mode": True, + } async def async_initialize_cluster_handler_specific(self, from_cache: bool) -> None: """Initialize cluster handler specific.""" @@ -403,3 +416,10 @@ class IkeaRemote(ClusterHandler): """Ikea Matter remote cluster handler.""" REPORT_CONFIG = () + + +@registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.register( + DoorLock.cluster_id, XIAOMI_AQARA_VIBRATION_AQ1 +) +class XiaomiVibrationAQ1ClusterHandler(MultistateInput): + """Xiaomi DoorLock Cluster is in fact a MultiStateInput Cluster.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py index 8fd38425dff..2ceaeaf1013 100644 --- a/homeassistant/components/zha/core/cluster_handlers/smartenergy.py +++ b/homeassistant/components/zha/core/cluster_handlers/smartenergy.py @@ -195,9 +195,9 @@ class Metering(ClusterHandler): ) # 1 digit to the right, 15 digits to the left self._summa_format = self.get_formatting(fmting) - async def async_force_update(self) -> None: + async def async_update(self) -> None: """Retrieve latest state.""" - self.debug("async_force_update") + self.debug("async_update") attrs = [ a["attr"] diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 7e591a596e5..ecbd347a621 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -139,6 +139,7 @@ CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" +CONF_USE_THREAD = "use_thread" CONF_ZIGPY = "zigpy_config" CONF_CONSIDER_UNAVAILABLE_MAINS = "consider_unavailable_mains" diff --git a/homeassistant/components/zha/core/decorators.py b/homeassistant/components/zha/core/decorators.py index 71bfd510bea..192f6848989 100644 --- a/homeassistant/components/zha/core/decorators.py +++ b/homeassistant/components/zha/core/decorators.py @@ -21,6 +21,24 @@ class DictRegistry(dict[int | str, _TypeT]): return decorator +class NestedDictRegistry(dict[int | str, dict[int | str | None, _TypeT]]): + """Dict Registry of multiple items per key.""" + + def register( + self, name: int | str, sub_name: int | str | None = None + ) -> Callable[[_TypeT], _TypeT]: + """Return decorator to register item with a specific and a quirk name.""" + + def decorator(cluster_handler: _TypeT) -> _TypeT: + """Register decorated cluster handler or item.""" + if name not in self: + self[name] = {} + self[name][sub_name] = cluster_handler + return cluster_handler + + return decorator + + class SetRegistry(set[int | str]): """Set Registry of items.""" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 0ce6f47b61e..1a3d3a2da1f 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -166,6 +166,9 @@ class ZHADevice(LogMixin): if not self.is_coordinator: keep_alive_interval = random.randint(*_UPDATE_ALIVE_INTERVAL) + self.debug( + "starting availability checks - interval: %s", keep_alive_interval + ) self.unsubs.append( async_track_time_interval( self.hass, @@ -447,35 +450,36 @@ class ZHADevice(LogMixin): self._checkins_missed_count = 0 return - if ( - self._checkins_missed_count >= _CHECKIN_GRACE_PERIODS - or self.manufacturer == "LUMI" - or not self._endpoints - ): - self.debug( - ( - "last_seen is %s seconds ago and ping attempts have been exhausted," - " marking the device unavailable" - ), - difference, - ) - self.update_available(False) - return + if self.hass.data[const.DATA_ZHA].allow_polling: + if ( + self._checkins_missed_count >= _CHECKIN_GRACE_PERIODS + or self.manufacturer == "LUMI" + or not self._endpoints + ): + self.debug( + ( + "last_seen is %s seconds ago and ping attempts have been exhausted," + " marking the device unavailable" + ), + difference, + ) + self.update_available(False) + return - self._checkins_missed_count += 1 - self.debug( - "Attempting to checkin with device - missed checkins: %s", - self._checkins_missed_count, - ) - if not self.basic_ch: - self.debug("does not have a mandatory basic cluster") - self.update_available(False) - return - res = await self.basic_ch.get_attribute_value( - ATTR_MANUFACTURER, from_cache=False - ) - if res is not None: - self._checkins_missed_count = 0 + self._checkins_missed_count += 1 + self.debug( + "Attempting to checkin with device - missed checkins: %s", + self._checkins_missed_count, + ) + if not self.basic_ch: + self.debug("does not have a mandatory basic cluster") + self.update_available(False) + return + res = await self.basic_ch.get_attribute_value( + ATTR_MANUFACTURER, from_cache=False + ) + if res is not None: + self._checkins_missed_count = 0 def update_available(self, available: bool) -> None: """Update device availability and signal entities.""" diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 90ed68f9b00..1944f632e9a 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -203,9 +203,20 @@ class ProbeEndpoint: if platform is None: continue - cluster_handler_class = zha_regs.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( - cluster_id, ClusterHandler + cluster_handler_classes = zha_regs.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id, {None: ClusterHandler} ) + + quirk_id = ( + endpoint.device.quirk_id + if endpoint.device.quirk_id in cluster_handler_classes + else None + ) + + cluster_handler_class = cluster_handler_classes.get( + quirk_id, ClusterHandler + ) + cluster_handler = cluster_handler_class(cluster, endpoint) self.probe_single_cluster(platform, cluster_handler, endpoint) diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index c87ee60d6b3..04c253128ee 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -6,7 +6,6 @@ from collections.abc import Callable import logging from typing import TYPE_CHECKING, Any, Final, TypeVar -import zigpy from zigpy.typing import EndpointType as ZigpyEndpointType from homeassistant.const import Platform @@ -15,7 +14,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from . import const, discovery, registries from .cluster_handlers import ClusterHandler -from .cluster_handlers.general import MultistateInput from .helpers import get_zha_data if TYPE_CHECKING: @@ -116,8 +114,16 @@ class Endpoint: def add_all_cluster_handlers(self) -> None: """Create and add cluster handlers for all input clusters.""" for cluster_id, cluster in self.zigpy_endpoint.in_clusters.items(): - cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( - cluster_id, ClusterHandler + cluster_handler_classes = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id, {None: ClusterHandler} + ) + quirk_id = ( + self.device.quirk_id + if self.device.quirk_id in cluster_handler_classes + else None + ) + cluster_handler_class = cluster_handler_classes.get( + quirk_id, ClusterHandler ) # Allow cluster handler to filter out bad matches @@ -129,15 +135,6 @@ class Endpoint: cluster_id, cluster_handler_class, ) - # really ugly hack to deal with xiaomi using the door lock cluster - # incorrectly. - if ( - hasattr(cluster, "ep_attribute") - and cluster_id == zigpy.zcl.clusters.closures.DoorLock.cluster_id - and cluster.ep_attribute == "multistate_input" - ): - cluster_handler_class = MultistateInput - # end of ugly hack try: cluster_handler = cluster_handler_class(cluster, self) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 6c461ac45c3..12e439f1059 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -46,7 +46,9 @@ from .const import ( ATTR_SIGNATURE, ATTR_TYPE, CONF_RADIO_TYPE, + CONF_USE_THREAD, CONF_ZIGPY, + DATA_ZHA, DEBUG_COMP_BELLOWS, DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY, @@ -157,6 +159,15 @@ class ZHAGateway: if CONF_NWK_VALIDATE_SETTINGS not in app_config: app_config[CONF_NWK_VALIDATE_SETTINGS] = True + # The bellows UART thread sometimes propagates a cancellation into the main Core + # event loop, when a connection to a TCP coordinator fails in a specific way + if ( + CONF_USE_THREAD not in app_config + and radio_type is RadioType.ezsp + and app_config[CONF_DEVICE][CONF_DEVICE_PATH].startswith("socket://") + ): + app_config[CONF_USE_THREAD] = False + # Local import to avoid circular dependencies # pylint: disable-next=import-outside-toplevel from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( @@ -292,6 +303,10 @@ class ZHAGateway: if dev.is_mains_powered ) ) + _LOGGER.debug( + "completed fetching current state for mains powered devices - allowing polled requests" + ) + self.hass.data[DATA_ZHA].allow_polling = True # background the fetching of state for mains powered devices self.config_entry.async_create_background_task( diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 0246c1e4b1c..bb87cb2cf58 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -442,6 +442,7 @@ class ZHAData: device_trigger_cache: dict[str, tuple[str, dict]] = dataclasses.field( default_factory=dict ) + allow_polling: bool = dataclasses.field(default=False) def get_zha_data(hass: HomeAssistant) -> ZHAData: diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 87f59f31e9b..b302116694d 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -15,7 +15,7 @@ from zigpy.types.named import EUI64 from homeassistant.const import Platform -from .decorators import DictRegistry, SetRegistry +from .decorators import DictRegistry, NestedDictRegistry, SetRegistry if TYPE_CHECKING: from ..entity import ZhaEntity, ZhaGroupEntity @@ -110,7 +110,9 @@ CLUSTER_HANDLER_ONLY_CLUSTERS = SetRegistry() CLIENT_CLUSTER_HANDLER_REGISTRY: DictRegistry[ type[ClientClusterHandler] ] = DictRegistry() -ZIGBEE_CLUSTER_HANDLER_REGISTRY: DictRegistry[type[ClusterHandler]] = DictRegistry() +ZIGBEE_CLUSTER_HANDLER_REGISTRY: NestedDictRegistry[ + type[ClusterHandler] +] = NestedDictRegistry() WEIGHT_ATTR = attrgetter("weight") diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index c6b9a104885..7364aed0d1b 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -20,10 +20,10 @@ from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) +from homeassistant.util.scaling import int_states_in_range from .core import discovery from .core.cluster_handlers import wrap_zigpy_exceptions diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index d545a331a6d..486b043b450 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -47,6 +47,7 @@ from .core.const import ( CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, CONF_GROUP_MEMBERS_ASSUME_STATE, + DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL, @@ -75,7 +76,6 @@ FLASH_EFFECTS = { STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.LIGHT) GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.LIGHT) -PARALLEL_UPDATES = 0 SIGNAL_LIGHT_GROUP_STATE_CHANGED = "zha_light_group_state_changed" SIGNAL_LIGHT_GROUP_TRANSITION_START = "zha_light_group_transition_start" SIGNAL_LIGHT_GROUP_TRANSITION_FINISHED = "zha_light_group_transition_finished" @@ -788,6 +788,7 @@ class Light(BaseLight, ZhaEntity): self._cancel_refresh_handle = async_track_time_interval( self.hass, self._refresh, timedelta(seconds=refresh_interval) ) + self.debug("started polling with refresh interval of %s", refresh_interval) self.async_accept_signal( None, SIGNAL_LIGHT_GROUP_STATE_CHANGED, @@ -838,6 +839,8 @@ class Light(BaseLight, ZhaEntity): """Disconnect entity object when removed.""" assert self._cancel_refresh_handle self._cancel_refresh_handle() + self._cancel_refresh_handle = None + self.debug("stopped polling during device removal") await super().async_will_remove_from_hass() @callback @@ -980,8 +983,16 @@ class Light(BaseLight, ZhaEntity): if self.is_transitioning: self.debug("skipping _refresh while transitioning") return - await self.async_get_state() - self.async_write_ha_state() + if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: + self.debug("polling for updated state") + await self.async_get_state() + self.async_write_ha_state() + else: + self.debug( + "skipping polling for updated state, available: %s, allow polled requests: %s", + self._zha_device.available, + self.hass.data[DATA_ZHA].allow_polling, + ) async def _maybe_force_refresh(self, signal): """Force update the state if the signal contains the entity id for this entity.""" @@ -989,8 +1000,16 @@ class Light(BaseLight, ZhaEntity): if self.is_transitioning: self.debug("skipping _maybe_force_refresh while transitioning") return - await self.async_get_state() - self.async_write_ha_state() + if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: + self.debug("forcing polling for updated state") + await self.async_get_state() + self.async_write_ha_state() + else: + self.debug( + "skipping _maybe_force_refresh, available: %s, allow polled requests: %s", + self._zha_device.available, + self.hass.data[DATA_ZHA].allow_polling, + ) @callback def _assume_group_state(self, signal, update_params) -> None: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index a2965e782f4..06ebfaaa6a0 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,12 +21,12 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.37.4", + "bellows==0.37.6", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.108", - "zigpy-deconz==0.22.3", - "zigpy==0.60.2", + "zha-quirks==0.0.109", + "zigpy-deconz==0.22.4", + "zigpy==0.60.4", "zigpy-xbee==0.20.1", "zigpy-zigate==0.12.0", "zigpy-znp==0.12.1", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 53d79d2d35f..24964d7a154 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -20,6 +20,7 @@ from .core.const import ( CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_THERMOSTAT, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -947,3 +948,21 @@ class AqaraThermostatAwayTemp(ZHANumberConfigurationEntity): _attr_mode: NumberMode = NumberMode.SLIDER _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS _attr_icon: str = ICONS[0] + + +@CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class ThermostatLocalTempCalibration(ZHANumberConfigurationEntity): + """Local temperature calibration.""" + + _unique_id_suffix = "local_temperature_calibration" + _attr_native_min_value: float = -2.5 + _attr_native_max_value: float = 2.5 + _attr_native_step: float = 0.1 + _attr_multiplier: float = 0.1 + _attribute_name = "local_temperature_calibration" + _attr_translation_key: str = "local_temperature_calibration" + + _attr_mode: NumberMode = NumberMode.SLIDER + _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS + _attr_icon: str = ICONS[0] diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 92a90e0e13a..d3ca03de8d8 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -10,6 +10,7 @@ import logging import os from typing import Any, Self +from bellows.config import CONF_USE_THREAD import voluptuous as vol from zigpy.application import ControllerApplication import zigpy.backups @@ -174,6 +175,7 @@ class ZhaRadioManager: app_config[CONF_DATABASE] = database_path app_config[CONF_DEVICE] = self.device_settings app_config[CONF_NWK_BACKUP_ENABLED] = False + app_config[CONF_USE_THREAD] = False app_config = self.radio_type.controller.SCHEMA(app_config) app = await self.radio_type.controller.new( diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 2ff8b7d36b9..1c13779209d 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -7,6 +7,8 @@ import logging from typing import TYPE_CHECKING, Any, Self from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, TUYA_PLUG_ONOFF +from zhaquirks.xiaomi.aqara.magnet_ac01 import OppleCluster as MagnetAC01OppleCluster +from zhaquirks.xiaomi.aqara.switch_acn047 import OppleCluster as T2RelayOppleCluster from zigpy import types from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasWd @@ -408,6 +410,66 @@ class AqaraApproachDistance(ZCLEnumSelectEntity): _attr_translation_key: str = "approach_distance" +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.magnet.ac01"} +) +class AqaraMagnetAC01DetectionDistance(ZCLEnumSelectEntity): + """Representation of a ZHA detection distance configuration entity.""" + + _unique_id_suffix = "detection_distance" + _attribute_name = "detection_distance" + _enum = MagnetAC01OppleCluster.DetectionDistance + _attr_translation_key: str = "detection_distance" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelaySwitchMode(ZCLEnumSelectEntity): + """Representation of a ZHA switch mode configuration entity.""" + + _unique_id_suffix = "switch_mode" + _attribute_name = "switch_mode" + _enum = T2RelayOppleCluster.SwitchMode + _attr_translation_key: str = "switch_mode" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelaySwitchType(ZCLEnumSelectEntity): + """Representation of a ZHA switch type configuration entity.""" + + _unique_id_suffix = "switch_type" + _attribute_name = "switch_type" + _enum = T2RelayOppleCluster.SwitchType + _attr_translation_key: str = "switch_type" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelayStartupOnOff(ZCLEnumSelectEntity): + """Representation of a ZHA startup on off configuration entity.""" + + _unique_id_suffix = "startup_on_off" + _attribute_name = "startup_on_off" + _enum = T2RelayOppleCluster.StartupOnOff + _attr_translation_key: str = "start_up_on_off" + + +@CONFIG_DIAGNOSTIC_MATCH( + cluster_handler_names="opple_cluster", models={"lumi.switch.acn047"} +) +class AqaraT2RelayDecoupledMode(ZCLEnumSelectEntity): + """Representation of a ZHA switch decoupled mode configuration entity.""" + + _unique_id_suffix = "decoupled_mode" + _attribute_name = "decoupled_mode" + _enum = T2RelayOppleCluster.DecoupledMode + _attr_translation_key: str = "decoupled_mode" + + class AqaraE1ReverseDirection(types.enum8): """Aqara curtain reversal.""" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 4fe96109c46..027e710e30c 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -1,9 +1,11 @@ """Sensors on Zigbee Home Automation networks.""" from __future__ import annotations +from datetime import timedelta import enum import functools import numbers +import random from typing import TYPE_CHECKING, Any, Self from zigpy import types @@ -37,9 +39,10 @@ from homeassistant.const import ( UnitOfVolume, UnitOfVolumeFlowRate, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import StateType from .core import discovery @@ -57,6 +60,7 @@ from .core.const import ( CLUSTER_HANDLER_SOIL_MOISTURE, CLUSTER_HANDLER_TEMPERATURE, CLUSTER_HANDLER_THERMOSTAT, + DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) @@ -68,8 +72,6 @@ if TYPE_CHECKING: from .core.cluster_handlers import ClusterHandler from .core.device import ZHADevice -PARALLEL_UPDATES = 5 - BATTERY_SIZES = { 0: "No battery", 1: "Built in", @@ -185,6 +187,55 @@ class Sensor(ZhaEntity, SensorEntity): return round(float(value * self._multiplier) / self._divisor) +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class PollableSensor(Sensor): + """Base ZHA sensor that polls for state.""" + + _use_custom_polling: bool = True + + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + cluster_handlers: list[ClusterHandler], + **kwargs: Any, + ) -> None: + """Init this sensor.""" + super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) + self._cancel_refresh_handle: CALLBACK_TYPE | None = None + + async def async_added_to_hass(self) -> None: + """Run when about to be added to hass.""" + await super().async_added_to_hass() + if self._use_custom_polling: + refresh_interval = random.randint(30, 60) + self._cancel_refresh_handle = async_track_time_interval( + self.hass, self._refresh, timedelta(seconds=refresh_interval) + ) + self.debug("started polling with refresh interval of %s", refresh_interval) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + assert self._cancel_refresh_handle + self._cancel_refresh_handle() + self._cancel_refresh_handle = None + self.debug("stopped polling during device removal") + await super().async_will_remove_from_hass() + + async def _refresh(self, time): + """Call async_update at a constrained random interval.""" + if self._zha_device.available and self.hass.data[DATA_ZHA].allow_polling: + self.debug("polling for updated state") + await self.async_update() + self.async_write_ha_state() + else: + self.debug( + "skipping polling for updated state, available: %s, allow polled requests: %s", + self._zha_device.available, + self.hass.data[DATA_ZHA].allow_polling, + ) + + @MULTI_MATCH( cluster_handler_names=CLUSTER_HANDLER_ANALOG_INPUT, manufacturers="Digi", @@ -231,7 +282,7 @@ class Battery(Sensor): def formatter(value: int) -> int | None: """Return the state of the entity.""" # per zcl specs battery percent is reported at 200% ¯\_(ツ)_/¯ - if not isinstance(value, numbers.Number) or value == -1: + if not isinstance(value, numbers.Number) or value == -1 or value == 255: return None value = round(value / 2) return value @@ -258,9 +309,10 @@ class Battery(Sensor): models={"VZM31-SN", "SP 234", "outletv4"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurement(Sensor): +class ElectricalMeasurement(PollableSensor): """Active power measurement.""" + _use_custom_polling: bool = False _attribute_name = "active_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT @@ -306,22 +358,17 @@ class ElectricalMeasurement(Sensor): class PolledElectricalMeasurement(ElectricalMeasurement): """Polled active power measurement.""" - _attr_should_poll = True # BaseZhaEntity defaults to False - - async def async_update(self) -> None: - """Retrieve latest state.""" - if not self.available: - return - await super().async_update() + _use_custom_polling: bool = True @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementApparentPower(ElectricalMeasurement): +class ElectricalMeasurementApparentPower(PolledElectricalMeasurement): """Apparent power measurement.""" _attribute_name = "apparent_power" _unique_id_suffix = "apparent_power" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER _attr_native_unit_of_measurement = UnitOfApparentPower.VOLT_AMPERE _div_mul_prefix = "ac_power" @@ -329,11 +376,12 @@ class ElectricalMeasurementApparentPower(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementRMSCurrent(ElectricalMeasurement): +class ElectricalMeasurementRMSCurrent(PolledElectricalMeasurement): """RMS current measurement.""" _attribute_name = "rms_current" _unique_id_suffix = "rms_current" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE _div_mul_prefix = "ac_current" @@ -341,11 +389,12 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementRMSVoltage(ElectricalMeasurement): +class ElectricalMeasurementRMSVoltage(PolledElectricalMeasurement): """RMS Voltage measurement.""" _attribute_name = "rms_voltage" _unique_id_suffix = "rms_voltage" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLTAGE _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT _div_mul_prefix = "ac_voltage" @@ -353,11 +402,12 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementFrequency(ElectricalMeasurement): +class ElectricalMeasurementFrequency(PolledElectricalMeasurement): """Frequency measurement.""" _attribute_name = "ac_frequency" _unique_id_suffix = "ac_frequency" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.FREQUENCY _attr_translation_key: str = "ac_frequency" _attr_native_unit_of_measurement = UnitOfFrequency.HERTZ @@ -366,11 +416,12 @@ class ElectricalMeasurementFrequency(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementPowerFactor(ElectricalMeasurement): +class ElectricalMeasurementPowerFactor(PolledElectricalMeasurement): """Frequency measurement.""" _attribute_name = "power_factor" _unique_id_suffix = "power_factor" + _use_custom_polling = False # Poll indirectly by ElectricalMeasurementSensor _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR _attr_native_unit_of_measurement = PERCENTAGE @@ -440,9 +491,10 @@ class Illuminance(Sensor): stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class SmartEnergyMetering(Sensor): +class SmartEnergyMetering(PollableSensor): """Metering sensor.""" + _use_custom_polling: bool = False _attribute_name = "instantaneous_demand" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT @@ -540,13 +592,7 @@ class SmartEnergySummation(SmartEnergyMetering): class PolledSmartEnergySummation(SmartEnergySummation): """Polled Smart Energy Metering summation sensor.""" - _attr_should_poll = True # BaseZhaEntity defaults to False - - async def async_update(self) -> None: - """Retrieve latest state.""" - if not self.available: - return - await self._cluster_handler.async_force_update() + _use_custom_polling: bool = True @MULTI_MATCH( @@ -557,6 +603,7 @@ class PolledSmartEnergySummation(SmartEnergySummation): class Tier1SmartEnergySummation(PolledSmartEnergySummation): """Tier 1 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier1_summ_delivered" _unique_id_suffix = "tier1_summation_delivered" _attr_translation_key: str = "tier1_summation_delivered" @@ -570,6 +617,7 @@ class Tier1SmartEnergySummation(PolledSmartEnergySummation): class Tier2SmartEnergySummation(PolledSmartEnergySummation): """Tier 2 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier2_summ_delivered" _unique_id_suffix = "tier2_summation_delivered" _attr_translation_key: str = "tier2_summation_delivered" @@ -583,6 +631,7 @@ class Tier2SmartEnergySummation(PolledSmartEnergySummation): class Tier3SmartEnergySummation(PolledSmartEnergySummation): """Tier 3 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier3_summ_delivered" _unique_id_suffix = "tier3_summation_delivered" _attr_translation_key: str = "tier3_summation_delivered" @@ -596,6 +645,7 @@ class Tier3SmartEnergySummation(PolledSmartEnergySummation): class Tier4SmartEnergySummation(PolledSmartEnergySummation): """Tier 4 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier4_summ_delivered" _unique_id_suffix = "tier4_summation_delivered" _attr_translation_key: str = "tier4_summation_delivered" @@ -609,6 +659,7 @@ class Tier4SmartEnergySummation(PolledSmartEnergySummation): class Tier5SmartEnergySummation(PolledSmartEnergySummation): """Tier 5 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier5_summ_delivered" _unique_id_suffix = "tier5_summation_delivered" _attr_translation_key: str = "tier5_summation_delivered" @@ -622,6 +673,7 @@ class Tier5SmartEnergySummation(PolledSmartEnergySummation): class Tier6SmartEnergySummation(PolledSmartEnergySummation): """Tier 6 Smart Energy Metering summation sensor.""" + _use_custom_polling = False # Poll indirectly by PolledSmartEnergySummation _attribute_name = "current_tier6_summ_delivered" _unique_id_suffix = "tier6_summation_delivered" _attr_translation_key: str = "tier6_summation_delivered" diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 18bb3ae4f82..8909af8a5ba 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -724,6 +724,9 @@ }, "quick_start_time": { "name": "Quick start time" + }, + "local_temperature_calibration": { + "name": "Local temperature offset" } }, "select": { @@ -780,6 +783,15 @@ }, "preset": { "name": "Preset" + }, + "detection_distance": { + "name": "Detection distance" + }, + "switch_mode": { + "name": "Switch mode" + }, + "decoupled_mode": { + "name": "Decoupled mode" } }, "sensor": { diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 71c6e9d90ad..d4e835751f5 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -108,11 +108,10 @@ class Switch(ZhaEntity, SwitchEntity): async def async_update(self) -> None: """Attempt to retrieve on off state from the switch.""" - await super().async_update() - if self._on_off_cluster_handler: - await self._on_off_cluster_handler.get_attribute_value( - "on_off", from_cache=False - ) + self.debug("Polling current state") + await self._on_off_cluster_handler.get_attribute_value( + "on_off", from_cache=False + ) @GROUP_MATCH() @@ -255,16 +254,14 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): async def async_update(self) -> None: """Attempt to retrieve the state of the entity.""" - await super().async_update() - self.error("Polling current state") - if self._cluster_handler: - value = await self._cluster_handler.get_attribute_value( - self._attribute_name, from_cache=False - ) - await self._cluster_handler.get_attribute_value( - self._inverter_attribute_name, from_cache=False - ) - self.debug("read value=%s, inverted=%s", value, self.inverted) + self.debug("Polling current state") + value = await self._cluster_handler.get_attribute_value( + self._attribute_name, from_cache=False + ) + await self._cluster_handler.get_attribute_value( + self._inverter_attribute_name, from_cache=False + ) + self.debug("read value=%s, inverted=%s", value, self.inverted) @CONFIG_DIAGNOSTIC_MATCH( diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index a8b3d300e3b..ccadc452bc7 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -449,7 +449,10 @@ class ControllerEvents: "remove_entity" ), ) - elif reason == RemoveNodeReason.RESET: + # We don't want to remove the device so we can keep the user customizations + return + + if reason == RemoveNodeReason.RESET: device_name = device.name_by_user or device.name or f"Node {node.node_id}" identifier = get_network_identifier_for_notification( self.hass, self.config_entry, self.driver_events.driver.controller @@ -471,8 +474,8 @@ class ControllerEvents: "Device Was Factory Reset!", f"{DOMAIN}.node_reset_and_removed.{dev_id[1]}", ) - else: - self.remove_device(device) + + self.remove_device(device) @callback def async_on_identify(self, event: dict) -> None: diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 9e50b55830c..7f4855bfbe5 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1,10 +1,10 @@ """Websocket API for Z-Wave JS.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine import dataclasses from functools import partial, wraps -from typing import Any, Literal, cast +from typing import Any, Concatenate, Literal, ParamSpec, cast from aiohttp import web, web_exceptions, web_request import voluptuous as vol @@ -85,6 +85,8 @@ from .helpers import ( get_device_id, ) +_P = ParamSpec("_P") + DATA_UNSUBSCRIBE = "unsubs" # general API constants @@ -264,8 +266,11 @@ QR_CODE_STRING_SCHEMA = vol.All(str, vol.Length(min=MINIMUM_QR_STRING_LENGTH)) async def _async_get_entry( - hass: HomeAssistant, connection: ActiveConnection, msg: dict, entry_id: str -) -> tuple[ConfigEntry | None, Client | None, Driver | None]: + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry_id: str, +) -> tuple[ConfigEntry, Client, Driver] | tuple[None, None, None]: """Get config entry and client from message data.""" entry = hass.config_entries.async_get_entry(entry_id) if entry is None: @@ -293,19 +298,26 @@ async def _async_get_entry( return entry, client, client.driver -def async_get_entry(orig_func: Callable) -> Callable: +def async_get_entry( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], ConfigEntry, Client, Driver], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate async function to get entry.""" @wraps(orig_func) async def async_get_entry_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" entry, client, driver = await _async_get_entry( hass, connection, msg, msg[ENTRY_ID] ) - if not entry and not client and not driver: + if not entry or not client or not driver: return await orig_func(hass, connection, msg, entry, client, driver) @@ -328,12 +340,19 @@ async def _async_get_node( return node -def async_get_node(orig_func: Callable) -> Callable: +def async_get_node( + orig_func: Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any], Node], + Coroutine[Any, Any, None], + ], +) -> Callable[ + [HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None] +]: """Decorate async function to get node.""" @wraps(orig_func) async def async_get_node_func( - hass: HomeAssistant, connection: ActiveConnection, msg: dict + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Provide user specific data and store to function.""" node = await _async_get_node(hass, connection, msg, msg[DEVICE_ID]) @@ -344,16 +363,24 @@ def async_get_node(orig_func: Callable) -> Callable: return async_get_node_func -def async_handle_failed_command(orig_func: Callable) -> Callable: +def async_handle_failed_command( + orig_func: Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], + ], +) -> Callable[ + Concatenate[HomeAssistant, ActiveConnection, dict[str, Any], _P], + Coroutine[Any, Any, None], +]: """Decorate async function to handle FailedCommand and send relevant error.""" @wraps(orig_func) async def async_handle_failed_command_func( hass: HomeAssistant, connection: ActiveConnection, - msg: dict, - *args: Any, - **kwargs: Any, + msg: dict[str, Any], + *args: _P.args, + **kwargs: _P.kwargs, ) -> None: """Handle FailedCommand within function and send relevant error.""" try: diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index acd6780d39f..cb460f37000 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -50,7 +50,7 @@ NOTIFICATION_IRRIGATION = "17" NOTIFICATION_GAS = "18" -@dataclass +@dataclass(frozen=True) class NotificationZWaveJSEntityDescription(BinarySensorEntityDescription): """Represent a Z-Wave JS binary sensor entity description.""" @@ -58,14 +58,14 @@ class NotificationZWaveJSEntityDescription(BinarySensorEntityDescription): states: tuple[str, ...] | None = None -@dataclass +@dataclass(frozen=True) class PropertyZWaveJSMixin: """Represent the mixin for property sensor descriptions.""" on_states: tuple[str, ...] -@dataclass +@dataclass(frozen=True) class PropertyZWaveJSEntityDescription( BinarySensorEntityDescription, PropertyZWaveJSMixin ): diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 5d78d3e57e7..a211832039b 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -456,7 +456,9 @@ def remove_keys_with_empty_values(config: ConfigType) -> ConfigType: return {key: value for key, value in config.items() if value not in ("", None)} -def check_type_schema_map(schema_map: dict[str, vol.Schema]) -> Callable: +def check_type_schema_map( + schema_map: dict[str, vol.Schema], +) -> Callable[[ConfigType], ConfigType]: """Check type specific schema against config.""" def _check_type_schema(config: ConfigType) -> ConfigType: diff --git a/homeassistant/components/zwave_js/humidifier.py b/homeassistant/components/zwave_js/humidifier.py index 02c6abbc732..14a43bea3af 100644 --- a/homeassistant/components/zwave_js/humidifier.py +++ b/homeassistant/components/zwave_js/humidifier.py @@ -34,7 +34,7 @@ from .entity import ZWaveBaseEntity PARALLEL_UPDATES = 0 -@dataclass +@dataclass(frozen=True) class ZwaveHumidifierEntityDescriptionRequiredKeys: """A class for humidifier entity description required keys.""" @@ -48,7 +48,7 @@ class ZwaveHumidifierEntityDescriptionRequiredKeys: setpoint_type: HumidityControlSetpointType -@dataclass +@dataclass(frozen=True) class ZwaveHumidifierEntityDescription( HumidifierEntityDescription, ZwaveHumidifierEntityDescriptionRequiredKeys ): diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index f2d32d499c9..9a66dae8e93 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.54.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.55.2"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 8d42bcfb366..56ed3f010b8 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -111,20 +111,20 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ tuple[str, str], SensorEntityDescription ] = { (ENTITY_DESC_KEY_BATTERY, PERCENTAGE): SensorEntityDescription( - ENTITY_DESC_KEY_BATTERY, + key=ENTITY_DESC_KEY_BATTERY, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), (ENTITY_DESC_KEY_CURRENT, UnitOfElectricCurrent.AMPERE): SensorEntityDescription( - ENTITY_DESC_KEY_CURRENT, + key=ENTITY_DESC_KEY_CURRENT, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, ), (ENTITY_DESC_KEY_VOLTAGE, UnitOfElectricPotential.VOLT): SensorEntityDescription( - ENTITY_DESC_KEY_VOLTAGE, + key=ENTITY_DESC_KEY_VOLTAGE, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -133,7 +133,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_VOLTAGE, UnitOfElectricPotential.MILLIVOLT, ): SensorEntityDescription( - ENTITY_DESC_KEY_VOLTAGE, + key=ENTITY_DESC_KEY_VOLTAGE, device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, @@ -142,67 +142,67 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, UnitOfEnergy.KILO_WATT_HOUR, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, + key=ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), (ENTITY_DESC_KEY_POWER, UnitOfPower.WATT): SensorEntityDescription( - ENTITY_DESC_KEY_POWER, + key=ENTITY_DESC_KEY_POWER, device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, ), (ENTITY_DESC_KEY_POWER_FACTOR, PERCENTAGE): SensorEntityDescription( - ENTITY_DESC_KEY_POWER_FACTOR, + key=ENTITY_DESC_KEY_POWER_FACTOR, device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), (ENTITY_DESC_KEY_CO, CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription( - ENTITY_DESC_KEY_CO, + key=ENTITY_DESC_KEY_CO, device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), (ENTITY_DESC_KEY_CO2, CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription( - ENTITY_DESC_KEY_CO2, + key=ENTITY_DESC_KEY_CO2, device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), (ENTITY_DESC_KEY_HUMIDITY, PERCENTAGE): SensorEntityDescription( - ENTITY_DESC_KEY_HUMIDITY, + key=ENTITY_DESC_KEY_HUMIDITY, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), (ENTITY_DESC_KEY_ILLUMINANCE, LIGHT_LUX): SensorEntityDescription( - ENTITY_DESC_KEY_ILLUMINANCE, + key=ENTITY_DESC_KEY_ILLUMINANCE, device_class=SensorDeviceClass.ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=LIGHT_LUX, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.KPA): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.KPA, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.PSI): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.PSI, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.INHG): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.INHG, ), (ENTITY_DESC_KEY_PRESSURE, UnitOfPressure.MMHG): SensorEntityDescription( - ENTITY_DESC_KEY_PRESSURE, + key=ENTITY_DESC_KEY_PRESSURE, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPressure.MMHG, @@ -211,7 +211,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_SIGNAL_STRENGTH, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ): SensorEntityDescription( - ENTITY_DESC_KEY_SIGNAL_STRENGTH, + key=ENTITY_DESC_KEY_SIGNAL_STRENGTH, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -219,7 +219,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ), (ENTITY_DESC_KEY_TEMPERATURE, UnitOfTemperature.CELSIUS): SensorEntityDescription( - ENTITY_DESC_KEY_TEMPERATURE, + key=ENTITY_DESC_KEY_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -228,7 +228,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_TEMPERATURE, UnitOfTemperature.FAHRENHEIT, ): SensorEntityDescription( - ENTITY_DESC_KEY_TEMPERATURE, + key=ENTITY_DESC_KEY_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, @@ -237,7 +237,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_TARGET_TEMPERATURE, UnitOfTemperature.CELSIUS, ): SensorEntityDescription( - ENTITY_DESC_KEY_TARGET_TEMPERATURE, + key=ENTITY_DESC_KEY_TARGET_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), @@ -245,7 +245,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_TARGET_TEMPERATURE, UnitOfTemperature.FAHRENHEIT, ): SensorEntityDescription( - ENTITY_DESC_KEY_TARGET_TEMPERATURE, + key=ENTITY_DESC_KEY_TARGET_TEMPERATURE, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), @@ -253,13 +253,13 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, UnitOfTime.SECONDS, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, name="Energy production time", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, ), (ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, UnitOfTime.HOURS): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.HOURS, ), @@ -267,7 +267,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, UnitOfEnergy.WATT_HOUR, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, name="Energy production today", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -277,7 +277,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, UnitOfEnergy.WATT_HOUR, ): SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, + key=ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, name="Energy production total", device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, @@ -287,7 +287,7 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER, UnitOfPower.WATT, ): SensorEntityDescription( - ENTITY_DESC_KEY_POWER, + key=ENTITY_DESC_KEY_POWER, name="Energy production power", device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, @@ -298,41 +298,41 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ # These descriptions are without device class. ENTITY_DESCRIPTION_KEY_MAP = { ENTITY_DESC_KEY_CO: SensorEntityDescription( - ENTITY_DESC_KEY_CO, + key=ENTITY_DESC_KEY_CO, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_ENERGY_MEASUREMENT: SensorEntityDescription( - ENTITY_DESC_KEY_ENERGY_MEASUREMENT, + key=ENTITY_DESC_KEY_ENERGY_MEASUREMENT, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_HUMIDITY: SensorEntityDescription( - ENTITY_DESC_KEY_HUMIDITY, + key=ENTITY_DESC_KEY_HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_ILLUMINANCE: SensorEntityDescription( - ENTITY_DESC_KEY_ILLUMINANCE, + key=ENTITY_DESC_KEY_ILLUMINANCE, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_POWER_FACTOR: SensorEntityDescription( - ENTITY_DESC_KEY_POWER_FACTOR, + key=ENTITY_DESC_KEY_POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_SIGNAL_STRENGTH: SensorEntityDescription( - ENTITY_DESC_KEY_SIGNAL_STRENGTH, + key=ENTITY_DESC_KEY_SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_MEASUREMENT: SensorEntityDescription( - ENTITY_DESC_KEY_MEASUREMENT, + key=ENTITY_DESC_KEY_MEASUREMENT, state_class=SensorStateClass.MEASUREMENT, ), ENTITY_DESC_KEY_TOTAL_INCREASING: SensorEntityDescription( - ENTITY_DESC_KEY_TOTAL_INCREASING, + key=ENTITY_DESC_KEY_TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING, ), ENTITY_DESC_KEY_UV_INDEX: SensorEntityDescription( - ENTITY_DESC_KEY_UV_INDEX, + key=ENTITY_DESC_KEY_UV_INDEX, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UV_INDEX, ), @@ -342,80 +342,80 @@ ENTITY_DESCRIPTION_KEY_MAP = { # Controller statistics descriptions ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ SensorEntityDescription( - "messagesTX", + key="messagesTX", name="Successful messages (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "messagesRX", + key="messagesRX", name="Successful messages (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "messagesDroppedTX", + key="messagesDroppedTX", name="Messages dropped (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "messagesDroppedRX", + key="messagesDroppedRX", name="Messages dropped (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "NAK", + key="NAK", name="Messages not accepted", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "CAN", name="Collisions", state_class=SensorStateClass.TOTAL + key="CAN", name="Collisions", state_class=SensorStateClass.TOTAL ), SensorEntityDescription( - "timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL + key="timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL ), SensorEntityDescription( - "timeoutResponse", + key="timeoutResponse", name="Timed out responses", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "timeoutCallback", + key="timeoutCallback", name="Timed out callbacks", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "backgroundRSSI.channel0.average", + key="backgroundRSSI.channel0.average", name="Average background RSSI (channel 0)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, ), SensorEntityDescription( - "backgroundRSSI.channel0.current", + key="backgroundRSSI.channel0.current", name="Current background RSSI (channel 0)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( - "backgroundRSSI.channel1.average", + key="backgroundRSSI.channel1.average", name="Average background RSSI (channel 1)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, ), SensorEntityDescription( - "backgroundRSSI.channel1.current", + key="backgroundRSSI.channel1.current", name="Current background RSSI (channel 1)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( - "backgroundRSSI.channel2.average", + key="backgroundRSSI.channel2.average", name="Average background RSSI (channel 2)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, ), SensorEntityDescription( - "backgroundRSSI.channel2.current", + key="backgroundRSSI.channel2.current", name="Current background RSSI (channel 2)", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -426,39 +426,39 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ # Node statistics descriptions ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ SensorEntityDescription( - "commandsRX", + key="commandsRX", name="Successful commands (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "commandsTX", + key="commandsTX", name="Successful commands (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "commandsDroppedRX", + key="commandsDroppedRX", name="Commands dropped (RX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "commandsDroppedTX", + key="commandsDroppedTX", name="Commands dropped (TX)", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "timeoutResponse", + key="timeoutResponse", name="Timed out responses", state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( - "rtt", + key="rtt", name="Round Trip Time", native_unit_of_measurement=UnitOfTime.MILLISECONDS, device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( - "rssi", + key="rssi", name="RSSI", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -478,7 +478,7 @@ def get_entity_description( ENTITY_DESCRIPTION_KEY_MAP.get( data_description_key, SensorEntityDescription( - "base_sensor", native_unit_of_measurement=data.unit_of_measurement + key="base_sensor", native_unit_of_measurement=data.unit_of_measurement ), ), ) diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 12c1ed242af..9b4f9827c1d 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -49,7 +49,7 @@ T = TypeVar("T", ZwaveNode, Endpoint) def parameter_name_does_not_need_bitmask( - val: dict[str, int | str | list[str]] + val: dict[str, int | str | list[str]], ) -> dict[str, int | str | list[str]]: """Validate that if a parameter name is provided, bitmask is not as well.""" if ( diff --git a/homeassistant/components/zwave_me/sensor.py b/homeassistant/components/zwave_me/sensor.py index 89048f4fec9..f96e2d789ff 100644 --- a/homeassistant/components/zwave_me/sensor.py +++ b/homeassistant/components/zwave_me/sensor.py @@ -31,7 +31,7 @@ from . import ZWaveMeController, ZWaveMeEntity from .const import DOMAIN, ZWaveMePlatform -@dataclass +@dataclass(frozen=True) class ZWaveMeSensorEntityDescription(SensorEntityDescription): """Class describing ZWaveMeSensor sensor entities.""" diff --git a/homeassistant/config.py b/homeassistant/config.py index b4850e372fd..949774d3361 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections import OrderedDict -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterable, Sequence from contextlib import suppress from dataclasses import dataclass from enum import StrEnum @@ -48,6 +48,7 @@ from .const import ( CONF_MEDIA_DIRS, CONF_NAME, CONF_PACKAGES, + CONF_PLATFORM, CONF_TEMPERATURE_UNIT, CONF_TIME_ZONE, CONF_TYPE, @@ -58,19 +59,14 @@ from .const import ( from .core import DOMAIN as CONF_CORE, ConfigSource, HomeAssistant, callback from .exceptions import ConfigValidationError, HomeAssistantError from .generated.currencies import HISTORIC_CURRENCIES -from .helpers import ( - config_per_platform, - config_validation as cv, - extract_domain_configs, - issue_registry as ir, -) +from .helpers import config_validation as cv, issue_registry as ir from .helpers.entity_values import EntityValues from .helpers.typing import ConfigType from .loader import ComponentProtocol, Integration, IntegrationNotFound from .requirements import RequirementsNotFound, async_get_integration_with_requirements from .util.package import is_docker_env from .util.unit_system import get_unit_system, validate_unit_system -from .util.yaml import SECRET_YAML, Secrets, load_yaml +from .util.yaml import SECRET_YAML, Secrets, YamlTypeError, load_yaml_dict _LOGGER = logging.getLogger(__name__) @@ -145,7 +141,7 @@ class ConfigExceptionInfo: exception: Exception translation_key: ConfigErrorTranslationKey - platform_name: str + platform_path: str config: ConfigType integration_link: str | None @@ -159,7 +155,7 @@ class IntegrationConfigInfo: def _no_duplicate_auth_provider( - configs: Sequence[dict[str, Any]] + configs: Sequence[dict[str, Any]], ) -> Sequence[dict[str, Any]]: """No duplicate auth provider config allowed in a list. @@ -180,7 +176,7 @@ def _no_duplicate_auth_provider( def _no_duplicate_auth_mfa_module( - configs: Sequence[dict[str, Any]] + configs: Sequence[dict[str, Any]], ) -> Sequence[dict[str, Any]]: """No duplicate auth mfa module item allowed in a list. @@ -276,6 +272,41 @@ def _raise_issue_if_no_country(hass: HomeAssistant, country: str | None) -> None ) +def _raise_issue_if_legacy_templates( + hass: HomeAssistant, legacy_templates: bool | None +) -> None: + # legacy_templates can have the following values: + # - None: Using default value (False) -> Delete repair issues + # - True: Create repair to adopt templates to new syntax + # - False: Create repair to tell user to remove config key + if legacy_templates: + ir.async_create_issue( + hass, + "homeassistant", + "legacy_templates_true", + is_fixable=False, + breaks_in_ha_version="2024.7.0", + severity=ir.IssueSeverity.WARNING, + translation_key="legacy_templates_true", + ) + return + + ir.async_delete_issue(hass, "homeassistant", "legacy_templates_true") + + if legacy_templates is False: + ir.async_create_issue( + hass, + "homeassistant", + "legacy_templates_false", + is_fixable=False, + breaks_in_ha_version="2024.7.0", + severity=ir.IssueSeverity.WARNING, + translation_key="legacy_templates_false", + ) + else: + ir.async_delete_issue(hass, "homeassistant", "legacy_templates_false") + + def _validate_currency(data: Any) -> Any: try: return cv.currency(data) @@ -453,6 +484,19 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> dict: base_exc.problem_mark.name = _relpath(hass, base_exc.problem_mark.name) raise + invalid_domains = [] + for key in config: + try: + cv.domain_key(key) + except vol.Invalid as exc: + suffix = "" + if annotation := find_annotation(config, exc.path): + suffix = f" at {_relpath(hass, annotation[0])}, line {annotation[1]}" + _LOGGER.error("Invalid domain '%s'%s", key, suffix) + invalid_domains.append(key) + for invalid_domain in invalid_domains: + config.pop(invalid_domain) + core_config = config.get(CONF_CORE, {}) await merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {})) return config @@ -467,15 +511,15 @@ def load_yaml_config_file( This method needs to run in an executor. """ - conf_dict = load_yaml(config_path, secrets) - - if not isinstance(conf_dict, dict): + try: + conf_dict = load_yaml_dict(config_path, secrets) + except YamlTypeError as exc: msg = ( f"The configuration file {os.path.basename(config_path)} " "does not contain a dictionary" ) _LOGGER.error(msg) - raise HomeAssistantError(msg) + raise HomeAssistantError(msg) from exc # Convert values to dictionaries if they are None for key, value in conf_dict.items(): @@ -663,7 +707,14 @@ def stringify_invalid( - Give a more user friendly output for unknown options - Give a more user friendly output for missing options """ - message_prefix = f"Invalid config for '{domain}'" + if "." in domain: + integration_domain, _, platform_domain = domain.partition(".") + message_prefix = ( + f"Invalid config for '{platform_domain}' from integration " + f"'{integration_domain}'" + ) + else: + message_prefix = f"Invalid config for '{domain}'" if domain != CONF_CORE and link: message_suffix = f", please check the docs at {link}" else: @@ -734,7 +785,14 @@ def format_homeassistant_error( link: str | None = None, ) -> str: """Format HomeAssistantError thrown by a custom config validator.""" - message_prefix = f"Invalid config for '{domain}'" + if "." in domain: + integration_domain, _, platform_domain = domain.partition(".") + message_prefix = ( + f"Invalid config for '{platform_domain}' from integration " + f"'{integration_domain}'" + ) + else: + message_prefix = f"Invalid config for '{domain}'" # HomeAssistantError raised by custom config validator has no path to the # offending configuration key, use the domain key as path instead. if annotation := find_annotation(config, [domain]): @@ -817,6 +875,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non if key in config: setattr(hac, attr, config[key]) + _raise_issue_if_legacy_templates(hass, config.get(CONF_LEGACY_TEMPLATES)) _raise_issue_if_historic_currency(hass, hass.config.currency) _raise_issue_if_no_country(hass, hass.config.country) @@ -972,9 +1031,13 @@ async def merge_packages_config( for comp_name, comp_conf in pack_conf.items(): if comp_name == CONF_CORE: continue - # If component name is given with a trailing description, remove it - # when looking for component - domain = comp_name.partition(" ")[0] + try: + domain = cv.domain_key(comp_name) + except vol.Invalid: + _log_pkg_error( + hass, pack_name, comp_name, config, f"Invalid domain '{comp_name}'" + ) + continue try: integration = await async_get_integration_with_requirements( @@ -1068,7 +1131,7 @@ def _get_log_message_and_stack_print_pref( ) -> tuple[str | None, bool, dict[str, str]]: """Get message to log and print stack trace preference.""" exception = platform_exception.exception - platform_name = platform_exception.platform_name + platform_path = platform_exception.platform_path platform_config = platform_exception.config link = platform_exception.integration_link @@ -1092,7 +1155,7 @@ def _get_log_message_and_stack_print_pref( True, ), ConfigErrorTranslationKey.PLATFORM_VALIDATOR_UNKNOWN_ERR: ( - f"Unknown error validating {platform_name} platform config with {domain} " + f"Unknown error validating {platform_path} platform config with {domain} " "component platform schema", True, ), @@ -1105,7 +1168,7 @@ def _get_log_message_and_stack_print_pref( True, ), ConfigErrorTranslationKey.PLATFORM_SCHEMA_VALIDATOR_ERR: ( - f"Unknown error validating config for {platform_name} platform " + f"Unknown error validating config for {platform_path} platform " f"for {domain} component with PLATFORM_SCHEMA", True, ), @@ -1119,7 +1182,7 @@ def _get_log_message_and_stack_print_pref( show_stack_trace = False if isinstance(exception, vol.Invalid): log_message = format_schema_error( - hass, exception, platform_name, platform_config, link + hass, exception, platform_path, platform_config, link ) if annotation := find_annotation(platform_config, exception.path): placeholders["config_file"], line = annotation @@ -1128,9 +1191,9 @@ def _get_log_message_and_stack_print_pref( if TYPE_CHECKING: assert isinstance(exception, HomeAssistantError) log_message = format_homeassistant_error( - hass, exception, platform_name, platform_config, link + hass, exception, platform_path, platform_config, link ) - if annotation := find_annotation(platform_config, [platform_name]): + if annotation := find_annotation(platform_config, [platform_path]): placeholders["config_file"], line = annotation placeholders["line"] = str(line) show_stack_trace = True @@ -1222,6 +1285,46 @@ def async_handle_component_errors( ) +def config_per_platform( + config: ConfigType, domain: str +) -> Iterable[tuple[str | None, ConfigType]]: + """Break a component config into different platforms. + + For example, will find 'switch', 'switch 2', 'switch 3', .. etc + Async friendly. + """ + for config_key in extract_domain_configs(config, domain): + if not (platform_config := config[config_key]): + continue + + if not isinstance(platform_config, list): + platform_config = [platform_config] + + item: ConfigType + platform: str | None + for item in platform_config: + try: + platform = item.get(CONF_PLATFORM) + except AttributeError: + platform = None + + yield platform, item + + +def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: + """Extract keys from config for given domain name. + + Async friendly. + """ + domain_configs = [] + for key in config: + with suppress(vol.Invalid): + if cv.domain_key(key) != domain: + continue + domain_configs.append(key) + return domain_configs + + async def async_process_component_config( # noqa: C901 hass: HomeAssistant, config: ConfigType, @@ -1332,7 +1435,7 @@ async def async_process_component_config( # noqa: C901 platforms: list[ConfigType] = [] for p_name, p_config in config_per_platform(config, domain): # Validate component specific platform schema - platform_name = f"{domain}.{p_name}" + platform_path = f"{p_name}.{domain}" try: p_validated = component_platform_schema(p_config) except vol.Invalid as exc: @@ -1369,7 +1472,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_ERR, - platform_name, + platform_path, p_config, integration_docs, ) @@ -1382,7 +1485,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_COMPONENT_LOAD_EXC, - platform_name, + platform_path, p_config, integration_docs, ) @@ -1397,7 +1500,7 @@ async def async_process_component_config( # noqa: C901 exc_info = ConfigExceptionInfo( exc, ConfigErrorTranslationKey.PLATFORM_CONFIG_VALIDATION_ERR, - platform_name, + platform_path, p_config, p_integration.documentation, ) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 756b2def581..336261c3632 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -205,6 +205,7 @@ class ConfigEntry: __slots__ = ( "entry_id", "version", + "minor_version", "domain", "title", "data", @@ -233,7 +234,9 @@ class ConfigEntry: def __init__( self, + *, version: int, + minor_version: int, domain: str, title: str, data: Mapping[str, Any], @@ -252,6 +255,7 @@ class ConfigEntry: # Version of the configuration. self.version = version + self.minor_version = minor_version # Domain the configuration belongs to self.domain = domain @@ -631,7 +635,8 @@ class ConfigEntry: while isinstance(handler, functools.partial): handler = handler.func # type: ignore[unreachable] - if self.version == handler.VERSION: + same_major_version = self.version == handler.VERSION + if same_major_version and self.minor_version == handler.MINOR_VERSION: return True if not (integration := self._integration_for_domain): @@ -639,6 +644,8 @@ class ConfigEntry: component = integration.get_component() supports_migrate = hasattr(component, "async_migrate_entry") if not supports_migrate: + if same_major_version: + return True _LOGGER.error( "Migration handler not found for entry %s for %s", self.title, @@ -676,6 +683,7 @@ class ConfigEntry: return { "entry_id": self.entry_id, "version": self.version, + "minor_version": self.minor_version, "domain": self.domain, "title": self.title, "data": dict(self.data), @@ -974,6 +982,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): entry = ConfigEntry( version=result["version"], + minor_version=result["minor_version"], domain=result["handler"], title=result["title"], data=result["data"], @@ -1196,6 +1205,7 @@ class ConfigEntries: config_entry = ConfigEntry( version=entry["version"], + minor_version=entry.get("minor_version", 1), domain=domain, entry_id=entry_id, data=entry["data"], diff --git a/homeassistant/const.py b/homeassistant/const.py index b2538d0c87a..6afa0430ba3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,12 +2,12 @@ from __future__ import annotations from enum import StrEnum -from typing import Final +from typing import Any, Final APPLICATION_NAME: Final = "HomeAssistant" -MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 12 -PATCH_VERSION: Final = "4" +MAJOR_VERSION: Final = 2024 +MINOR_VERSION: Final = 1 +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, 11, 0) @@ -58,6 +58,7 @@ class Platform(StrEnum): TODO = "todo" TTS = "tts" VACUUM = "vacuum" + VALVE = "valve" UPDATE = "update" WAKE_WORD = "wake_word" WATER_HEATER = "water_heater" @@ -129,6 +130,7 @@ CONF_CONTINUE_ON_ERROR: Final = "continue_on_error" CONF_CONTINUE_ON_TIMEOUT: Final = "continue_on_timeout" CONF_COUNT: Final = "count" CONF_COUNTRY: Final = "country" +CONF_COUNTRY_CODE: Final = "country_code" CONF_COVERS: Final = "covers" CONF_CURRENCY: Final = "currency" CONF_CUSTOMIZE: Final = "customize" @@ -305,34 +307,147 @@ EVENT_SHOPPING_LIST_UPDATED: Final = "shopping_list_updated" # #### DEVICE CLASSES #### # DEVICE_CLASS_* below are deprecated as of 2021.12 # use the SensorDeviceClass enum instead. -DEVICE_CLASS_AQI: Final = "aqi" -DEVICE_CLASS_BATTERY: Final = "battery" -DEVICE_CLASS_CO: Final = "carbon_monoxide" -DEVICE_CLASS_CO2: Final = "carbon_dioxide" -DEVICE_CLASS_CURRENT: Final = "current" -DEVICE_CLASS_DATE: Final = "date" -DEVICE_CLASS_ENERGY: Final = "energy" -DEVICE_CLASS_FREQUENCY: Final = "frequency" -DEVICE_CLASS_GAS: Final = "gas" -DEVICE_CLASS_HUMIDITY: Final = "humidity" -DEVICE_CLASS_ILLUMINANCE: Final = "illuminance" -DEVICE_CLASS_MONETARY: Final = "monetary" -DEVICE_CLASS_NITROGEN_DIOXIDE = "nitrogen_dioxide" -DEVICE_CLASS_NITROGEN_MONOXIDE = "nitrogen_monoxide" -DEVICE_CLASS_NITROUS_OXIDE = "nitrous_oxide" -DEVICE_CLASS_OZONE: Final = "ozone" -DEVICE_CLASS_PM1: Final = "pm1" -DEVICE_CLASS_PM10: Final = "pm10" -DEVICE_CLASS_PM25: Final = "pm25" -DEVICE_CLASS_POWER_FACTOR: Final = "power_factor" -DEVICE_CLASS_POWER: Final = "power" -DEVICE_CLASS_PRESSURE: Final = "pressure" -DEVICE_CLASS_SIGNAL_STRENGTH: Final = "signal_strength" -DEVICE_CLASS_SULPHUR_DIOXIDE = "sulphur_dioxide" -DEVICE_CLASS_TEMPERATURE: Final = "temperature" -DEVICE_CLASS_TIMESTAMP: Final = "timestamp" -DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" -DEVICE_CLASS_VOLTAGE: Final = "voltage" +_DEPRECATED_DEVICE_CLASS_AQI: Final = ("aqi", "SensorDeviceClass.AQI", "2025.1") +_DEPRECATED_DEVICE_CLASS_BATTERY: Final = ( + "battery", + "SensorDeviceClass.BATTERY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_CO: Final = ( + "carbon_monoxide", + "SensorDeviceClass.CO", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_CO2: Final = ( + "carbon_dioxide", + "SensorDeviceClass.CO2", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_CURRENT: Final = ( + "current", + "SensorDeviceClass.CURRENT", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_DATE: Final = ("date", "SensorDeviceClass.DATE", "2025.1") +_DEPRECATED_DEVICE_CLASS_ENERGY: Final = ( + "energy", + "SensorDeviceClass.ENERGY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_FREQUENCY: Final = ( + "frequency", + "SensorDeviceClass.FREQUENCY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_GAS: Final = ("gas", "SensorDeviceClass.GAS", "2025.1") +_DEPRECATED_DEVICE_CLASS_HUMIDITY: Final = ( + "humidity", + "SensorDeviceClass.HUMIDITY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_ILLUMINANCE: Final = ( + "illuminance", + "SensorDeviceClass.ILLUMINANCE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_MONETARY: Final = ( + "monetary", + "SensorDeviceClass.MONETARY", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_NITROGEN_DIOXIDE = ( + "nitrogen_dioxide", + "SensorDeviceClass.NITROGEN_DIOXIDE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_NITROGEN_MONOXIDE = ( + "nitrogen_monoxide", + "SensorDeviceClass.NITROGEN_MONOXIDE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_NITROUS_OXIDE = ( + "nitrous_oxide", + "SensorDeviceClass.NITROUS_OXIDE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_OZONE: Final = ("ozone", "SensorDeviceClass.OZONE", "2025.1") +_DEPRECATED_DEVICE_CLASS_PM1: Final = ("pm1", "SensorDeviceClass.PM1", "2025.1") +_DEPRECATED_DEVICE_CLASS_PM10: Final = ("pm10", "SensorDeviceClass.PM10", "2025.1") +_DEPRECATED_DEVICE_CLASS_PM25: Final = ("pm25", "SensorDeviceClass.PM25", "2025.1") +_DEPRECATED_DEVICE_CLASS_POWER_FACTOR: Final = ( + "power_factor", + "SensorDeviceClass.POWER_FACTOR", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_POWER: Final = ("power", "SensorDeviceClass.POWER", "2025.1") +_DEPRECATED_DEVICE_CLASS_PRESSURE: Final = ( + "pressure", + "SensorDeviceClass.PRESSURE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_SIGNAL_STRENGTH: Final = ( + "signal_strength", + "SensorDeviceClass.SIGNAL_STRENGTH", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_SULPHUR_DIOXIDE = ( + "sulphur_dioxide", + "SensorDeviceClass.SULPHUR_DIOXIDE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_TEMPERATURE: Final = ( + "temperature", + "SensorDeviceClass.TEMPERATURE", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_TIMESTAMP: Final = ( + "timestamp", + "SensorDeviceClass.TIMESTAMP", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = ( + "volatile_organic_compounds", + "SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS", + "2025.1", +) +_DEPRECATED_DEVICE_CLASS_VOLTAGE: Final = ( + "voltage", + "SensorDeviceClass.VOLTAGE", + "2025.1", +) + + +# Can be removed if no deprecated constant are in this module anymore +def __getattr__(name: str) -> Any: + """Check if the not found name is a deprecated constant. + + If it is, print a deprecation warning and return the value of the constant. + Otherwise raise AttributeError. + """ + module_globals = globals() + if f"_DEPRECATED_{name}" not in module_globals: + raise AttributeError(f"Module {__name__} has no attribute {name!r}") + + # Avoid circular import + from .helpers.deprecation import ( # pylint: disable=import-outside-toplevel + check_if_deprecated_constant, + ) + + return check_if_deprecated_constant(name, module_globals) + + +# Can be removed if no deprecated constant are in this module anymore +def __dir__() -> list[str]: + """Return dir() with deprecated constants.""" + # Copied method from homeassistant.helpers.deprecattion#dir_with_deprecated_constants to avoid import cycle + module_globals = globals() + + return list(module_globals) + [ + name.removeprefix("_DEPRECATED_") + for name in module_globals + if name.startswith("_DEPRECATED_") + ] + # #### STATES #### STATE_ON: Final = "on" @@ -506,7 +621,10 @@ class UnitOfApparentPower(StrEnum): VOLT_AMPERE = "VA" -POWER_VOLT_AMPERE: Final = "VA" +_DEPRECATED_POWER_VOLT_AMPERE: Final = ( + UnitOfApparentPower.VOLT_AMPERE, + "2025.1", +) """Deprecated: please use UnitOfApparentPower.VOLT_AMPERE.""" @@ -519,11 +637,20 @@ class UnitOfPower(StrEnum): BTU_PER_HOUR = "BTU/h" -POWER_WATT: Final = "W" +_DEPRECATED_POWER_WATT: Final = ( + UnitOfPower.WATT, + "2025.1", +) """Deprecated: please use UnitOfPower.WATT.""" -POWER_KILO_WATT: Final = "kW" +_DEPRECATED_POWER_KILO_WATT: Final = ( + UnitOfPower.KILO_WATT, + "2025.1", +) """Deprecated: please use UnitOfPower.KILO_WATT.""" -POWER_BTU_PER_HOUR: Final = "BTU/h" +_DEPRECATED_POWER_BTU_PER_HOUR: Final = ( + UnitOfPower.BTU_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfPower.BTU_PER_HOUR.""" # Reactive power units @@ -541,11 +668,20 @@ class UnitOfEnergy(StrEnum): WATT_HOUR = "Wh" -ENERGY_KILO_WATT_HOUR: Final = "kWh" +_DEPRECATED_ENERGY_KILO_WATT_HOUR: Final = ( + UnitOfEnergy.KILO_WATT_HOUR, + "2025.1", +) """Deprecated: please use UnitOfEnergy.KILO_WATT_HOUR.""" -ENERGY_MEGA_WATT_HOUR: Final = "MWh" +_DEPRECATED_ENERGY_MEGA_WATT_HOUR: Final = ( + UnitOfEnergy.MEGA_WATT_HOUR, + "2025.1", +) """Deprecated: please use UnitOfEnergy.MEGA_WATT_HOUR.""" -ENERGY_WATT_HOUR: Final = "Wh" +_DEPRECATED_ENERGY_WATT_HOUR: Final = ( + UnitOfEnergy.WATT_HOUR, + "2025.1", +) """Deprecated: please use UnitOfEnergy.WATT_HOUR.""" @@ -557,9 +693,15 @@ class UnitOfElectricCurrent(StrEnum): AMPERE = "A" -ELECTRIC_CURRENT_MILLIAMPERE: Final = "mA" +_DEPRECATED_ELECTRIC_CURRENT_MILLIAMPERE: Final = ( + UnitOfElectricCurrent.MILLIAMPERE, + "2025.1", +) """Deprecated: please use UnitOfElectricCurrent.MILLIAMPERE.""" -ELECTRIC_CURRENT_AMPERE: Final = "A" +_DEPRECATED_ELECTRIC_CURRENT_AMPERE: Final = ( + UnitOfElectricCurrent.AMPERE, + "2025.1", +) """Deprecated: please use UnitOfElectricCurrent.AMPERE.""" @@ -571,9 +713,15 @@ class UnitOfElectricPotential(StrEnum): VOLT = "V" -ELECTRIC_POTENTIAL_MILLIVOLT: Final = "mV" +_DEPRECATED_ELECTRIC_POTENTIAL_MILLIVOLT: Final = ( + UnitOfElectricPotential.MILLIVOLT, + "2025.1", +) """Deprecated: please use UnitOfElectricPotential.MILLIVOLT.""" -ELECTRIC_POTENTIAL_VOLT: Final = "V" +_DEPRECATED_ELECTRIC_POTENTIAL_VOLT: Final = ( + UnitOfElectricPotential.VOLT, + "2025.1", +) """Deprecated: please use UnitOfElectricPotential.VOLT.""" # Degree units @@ -594,11 +742,20 @@ class UnitOfTemperature(StrEnum): KELVIN = "K" -TEMP_CELSIUS: Final = "°C" +_DEPRECATED_TEMP_CELSIUS: Final = ( + UnitOfTemperature.CELSIUS, + "2025.1", +) """Deprecated: please use UnitOfTemperature.CELSIUS""" -TEMP_FAHRENHEIT: Final = "°F" +_DEPRECATED_TEMP_FAHRENHEIT: Final = ( + UnitOfTemperature.FAHRENHEIT, + "2025.1", +) """Deprecated: please use UnitOfTemperature.FAHRENHEIT""" -TEMP_KELVIN: Final = "K" +_DEPRECATED_TEMP_KELVIN: Final = ( + UnitOfTemperature.KELVIN, + "2025.1", +) """Deprecated: please use UnitOfTemperature.KELVIN""" @@ -617,23 +774,50 @@ class UnitOfTime(StrEnum): YEARS = "y" -TIME_MICROSECONDS: Final = "μs" +_DEPRECATED_TIME_MICROSECONDS: Final = ( + UnitOfTime.MICROSECONDS, + "2025.1", +) """Deprecated: please use UnitOfTime.MICROSECONDS.""" -TIME_MILLISECONDS: Final = "ms" +_DEPRECATED_TIME_MILLISECONDS: Final = ( + UnitOfTime.MILLISECONDS, + "2025.1", +) """Deprecated: please use UnitOfTime.MILLISECONDS.""" -TIME_SECONDS: Final = "s" +_DEPRECATED_TIME_SECONDS: Final = ( + UnitOfTime.SECONDS, + "2025.1", +) """Deprecated: please use UnitOfTime.SECONDS.""" -TIME_MINUTES: Final = "min" +_DEPRECATED_TIME_MINUTES: Final = ( + UnitOfTime.MINUTES, + "2025.1", +) """Deprecated: please use UnitOfTime.MINUTES.""" -TIME_HOURS: Final = "h" +_DEPRECATED_TIME_HOURS: Final = ( + UnitOfTime.HOURS, + "2025.1", +) """Deprecated: please use UnitOfTime.HOURS.""" -TIME_DAYS: Final = "d" +_DEPRECATED_TIME_DAYS: Final = ( + UnitOfTime.DAYS, + "2025.1", +) """Deprecated: please use UnitOfTime.DAYS.""" -TIME_WEEKS: Final = "w" +_DEPRECATED_TIME_WEEKS: Final = ( + UnitOfTime.WEEKS, + "2025.1", +) """Deprecated: please use UnitOfTime.WEEKS.""" -TIME_MONTHS: Final = "m" +_DEPRECATED_TIME_MONTHS: Final = ( + UnitOfTime.MONTHS, + "2025.1", +) """Deprecated: please use UnitOfTime.MONTHS.""" -TIME_YEARS: Final = "y" +_DEPRECATED_TIME_YEARS: Final = ( + UnitOfTime.YEARS, + "2025.1", +) """Deprecated: please use UnitOfTime.YEARS.""" @@ -651,21 +835,45 @@ class UnitOfLength(StrEnum): MILES = "mi" -LENGTH_MILLIMETERS: Final = "mm" +_DEPRECATED_LENGTH_MILLIMETERS: Final = ( + UnitOfLength.MILLIMETERS, + "2025.1", +) """Deprecated: please use UnitOfLength.MILLIMETERS.""" -LENGTH_CENTIMETERS: Final = "cm" +_DEPRECATED_LENGTH_CENTIMETERS: Final = ( + UnitOfLength.CENTIMETERS, + "2025.1", +) """Deprecated: please use UnitOfLength.CENTIMETERS.""" -LENGTH_METERS: Final = "m" +_DEPRECATED_LENGTH_METERS: Final = ( + UnitOfLength.METERS, + "2025.1", +) """Deprecated: please use UnitOfLength.METERS.""" -LENGTH_KILOMETERS: Final = "km" +_DEPRECATED_LENGTH_KILOMETERS: Final = ( + UnitOfLength.KILOMETERS, + "2025.1", +) """Deprecated: please use UnitOfLength.KILOMETERS.""" -LENGTH_INCHES: Final = "in" +_DEPRECATED_LENGTH_INCHES: Final = ( + UnitOfLength.INCHES, + "2025.1", +) """Deprecated: please use UnitOfLength.INCHES.""" -LENGTH_FEET: Final = "ft" +_DEPRECATED_LENGTH_FEET: Final = ( + UnitOfLength.FEET, + "2025.1", +) """Deprecated: please use UnitOfLength.FEET.""" -LENGTH_YARD: Final = "yd" +_DEPRECATED_LENGTH_YARD: Final = ( + UnitOfLength.YARDS, + "2025.1", +) """Deprecated: please use UnitOfLength.YARDS.""" -LENGTH_MILES: Final = "mi" +_DEPRECATED_LENGTH_MILES: Final = ( + UnitOfLength.MILES, + "2025.1", +) """Deprecated: please use UnitOfLength.MILES.""" @@ -679,13 +887,25 @@ class UnitOfFrequency(StrEnum): GIGAHERTZ = "GHz" -FREQUENCY_HERTZ: Final = "Hz" +_DEPRECATED_FREQUENCY_HERTZ: Final = ( + UnitOfFrequency.HERTZ, + "2025.1", +) """Deprecated: please use UnitOfFrequency.HERTZ""" -FREQUENCY_KILOHERTZ: Final = "kHz" +_DEPRECATED_FREQUENCY_KILOHERTZ: Final = ( + UnitOfFrequency.KILOHERTZ, + "2025.1", +) """Deprecated: please use UnitOfFrequency.KILOHERTZ""" -FREQUENCY_MEGAHERTZ: Final = "MHz" +_DEPRECATED_FREQUENCY_MEGAHERTZ: Final = ( + UnitOfFrequency.MEGAHERTZ, + "2025.1", +) """Deprecated: please use UnitOfFrequency.MEGAHERTZ""" -FREQUENCY_GIGAHERTZ: Final = "GHz" +_DEPRECATED_FREQUENCY_GIGAHERTZ: Final = ( + UnitOfFrequency.GIGAHERTZ, + "2025.1", +) """Deprecated: please use UnitOfFrequency.GIGAHERTZ""" @@ -704,23 +924,50 @@ class UnitOfPressure(StrEnum): PSI = "psi" -PRESSURE_PA: Final = "Pa" +_DEPRECATED_PRESSURE_PA: Final = ( + UnitOfPressure.PA, + "2025.1", +) """Deprecated: please use UnitOfPressure.PA""" -PRESSURE_HPA: Final = "hPa" +_DEPRECATED_PRESSURE_HPA: Final = ( + UnitOfPressure.HPA, + "2025.1", +) """Deprecated: please use UnitOfPressure.HPA""" -PRESSURE_KPA: Final = "kPa" +_DEPRECATED_PRESSURE_KPA: Final = ( + UnitOfPressure.KPA, + "2025.1", +) """Deprecated: please use UnitOfPressure.KPA""" -PRESSURE_BAR: Final = "bar" +_DEPRECATED_PRESSURE_BAR: Final = ( + UnitOfPressure.BAR, + "2025.1", +) """Deprecated: please use UnitOfPressure.BAR""" -PRESSURE_CBAR: Final = "cbar" +_DEPRECATED_PRESSURE_CBAR: Final = ( + UnitOfPressure.CBAR, + "2025.1", +) """Deprecated: please use UnitOfPressure.CBAR""" -PRESSURE_MBAR: Final = "mbar" +_DEPRECATED_PRESSURE_MBAR: Final = ( + UnitOfPressure.MBAR, + "2025.1", +) """Deprecated: please use UnitOfPressure.MBAR""" -PRESSURE_MMHG: Final = "mmHg" +_DEPRECATED_PRESSURE_MMHG: Final = ( + UnitOfPressure.MMHG, + "2025.1", +) """Deprecated: please use UnitOfPressure.MMHG""" -PRESSURE_INHG: Final = "inHg" +_DEPRECATED_PRESSURE_INHG: Final = ( + UnitOfPressure.INHG, + "2025.1", +) """Deprecated: please use UnitOfPressure.INHG""" -PRESSURE_PSI: Final = "psi" +_DEPRECATED_PRESSURE_PSI: Final = ( + UnitOfPressure.PSI, + "2025.1", +) """Deprecated: please use UnitOfPressure.PSI""" @@ -732,9 +979,15 @@ class UnitOfSoundPressure(StrEnum): WEIGHTED_DECIBEL_A = "dBA" -SOUND_PRESSURE_DB: Final = "dB" +_DEPRECATED_SOUND_PRESSURE_DB: Final = ( + UnitOfSoundPressure.DECIBEL, + "2025.1", +) """Deprecated: please use UnitOfSoundPressure.DECIBEL""" -SOUND_PRESSURE_WEIGHTED_DBA: Final = "dBa" +_DEPRECATED_SOUND_PRESSURE_WEIGHTED_DBA: Final = ( + UnitOfSoundPressure.WEIGHTED_DECIBEL_A, + "2025.1", +) """Deprecated: please use UnitOfSoundPressure.WEIGHTED_DECIBEL_A""" @@ -757,18 +1010,36 @@ class UnitOfVolume(StrEnum): British/Imperial fluid ounces are not yet supported""" -VOLUME_LITERS: Final = "L" +_DEPRECATED_VOLUME_LITERS: Final = ( + UnitOfVolume.LITERS, + "2025.1", +) """Deprecated: please use UnitOfVolume.LITERS""" -VOLUME_MILLILITERS: Final = "mL" +_DEPRECATED_VOLUME_MILLILITERS: Final = ( + UnitOfVolume.MILLILITERS, + "2025.1", +) """Deprecated: please use UnitOfVolume.MILLILITERS""" -VOLUME_CUBIC_METERS: Final = "m³" +_DEPRECATED_VOLUME_CUBIC_METERS: Final = ( + UnitOfVolume.CUBIC_METERS, + "2025.1", +) """Deprecated: please use UnitOfVolume.CUBIC_METERS""" -VOLUME_CUBIC_FEET: Final = "ft³" +_DEPRECATED_VOLUME_CUBIC_FEET: Final = ( + UnitOfVolume.CUBIC_FEET, + "2025.1", +) """Deprecated: please use UnitOfVolume.CUBIC_FEET""" -VOLUME_GALLONS: Final = "gal" +_DEPRECATED_VOLUME_GALLONS: Final = ( + UnitOfVolume.GALLONS, + "2025.1", +) """Deprecated: please use UnitOfVolume.GALLONS""" -VOLUME_FLUID_OUNCE: Final = "fl. oz." +_DEPRECATED_VOLUME_FLUID_OUNCE: Final = ( + UnitOfVolume.FLUID_OUNCES, + "2025.1", +) """Deprecated: please use UnitOfVolume.FLUID_OUNCES""" @@ -780,9 +1051,15 @@ class UnitOfVolumeFlowRate(StrEnum): CUBIC_FEET_PER_MINUTE = "ft³/m" -VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = "m³/h" +_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR: Final = ( + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR""" -VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE: Final = "ft³/m" +_DEPRECATED_VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE: Final = ( + UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE, + "2025.1", +) """Deprecated: please use UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE""" # Area units @@ -802,17 +1079,35 @@ class UnitOfMass(StrEnum): STONES = "st" -MASS_GRAMS: Final = "g" +_DEPRECATED_MASS_GRAMS: Final = ( + UnitOfMass.GRAMS, + "2025.1", +) """Deprecated: please use UnitOfMass.GRAMS""" -MASS_KILOGRAMS: Final = "kg" +_DEPRECATED_MASS_KILOGRAMS: Final = ( + UnitOfMass.KILOGRAMS, + "2025.1", +) """Deprecated: please use UnitOfMass.KILOGRAMS""" -MASS_MILLIGRAMS: Final = "mg" +_DEPRECATED_MASS_MILLIGRAMS: Final = ( + UnitOfMass.MILLIGRAMS, + "2025.1", +) """Deprecated: please use UnitOfMass.MILLIGRAMS""" -MASS_MICROGRAMS: Final = "µg" +_DEPRECATED_MASS_MICROGRAMS: Final = ( + UnitOfMass.MICROGRAMS, + "2025.1", +) """Deprecated: please use UnitOfMass.MICROGRAMS""" -MASS_OUNCES: Final = "oz" +_DEPRECATED_MASS_OUNCES: Final = ( + UnitOfMass.OUNCES, + "2025.1", +) """Deprecated: please use UnitOfMass.OUNCES""" -MASS_POUNDS: Final = "lb" +_DEPRECATED_MASS_POUNDS: Final = ( + UnitOfMass.POUNDS, + "2025.1", +) """Deprecated: please use UnitOfMass.POUNDS""" # Conductivity units @@ -840,9 +1135,15 @@ class UnitOfIrradiance(StrEnum): # Irradiation units -IRRADIATION_WATTS_PER_SQUARE_METER: Final = "W/m²" +_DEPRECATED_IRRADIATION_WATTS_PER_SQUARE_METER: Final = ( + UnitOfIrradiance.WATTS_PER_SQUARE_METER, + "2025.1", +) """Deprecated: please use UnitOfIrradiance.WATTS_PER_SQUARE_METER""" -IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = "BTU/(h×ft²)" +_DEPRECATED_IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = ( + UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT, + "2025.1", +) """Deprecated: please use UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT""" @@ -884,13 +1185,22 @@ class UnitOfPrecipitationDepth(StrEnum): # Precipitation units -PRECIPITATION_INCHES: Final = "in" +_DEPRECATED_PRECIPITATION_INCHES: Final = (UnitOfPrecipitationDepth.INCHES, "2025.1") """Deprecated: please use UnitOfPrecipitationDepth.INCHES""" -PRECIPITATION_MILLIMETERS: Final = "mm" +_DEPRECATED_PRECIPITATION_MILLIMETERS: Final = ( + UnitOfPrecipitationDepth.MILLIMETERS, + "2025.1", +) """Deprecated: please use UnitOfPrecipitationDepth.MILLIMETERS""" -PRECIPITATION_MILLIMETERS_PER_HOUR: Final = "mm/h" +_DEPRECATED_PRECIPITATION_MILLIMETERS_PER_HOUR: Final = ( + UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR""" -PRECIPITATION_INCHES_PER_HOUR: Final = "in/h" +_DEPRECATED_PRECIPITATION_INCHES_PER_HOUR: Final = ( + UnitOfVolumetricFlux.INCHES_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR""" # Concentration units @@ -913,24 +1223,36 @@ class UnitOfSpeed(StrEnum): MILES_PER_HOUR = "mph" -SPEED_FEET_PER_SECOND: Final = "ft/s" +_DEPRECATED_SPEED_FEET_PER_SECOND: Final = (UnitOfSpeed.FEET_PER_SECOND, "2025.1") """Deprecated: please use UnitOfSpeed.FEET_PER_SECOND""" -SPEED_METERS_PER_SECOND: Final = "m/s" +_DEPRECATED_SPEED_METERS_PER_SECOND: Final = (UnitOfSpeed.METERS_PER_SECOND, "2025.1") """Deprecated: please use UnitOfSpeed.METERS_PER_SECOND""" -SPEED_KILOMETERS_PER_HOUR: Final = "km/h" +_DEPRECATED_SPEED_KILOMETERS_PER_HOUR: Final = ( + UnitOfSpeed.KILOMETERS_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfSpeed.KILOMETERS_PER_HOUR""" -SPEED_KNOTS: Final = "kn" +_DEPRECATED_SPEED_KNOTS: Final = (UnitOfSpeed.KNOTS, "2025.1") """Deprecated: please use UnitOfSpeed.KNOTS""" -SPEED_MILES_PER_HOUR: Final = "mph" +_DEPRECATED_SPEED_MILES_PER_HOUR: Final = (UnitOfSpeed.MILES_PER_HOUR, "2025.1") """Deprecated: please use UnitOfSpeed.MILES_PER_HOUR""" -SPEED_MILLIMETERS_PER_DAY: Final = "mm/d" +_DEPRECATED_SPEED_MILLIMETERS_PER_DAY: Final = ( + UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.MILLIMETERS_PER_DAY""" -SPEED_INCHES_PER_DAY: Final = "in/d" +_DEPRECATED_SPEED_INCHES_PER_DAY: Final = ( + UnitOfVolumetricFlux.INCHES_PER_DAY, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_DAY""" -SPEED_INCHES_PER_HOUR: Final = "in/h" +_DEPRECATED_SPEED_INCHES_PER_HOUR: Final = ( + UnitOfVolumetricFlux.INCHES_PER_HOUR, + "2025.1", +) """Deprecated: please use UnitOfVolumetricFlux.INCHES_PER_HOUR""" @@ -966,47 +1288,47 @@ class UnitOfInformation(StrEnum): YOBIBYTES = "YiB" -DATA_BITS: Final = "bit" +_DEPRECATED_DATA_BITS: Final = (UnitOfInformation.BITS, "2025.1") """Deprecated: please use UnitOfInformation.BITS""" -DATA_KILOBITS: Final = "kbit" +_DEPRECATED_DATA_KILOBITS: Final = (UnitOfInformation.KILOBITS, "2025.1") """Deprecated: please use UnitOfInformation.KILOBITS""" -DATA_MEGABITS: Final = "Mbit" +_DEPRECATED_DATA_MEGABITS: Final = (UnitOfInformation.MEGABITS, "2025.1") """Deprecated: please use UnitOfInformation.MEGABITS""" -DATA_GIGABITS: Final = "Gbit" +_DEPRECATED_DATA_GIGABITS: Final = (UnitOfInformation.GIGABITS, "2025.1") """Deprecated: please use UnitOfInformation.GIGABITS""" -DATA_BYTES: Final = "B" +_DEPRECATED_DATA_BYTES: Final = (UnitOfInformation.BYTES, "2025.1") """Deprecated: please use UnitOfInformation.BYTES""" -DATA_KILOBYTES: Final = "kB" +_DEPRECATED_DATA_KILOBYTES: Final = (UnitOfInformation.KILOBYTES, "2025.1") """Deprecated: please use UnitOfInformation.KILOBYTES""" -DATA_MEGABYTES: Final = "MB" +_DEPRECATED_DATA_MEGABYTES: Final = (UnitOfInformation.MEGABYTES, "2025.1") """Deprecated: please use UnitOfInformation.MEGABYTES""" -DATA_GIGABYTES: Final = "GB" +_DEPRECATED_DATA_GIGABYTES: Final = (UnitOfInformation.GIGABYTES, "2025.1") """Deprecated: please use UnitOfInformation.GIGABYTES""" -DATA_TERABYTES: Final = "TB" +_DEPRECATED_DATA_TERABYTES: Final = (UnitOfInformation.TERABYTES, "2025.1") """Deprecated: please use UnitOfInformation.TERABYTES""" -DATA_PETABYTES: Final = "PB" +_DEPRECATED_DATA_PETABYTES: Final = (UnitOfInformation.PETABYTES, "2025.1") """Deprecated: please use UnitOfInformation.PETABYTES""" -DATA_EXABYTES: Final = "EB" +_DEPRECATED_DATA_EXABYTES: Final = (UnitOfInformation.EXABYTES, "2025.1") """Deprecated: please use UnitOfInformation.EXABYTES""" -DATA_ZETTABYTES: Final = "ZB" +_DEPRECATED_DATA_ZETTABYTES: Final = (UnitOfInformation.ZETTABYTES, "2025.1") """Deprecated: please use UnitOfInformation.ZETTABYTES""" -DATA_YOTTABYTES: Final = "YB" +_DEPRECATED_DATA_YOTTABYTES: Final = (UnitOfInformation.YOTTABYTES, "2025.1") """Deprecated: please use UnitOfInformation.YOTTABYTES""" -DATA_KIBIBYTES: Final = "KiB" +_DEPRECATED_DATA_KIBIBYTES: Final = (UnitOfInformation.KIBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.KIBIBYTES""" -DATA_MEBIBYTES: Final = "MiB" +_DEPRECATED_DATA_MEBIBYTES: Final = (UnitOfInformation.MEBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.MEBIBYTES""" -DATA_GIBIBYTES: Final = "GiB" +_DEPRECATED_DATA_GIBIBYTES: Final = (UnitOfInformation.GIBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.GIBIBYTES""" -DATA_TEBIBYTES: Final = "TiB" +_DEPRECATED_DATA_TEBIBYTES: Final = (UnitOfInformation.TEBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.TEBIBYTES""" -DATA_PEBIBYTES: Final = "PiB" +_DEPRECATED_DATA_PEBIBYTES: Final = (UnitOfInformation.PEBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.PEBIBYTES""" -DATA_EXBIBYTES: Final = "EiB" +_DEPRECATED_DATA_EXBIBYTES: Final = (UnitOfInformation.EXBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.EXBIBYTES""" -DATA_ZEBIBYTES: Final = "ZiB" +_DEPRECATED_DATA_ZEBIBYTES: Final = (UnitOfInformation.ZEBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.ZEBIBYTES""" -DATA_YOBIBYTES: Final = "YiB" +_DEPRECATED_DATA_YOBIBYTES: Final = (UnitOfInformation.YOBIBYTES, "2025.1") """Deprecated: please use UnitOfInformation.YOBIBYTES""" @@ -1027,27 +1349,60 @@ class UnitOfDataRate(StrEnum): GIBIBYTES_PER_SECOND = "GiB/s" -DATA_RATE_BITS_PER_SECOND: Final = "bit/s" +_DEPRECATED_DATA_RATE_BITS_PER_SECOND: Final = ( + UnitOfDataRate.BITS_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.BITS_PER_SECOND""" -DATA_RATE_KILOBITS_PER_SECOND: Final = "kbit/s" +_DEPRECATED_DATA_RATE_KILOBITS_PER_SECOND: Final = ( + UnitOfDataRate.KILOBITS_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.KILOBITS_PER_SECOND""" -DATA_RATE_MEGABITS_PER_SECOND: Final = "Mbit/s" +_DEPRECATED_DATA_RATE_MEGABITS_PER_SECOND: Final = ( + UnitOfDataRate.MEGABITS_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.MEGABITS_PER_SECOND""" -DATA_RATE_GIGABITS_PER_SECOND: Final = "Gbit/s" +_DEPRECATED_DATA_RATE_GIGABITS_PER_SECOND: Final = ( + UnitOfDataRate.GIGABITS_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.GIGABITS_PER_SECOND""" -DATA_RATE_BYTES_PER_SECOND: Final = "B/s" +_DEPRECATED_DATA_RATE_BYTES_PER_SECOND: Final = ( + UnitOfDataRate.BYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.BYTES_PER_SECOND""" -DATA_RATE_KILOBYTES_PER_SECOND: Final = "kB/s" +_DEPRECATED_DATA_RATE_KILOBYTES_PER_SECOND: Final = ( + UnitOfDataRate.KILOBYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.KILOBYTES_PER_SECOND""" -DATA_RATE_MEGABYTES_PER_SECOND: Final = "MB/s" +_DEPRECATED_DATA_RATE_MEGABYTES_PER_SECOND: Final = ( + UnitOfDataRate.MEGABYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.MEGABYTES_PER_SECOND""" -DATA_RATE_GIGABYTES_PER_SECOND: Final = "GB/s" +_DEPRECATED_DATA_RATE_GIGABYTES_PER_SECOND: Final = ( + UnitOfDataRate.GIGABYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.GIGABYTES_PER_SECOND""" -DATA_RATE_KIBIBYTES_PER_SECOND: Final = "KiB/s" +_DEPRECATED_DATA_RATE_KIBIBYTES_PER_SECOND: Final = ( + UnitOfDataRate.KIBIBYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.KIBIBYTES_PER_SECOND""" -DATA_RATE_MEBIBYTES_PER_SECOND: Final = "MiB/s" +_DEPRECATED_DATA_RATE_MEBIBYTES_PER_SECOND: Final = ( + UnitOfDataRate.MEBIBYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.MEBIBYTES_PER_SECOND""" -DATA_RATE_GIBIBYTES_PER_SECOND: Final = "GiB/s" +_DEPRECATED_DATA_RATE_GIBIBYTES_PER_SECOND: Final = ( + UnitOfDataRate.GIBIBYTES_PER_SECOND, + "2025.1", +) """Deprecated: please use UnitOfDataRate.GIBIBYTES_PER_SECOND""" @@ -1104,6 +1459,11 @@ SERVICE_STOP_COVER: Final = "stop_cover" SERVICE_STOP_COVER_TILT: Final = "stop_cover_tilt" SERVICE_TOGGLE_COVER_TILT: Final = "toggle_cover_tilt" +SERVICE_CLOSE_VALVE: Final = "close_valve" +SERVICE_OPEN_VALVE: Final = "open_valve" +SERVICE_SET_VALVE_POSITION: Final = "set_valve_position" +SERVICE_STOP_VALVE: Final = "stop_valve" + SERVICE_SELECT_OPTION: Final = "select_option" # #### API / REMOTE #### @@ -1161,30 +1521,6 @@ PRECISION_TENTHS: Final = 0.1 # cloud, alexa, or google_home components CLOUD_NEVER_EXPOSED_ENTITIES: Final[list[str]] = ["group.all_locks"] -# ENTITY_CATEGOR* below are deprecated as of 2021.12 -# use the EntityCategory enum instead. -ENTITY_CATEGORY_CONFIG: Final = "config" -ENTITY_CATEGORY_DIAGNOSTIC: Final = "diagnostic" -ENTITY_CATEGORIES: Final[list[str]] = [ - ENTITY_CATEGORY_CONFIG, - ENTITY_CATEGORY_DIAGNOSTIC, -] - -# The ID of the Home Assistant Media Player Cast App -CAST_APP_ID_HOMEASSISTANT_MEDIA: Final = "B45F4572" -# The ID of the Home Assistant Lovelace Cast App -CAST_APP_ID_HOMEASSISTANT_LOVELACE: Final = "A078F6B0" - -# User used by Supervisor -HASSIO_USER_NAME = "Supervisor" - -SIGNAL_BOOTSTRAP_INTEGRATIONS = "bootstrap_integrations" - -# Date/Time formats -FORMAT_DATE: Final = "%Y-%m-%d" -FORMAT_TIME: Final = "%H:%M:%S" -FORMAT_DATETIME: Final = f"{FORMAT_DATE} {FORMAT_TIME}" - class EntityCategory(StrEnum): """Category of an entity. @@ -1200,3 +1536,25 @@ class EntityCategory(StrEnum): # Diagnostic: An entity exposing some configuration parameter, # or diagnostics of a device. DIAGNOSTIC = "diagnostic" + + +# ENTITY_CATEGOR* below are deprecated as of 2021.12 +# use the EntityCategory enum instead. +_DEPRECATED_ENTITY_CATEGORY_CONFIG: Final = (EntityCategory.CONFIG, "2025.1") +_DEPRECATED_ENTITY_CATEGORY_DIAGNOSTIC: Final = (EntityCategory.DIAGNOSTIC, "2025.1") +ENTITY_CATEGORIES: Final[list[str]] = [cls.value for cls in EntityCategory] + +# The ID of the Home Assistant Media Player Cast App +CAST_APP_ID_HOMEASSISTANT_MEDIA: Final = "B45F4572" +# The ID of the Home Assistant Lovelace Cast App +CAST_APP_ID_HOMEASSISTANT_LOVELACE: Final = "A078F6B0" + +# User used by Supervisor +HASSIO_USER_NAME = "Supervisor" + +SIGNAL_BOOTSTRAP_INTEGRATIONS = "bootstrap_integrations" + +# Date/Time formats +FORMAT_DATE: Final = "%Y-%m-%d" +FORMAT_TIME: Final = "%H:%M:%S" +FORMAT_DATETIME: Final = f"{FORMAT_DATE} {FORMAT_TIME}" diff --git a/homeassistant/core.py b/homeassistant/core.py index 7d9d8d19b49..51cb3d4e496 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -18,6 +18,7 @@ from collections.abc import ( ) import concurrent.futures from contextlib import suppress +from dataclasses import dataclass import datetime import enum import functools @@ -66,10 +67,10 @@ from .const import ( EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_STATE_CHANGED, - LENGTH_METERS, MATCH_ALL, MAX_LENGTH_EVENT_EVENT_TYPE, MAX_LENGTH_STATE_STATE, + UnitOfLength, __version__, ) from .exceptions import ( @@ -107,9 +108,10 @@ if TYPE_CHECKING: from .helpers.entity import StateInfo -STAGE_1_SHUTDOWN_TIMEOUT = 100 -STAGE_2_SHUTDOWN_TIMEOUT = 60 -STAGE_3_SHUTDOWN_TIMEOUT = 30 +STOPPING_STAGE_SHUTDOWN_TIMEOUT = 20 +STOP_STAGE_SHUTDOWN_TIMEOUT = 100 +FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT = 60 +CLOSE_STAGE_SHUTDOWN_TIMEOUT = 30 block_async_io.enable() @@ -145,9 +147,42 @@ class ConfigSource(enum.StrEnum): # SOURCE_* are deprecated as of Home Assistant 2022.2, use ConfigSource instead -SOURCE_DISCOVERED = ConfigSource.DISCOVERED.value -SOURCE_STORAGE = ConfigSource.STORAGE.value -SOURCE_YAML = ConfigSource.YAML.value +_DEPRECATED_SOURCE_DISCOVERED = (ConfigSource.DISCOVERED, "2025.1") +_DEPRECATED_SOURCE_STORAGE = (ConfigSource.STORAGE, "2025.1") +_DEPRECATED_SOURCE_YAML = (ConfigSource.YAML, "2025.1") + + +# Can be removed if no deprecated constant are in this module anymore +def __getattr__(name: str) -> Any: + """Check if the not found name is a deprecated constant. + + If it is, print a deprecation warning and return the value of the constant. + Otherwise raise AttributeError. + """ + module_globals = globals() + if f"_DEPRECATED_{name}" not in module_globals: + raise AttributeError(f"Module {__name__} has no attribute {name!r}") + + # Avoid circular import + from .helpers.deprecation import ( # pylint: disable=import-outside-toplevel + check_if_deprecated_constant, + ) + + return check_if_deprecated_constant(name, module_globals) + + +# Can be removed if no deprecated constant are in this module anymore +def __dir__() -> list[str]: + """Return dir() with deprecated constants.""" + # Copied method from homeassistant.helpers.deprecattion#dir_with_deprecated_constants to avoid import cycle + module_globals = globals() + + return list(module_globals) + [ + name.removeprefix("_DEPRECATED_") + for name in module_globals + if name.startswith("_DEPRECATED_") + ] + # How long to wait until things that run on startup have to finish. TIMEOUT_EVENT_START = 15 @@ -299,6 +334,14 @@ class HassJob(Generic[_P, _R_co]): return f"" +@dataclass(frozen=True) +class HassJobWithArgs: + """Container for a HassJob and arguments.""" + + job: HassJob[..., Coroutine[Any, Any, Any] | Any] + args: Iterable[Any] + + def _get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType: """Determine the job type from the callable.""" # Check for partials to properly determine if coroutine function @@ -370,6 +413,7 @@ class HomeAssistant: # Timeout handler for Core/Helper namespace self.timeout: TimeoutManager = TimeoutManager() self._stop_future: concurrent.futures.Future[None] | None = None + self._shutdown_jobs: list[HassJobWithArgs] = [] @property def is_running(self) -> bool: @@ -766,6 +810,42 @@ class HomeAssistant: for task in pending: _LOGGER.debug("Waited %s seconds for task: %s", wait_time, task) + @overload + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any]], *args: Any + ) -> CALLBACK_TYPE: + ... + + @overload + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any + ) -> CALLBACK_TYPE: + ... + + @callback + def async_add_shutdown_job( + self, hassjob: HassJob[..., Coroutine[Any, Any, Any] | Any], *args: Any + ) -> CALLBACK_TYPE: + """Add a HassJob which will be executed on shutdown. + + This method must be run in the event loop. + + hassjob: HassJob + args: parameters for method to call. + + Returns function to remove the job. + """ + job_with_args = HassJobWithArgs(hassjob, args) + self._shutdown_jobs.append(job_with_args) + + @callback + def remove_job() -> None: + self._shutdown_jobs.remove(job_with_args) + + return remove_job + def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" if self.state == CoreState.not_running: # just ignore @@ -799,6 +879,26 @@ class HomeAssistant: "Stopping Home Assistant before startup has completed may fail" ) + # Stage 1 - Run shutdown jobs + try: + async with self.timeout.async_timeout(STOPPING_STAGE_SHUTDOWN_TIMEOUT): + tasks: list[asyncio.Future[Any]] = [] + for job in self._shutdown_jobs: + task_or_none = self.async_run_hass_job(job.job, *job.args) + if not task_or_none: + continue + tasks.append(task_or_none) + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + except asyncio.TimeoutError: + _LOGGER.warning( + "Timed out waiting for shutdown jobs to complete, the shutdown will" + " continue" + ) + self._async_log_running_tasks("run shutdown jobs") + + # Stage 2 - Stop integrations + # Keep holding the reference to the tasks but do not allow them # to block shutdown. Only tasks created after this point will # be waited for. @@ -816,33 +916,32 @@ class HomeAssistant: self.exit_code = exit_code - # stage 1 self.state = CoreState.stopping self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) try: - async with self.timeout.async_timeout(STAGE_1_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(STOP_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 1 to complete, the shutdown will" + "Timed out waiting for integrations to stop, the shutdown will" " continue" ) - self._async_log_running_tasks(1) + self._async_log_running_tasks("stop integrations") - # stage 2 + # Stage 3 - Final write self.state = CoreState.final_write self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) try: - async with self.timeout.async_timeout(STAGE_2_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 2 to complete, the shutdown will" + "Timed out waiting for final writes to complete, the shutdown will" " continue" ) - self._async_log_running_tasks(2) + self._async_log_running_tasks("final write") - # stage 3 + # Stage 4 - Close self.state = CoreState.not_running self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) @@ -856,12 +955,12 @@ class HomeAssistant: # were awaiting another task continue _LOGGER.warning( - "Task %s was still running after stage 2 shutdown; " + "Task %s was still running after final writes shutdown stage; " "Integrations should cancel non-critical tasks when receiving " "the stop event to prevent delaying shutdown", task, ) - task.cancel("Home Assistant stage 2 shutdown") + task.cancel("Home Assistant final writes shutdown stage") try: async with asyncio.timeout(0.1): await task @@ -870,11 +969,11 @@ class HomeAssistant: except asyncio.TimeoutError: # Task may be shielded from cancellation. _LOGGER.exception( - "Task %s could not be canceled during stage 3 shutdown", task + "Task %s could not be canceled during final shutdown stage", task ) except Exception as exc: # pylint: disable=broad-except _LOGGER.exception( - "Task %s error during stage 3 shutdown: %s", task, exc + "Task %s error during final shutdown stage: %s", task, exc ) # Prevent run_callback_threadsafe from scheduling any additional @@ -885,14 +984,14 @@ class HomeAssistant: shutdown_run_callback_threadsafe(self.loop) try: - async with self.timeout.async_timeout(STAGE_3_SHUTDOWN_TIMEOUT): + async with self.timeout.async_timeout(CLOSE_STAGE_SHUTDOWN_TIMEOUT): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( - "Timed out waiting for shutdown stage 3 to complete, the shutdown will" + "Timed out waiting for close event to be processed, the shutdown will" " continue" ) - self._async_log_running_tasks(3) + self._async_log_running_tasks("close") self.state = CoreState.stopped @@ -912,10 +1011,10 @@ class HomeAssistant: ): handle.cancel() - def _async_log_running_tasks(self, stage: int) -> None: + def _async_log_running_tasks(self, stage: str) -> None: """Log all running tasks.""" for task in self._tasks: - _LOGGER.warning("Shutdown stage %s: still running: %s", stage, task) + _LOGGER.warning("Shutdown stage '%s': still running: %s", stage, task) class Context: @@ -1871,13 +1970,20 @@ class ServiceRegistry: Coroutine[Any, Any, ServiceResponse] | ServiceResponse | None, ], schema: vol.Schema | None = None, + supports_response: SupportsResponse = SupportsResponse.NONE, ) -> None: """Register a service. Schema is called to coerce and validate the service data. """ run_callback_threadsafe( - self._hass.loop, self.async_register, domain, service, service_func, schema + self._hass.loop, + self.async_register, + domain, + service, + service_func, + schema, + supports_response, ).result() @callback @@ -2176,7 +2282,8 @@ class Config: Async friendly. """ return self.units.length( - location.distance(self.latitude, self.longitude, lat, lon), LENGTH_METERS + location.distance(self.latitude, self.longitude, lat, lon), + UnitOfLength.METERS, ) def path(self, *path: str) -> str: diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index e0ea195a3ff..5c9c0ff1ce4 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -6,6 +6,7 @@ from collections.abc import Callable, Iterable, Mapping import copy from dataclasses import dataclass from enum import StrEnum +from functools import partial import logging from types import MappingProxyType from typing import Any, Required, TypedDict @@ -14,6 +15,11 @@ import voluptuous as vol from .core import HomeAssistant, callback from .exceptions import HomeAssistantError +from .helpers.deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from .helpers.frame import report from .util import uuid as uuid_util @@ -34,18 +40,41 @@ class FlowResultType(StrEnum): # RESULT_TYPE_* is deprecated, to be removed in 2022.9 -RESULT_TYPE_FORM = "form" -RESULT_TYPE_CREATE_ENTRY = "create_entry" -RESULT_TYPE_ABORT = "abort" -RESULT_TYPE_EXTERNAL_STEP = "external" -RESULT_TYPE_EXTERNAL_STEP_DONE = "external_done" -RESULT_TYPE_SHOW_PROGRESS = "progress" -RESULT_TYPE_SHOW_PROGRESS_DONE = "progress_done" -RESULT_TYPE_MENU = "menu" +_DEPRECATED_RESULT_TYPE_FORM = DeprecatedConstantEnum(FlowResultType.FORM, "2025.1") +_DEPRECATED_RESULT_TYPE_CREATE_ENTRY = DeprecatedConstantEnum( + FlowResultType.CREATE_ENTRY, "2025.1" +) +_DEPRECATED_RESULT_TYPE_ABORT = DeprecatedConstantEnum(FlowResultType.ABORT, "2025.1") +_DEPRECATED_RESULT_TYPE_EXTERNAL_STEP = DeprecatedConstantEnum( + FlowResultType.EXTERNAL_STEP, "2025.1" +) +_DEPRECATED_RESULT_TYPE_EXTERNAL_STEP_DONE = DeprecatedConstantEnum( + FlowResultType.EXTERNAL_STEP_DONE, "2025.1" +) +_DEPRECATED_RESULT_TYPE_SHOW_PROGRESS = DeprecatedConstantEnum( + FlowResultType.SHOW_PROGRESS, "2025.1" +) +_DEPRECATED_RESULT_TYPE_SHOW_PROGRESS_DONE = DeprecatedConstantEnum( + FlowResultType.SHOW_PROGRESS_DONE, "2025.1" +) +_DEPRECATED_RESULT_TYPE_MENU = DeprecatedConstantEnum(FlowResultType.MENU, "2025.1") + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) # Event that is fired when a flow is progressed via external or progress source. EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed" +FLOW_NOT_COMPLETE_STEPS = { + FlowResultType.FORM, + FlowResultType.EXTERNAL_STEP, + FlowResultType.EXTERNAL_STEP_DONE, + FlowResultType.SHOW_PROGRESS, + FlowResultType.SHOW_PROGRESS_DONE, + FlowResultType.MENU, +} + @dataclass(slots=True) class BaseServiceInfo: @@ -94,6 +123,7 @@ class FlowResult(TypedDict, total=False): handler: Required[str] last_step: bool | None menu_options: list[str] | dict[str, str] + minor_version: int options: Mapping[str, Any] preview: str | None progress_action: str @@ -406,14 +436,7 @@ class FlowManager(abc.ABC): error_if_core=False, ) - if result["type"] in ( - FlowResultType.FORM, - FlowResultType.EXTERNAL_STEP, - FlowResultType.EXTERNAL_STEP_DONE, - FlowResultType.SHOW_PROGRESS, - FlowResultType.SHOW_PROGRESS_DONE, - FlowResultType.MENU, - ): + if result["type"] in FLOW_NOT_COMPLETE_STEPS: self._raise_if_step_does_not_exist(flow, result["step_id"]) flow.cur_step = result return result @@ -470,6 +493,7 @@ class FlowHandler: # Set by developer VERSION = 1 + MINOR_VERSION = 1 @property def source(self) -> str | None: @@ -549,6 +573,7 @@ class FlowHandler: """Finish flow.""" flow_result = FlowResult( version=self.VERSION, + minor_version=self.MINOR_VERSION, type=FlowResultType.CREATE_ENTRY, flow_id=self.flow_id, handler=self.handler, diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index eeee6532792..cba1a88d25b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -14,6 +14,7 @@ FLOWS = { "template", "threshold", "tod", + "trend", "utility_meter", ], "integration": [ @@ -46,6 +47,7 @@ FLOWS = { "androidtv_remote", "anova", "anthemav", + "aosmith", "apcupsd", "apple_tv", "aranet", @@ -65,6 +67,7 @@ FLOWS = { "balboa", "blebox", "blink", + "blue_current", "bluemaestro", "bluetooth", "bmw_connected_drive", @@ -81,6 +84,7 @@ FLOWS = { "caldav", "canary", "cast", + "ccm15", "cert_expiry", "cloudflare", "co2signal", @@ -110,6 +114,7 @@ FLOWS = { "doorbird", "dormakaba_dkey", "dremel_3d_printer", + "drop_connect", "dsmr", "dsmr_reader", "dunehd", @@ -149,6 +154,7 @@ FLOWS = { "fitbit", "fivem", "fjaraskupan", + "flexit_bacnet", "flick_electric", "flipr", "flo", @@ -200,6 +206,7 @@ FLOWS = { "hisense_aehw4a1", "hive", "hlk_sw16", + "holiday", "home_connect", "home_plus_control", "homeassistant_sky_connect", @@ -301,6 +308,7 @@ FLOWS = { "mopeka", "motion_blinds", "motioneye", + "motionmount", "mqtt", "mullvad", "mutesync", @@ -312,6 +320,7 @@ FLOWS = { "nest", "netatmo", "netgear", + "netgear_lte", "nexia", "nextbus", "nextcloud", @@ -346,6 +355,7 @@ FLOWS = { "openweathermap", "opower", "oralb", + "osoenergy", "otbr", "ourgroceries", "overkiz", @@ -393,6 +403,7 @@ FLOWS = { "rapt_ble", "rdw", "recollect_waste", + "refoss", "renault", "renson", "reolink", @@ -466,9 +477,13 @@ FLOWS = { "steamist", "stookalert", "stookwijzer", + "streamlabswater", "subaru", + "suez_water", "sun", + "sunweg", "surepetcare", + "swiss_public_transport", "switchbee", "switchbot", "switchbot_cloud", @@ -477,14 +492,17 @@ FLOWS = { "syncthru", "synology_dsm", "system_bridge", + "systemmonitor", "tado", "tailscale", + "tailwind", "tami4", "tankerkoenig", "tasmota", "tautulli", "tellduslive", "tesla_wall_connector", + "tessie", "thermobeacon", "thermopro", "thread", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 6d04d7602f2..33d069c5663 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -571,6 +571,10 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "tado", "hostname": "tado*", }, + { + "domain": "tailwind", + "registered_devices": True, + }, { "domain": "tesla_wall_connector", "hostname": "teslawallconnector_*", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2c6d8277309..45bcc1788cd 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -65,6 +65,16 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "aep_ohio": { + "name": "AEP Ohio", + "integration_type": "virtual", + "supported_by": "opower" + }, + "aep_texas": { + "name": "AEP Texas", + "integration_type": "virtual", + "supported_by": "opower" + }, "aftership": { "name": "AfterShip", "integration_type": "hub", @@ -286,6 +296,12 @@ "integration_type": "virtual", "supported_by": "energyzero" }, + "aosmith": { + "name": "A. O. Smith", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "apache_kafka": { "name": "Apache Kafka", "integration_type": "hub", @@ -298,6 +314,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "appalachianpower": { + "name": "Appalachian Power", + "integration_type": "virtual", + "supported_by": "opower" + }, "apple": { "name": "Apple", "integrations": { @@ -629,6 +650,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "blue_current": { + "name": "Blue Current", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "bluemaestro": { "name": "BlueMaestro", "integration_type": "hub", @@ -774,6 +801,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "ccm15": { + "name": "Midea ccm15 AC Controller", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "cert_expiry": { "integration_type": "hub", "config_flow": true, @@ -1232,6 +1265,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "drop_connect": { + "name": "DROP", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "dsmr": { "name": "DSMR Slimme Meter", "integration_type": "hub", @@ -1766,9 +1805,20 @@ }, "flexit": { "name": "Flexit", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" + "integrations": { + "flexit": { + "integration_type": "hub", + "config_flow": false, + "iot_class": "local_polling", + "name": "Flexit" + }, + "flexit_bacnet": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling", + "name": "Flexit Nordic (BACnet)" + } + } }, "flexom": { "name": "Bouygues Flexom", @@ -1930,6 +1980,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "fujitsu_anywair": { + "name": "Fujitsu anywAIR", + "integration_type": "virtual", + "supported_by": "advantage_air" + }, "fully_kiosk": { "name": "Fully Kiosk Browser", "integration_type": "hub", @@ -2397,6 +2452,12 @@ "config_flow": true, "iot_class": "local_push" }, + "holiday": { + "name": "Holiday", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "home_connect": { "name": "Home Connect", "integration_type": "hub", @@ -2626,6 +2687,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "indianamichiganpower": { + "name": "Indiana Michigan Power", + "integration_type": "virtual", + "supported_by": "opower" + }, "influxdb": { "name": "InfluxDB", "integration_type": "hub", @@ -2827,6 +2893,11 @@ "config_flow": true, "iot_class": "local_push" }, + "kentuckypower": { + "name": "Kentucky Power", + "integration_type": "virtual", + "supported_by": "opower" + }, "keyboard": { "name": "Keyboard", "integration_type": "hub", @@ -3560,6 +3631,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "motionmount": { + "name": "Vogel's MotionMount", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "mpd": { "name": "Music Player Daemon (MPD)", "integration_type": "hub", @@ -3714,7 +3791,7 @@ }, "netgear_lte": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling", "name": "NETGEAR LTE" } @@ -4128,6 +4205,12 @@ "config_flow": false, "iot_class": "local_push" }, + "osoenergy": { + "name": "OSO Energy", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "osramlightify": { "name": "Osramlightify", "integration_type": "hub", @@ -4442,6 +4525,11 @@ "integration_type": "virtual", "supported_by": "opower" }, + "psoklahoma": { + "name": "Public Service Company of Oklahoma (PSO)", + "integration_type": "virtual", + "supported_by": "opower" + }, "pulseaudio_loopback": { "name": "PulseAudio Loopback", "integration_type": "hub", @@ -4676,6 +4764,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "refoss": { + "name": "Refoss", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "rejseplanen": { "name": "Rejseplanen", "integration_type": "hub", @@ -4931,6 +5025,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "scl": { + "name": "Seattle City Light (SCL)", + "integration_type": "virtual", + "supported_by": "opower" + }, "scrape": { "name": "Scrape", "integration_type": "hub", @@ -5486,7 +5585,7 @@ "streamlabswater": { "name": "StreamLabs", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "subaru": { @@ -5498,7 +5597,7 @@ "suez_water": { "name": "Suez Water", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "sun": { @@ -5506,6 +5605,12 @@ "config_flow": true, "iot_class": "calculated" }, + "sunweg": { + "name": "Sun WEG", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "supervisord": { "name": "Supervisord", "integration_type": "hub", @@ -5524,6 +5629,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "swepco": { + "name": "Southwestern Electric Power Company (SWEPCO)", + "integration_type": "virtual", + "supported_by": "opower" + }, "swiss_hydrological_data": { "name": "Swiss Hydrological Data", "integration_type": "hub", @@ -5533,7 +5643,7 @@ "swiss_public_transport": { "name": "Swiss public transport", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "swisscom": { @@ -5621,7 +5731,7 @@ "systemmonitor": { "name": "System Monitor", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "tado": { @@ -5640,6 +5750,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "tailwind": { + "name": "Tailwind", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "tami4": { "name": "Tami4 Edge / Edge+", "integration_type": "hub", @@ -5757,6 +5873,12 @@ } } }, + "tessie": { + "name": "Tessie", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "tfiac": { "name": "Tfiac", "integration_type": "hub", @@ -5991,12 +6113,6 @@ "config_flow": false, "iot_class": "cloud_polling" }, - "trend": { - "name": "Trend", - "integration_type": "hub", - "config_flow": false, - "iot_class": "calculated" - }, "tuya": { "name": "Tuya", "integration_type": "hub", @@ -6660,7 +6776,7 @@ "iot_class": "local_polling" }, "zamg": { - "name": "Zentralanstalt f\u00fcr Meteorologie und Geodynamik (ZAMG)", + "name": "GeoSphere Austria", "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling" @@ -6822,6 +6938,12 @@ "config_flow": true, "iot_class": "calculated" }, + "trend": { + "name": "Trend", + "integration_type": "helper", + "config_flow": true, + "iot_class": "calculated" + }, "utility_meter": { "integration_type": "helper", "config_flow": true, diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py index 69abf7c64fe..0c456774e4d 100644 --- a/homeassistant/generated/mqtt.py +++ b/homeassistant/generated/mqtt.py @@ -4,6 +4,9 @@ To update, run python3 -m script.hassfest """ MQTT = { + "drop_connect": [ + "drop_connect/discovery/#", + ], "dsmr_reader": [ "dsmr/#", ], diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 55570078d80..fea1d4ec889 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -494,6 +494,12 @@ ZEROCONF = { "vendor": "synology*", }, }, + { + "domain": "tailwind", + "properties": { + "vendor": "tailwind", + }, + }, ], "_hue._tcp.local.": [ { @@ -699,6 +705,11 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_tvm._tcp.local.": [ + { + "domain": "motionmount", + }, + ], "_uzg-01._tcp.local.": [ { "domain": "zha", diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index c9acdf0d712..52197e83495 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -2,11 +2,8 @@ from __future__ import annotations from collections.abc import Iterable, Sequence -import re from typing import TYPE_CHECKING -from homeassistant.const import CONF_PLATFORM - if TYPE_CHECKING: from .typing import ConfigType @@ -19,22 +16,23 @@ def config_per_platform( For example, will find 'switch', 'switch 2', 'switch 3', .. etc Async friendly. """ - for config_key in extract_domain_configs(config, domain): - if not (platform_config := config[config_key]): - continue + # pylint: disable-next=import-outside-toplevel + from homeassistant import config as ha_config - if not isinstance(platform_config, list): - platform_config = [platform_config] + # pylint: disable-next=import-outside-toplevel + from .deprecation import _print_deprecation_warning - item: ConfigType - platform: str | None - for item in platform_config: - try: - platform = item.get(CONF_PLATFORM) - except AttributeError: - platform = None + _print_deprecation_warning( + config_per_platform, + "config.config_per_platform", + "function", + "called", + "2024.6", + ) + return ha_config.config_per_platform(config, domain) - yield platform, item + +config_per_platform.__name__ = "helpers.config_per_platform" def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: @@ -42,5 +40,20 @@ def extract_domain_configs(config: ConfigType, domain: str) -> Sequence[str]: Async friendly. """ - pattern = re.compile(rf"^{domain}(| .+)$") - return [key for key in config if pattern.match(key)] + # pylint: disable-next=import-outside-toplevel + from homeassistant import config as ha_config + + # pylint: disable-next=import-outside-toplevel + from .deprecation import _print_deprecation_warning + + _print_deprecation_warning( + extract_domain_configs, + "config.extract_domain_configs", + "function", + "called", + "2024.6", + ) + return ha_config.extract_domain_configs(config, domain) + + +extract_domain_configs.__name__ = "helpers.extract_domain_configs" diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 23707949dcd..1c8efadfdc5 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -31,6 +31,7 @@ from homeassistant.requirements import ( ) import homeassistant.util.yaml.loader as yaml_loader +from . import config_validation as cv from .typing import ConfigType @@ -175,7 +176,7 @@ async def async_check_ha_config_file( # noqa: C901 core_config.pop(CONF_PACKAGES, None) # Filter out repeating config sections - components = {key.partition(" ")[0] for key in config} + components = {cv.domain_key(key) for key in config} frontend_dependencies: set[str] = set() if "frontend" in components or "default_config" in components: @@ -276,13 +277,17 @@ async def async_check_ha_config_file( # noqa: C901 # show errors for a missing integration in recovery mode or safe mode to # not confuse the user. if not hass.config.recovery_mode and not hass.config.safe_mode: - result.add_warning(f"Platform error {domain}.{p_name} - {ex}") + result.add_warning( + f"Platform error '{domain}' from integration '{p_name}' - {ex}" + ) continue except ( RequirementsNotFound, ImportError, ) as ex: - result.add_warning(f"Platform error {domain}.{p_name} - {ex}") + result.add_warning( + f"Platform error '{domain}' from integration '{p_name}' - {ex}" + ) continue # Validate platform specific schema diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 18445ba0789..e4b62dd679d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -99,6 +99,7 @@ from homeassistant.generated.countries import COUNTRIES from homeassistant.generated.languages import LANGUAGES from homeassistant.util import raise_if_invalid_path, slugify as util_slugify import homeassistant.util.dt as dt_util +from homeassistant.util.yaml.objects import NodeStrClass from . import script_variables as script_variables_helper, template as template_helper @@ -350,6 +351,30 @@ comp_entity_ids_or_uuids = vol.Any( ) +def domain_key(config_key: Any) -> str: + """Validate a top level config key with an optional label and return the domain. + + A domain is separated from a label by one or more spaces, empty labels are not + allowed. + + Examples: + 'hue' returns 'hue' + 'hue 1' returns 'hue' + 'hue 1' returns 'hue' + 'hue ' raises + 'hue ' raises + """ + if not isinstance(config_key, str): + raise vol.Invalid("invalid domain", path=[config_key]) + + parts = config_key.partition(" ") + _domain = parts[0] if parts[2].strip(" ") else config_key + if not _domain or _domain.strip(" ") != _domain: + raise vol.Invalid("invalid domain", path=[config_key]) + + return _domain + + def entity_domain(domain: str | list[str]) -> Callable[[Any], str]: """Validate that entity belong to domain.""" ent_domain = entities_domain(domain) @@ -581,7 +606,11 @@ def string(value: Any) -> str: raise vol.Invalid("string value is None") # This is expected to be the most common case, so check it first. - if type(value) is str: # noqa: E721 + if ( + type(value) is str # noqa: E721 + or type(value) is NodeStrClass # noqa: E721 + or isinstance(value, str) + ): return value if isinstance(value, template_helper.ResultWrapper): diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 5a0682fdda2..efd0363732a 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -3,10 +3,11 @@ from __future__ import annotations from collections.abc import Callable from contextlib import suppress +from enum import Enum import functools import inspect import logging -from typing import Any, ParamSpec, TypeVar +from typing import Any, NamedTuple, ParamSpec, TypeVar from homeassistant.core import HomeAssistant, async_get_hass from homeassistant.exceptions import HomeAssistantError @@ -97,7 +98,7 @@ def get_deprecated( def deprecated_class( - replacement: str, + replacement: str, *, breaks_in_ha_version: str | None = None ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: """Mark class as deprecated and provide a replacement class to be used instead. @@ -111,7 +112,9 @@ def deprecated_class( @functools.wraps(cls) def deprecated_cls(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original class.""" - _print_deprecation_warning(cls, replacement, "class", "instantiated") + _print_deprecation_warning( + cls, replacement, "class", "instantiated", breaks_in_ha_version + ) return cls(*args, **kwargs) return deprecated_cls @@ -120,7 +123,7 @@ def deprecated_class( def deprecated_function( - replacement: str, + replacement: str, *, breaks_in_ha_version: str | None = None ) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: """Mark function as deprecated and provide a replacement to be used instead. @@ -134,7 +137,9 @@ def deprecated_function( @functools.wraps(func) def deprecated_func(*args: _P.args, **kwargs: _P.kwargs) -> _R: """Wrap for the original function.""" - _print_deprecation_warning(func, replacement, "function", "called") + _print_deprecation_warning( + func, replacement, "function", "called", breaks_in_ha_version + ) return func(*args, **kwargs) return deprecated_func @@ -147,17 +152,45 @@ def _print_deprecation_warning( replacement: str, description: str, verb: str, + breaks_in_ha_version: str | None, ) -> None: - logger = logging.getLogger(obj.__module__) + _print_deprecation_warning_internal( + obj.__name__, + obj.__module__, + replacement, + description, + verb, + breaks_in_ha_version, + log_when_no_integration_is_found=True, + ) + + +def _print_deprecation_warning_internal( + obj_name: str, + module_name: str, + replacement: str, + description: str, + verb: str, + breaks_in_ha_version: str | None, + *, + log_when_no_integration_is_found: bool, +) -> None: + logger = logging.getLogger(module_name) + if breaks_in_ha_version: + breaks_in = f" which will be removed in HA Core {breaks_in_ha_version}" + else: + breaks_in = "" try: integration_frame = get_integration_frame() except MissingIntegrationFrame: - logger.warning( - "%s is a deprecated %s. Use %s instead", - obj.__name__, - description, - replacement, - ) + if log_when_no_integration_is_found: + logger.warning( + "%s is a deprecated %s%s. Use %s instead", + obj_name, + description, + breaks_in, + replacement, + ) else: if integration_frame.custom_integration: hass: HomeAssistant | None = None @@ -170,22 +203,110 @@ def _print_deprecation_warning( ) logger.warning( ( - "%s was %s from %s, this is a deprecated %s. Use %s instead," + "%s was %s from %s, this is a deprecated %s%s. Use %s instead," " please %s" ), - obj.__name__, + obj_name, verb, integration_frame.integration, description, + breaks_in, replacement, report_issue, ) else: logger.warning( - "%s was %s from %s, this is a deprecated %s. Use %s instead", - obj.__name__, + "%s was %s from %s, this is a deprecated %s%s. Use %s instead", + obj_name, verb, integration_frame.integration, description, + breaks_in, replacement, ) + + +class DeprecatedConstant(NamedTuple): + """Deprecated constant.""" + + value: Any + replacement: str + breaks_in_ha_version: str | None + + +class DeprecatedConstantEnum(NamedTuple): + """Deprecated constant.""" + + enum: Enum + breaks_in_ha_version: str | None + + +_PREFIX_DEPRECATED = "_DEPRECATED_" + + +def check_if_deprecated_constant(name: str, module_globals: dict[str, Any]) -> Any: + """Check if the not found name is a deprecated constant. + + If it is, print a deprecation warning and return the value of the constant. + Otherwise raise AttributeError. + """ + module_name = module_globals.get("__name__") + logger = logging.getLogger(module_name) + value = replacement = None + if (deprecated_const := module_globals.get(_PREFIX_DEPRECATED + name)) is None: + raise AttributeError(f"Module {module_name!r} has no attribute {name!r}") + if isinstance(deprecated_const, DeprecatedConstant): + value = deprecated_const.value + replacement = deprecated_const.replacement + breaks_in_ha_version = deprecated_const.breaks_in_ha_version + elif isinstance(deprecated_const, DeprecatedConstantEnum): + value = deprecated_const.enum.value + replacement = ( + f"{deprecated_const.enum.__class__.__name__}.{deprecated_const.enum.name}" + ) + breaks_in_ha_version = deprecated_const.breaks_in_ha_version + elif isinstance(deprecated_const, tuple): + # Use DeprecatedConstant and DeprecatedConstant instead, where possible + # Used to avoid import cycles. + if len(deprecated_const) == 3: + value = deprecated_const[0] + replacement = deprecated_const[1] + breaks_in_ha_version = deprecated_const[2] + elif len(deprecated_const) == 2 and isinstance(deprecated_const[0], Enum): + enum = deprecated_const[0] + value = enum.value + replacement = f"{enum.__class__.__name__}.{enum.name}" + breaks_in_ha_version = deprecated_const[1] + + if value is None or replacement is None: + msg = ( + f"Value of {_PREFIX_DEPRECATED}{name} is an instance of {type(deprecated_const)} " + "but an instance of DeprecatedConstant or DeprecatedConstantEnum is required" + ) + + logger.debug(msg) + # PEP 562 -- Module __getattr__ and __dir__ + # specifies that __getattr__ should raise AttributeError if the attribute is not + # found. + # https://peps.python.org/pep-0562/#specification + raise AttributeError(msg) # noqa: TRY004 + + _print_deprecation_warning_internal( + name, + module_name or __name__, + replacement, + "constant", + "used", + breaks_in_ha_version, + log_when_no_integration_is_found=False, + ) + return value + + +def dir_with_deprecated_constants(module_globals: dict[str, Any]) -> list[str]: + """Return dir() with deprecated constants.""" + return list(module_globals) + [ + name.removeprefix(_PREFIX_DEPRECATED) + for name in module_globals + if name.startswith(_PREFIX_DEPRECATED) + ] diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9a26821faaf..bd509cb47ec 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections import UserDict from collections.abc import Coroutine, ValuesView from enum import StrEnum +from functools import partial import logging import time from typing import TYPE_CHECKING, Any, Literal, TypedDict, TypeVar, cast @@ -21,6 +22,11 @@ import homeassistant.util.uuid as uuid_util from . import storage from .debounce import Debouncer +from .deprecation import ( + DeprecatedConstantEnum, + check_if_deprecated_constant, + dir_with_deprecated_constants, +) from .frame import report from .json import JSON_DUMP, find_paths_unserializable_data from .typing import UNDEFINED, UndefinedType @@ -61,9 +67,17 @@ class DeviceEntryDisabler(StrEnum): # DISABLED_* are deprecated, to be removed in 2022.3 -DISABLED_CONFIG_ENTRY = DeviceEntryDisabler.CONFIG_ENTRY.value -DISABLED_INTEGRATION = DeviceEntryDisabler.INTEGRATION.value -DISABLED_USER = DeviceEntryDisabler.USER.value +_DEPRECATED_DISABLED_CONFIG_ENTRY = DeprecatedConstantEnum( + DeviceEntryDisabler.CONFIG_ENTRY, "2025.1" +) +_DEPRECATED_DISABLED_INTEGRATION = DeprecatedConstantEnum( + DeviceEntryDisabler.INTEGRATION, "2025.1" +) +_DEPRECATED_DISABLED_USER = DeprecatedConstantEnum(DeviceEntryDisabler.USER, "2025.1") + +# Both can be removed if no deprecated constant are in this module anymore +__getattr__ = partial(check_if_deprecated_constant, module_globals=globals()) +__dir__ = partial(dir_with_deprecated_constants, module_globals=globals()) class DeviceInfo(TypedDict, total=False): diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index e416d939914..07112226ecf 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -90,20 +90,22 @@ def dispatcher_send(hass: HomeAssistant, signal: str, *args: Any) -> None: hass.loop.call_soon_threadsafe(async_dispatcher_send, hass, signal, *args) +def _format_err(signal: str, target: Callable[..., Any], *args: Any) -> str: + """Format error message.""" + return "Exception in {} when dispatching '{}': {}".format( + # Functions wrapped in partial do not have a __name__ + getattr(target, "__name__", None) or str(target), + signal, + args, + ) + + def _generate_job( signal: str, target: Callable[..., Any] ) -> HassJob[..., None | Coroutine[Any, Any, None]]: """Generate a HassJob for a signal and target.""" return HassJob( - catch_log_exception( - target, - lambda *args: "Exception in {} when dispatching '{}': {}".format( - # Functions wrapped in partial do not have a __name__ - getattr(target, "__name__", None) or str(target), - signal, - args, - ), - ), + catch_log_exception(target, partial(_format_err, signal, target)), f"dispatcher {signal}", ) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 7877ca0e613..3c3c8474e67 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1,12 +1,13 @@ """An abstract class for entities.""" from __future__ import annotations -from abc import ABC +from abc import ABCMeta import asyncio -from collections.abc import Coroutine, Iterable, Mapping, MutableMapping -from dataclasses import dataclass +from collections import deque +from collections.abc import Callable, Coroutine, Iterable, Mapping, MutableMapping +import dataclasses from datetime import timedelta -from enum import Enum, auto +from enum import Enum, IntFlag, auto import functools as ft import logging import math @@ -25,7 +26,6 @@ from typing import ( import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config import DATA_CUSTOMIZE from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -51,6 +51,7 @@ from homeassistant.exceptions import ( ) from homeassistant.loader import async_suggest_report_issue, bind_hass from homeassistant.util import ensure_unique_string, slugify +from homeassistant.util.frozen_dataclass_compat import FrozenOrThawed from . import device_registry as dr, entity_registry as er from .device_registry import DeviceInfo, EventDeviceRegistryUpdatedData @@ -61,8 +62,11 @@ from .event import ( from .typing import UNDEFINED, EventType, StateType, UndefinedType if TYPE_CHECKING: - from .entity_platform import EntityPlatform + from functools import cached_property + from .entity_platform import EntityPlatform +else: + from homeassistant.backports.functools import cached_property _T = TypeVar("_T") @@ -74,6 +78,11 @@ DATA_ENTITY_SOURCE = "entity_info" # epsilon to make the string representation readable FLOAT_PRECISION = abs(int(math.floor(math.log10(abs(sys.float_info.epsilon))))) - 1 +# How many times per hour we allow capabilities to be updated before logging a warning +CAPABILITIES_UPDATE_LIMIT = 100 + +CONTEXT_RECENT_TIME = timedelta(seconds=5) # Time that a context is considered recent + @callback def async_setup(hass: HomeAssistant) -> None: @@ -218,8 +227,10 @@ class EntityPlatformState(Enum): REMOVED = auto() -@dataclass(slots=True) -class EntityDescription: +_SENTINEL = object() + + +class EntityDescription(metaclass=FrozenOrThawed, frozen_or_thawed=True): """A class that describes Home Assistant entities.""" # This is the key identifier for this entity @@ -237,7 +248,195 @@ class EntityDescription: unit_of_measurement: str | None = None -class Entity(ABC): +@dataclasses.dataclass(frozen=True, slots=True) +class CalculatedState: + """Container with state and attributes. + + Returned by Entity._async_calculate_state. + """ + + state: str + # The union of all attributes, after overriding with entity registry settings + attributes: dict[str, Any] + # Capability attributes returned by the capability_attributes property + capability_attributes: Mapping[str, Any] | None + # Attributes which may be overridden by the entity registry + shadowed_attributes: Mapping[str, Any] + + +class CachedProperties(type): + """Metaclass which invalidates cached entity properties on write to _attr_. + + A class which has CachedProperties can optionally have a list of cached + properties, passed as cached_properties, which must be a set of strings. + - Each item in the cached_property set must be the name of a method decorated + with @cached_property + - For each item in the cached_property set, a property function with the + same name, prefixed with _attr_, will be created + - The property _attr_-property functions allow setting, getting and deleting + data, which will be stored in an attribute prefixed with __attr_ + - The _attr_-property setter will invalidate the @cached_property by calling + delattr on it + """ + + def __new__( + mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + name: str, + bases: tuple[type, ...], + namespace: dict[Any, Any], + cached_properties: set[str] | None = None, + **kwargs: Any, + ) -> Any: + """Start creating a new CachedProperties. + + Pop cached_properties and store it in the namespace. + """ + namespace["_CachedProperties__cached_properties"] = cached_properties or set() + return super().__new__(mcs, name, bases, namespace) + + def __init__( + cls, + name: str, + bases: tuple[type, ...], + namespace: dict[Any, Any], + **kwargs: Any, + ) -> None: + """Finish creating a new CachedProperties. + + Wrap _attr_ for cached properties in property objects. + """ + + def deleter(name: str) -> Callable[[Any], None]: + """Create a deleter for an _attr_ property.""" + private_attr_name = f"__attr_{name}" + + def _deleter(o: Any) -> None: + """Delete an _attr_ property. + + Does two things: + - Delete the __attr_ attribute + - Invalidate the cache of the cached property + + Raises AttributeError if the __attr_ attribute does not exist + """ + # Invalidate the cache of the cached property + try: # noqa: SIM105 suppress is much slower + delattr(o, name) + except AttributeError: + pass + # Delete the __attr_ attribute + delattr(o, private_attr_name) + + return _deleter + + def getter(name: str) -> Callable[[Any], Any]: + """Create a getter for an _attr_ property.""" + private_attr_name = f"__attr_{name}" + + def _getter(o: Any) -> Any: + """Get an _attr_ property from the backing __attr attribute.""" + return getattr(o, private_attr_name) + + return _getter + + def setter(name: str) -> Callable[[Any, Any], None]: + """Create a setter for an _attr_ property.""" + private_attr_name = f"__attr_{name}" + + def _setter(o: Any, val: Any) -> None: + """Set an _attr_ property to the backing __attr attribute. + + Also invalidates the corresponding cached_property by calling + delattr on it. + """ + if getattr(o, private_attr_name, _SENTINEL) == val: + return + setattr(o, private_attr_name, val) + try: # noqa: SIM105 suppress is much slower + delattr(o, name) + except AttributeError: + pass + + return _setter + + def make_property(name: str) -> property: + """Help create a property object.""" + return property(fget=getter(name), fset=setter(name), fdel=deleter(name)) + + def wrap_attr(cls: CachedProperties, property_name: str) -> None: + """Wrap a cached property's corresponding _attr in a property. + + If the class being created has an _attr class attribute, move it, and its + annotations, to the __attr attribute. + """ + attr_name = f"_attr_{property_name}" + private_attr_name = f"__attr_{property_name}" + # Check if an _attr_ class attribute exits and move it to __attr_. We check + # __dict__ here because we don't care about _attr_ class attributes in parents. + if attr_name in cls.__dict__: + setattr(cls, private_attr_name, getattr(cls, attr_name)) + annotations = cls.__annotations__ + if attr_name in annotations: + annotations[private_attr_name] = annotations.pop(attr_name) + # Create the _attr_ property + setattr(cls, attr_name, make_property(property_name)) + + cached_properties: set[str] = namespace["_CachedProperties__cached_properties"] + seen_props: set[str] = set() # Keep track of properties which have been handled + for property_name in cached_properties: + wrap_attr(cls, property_name) + seen_props.add(property_name) + + # Look for cached properties of parent classes where this class has + # corresponding _attr_ class attributes and re-wrap them. + for parent in cls.__mro__[:0:-1]: + if "_CachedProperties__cached_properties" not in parent.__dict__: + continue + cached_properties = getattr(parent, "_CachedProperties__cached_properties") + for property_name in cached_properties: + if property_name in seen_props: + continue + attr_name = f"_attr_{property_name}" + # Check if an _attr_ class attribute exits. We check __dict__ here because + # we don't care about _attr_ class attributes in parents. + if (attr_name) not in cls.__dict__: + continue + wrap_attr(cls, property_name) + seen_props.add(property_name) + + +class ABCCachedProperties(CachedProperties, ABCMeta): + """Add ABCMeta to CachedProperties.""" + + +CACHED_PROPERTIES_WITH_ATTR_ = { + "assumed_state", + "attribution", + "available", + "capability_attributes", + "device_class", + "device_info", + "entity_category", + "has_entity_name", + "entity_picture", + "entity_registry_enabled_default", + "entity_registry_visible_default", + "extra_state_attributes", + "force_update", + "icon", + "name", + "should_poll", + "state", + "supported_features", + "translation_key", + "unique_id", + "unit_of_measurement", +} + + +class Entity( + metaclass=ABCCachedProperties, cached_properties=CACHED_PROPERTIES_WITH_ATTR_ +): """An abstract class for Home Assistant entities.""" # SAFE TO OVERWRITE @@ -261,6 +460,9 @@ class Entity(ABC): # If we reported if this entity was slow _slow_reported = False + # If we reported deprecated supported features constants + _deprecated_supported_features_reported = False + # If we reported this entity is updated while disabled _disabled_reported = False @@ -311,6 +513,8 @@ class Entity(ABC): # and removes the need for constant None checks or asserts. _state_info: StateInfo = None # type: ignore[assignment] + __capabilities_updated_at: deque[float] + __capabilities_updated_at_reported: bool = False __remove_event: asyncio.Event | None = None # Entity Properties @@ -318,7 +522,6 @@ class Entity(ABC): _attr_attribution: str | None = None _attr_available: bool = True _attr_capability_attributes: Mapping[str, Any] | None = None - _attr_context_recent_time: timedelta = timedelta(seconds=5) _attr_device_class: str | None _attr_device_info: DeviceInfo | None = None _attr_entity_category: EntityCategory | None @@ -344,7 +547,7 @@ class Entity(ABC): cls._entity_component_unrecorded_attributes | cls._unrecorded_attributes ) - @property + @cached_property def should_poll(self) -> bool: """Return True if entity has to be polled for state. @@ -352,7 +555,7 @@ class Entity(ABC): """ return self._attr_should_poll - @property + @cached_property def unique_id(self) -> str | None: """Return a unique ID.""" return self._attr_unique_id @@ -375,7 +578,7 @@ class Entity(ABC): return not self.name - @property + @cached_property def has_entity_name(self) -> bool: """Return if the name of the entity is describing only the entity itself.""" if hasattr(self, "_attr_has_entity_name"): @@ -456,10 +659,17 @@ class Entity(ABC): @property def suggested_object_id(self) -> str | None: """Return input for object id.""" - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 - if self.__class__.name.fget is Entity.name.fget and self.platform: # type: ignore[attr-defined] + if ( + # Check our class has overridden the name property from Entity + # We need to use type.__getattribute__ to retrieve the underlying + # property or cached_property object instead of the property's + # value. + type.__getattribute__(self.__class__, "name") + is type.__getattribute__(Entity, "name") + # The check for self.platform guards against integrations not using an + # EntityComponent and can be removed in HA Core 2024.1 + and self.platform + ): name = self._name_internal( self._object_id_device_class_name, self.platform.object_id_platform_translations, @@ -468,7 +678,7 @@ class Entity(ABC): name = self.name return None if name is UNDEFINED else name - @property + @cached_property def name(self) -> str | UndefinedType | None: """Return the name of the entity.""" # The check for self.platform guards against integrations not using an @@ -480,12 +690,12 @@ class Entity(ABC): self.platform.platform_translations, ) - @property + @cached_property def state(self) -> StateType: """Return the state of the entity.""" return self._attr_state - @property + @cached_property def capability_attributes(self) -> Mapping[str, Any] | None: """Return the capability attributes. @@ -508,7 +718,7 @@ class Entity(ABC): """ return None - @property + @cached_property def state_attributes(self) -> dict[str, Any] | None: """Return the state attributes. @@ -517,16 +727,7 @@ class Entity(ABC): """ return None - @property - def device_state_attributes(self) -> Mapping[str, Any] | None: - """Return entity specific state attributes. - - This method is deprecated, platform classes should implement - extra_state_attributes instead. - """ - return None - - @property + @cached_property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes. @@ -537,7 +738,7 @@ class Entity(ABC): return self._attr_extra_state_attributes return None - @property + @cached_property def device_info(self) -> DeviceInfo | None: """Return device specific attributes. @@ -545,7 +746,7 @@ class Entity(ABC): """ return self._attr_device_info - @property + @cached_property def device_class(self) -> str | None: """Return the class of this device, from component DEVICE_CLASSES.""" if hasattr(self, "_attr_device_class"): @@ -554,7 +755,7 @@ class Entity(ABC): return self.entity_description.device_class return None - @property + @cached_property def unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" if hasattr(self, "_attr_unit_of_measurement"): @@ -563,7 +764,7 @@ class Entity(ABC): return self.entity_description.unit_of_measurement return None - @property + @cached_property def icon(self) -> str | None: """Return the icon to use in the frontend, if any.""" if hasattr(self, "_attr_icon"): @@ -572,22 +773,22 @@ class Entity(ABC): return self.entity_description.icon return None - @property + @cached_property def entity_picture(self) -> str | None: """Return the entity picture to use in the frontend, if any.""" return self._attr_entity_picture - @property + @cached_property def available(self) -> bool: """Return True if entity is available.""" return self._attr_available - @property + @cached_property def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" return self._attr_assumed_state - @property + @cached_property def force_update(self) -> bool: """Return True if state updates should be forced. @@ -600,17 +801,12 @@ class Entity(ABC): return self.entity_description.force_update return False - @property + @cached_property def supported_features(self) -> int | None: """Flag supported features.""" return self._attr_supported_features - @property - def context_recent_time(self) -> timedelta: - """Time that a context is considered recent.""" - return self._attr_context_recent_time - - @property + @cached_property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added. @@ -622,7 +818,7 @@ class Entity(ABC): return self.entity_description.entity_registry_enabled_default return True - @property + @cached_property def entity_registry_visible_default(self) -> bool: """Return if the entity should be visible when first added. @@ -634,12 +830,12 @@ class Entity(ABC): return self.entity_description.entity_registry_visible_default return True - @property + @cached_property def attribution(self) -> str | None: """Return the attribution.""" return self._attr_attribution - @property + @cached_property def entity_category(self) -> EntityCategory | None: """Return the category of the entity, if any.""" if hasattr(self, "_attr_entity_category"): @@ -648,7 +844,7 @@ class Entity(ABC): return self.entity_description.entity_category return None - @property + @cached_property def translation_key(self) -> str | None: """Return the translation key to translate the entity's states.""" if hasattr(self, "_attr_translation_key"): @@ -770,17 +966,34 @@ class Entity(ABC): return name device_name = device_entry.name_by_user or device_entry.name - if self.use_device_name: + if name is None and self.use_device_name: return device_name return f"{device_name} {name}" if device_name else name @callback - def _async_generate_attributes(self) -> tuple[str, dict[str, Any]]: + def _async_calculate_state(self) -> CalculatedState: """Calculate state string and attribute mapping.""" + return CalculatedState(*self.__async_calculate_state()) + + def __async_calculate_state( + self, + ) -> tuple[str, dict[str, Any], Mapping[str, Any] | None, Mapping[str, Any]]: + """Calculate state string and attribute mapping. + + Returns a tuple (state, attr, capability_attr, shadowed_attr). + state - the stringified state + attr - the attribute dictionary + capability_attr - a mapping with capability attributes + shadowed_attr - a mapping with attributes which may be overridden + + This method is called when writing the state to avoid the overhead of creating + a dataclass object. + """ entry = self.registry_entry - attr = self.capability_attributes - attr = dict(attr) if attr else {} + capability_attr = self.capability_attributes + attr = dict(capability_attr) if capability_attr else {} + shadowed_attr = {} available = self.available # only call self.available once per update cycle state = self._stringify_state(available) @@ -797,26 +1010,30 @@ class Entity(ABC): if (attribution := self.attribution) is not None: attr[ATTR_ATTRIBUTION] = attribution + shadowed_attr[ATTR_DEVICE_CLASS] = self.device_class if ( - device_class := (entry and entry.device_class) or self.device_class + device_class := (entry and entry.device_class) + or shadowed_attr[ATTR_DEVICE_CLASS] ) is not None: attr[ATTR_DEVICE_CLASS] = str(device_class) if (entity_picture := self.entity_picture) is not None: attr[ATTR_ENTITY_PICTURE] = entity_picture - if (icon := (entry and entry.icon) or self.icon) is not None: + shadowed_attr[ATTR_ICON] = self.icon + if (icon := (entry and entry.icon) or shadowed_attr[ATTR_ICON]) is not None: attr[ATTR_ICON] = icon + shadowed_attr[ATTR_FRIENDLY_NAME] = self._friendly_name_internal() if ( - name := (entry and entry.name) or self._friendly_name_internal() + name := (entry and entry.name) or shadowed_attr[ATTR_FRIENDLY_NAME] ) is not None: attr[ATTR_FRIENDLY_NAME] = name if (supported_features := self.supported_features) is not None: attr[ATTR_SUPPORTED_FEATURES] = supported_features - return (state, attr) + return (state, attr, capability_attr, shadowed_attr) @callback def _async_write_ha_state(self) -> None: @@ -842,9 +1059,45 @@ class Entity(ABC): return start = timer() - state, attr = self._async_generate_attributes() + state, attr, capabilities, shadowed_attr = self.__async_calculate_state() end = timer() + if entry: + # Make sure capabilities in the entity registry are up to date. Capabilities + # include capability attributes, device class and supported features + original_device_class: str | None = shadowed_attr[ATTR_DEVICE_CLASS] + supported_features: int = attr.get(ATTR_SUPPORTED_FEATURES) or 0 + if ( + capabilities != entry.capabilities + or original_device_class != entry.original_device_class + or supported_features != entry.supported_features + ): + if not self.__capabilities_updated_at_reported: + time_now = hass.loop.time() + capabilities_updated_at = self.__capabilities_updated_at + capabilities_updated_at.append(time_now) + while time_now - capabilities_updated_at[0] > 3600: + capabilities_updated_at.popleft() + if len(capabilities_updated_at) > CAPABILITIES_UPDATE_LIMIT: + self.__capabilities_updated_at_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + ( + "Entity %s (%s) is updating its capabilities too often," + " please %s" + ), + entity_id, + type(self), + report_issue, + ) + entity_registry = er.async_get(self.hass) + self.registry_entry = entity_registry.async_update_entity( + self.entity_id, + capabilities=capabilities, + original_device_class=original_device_class, + supported_features=supported_features, + ) + if end - start > 0.4 and not self._slow_reported: self._slow_reported = True report_issue = self._suggest_report_issue() @@ -863,7 +1116,7 @@ class Entity(ABC): if ( self._context_set is not None and hass.loop.time() - self._context_set - > self.context_recent_time.total_seconds() + > CONTEXT_RECENT_TIME.total_seconds() ): self._context = None self._context_set = None @@ -1118,6 +1371,8 @@ class Entity(ABC): ) self._async_subscribe_device_updates() + self.__capabilities_updated_at = deque(maxlen=CAPABILITIES_UPDATE_LIMIT + 1) + async def async_internal_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass. @@ -1221,7 +1476,12 @@ class Entity(ABC): self.async_on_remove(self._async_unsubscribe_device_updates) def __repr__(self) -> str: - """Return the representation.""" + """Return the representation. + + If the entity is not added to a platform it's not safe to call _stringify_state. + """ + if self._platform_state != EntityPlatformState.ADDED: + return f"" return f"" async def async_request_call(self, coro: Coroutine[Any, Any, _T]) -> _T: @@ -1244,13 +1504,42 @@ class Entity(ABC): self.hass, integration_domain=platform_name, module=type(self).__module__ ) + @callback + def _report_deprecated_supported_features_values( + self, replacement: IntFlag + ) -> None: + """Report deprecated supported features values.""" + if self._deprecated_supported_features_reported is True: + return + self._deprecated_supported_features_reported = True + report_issue = self._suggest_report_issue() + report_issue += ( + " and reference " + "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" + ) + _LOGGER.warning( + ( + "Entity %s (%s) is using deprecated supported features" + " values which will be removed in HA Core 2025.1. Instead it should use" + " %s, please %s" + ), + self.entity_id, + type(self), + repr(replacement), + report_issue, + ) -@dataclass(slots=True) -class ToggleEntityDescription(EntityDescription): + +class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes toggle entities.""" -class ToggleEntity(Entity): +TOGGLE_ENTITY_CACHED_PROPERTIES_WITH_ATTR_ = {"is_on"} + + +class ToggleEntity( + Entity, cached_properties=TOGGLE_ENTITY_CACHED_PROPERTIES_WITH_ATTR_ +): """An abstract class for entities that can be turned on and off.""" entity_description: ToggleEntityDescription @@ -1265,7 +1554,7 @@ class ToggleEntity(Entity): return None return STATE_ON if is_on else STATE_OFF - @property + @cached_property def is_on(self) -> bool | None: """Return True if entity is on.""" return self._attr_is_on diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 775d0934c36..30e892a8840 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -32,7 +32,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration, bind_hass from homeassistant.setup import async_prepare_setup_platform -from . import config_per_platform, config_validation as cv, discovery, entity, service +from . import config_validation as cv, discovery, entity, service from .entity_platform import EntityPlatform from .typing import ConfigType, DiscoveryInfoType @@ -148,7 +148,7 @@ class EntityComponent(Generic[_EntityT]): self.config = config # Look in config for Domain, Domain 2, Domain 3 etc and load them - for p_type, p_config in config_per_platform(config, self.domain): + for p_type, p_config in conf_util.config_per_platform(config, self.domain): if p_type is not None: self.hass.async_create_task( self.async_setup_platform(p_type, p_config), diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 2fc82567739..221203902c5 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -31,7 +31,6 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.generated import languages from homeassistant.setup import async_start_setup -from homeassistant.util.async_ import run_callback_threadsafe from . import ( config_validation as cv, @@ -304,7 +303,7 @@ class EntityPlatform: current_platform.set(self) logger = self.logger hass = self.hass - full_name = f"{self.domain}.{self.platform_name}" + full_name = f"{self.platform_name}.{self.domain}" object_id_language = ( hass.config.language if hass.config.language in languages.NATIVE_ENTITY_IDS @@ -429,12 +428,11 @@ class EntityPlatform: self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: """Schedule adding entities for a single platform, synchronously.""" - run_callback_threadsafe( - self.hass.loop, + self.hass.loop.call_soon_threadsafe( self._async_schedule_add_entities, list(new_entities), update_before_add, - ).result() + ) @callback def _async_schedule_add_entities( diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index 1a449ec15f0..dd61357f53e 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -93,7 +93,7 @@ FILTER_SCHEMA = vol.All(BASE_FILTER_SCHEMA, convert_filter) def convert_include_exclude_filter( - config: dict[str, dict[str, list[str]]] + config: dict[str, dict[str, list[str]]], ) -> EntityFilter: """Convert the include exclude filter schema into a filter.""" include = config[CONF_INCLUDE] diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 1de7a6c6a43..02add8ff012 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -136,7 +136,7 @@ class EventStateChangedData(TypedDict): def threaded_listener_factory( - async_factory: Callable[Concatenate[HomeAssistant, _P], Any] + async_factory: Callable[Concatenate[HomeAssistant, _P], Any], ) -> Callable[Concatenate[HomeAssistant, _P], CALLBACK_TYPE]: """Convert an async event helper to a threaded one.""" diff --git a/homeassistant/helpers/ratelimit.py b/homeassistant/helpers/ratelimit.py index 1da79eb5f7d..b2a93e7302f 100644 --- a/homeassistant/helpers/ratelimit.py +++ b/homeassistant/helpers/ratelimit.py @@ -5,11 +5,13 @@ import asyncio from collections.abc import Callable, Hashable from datetime import datetime, timedelta import logging -from typing import Any +from typing import TypeVarTuple from homeassistant.core import HomeAssistant, callback import homeassistant.util.dt as dt_util +_Ts = TypeVarTuple("_Ts") + _LOGGER = logging.getLogger(__name__) @@ -59,8 +61,8 @@ class KeyedRateLimit: key: Hashable, rate_limit: timedelta | None, now: datetime, - action: Callable, - *args: Any, + action: Callable[[*_Ts], None], + *args: *_Ts, ) -> datetime | None: """Check rate limits and schedule an action if we hit the limit. diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py index 42ebc2d0869..983b4e2da52 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -13,7 +13,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component -from . import config_per_platform from .entity import Entity from .entity_component import EntityComponent from .entity_platform import EntityPlatform, async_get_platforms @@ -69,7 +68,7 @@ async def _resetup_platform( root_config: dict[str, list[ConfigType]] = {platform_domain: []} # Extract only the config for template, ignore the rest. - for p_type, p_config in config_per_platform(conf, platform_domain): + for p_type, p_config in conf_util.config_per_platform(conf, platform_domain): if p_type != integration_domain: continue diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index dcf7f07bf6b..2bbad0ed63a 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -331,7 +331,12 @@ class SchemaConfigFlowHandler(config_entries.ConfigFlow, ABC): return cls.options_flow is not None @staticmethod - def _async_step(step_id: str) -> Callable: + def _async_step( + step_id: str, + ) -> Callable[ + [SchemaConfigFlowHandler, dict[str, Any] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler.""" async def _async_step( @@ -421,7 +426,12 @@ class SchemaOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): setattr(self, "async_setup_preview", async_setup_preview) @staticmethod - def _async_step(step_id: str) -> Callable: + def _async_step( + step_id: str, + ) -> Callable[ + [SchemaConfigFlowHandler, dict[str, Any] | None], + Coroutine[Any, Any, FlowResult], + ]: """Generate a step handler.""" async def _async_step( diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index a1d045eb542..07f10e13dbf 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Mapping, Sequence +from collections.abc import AsyncGenerator, Callable, Mapping, Sequence from contextlib import asynccontextmanager, suppress from contextvars import ContextVar from copy import copy @@ -157,7 +157,12 @@ def action_trace_append(variables, path): @asynccontextmanager -async def trace_action(hass, script_run, stop, variables): +async def trace_action( + hass: HomeAssistant, + script_run: _ScriptRun, + stop: asyncio.Event, + variables: dict[str, Any], +) -> AsyncGenerator[TraceElement, None]: """Trace action execution.""" path = trace_path_get() trace_element = action_trace_append(variables, path) @@ -362,6 +367,8 @@ class _StopScript(_HaltScript): class _ScriptRun: """Manage Script sequence run.""" + _action: dict[str, Any] + def __init__( self, hass: HomeAssistant, @@ -376,7 +383,6 @@ class _ScriptRun: self._context = context self._log_exceptions = log_exceptions self._step = -1 - self._action: dict[str, Any] | None = None self._stop = asyncio.Event() self._stopped = asyncio.Event() @@ -446,11 +452,13 @@ class _ScriptRun: return ScriptRunResult(response, self._variables) - async def _async_step(self, log_exceptions): + async def _async_step(self, log_exceptions: bool) -> None: continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False) with trace_path(str(self._step)): - async with trace_action(self._hass, self, self._stop, self._variables): + async with trace_action( + self._hass, self, self._stop, self._variables + ) as trace_element: if self._stop.is_set(): return @@ -466,6 +474,7 @@ class _ScriptRun: try: handler = f"_async_{action}_step" await getattr(self, handler)() + trace_element.update_variables(self._variables) except Exception as ex: # pylint: disable=broad-except self._handle_exception( ex, continue_on_error, self._log_exceptions or log_exceptions diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index f7ceb4ab812..9c4266583e8 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -102,6 +102,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: from homeassistant.components.todo import TodoListEntityFeature from homeassistant.components.update import UpdateEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature + from homeassistant.components.valve import ValveEntityFeature from homeassistant.components.water_heater import WaterHeaterEntityFeature from homeassistant.components.weather import WeatherEntityFeature @@ -122,6 +123,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: "TodoListEntityFeature": TodoListEntityFeature, "UpdateEntityFeature": UpdateEntityFeature, "VacuumEntityFeature": VacuumEntityFeature, + "ValveEntityFeature": ValveEntityFeature, "WaterHeaterEntityFeature": WaterHeaterEntityFeature, "WeatherEntityFeature": WeatherEntityFeature, } @@ -951,9 +953,6 @@ def validate_slider(data: Any) -> Any: if "min" not in data or "max" not in data: raise vol.Invalid("min and max are required in slider mode") - if "step" in data and data["step"] == "any": - raise vol.Invalid("step 'any' is not allowed in slider mode") - return data diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 32f51a924f7..9af69acc6b2 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -42,7 +42,7 @@ from homeassistant.exceptions import ( UnknownUser, ) from homeassistant.loader import Integration, async_get_integrations, bind_hass -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from homeassistant.util.yaml.loader import JSON_TYPE from . import ( @@ -88,6 +88,7 @@ def _base_components() -> dict[str, ModuleType]: media_player, remote, siren, + todo, update, vacuum, water_heater, @@ -106,6 +107,7 @@ def _base_components() -> dict[str, ModuleType]: "media_player": media_player, "remote": remote, "siren": siren, + "todo": todo, "update": update, "vacuum": vacuum, "water_heater": water_heater, @@ -542,7 +544,9 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T try: return cast( JSON_TYPE, - _SERVICES_SCHEMA(load_yaml(str(integration.file_path / "services.yaml"))), + _SERVICES_SCHEMA( + load_yaml_dict(str(integration.file_path / "services.yaml")) + ), ) except FileNotFoundError: _LOGGER.warning( @@ -996,7 +1000,7 @@ def verify_domain_control( """Ensure permission to access any entity under domain in service call.""" def decorator( - service_handler: Callable[[ServiceCall], Any] + service_handler: Callable[[ServiceCall], Any], ) -> Callable[[ServiceCall], Any]: """Decorate.""" if not asyncio.iscoroutinefunction(service_handler): diff --git a/homeassistant/helpers/significant_change.py b/homeassistant/helpers/significant_change.py index 589d792f1f8..9bda3ca4eb2 100644 --- a/homeassistant/helpers/significant_change.py +++ b/homeassistant/helpers/significant_change.py @@ -147,6 +147,15 @@ def check_percentage_change( return _check_numeric_change(old_state, new_state, change, percentage_change) +def check_valid_float(value: str | int | float) -> bool: + """Check if given value is a valid float.""" + try: + float(value) + except ValueError: + return False + return True + + class SignificantlyChangedChecker: """Class to keep track of entities to see if they have significantly changed. diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index df8b1c1e019..f96b2c53b50 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -5,7 +5,7 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Callable, Collection, Generator, Iterable, MutableMapping +from collections.abc import Callable, Collection, Generator, Iterable from contextlib import AbstractContextManager, suppress from contextvars import ContextVar from datetime import datetime, timedelta @@ -40,7 +40,7 @@ from jinja2 import pass_context, pass_environment, pass_eval_context from jinja2.runtime import AsyncLoopContext, LoopContext from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU import orjson import voluptuous as vol @@ -147,10 +147,8 @@ EVAL_CACHE_SIZE = 512 MAX_CUSTOM_TEMPLATE_SIZE = 5 * 1024 * 1024 -CACHED_TEMPLATE_LRU: MutableMapping[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) -CACHED_TEMPLATE_NO_COLLECT_LRU: MutableMapping[State, TemplateState] = LRU( - CACHED_TEMPLATE_STATES -) +CACHED_TEMPLATE_LRU: LRU[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) +CACHED_TEMPLATE_NO_COLLECT_LRU: LRU[State, TemplateState] = LRU(CACHED_TEMPLATE_STATES) ENTITY_COUNT_GROWTH_FACTOR = 1.2 ORJSON_PASSTHROUGH_OPTIONS = ( @@ -187,9 +185,9 @@ def async_setup(hass: HomeAssistant) -> bool: ) for lru in (CACHED_TEMPLATE_LRU, CACHED_TEMPLATE_NO_COLLECT_LRU): # There is no typing for LRU - current_size = lru.get_size() # type: ignore[attr-defined] + current_size = lru.get_size() if new_size > current_size: - lru.set_size(new_size) # type: ignore[attr-defined] + lru.set_size(new_size) from .event import ( # pylint: disable=import-outside-toplevel async_track_time_interval, @@ -1910,6 +1908,66 @@ def average(*args: Any, default: Any = _SENTINEL) -> Any: return default +def median(*args: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to calculate the median. + + Calculates median of an iterable of two or more arguments. + + The parameters may be passed as an iterable or as separate arguments. + """ + if len(args) == 0: + raise TypeError("median expected at least 1 argument, got 0") + + # If first argument is a list or tuple and more than 1 argument provided but not a named + # default, then use 2nd argument as default. + if isinstance(args[0], Iterable): + median_list = args[0] + if len(args) > 1 and default is _SENTINEL: + default = args[1] + elif len(args) == 1: + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + median_list = args + + try: + return statistics.median(median_list) + except (TypeError, statistics.StatisticsError): + if default is _SENTINEL: + raise_no_default("median", args) + return default + + +def statistical_mode(*args: Any, default: Any = _SENTINEL) -> Any: + """Filter and function to calculate the statistical mode. + + Calculates mode of an iterable of two or more arguments. + + The parameters may be passed as an iterable or as separate arguments. + """ + if not args: + raise TypeError("statistical_mode expected at least 1 argument, got 0") + + # If first argument is a list or tuple and more than 1 argument provided but not a named + # default, then use 2nd argument as default. + if len(args) == 1 and isinstance(args[0], Iterable): + mode_list = args[0] + elif isinstance(args[0], list | tuple): + mode_list = args[0] + if len(args) > 1 and default is _SENTINEL: + default = args[1] + elif len(args) == 1: + raise TypeError(f"'{type(args[0]).__name__}' object is not iterable") + else: + mode_list = args + + try: + return statistics.mode(mode_list) + except (TypeError, statistics.StatisticsError): + if default is _SENTINEL: + raise_no_default("statistical_mode", args) + return default + + def forgiving_float(value, default=_SENTINEL): """Try to convert value to a float.""" try: @@ -2392,6 +2450,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["from_json"] = from_json self.filters["is_defined"] = fail_when_undefined self.filters["average"] = average + self.filters["median"] = median + self.filters["statistical_mode"] = statistical_mode self.filters["random"] = random_every_time self.filters["base64_encode"] = base64_encode self.filters["base64_decode"] = base64_decode @@ -2414,6 +2474,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["bool"] = forgiving_boolean self.filters["version"] = version self.filters["contains"] = contains + self.filters["median"] = median + self.filters["statistical_mode"] = statistical_mode self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -2435,6 +2497,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["strptime"] = strptime self.globals["urlencode"] = urlencode self.globals["average"] = average + self.globals["median"] = median + self.globals["statistical_mode"] = statistical_mode self.globals["max"] = min_max_from_filter(self.filters["max"], "max") self.globals["min"] = min_max_from_filter(self.filters["min"], "min") self.globals["is_number"] = is_number @@ -2447,6 +2511,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["iif"] = iif self.globals["bool"] = forgiving_boolean self.globals["version"] = version + self.globals["median"] = median + self.globals["statistical_mode"] = statistical_mode self.tests["is_number"] = is_number self.tests["list"] = _is_list self.tests["set"] = _is_set diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index fd7a3081f7a..6c7d6cf0a7a 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -21,6 +21,7 @@ class TraceElement: "_child_key", "_child_run_id", "_error", + "_last_variables", "path", "_result", "reuse_by_child", @@ -38,16 +39,8 @@ class TraceElement: self.reuse_by_child = False self._timestamp = dt_util.utcnow() - if variables is None: - variables = {} - last_variables = variables_cv.get() or {} - variables_cv.set(dict(variables)) - changed_variables = { - key: value - for key, value in variables.items() - if key not in last_variables or last_variables[key] != value - } - self._variables = changed_variables + self._last_variables = variables_cv.get() or {} + self.update_variables(variables) def __repr__(self) -> str: """Container for trace data.""" @@ -71,6 +64,19 @@ class TraceElement: old_result = self._result or {} self._result = {**old_result, **kwargs} + def update_variables(self, variables: TemplateVarsType) -> None: + """Update variables.""" + if variables is None: + variables = {} + last_variables = self._last_variables + variables_cv.set(dict(variables)) + changed_variables = { + key: value + for key, value in variables.items() + if key not in last_variables or last_variables[key] != value + } + self._variables = changed_variables + def as_dict(self) -> dict[str, Any]: """Return dictionary version of this TraceElement.""" result: dict[str, Any] = {"path": self.path, "timestamp": self._timestamp} diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 41ad591d878..eac5cdb0a3f 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -48,7 +48,7 @@ def component_translation_path( If component is just a single file, will return None. """ parts = component.split(".") - domain = parts[-1] + domain = parts[0] is_platform = len(parts) == 2 # If it's a component that is just one file, we don't support translations @@ -57,7 +57,7 @@ def component_translation_path( return None if is_platform: - filename = f"{parts[0]}.{language}.json" + filename = f"{parts[1]}.{language}.json" else: filename = f"{language}.json" @@ -67,7 +67,7 @@ def component_translation_path( def load_translations_files( - translation_files: dict[str, str] + translation_files: dict[str, str], ) -> dict[str, dict[str, Any]]: """Load and parse translation.json files.""" loaded = {} @@ -96,7 +96,7 @@ def _merge_resources( # Build response resources: dict[str, dict[str, Any]] = {} for component in components: - domain = component.partition(".")[0] + domain = component.rpartition(".")[-1] domain_resources = resources.setdefault(domain, {}) @@ -154,7 +154,7 @@ async def _async_get_component_strings( # Determine paths of missing components/platforms files_to_load = {} for loaded in components: - domain = loaded.rpartition(".")[-1] + domain = loaded.partition(".")[0] integration = integrations[domain] path = component_translation_path(loaded, language, integration) @@ -225,7 +225,7 @@ class _TranslationCache: languages = [LOCALE_EN] if language == LOCALE_EN else [LOCALE_EN, language] integrations: dict[str, Integration] = {} - domains = list({loaded.rpartition(".")[-1] for loaded in components}) + domains = list({loaded.partition(".")[0] for loaded in components}) ints_or_excs = await async_get_integrations(self.hass, domains) for domain, int_or_exc in ints_or_excs.items(): if isinstance(int_or_exc, Exception): diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 6fb538a5aef..0a44ccb05c9 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -131,6 +131,14 @@ class HomeKitDiscoveredIntegration: always_discover: bool +class ZeroconfMatcher(TypedDict, total=False): + """Matcher for zeroconf.""" + + domain: str + name: str + properties: dict[str, str] + + class Manifest(TypedDict, total=False): """Integration manifest. @@ -374,7 +382,7 @@ async def async_get_application_credentials(hass: HomeAssistant) -> list[str]: ] -def async_process_zeroconf_match_dict(entry: dict[str, Any]) -> dict[str, Any]: +def async_process_zeroconf_match_dict(entry: dict[str, Any]) -> ZeroconfMatcher: """Handle backwards compat with zeroconf matchers.""" entry_without_type: dict[str, Any] = entry.copy() del entry_without_type["type"] @@ -396,21 +404,21 @@ def async_process_zeroconf_match_dict(entry: dict[str, Any]) -> dict[str, Any]: else: prop_dict = entry_without_type["properties"] prop_dict[moved_prop] = value.lower() - return entry_without_type + return cast(ZeroconfMatcher, entry_without_type) async def async_get_zeroconf( hass: HomeAssistant, -) -> dict[str, list[dict[str, str | dict[str, str]]]]: +) -> dict[str, list[ZeroconfMatcher]]: """Return cached list of zeroconf types.""" - zeroconf: dict[str, list[dict[str, str | dict[str, str]]]] = ZEROCONF.copy() # type: ignore[assignment] + zeroconf: dict[str, list[ZeroconfMatcher]] = ZEROCONF.copy() # type: ignore[assignment] integrations = await async_get_custom_components(hass) for integration in integrations.values(): if not integration.zeroconf: continue for entry in integration.zeroconf: - data: dict[str, str | dict[str, str]] = {"domain": integration.domain} + data: ZeroconfMatcher = {"domain": integration.domain} if isinstance(entry, dict): typ = entry["type"] data.update(async_process_zeroconf_match_dict(entry)) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ba511ba7e40..0f069a0e0b5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,38 +1,40 @@ # Automatically generated by gen_requirements_all.py, do not edit -aiodiscover==1.5.1 +aiodiscover==1.6.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.1.1 +aiohttp-zlib-ng==0.1.3 aiohttp==3.9.1 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.36.2 +async-upnp-client==0.38.0 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.11.0 bcrypt==4.0.1 -bleak-retry-connector==3.3.0 +bleak-retry-connector==3.4.0 bleak==0.21.1 -bluetooth-adapters==0.16.1 +bluetooth-adapters==0.16.2 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.15.0 +bluetooth-data-tools==1.19.0 +cached_ipaddress==0.3.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.7 -dbus-fast==2.14.0 +dbus-fast==2.21.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 ha-ffmpeg==3.1.0 -hass-nabucasa==0.74.0 +habluetooth==2.0.1 +hass-nabucasa==0.75.1 hassil==1.5.1 -home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231208.2 -home-assistant-intents==2023.12.05 -httpx==0.25.0 +home-assistant-bluetooth==1.11.0 +home-assistant-frontend==20240103.3 +home-assistant-intents==2024.1.2 +httpx==0.26.0 ifaddr==0.2.0 janus==1.0.0 Jinja2==3.1.2 -lru-dict==1.2.0 +lru-dict==1.3.0 mutagen==1.47.0 orjson==3.9.9 packaging>=23.1 @@ -51,23 +53,19 @@ PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 SQLAlchemy==2.0.23 -typing-extensions>=4.8.0,<5.0 +typing-extensions>=4.9.0,<5.0 ulid-transform==0.9.0 +urllib3>=1.26.5,<2 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 -yarl==1.9.2 -zeroconf==0.128.5 +yarl==1.9.4 +zeroconf==0.131.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 -# 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 httplib2>=0.19.0 @@ -107,9 +105,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==4.0.0 +anyio==4.1.0 h11==0.14.0 -httpcore==0.18.0 +httpcore==1.0.2 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation @@ -183,3 +181,11 @@ get-mac==1000000000.0.0 # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. charset-normalizer==3.2.0 + +# lxml 5.0.0 currently does not build on alpine 3.18 +# https://bugs.launchpad.net/lxml/+bug/2047718 +lxml==4.9.4 + +# dacite: Ensure we have a version that is able to handle type unions for +# Roborock, NAM, Brother, and GIOS. +dacite>=1.7.0 diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 0e00d0b75f2..dcccdbccf40 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -32,7 +32,7 @@ REQUIREMENTS = ("colorlog==6.7.0",) _LOGGER = logging.getLogger(__name__) MOCKS: dict[str, tuple[str, Callable]] = { "load": ("homeassistant.util.yaml.loader.load_yaml", yaml_loader.load_yaml), - "load*": ("homeassistant.config.load_yaml", yaml_loader.load_yaml), + "load*": ("homeassistant.config.load_yaml_dict", yaml_loader.load_yaml_dict), "secrets": ("homeassistant.util.yaml.loader.secret_yaml", yaml_loader.secret_yaml), } diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 679042bc4e9..7a7f4323be6 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -263,7 +263,7 @@ async def _async_setup_component( if platform_exception.translation_key not in NOTIFY_FOR_TRANSLATION_KEYS: continue async_notify_setup_error( - hass, platform_exception.platform_name, platform_exception.integration_link + hass, platform_exception.platform_path, platform_exception.integration_link ) if processed_config is None: log_error("Invalid config.") @@ -538,7 +538,7 @@ def async_get_loaded_integrations(hass: core.HomeAssistant) -> set[str]: if "." not in component: integrations.add(component) continue - domain, _, platform = component.partition(".") + platform, _, domain = component.partition(".") if domain in BASE_PLATFORMS: integrations.add(platform) return integrations @@ -563,7 +563,7 @@ def async_start_setup( time_taken = dt_util.utcnow() - started for unique, domain in unique_components.items(): del setup_started[unique] - integration = domain.rpartition(".")[-1] + integration = domain.partition(".")[0] if integration in setup_time: setup_time[integration] += time_taken else: diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index bcc7be62265..1b8496fe327 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -10,7 +10,7 @@ import functools import logging import threading from traceback import extract_stack -from typing import Any, ParamSpec, TypeVar +from typing import Any, ParamSpec, TypeVar, TypeVarTuple from homeassistant.exceptions import HomeAssistantError @@ -21,6 +21,7 @@ _SHUTDOWN_RUN_CALLBACK_THREADSAFE = "_shutdown_run_callback_threadsafe" _T = TypeVar("_T") _R = TypeVar("_R") _P = ParamSpec("_P") +_Ts = TypeVarTuple("_Ts") def cancelling(task: Future[Any]) -> bool: @@ -29,7 +30,7 @@ def cancelling(task: Future[Any]) -> bool: def run_callback_threadsafe( - loop: AbstractEventLoop, callback: Callable[..., _T], *args: Any + loop: AbstractEventLoop, callback: Callable[[*_Ts], _T], *args: *_Ts ) -> concurrent.futures.Future[_T]: """Submit a callback object to a given event loop. diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 8e7fc3dc155..0ab4ac8c6c1 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -7,6 +7,8 @@ from typing import NamedTuple import attr +from .scaling import scale_to_ranged_value + class RGBColor(NamedTuple): """RGB hex values.""" @@ -576,6 +578,18 @@ def _white_levels_to_color_temperature( ), min(255, round(brightness * 255)) +def color_xy_to_temperature(x: float, y: float) -> int: + """Convert an xy color to a color temperature in Kelvin. + + Uses McCamy's approximation (https://doi.org/10.1002/col.5080170211), + close enough for uses between 2000 K and 10000 K. + """ + n = (x - 0.3320) / (0.1858 - y) + CCT = 437 * (n**3) + 3601 * (n**2) + 6861 * n + 5517 + + return int(CCT) + + def _clamp(color_component: float, minimum: float = 0, maximum: float = 255) -> float: """Clamp the given color component value between the given min and max values. @@ -732,3 +746,38 @@ def check_valid_gamut(Gamut: GamutType) -> bool: ) return not_on_line and red_valid and green_valid and blue_valid + + +def brightness_to_value(low_high_range: tuple[float, float], brightness: int) -> float: + """Given a brightness_scale convert a brightness to a single value. + + Do not include 0 if the light is off for value 0. + + Given a brightness low_high_range of (1,100) this function + will return: + + 255: 100.0 + 127: ~49.8039 + 10: ~3.9216 + """ + return scale_to_ranged_value((1, 255), low_high_range, brightness) + + +def value_to_brightness(low_high_range: tuple[float, float], value: float) -> int: + """Given a brightness_scale convert a single value to a brightness. + + Do not include 0 if the light is off for value 0. + + Given a brightness low_high_range of (1,100) this function + will return: + + 100: 255 + 50: 128 + 4: 10 + + The value will be clamped between 1..255 to ensure valid value. + """ + return min( + 255, + max(1, round(scale_to_ranged_value(low_high_range, (1, 255), value))), + ) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 34a81728d14..4859c5c85dd 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -5,9 +5,7 @@ import bisect from contextlib import suppress import datetime as dt from functools import partial -import platform import re -import time from typing import Any import zoneinfo @@ -16,7 +14,6 @@ import ciso8601 DATE_STR_FORMAT = "%Y-%m-%d" UTC = dt.UTC DEFAULT_TIME_ZONE: dt.tzinfo = dt.UTC -CLOCK_MONOTONIC_COARSE = 6 # EPOCHORDINAL is not exposed as a constant # https://github.com/python/cpython/blob/3.10/Lib/zoneinfo/_zoneinfo.py#L12 @@ -476,29 +473,3 @@ def _datetime_ambiguous(dattim: dt.datetime) -> bool: assert dattim.tzinfo is not None opposite_fold = dattim.replace(fold=not dattim.fold) return _datetime_exists(dattim) and dattim.utcoffset() != opposite_fold.utcoffset() - - -def __gen_monotonic_time_coarse() -> partial[float]: - """Return a function that provides monotonic time in seconds. - - This is the coarse version of time_monotonic, which is faster but less accurate. - - Since many arm64 and 32-bit platforms don't support VDSO with time.monotonic - because of errata, we can't rely on the kernel to provide a fast - monotonic time. - - https://lore.kernel.org/lkml/20170404171826.25030-1-marc.zyngier@arm.com/ - """ - # We use a partial here since its implementation is in native code - # which allows us to avoid the overhead of the global lookup - # of CLOCK_MONOTONIC_COARSE. - return partial(time.clock_gettime, CLOCK_MONOTONIC_COARSE) - - -monotonic_time_coarse = time.monotonic -with suppress(Exception): - if ( - platform.system() == "Linux" - and abs(time.monotonic() - __gen_monotonic_time_coarse()()) < 1 - ): - monotonic_time_coarse = __gen_monotonic_time_coarse() diff --git a/homeassistant/util/frozen_dataclass_compat.py b/homeassistant/util/frozen_dataclass_compat.py new file mode 100644 index 00000000000..858084bcabc --- /dev/null +++ b/homeassistant/util/frozen_dataclass_compat.py @@ -0,0 +1,122 @@ +"""Utility to create classes from which frozen or mutable dataclasses can be derived. + +This module enabled a non-breaking transition from mutable to frozen dataclasses +derived from EntityDescription and sub classes thereof. +""" +from __future__ import annotations + +import dataclasses +import sys +from typing import Any + +from typing_extensions import dataclass_transform + + +def _class_fields(cls: type, kw_only: bool) -> list[tuple[str, Any, Any]]: + """Return a list of dataclass fields. + + Extracted from dataclasses._process_class. + """ + # pylint: disable=protected-access + cls_annotations = cls.__dict__.get("__annotations__", {}) + + cls_fields: list[dataclasses.Field[Any]] = [] + + _dataclasses = sys.modules[dataclasses.__name__] + for name, _type in cls_annotations.items(): + # See if this is a marker to change the value of kw_only. + if dataclasses._is_kw_only(type, _dataclasses) or ( # type: ignore[attr-defined] + isinstance(_type, str) + and dataclasses._is_type( # type: ignore[attr-defined] + _type, + cls, + _dataclasses, + dataclasses.KW_ONLY, + dataclasses._is_kw_only, # type: ignore[attr-defined] + ) + ): + kw_only = True + else: + # Otherwise it's a field of some type. + cls_fields.append(dataclasses._get_field(cls, name, _type, kw_only)) # type: ignore[attr-defined] + + return [(field.name, field.type, field) for field in cls_fields] + + +@dataclass_transform( + field_specifiers=(dataclasses.field, dataclasses.Field), + frozen_default=True, # Set to allow setting frozen in child classes + kw_only_default=True, # Set to allow setting kw_only in child classes +) +class FrozenOrThawed(type): + """Metaclass which which makes classes which behave like a dataclass. + + This allows child classes to be either mutable or frozen dataclasses. + """ + + def _make_dataclass(cls, name: str, bases: tuple[type, ...], kw_only: bool) -> None: + class_fields = _class_fields(cls, kw_only) + dataclass_bases = [] + for base in bases: + dataclass_bases.append(getattr(base, "_dataclass", base)) + cls._dataclass = dataclasses.make_dataclass( + name, class_fields, bases=tuple(dataclass_bases), frozen=True + ) + + def __new__( + mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass + name: str, + bases: tuple[type, ...], + namespace: dict[Any, Any], + frozen_or_thawed: bool = False, + **kwargs: Any, + ) -> Any: + """Pop frozen_or_thawed and store it in the namespace.""" + namespace["_FrozenOrThawed__frozen_or_thawed"] = frozen_or_thawed + return super().__new__(mcs, name, bases, namespace) + + def __init__( + cls, + name: str, + bases: tuple[type, ...], + namespace: dict[Any, Any], + **kwargs: Any, + ) -> None: + """Optionally create a dataclass and store it in cls._dataclass. + + A dataclass will be created if frozen_or_thawed is set, if not we assume the + class will be a real dataclass, i.e. it's decorated with @dataclass. + """ + if not namespace["_FrozenOrThawed__frozen_or_thawed"]: + # This class is a real dataclass, optionally inject the parent's annotations + if all(dataclasses.is_dataclass(base) for base in bases): + # All direct parents are dataclasses, rely on dataclass inheritance + return + # Parent is not a dataclass, inject all parents' annotations + annotations: dict = {} + for parent in cls.__mro__[::-1]: + if parent is object: + continue + annotations |= parent.__annotations__ + cls.__annotations__ = annotations + return + + # First try without setting the kw_only flag, and if that fails, try setting it + try: + cls._make_dataclass(name, bases, False) + except TypeError: + cls._make_dataclass(name, bases, True) + + def __new__(*args: Any, **kwargs: Any) -> object: + """Create a new instance. + + The function has no named arguments to avoid name collisions with dataclass + field names. + """ + cls, *_args = args + if dataclasses.is_dataclass(cls): + return object.__new__(cls) + return cls._dataclass(*_args, **kwargs) + + cls.__init__ = cls._dataclass.__init__ # type: ignore[misc] + cls.__new__ = __new__ # type: ignore[method-assign] diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 1328e8ded60..0f86cde50fe 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -9,11 +9,12 @@ import logging import logging.handlers import queue import traceback -from typing import Any, TypeVar, cast, overload +from typing import Any, TypeVar, TypeVarTuple, cast, overload from homeassistant.core import HomeAssistant, callback, is_callback _T = TypeVar("_T") +_Ts = TypeVarTuple("_Ts") class HomeAssistantQueueHandler(logging.handlers.QueueHandler): @@ -83,7 +84,7 @@ def async_activate_log_queue_handler(hass: HomeAssistant) -> None: listener.start() -def log_exception(format_err: Callable[..., Any], *args: Any) -> None: +def log_exception(format_err: Callable[[*_Ts], Any], *args: *_Ts) -> None: """Log an exception with additional context.""" module = inspect.getmodule(inspect.stack(context=0)[1].frame) if module is not None: @@ -101,23 +102,56 @@ def log_exception(format_err: Callable[..., Any], *args: Any) -> None: logging.getLogger(module_name).error("%s\n%s", friendly_msg, exc_msg) +async def _async_wrapper( + async_func: Callable[[*_Ts], Coroutine[Any, Any, None]], + format_err: Callable[[*_Ts], Any], + *args: *_Ts, +) -> None: + """Catch and log exception.""" + try: + await async_func(*args) + except Exception: # pylint: disable=broad-except + log_exception(format_err, *args) + + +def _sync_wrapper( + func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any], *args: *_Ts +) -> None: + """Catch and log exception.""" + try: + func(*args) + except Exception: # pylint: disable=broad-except + log_exception(format_err, *args) + + +@callback +def _callback_wrapper( + func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any], *args: *_Ts +) -> None: + """Catch and log exception.""" + try: + func(*args) + except Exception: # pylint: disable=broad-except + log_exception(format_err, *args) + + @overload def catch_log_exception( - func: Callable[..., Coroutine[Any, Any, Any]], format_err: Callable[..., Any] -) -> Callable[..., Coroutine[Any, Any, None]]: + func: Callable[[*_Ts], Coroutine[Any, Any, Any]], format_err: Callable[[*_Ts], Any] +) -> Callable[[*_Ts], Coroutine[Any, Any, None]]: ... @overload def catch_log_exception( - func: Callable[..., Any], format_err: Callable[..., Any] -) -> Callable[..., None] | Callable[..., Coroutine[Any, Any, None]]: + func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any] +) -> Callable[[*_Ts], None] | Callable[[*_Ts], Coroutine[Any, Any, None]]: ... def catch_log_exception( - func: Callable[..., Any], format_err: Callable[..., Any] -) -> Callable[..., None] | Callable[..., Coroutine[Any, Any, None]]: + func: Callable[[*_Ts], Any], format_err: Callable[[*_Ts], Any] +) -> Callable[[*_Ts], None] | Callable[[*_Ts], Coroutine[Any, Any, None]]: """Decorate a function func to catch and log exceptions. If func is a coroutine function, a coroutine function will be returned. @@ -126,45 +160,24 @@ def catch_log_exception( # Check for partials to properly determine if coroutine function check_func = func while isinstance(check_func, partial): - check_func = check_func.func + check_func = check_func.func # type: ignore[unreachable] # false positive - wrapper_func: Callable[..., None] | Callable[..., Coroutine[Any, Any, None]] if asyncio.iscoroutinefunction(check_func): - async_func = cast(Callable[..., Coroutine[Any, Any, None]], func) + async_func = cast(Callable[[*_Ts], Coroutine[Any, Any, None]], func) + return wraps(async_func)(partial(_async_wrapper, async_func, format_err)) # type: ignore[return-value] - @wraps(async_func) - async def async_wrapper(*args: Any) -> None: - """Catch and log exception.""" - try: - await async_func(*args) - except Exception: # pylint: disable=broad-except - log_exception(format_err, *args) + if is_callback(check_func): + return wraps(func)(partial(_callback_wrapper, func, format_err)) # type: ignore[return-value] - wrapper_func = async_wrapper - - else: - - @wraps(func) - def wrapper(*args: Any) -> None: - """Catch and log exception.""" - try: - func(*args) - except Exception: # pylint: disable=broad-except - log_exception(format_err, *args) - - if is_callback(check_func): - wrapper = callback(wrapper) - - wrapper_func = wrapper - return wrapper_func + return wraps(func)(partial(_sync_wrapper, func, format_err)) # type: ignore[return-value] def catch_log_coro_exception( - target: Coroutine[Any, Any, _T], format_err: Callable[..., Any], *args: Any + target: Coroutine[Any, Any, _T], format_err: Callable[[*_Ts], Any], *args: *_Ts ) -> Coroutine[Any, Any, _T | None]: """Decorate a coroutine to catch and log exceptions.""" - async def coro_wrapper(*args: Any) -> _T | None: + async def coro_wrapper(*args: *_Ts) -> _T | None: """Catch and log exception.""" try: return await target @@ -176,7 +189,7 @@ def catch_log_coro_exception( def async_create_catching_coro( - target: Coroutine[Any, Any, _T] + target: Coroutine[Any, Any, _T], ) -> Coroutine[Any, Any, _T | None]: """Wrap a coroutine to catch and log exceptions. diff --git a/homeassistant/util/percentage.py b/homeassistant/util/percentage.py index ca5931b2670..cc4835022d3 100644 --- a/homeassistant/util/percentage.py +++ b/homeassistant/util/percentage.py @@ -3,6 +3,13 @@ from __future__ import annotations from typing import TypeVar +from .scaling import ( # noqa: F401 + int_states_in_range, + scale_ranged_value_to_int_range, + scale_to_ranged_value, + states_in_range, +) + _T = TypeVar("_T") @@ -69,8 +76,7 @@ def ranged_value_to_percentage( (1,255), 127: 50 (1,255), 10: 4 """ - offset = low_high_range[0] - 1 - return int(((value - offset) * 100) // states_in_range(low_high_range)) + return scale_ranged_value_to_int_range(low_high_range, (1, 100), value) def percentage_to_ranged_value( @@ -87,15 +93,4 @@ def percentage_to_ranged_value( (1,255), 50: 127.5 (1,255), 4: 10.2 """ - offset = low_high_range[0] - 1 - return states_in_range(low_high_range) * percentage / 100 + offset - - -def states_in_range(low_high_range: tuple[float, float]) -> float: - """Given a range of low and high values return how many states exist.""" - return low_high_range[1] - low_high_range[0] + 1 - - -def int_states_in_range(low_high_range: tuple[float, float]) -> int: - """Given a range of low and high values return how many integer states exist.""" - return int(states_in_range(low_high_range)) + return scale_to_ranged_value((1, 100), low_high_range, percentage) diff --git a/homeassistant/util/scaling.py b/homeassistant/util/scaling.py new file mode 100644 index 00000000000..70e2ac2516a --- /dev/null +++ b/homeassistant/util/scaling.py @@ -0,0 +1,62 @@ +"""Scaling util functions.""" +from __future__ import annotations + + +def scale_ranged_value_to_int_range( + source_low_high_range: tuple[float, float], + target_low_high_range: tuple[float, float], + value: float, +) -> int: + """Given a range of low and high values convert a single value to another range. + + Given a source low value of 1 and a high value of 255 and + a target range from 1 to 100 this function + will return: + + (1,255), (1,100), 255: 100 + (1,255), (1,100), 127: 49 + (1,255), (1,100), 10: 3 + """ + source_offset = source_low_high_range[0] - 1 + target_offset = target_low_high_range[0] - 1 + return int( + (value - source_offset) + * states_in_range(target_low_high_range) + // states_in_range(source_low_high_range) + + target_offset + ) + + +def scale_to_ranged_value( + source_low_high_range: tuple[float, float], + target_low_high_range: tuple[float, float], + value: float, +) -> float: + """Given a range of low and high values convert a single value to another range. + + Do not include 0 in a range if 0 means off, + e.g. for brightness or fan speed. + + Given a source low value of 1 and a high value of 255 and + a target range from 1 to 100 this function + will return: + + (1,255), 255: 100 + (1,255), 127: ~49.8039 + (1,255), 10: ~3.9216 + """ + source_offset = source_low_high_range[0] - 1 + target_offset = target_low_high_range[0] - 1 + return (value - source_offset) * ( + states_in_range(target_low_high_range) + ) / states_in_range(source_low_high_range) + target_offset + + +def states_in_range(low_high_range: tuple[float, float]) -> float: + """Given a range of low and high values return how many states exist.""" + return low_high_range[1] - low_high_range[0] + 1 + + +def int_states_in_range(low_high_range: tuple[float, float]) -> int: + """Given a range of low and high values return how many integer states exist.""" + return int(states_in_range(low_high_range)) diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py index 2b503716063..6bfbec88a33 100644 --- a/homeassistant/util/ssl.py +++ b/homeassistant/util/ssl.py @@ -61,16 +61,11 @@ SSL_CIPHER_LISTS = { @cache -def create_no_verify_ssl_context( - ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, -) -> ssl.SSLContext: - """Return an SSL context that does not verify the server certificate. +def _create_no_verify_ssl_context(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: + # This is a copy of aiohttp's create_default_context() function, with the + # ssl verify turned off. + # https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 - This is a copy of aiohttp's create_default_context() function, with the - ssl verify turned off. - - https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911 - """ sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) sslcontext.check_hostname = False sslcontext.verify_mode = ssl.CERT_NONE @@ -84,12 +79,16 @@ def create_no_verify_ssl_context( return sslcontext -@cache -def client_context( +def create_no_verify_ssl_context( ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, ) -> ssl.SSLContext: - """Return an SSL context for making requests.""" + """Return an SSL context that does not verify the server certificate.""" + return _create_no_verify_ssl_context(ssl_cipher_list=ssl_cipher_list) + + +@cache +def _client_context(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext: # Reuse environment variable definition from requests, since it's already a # requirement. If the environment variable has no value, fall back to using # certs from certifi package. @@ -104,6 +103,14 @@ def client_context( return sslcontext +def client_context( + ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT, +) -> ssl.SSLContext: + """Return an SSL context for making requests.""" + + return _client_context(ssl_cipher_list=ssl_cipher_list) + + # Create this only once and reuse it _DEFAULT_SSL_CONTEXT = client_context() _DEFAULT_NO_VERIFY_SSL_CONTEXT = create_no_verify_ssl_context() diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py index b3f1b7ecd43..fe4f01677cd 100644 --- a/homeassistant/util/yaml/__init__.py +++ b/homeassistant/util/yaml/__init__.py @@ -2,7 +2,14 @@ from .const import SECRET_YAML from .dumper import dump, save_yaml from .input import UndefinedSubstitution, extract_inputs, substitute -from .loader import Secrets, load_yaml, parse_yaml, secret_yaml +from .loader import ( + Secrets, + YamlTypeError, + load_yaml, + load_yaml_dict, + parse_yaml, + secret_yaml, +) from .objects import Input __all__ = [ @@ -11,7 +18,9 @@ __all__ = [ "dump", "save_yaml", "Secrets", + "YamlTypeError", "load_yaml", + "load_yaml_dict", "secret_yaml", "parse_yaml", "UndefinedSubstitution", diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 4a14afb53b2..60e917a6a99 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -36,6 +36,10 @@ _DictT = TypeVar("_DictT", bound=dict) _LOGGER = logging.getLogger(__name__) +class YamlTypeError(HomeAssistantError): + """Raised by load_yaml_dict if top level data is not a dict.""" + + class Secrets: """Store secrets while loading YAML.""" @@ -211,7 +215,7 @@ class SafeLineLoader(PythonSafeLoader): LoaderType = FastSafeLoader | PythonSafeLoader -def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: +def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE | None: """Load a YAML file.""" try: with open(fname, encoding="utf-8") as conf_file: @@ -221,6 +225,20 @@ def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: raise HomeAssistantError(exc) from exc +def load_yaml_dict(fname: str, secrets: Secrets | None = None) -> dict: + """Load a YAML file and ensure the top level is a dict. + + Raise if the top level is not a dict. + Return an empty dict if the file is empty. + """ + loaded_yaml = load_yaml(fname, secrets) + if loaded_yaml is None: + loaded_yaml = {} + if not isinstance(loaded_yaml, dict): + raise YamlTypeError(f"YAML file {fname} does not contain a dict") + return loaded_yaml + + def parse_yaml( content: str | TextIO | StringIO, secrets: Secrets | None = None ) -> JSON_TYPE: @@ -255,12 +273,7 @@ def _parse_yaml( secrets: Secrets | None = None, ) -> JSON_TYPE: """Load a YAML file.""" - # If configuration file is empty YAML returns None - # We convert that to an empty dict - return ( - yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] - or NodeDictClass() - ) + return yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type] @overload @@ -309,7 +322,10 @@ def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """ fname = os.path.join(os.path.dirname(loader.get_name()), node.value) try: - return _add_reference(load_yaml(fname, loader.secrets), loader, node) + loaded_yaml = load_yaml(fname, loader.secrets) + if loaded_yaml is None: + loaded_yaml = NodeDictClass() + return _add_reference(loaded_yaml, loader, node) except FileNotFoundError as exc: raise HomeAssistantError( f"{node.start_mark}: Unable to read file {fname}." @@ -339,7 +355,10 @@ def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDi filename = os.path.splitext(os.path.basename(fname))[0] if os.path.basename(fname) == SECRET_YAML: continue - mapping[filename] = load_yaml(fname, loader.secrets) + loaded_yaml = load_yaml(fname, loader.secrets) + if loaded_yaml is None: + continue + mapping[filename] = loaded_yaml return _add_reference(mapping, loader, node) @@ -364,9 +383,10 @@ def _include_dir_list_yaml( """Load multiple files from directory as a list.""" loc = os.path.join(os.path.dirname(loader.get_name()), node.value) return [ - load_yaml(f, loader.secrets) + loaded_yaml for f in _find_files(loc, "*.yaml") if os.path.basename(f) != SECRET_YAML + and (loaded_yaml := load_yaml(f, loader.secrets)) is not None ] diff --git a/machine/raspberrypi b/machine/raspberrypi index 3cce504661e..2ed3b3c8e44 100644 --- a/machine/raspberrypi +++ b/machine/raspberrypi @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi camera binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi2 b/machine/raspberrypi2 index c49db40b408..2ed3b3c8e44 100644 --- a/machine/raspberrypi2 +++ b/machine/raspberrypi2 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi3 b/machine/raspberrypi3 index c49db40b408..2ed3b3c8e44 100644 --- a/machine/raspberrypi3 +++ b/machine/raspberrypi3 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi3-64 b/machine/raspberrypi3-64 index c49db40b408..2ed3b3c8e44 100644 --- a/machine/raspberrypi3-64 +++ b/machine/raspberrypi3-64 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi4 b/machine/raspberrypi4 index c49db40b408..2ed3b3c8e44 100644 --- a/machine/raspberrypi4 +++ b/machine/raspberrypi4 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/raspberrypi4-64 b/machine/raspberrypi4-64 index c49db40b408..2ed3b3c8e44 100644 --- a/machine/raspberrypi4-64 +++ b/machine/raspberrypi4-64 @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/machine/yellow b/machine/yellow index c49db40b408..2ed3b3c8e44 100644 --- a/machine/yellow +++ b/machine/yellow @@ -4,12 +4,5 @@ ARG \ FROM $BUILD_FROM RUN apk --no-cache add \ - raspberrypi \ - raspberrypi-libs - -## -# Set symlinks for raspberry pi binaries. -RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ - && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ - && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ - && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv + raspberrypi-userland \ + raspberrypi-userland-libs diff --git a/mypy.ini b/mypy.ini index 0ed06edaa1d..e19c6c6fa92 100644 --- a/mypy.ini +++ b/mypy.ini @@ -190,6 +190,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.adax.*] +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.adguard.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -230,6 +240,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airnow.*] +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.airvisual.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -240,6 +260,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airvisual_pro.*] +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.airzone.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -350,6 +380,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.android_ip_webcam.*] +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.androidtv_remote.*] +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.anova.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -380,6 +430,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.apprise.*] +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.aqualogic.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -390,6 +450,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.aranet.*] +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.aseko_pool_live.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -510,6 +580,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.blue_current.*] +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.bluetooth.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -931,6 +1011,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.enigma2.*] +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.esphome.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -961,6 +1051,26 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.evohome.*] +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.faa_delays.*] +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.fan.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1031,6 +1141,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.flexit_bacnet.*] +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.flux_led.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1261,6 +1381,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.holiday.*] +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.homeassistant.exposed_entities] check_untyped_defs = true disallow_incomplete_defs = true @@ -2041,6 +2171,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.motionmount.*] +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.mqtt.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2401,6 +2541,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.pushbullet.*] +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.pvoutput.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2892,6 +3042,26 @@ warn_return_any = true warn_unreachable = true no_implicit_reexport = true +[mypy-homeassistant.components.streamlabswater.*] +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.suez_water.*] +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.sun.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2992,6 +3162,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tailwind.*] +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.tami4.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3293,6 +3473,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.valve.*] +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.velbus.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index f43dd9b6672..b2620dd3e1e 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -587,10 +587,6 @@ _ENTITY_MATCH: list[TypeHintMatch] = [ function_name="state_attributes", return_type=["dict[str, Any]", None], ), - TypeHintMatch( - function_name="device_state_attributes", - return_type=["Mapping[str, Any]", None], - ), TypeHintMatch( function_name="extra_state_attributes", return_type=["Mapping[str, Any]", None], @@ -631,10 +627,6 @@ _ENTITY_MATCH: list[TypeHintMatch] = [ function_name="supported_features", return_type=["int", None], ), - TypeHintMatch( - function_name="context_recent_time", - return_type="timedelta", - ), TypeHintMatch( function_name="entity_registry_enabled_default", return_type="bool", diff --git a/pyproject.toml b/pyproject.toml index a408e0de07e..ec313a5bcf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.12.4" +version = "2024.1.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -26,7 +26,7 @@ dependencies = [ "aiohttp==3.9.1", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", - "aiohttp-zlib-ng==0.1.1", + "aiohttp-zlib-ng==0.1.3", "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", @@ -36,11 +36,11 @@ dependencies = [ "ciso8601==2.3.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.25.0", - "home-assistant-bluetooth==1.10.4", + "httpx==0.26.0", + "home-assistant-bluetooth==1.11.0", "ifaddr==0.2.0", "Jinja2==3.1.2", - "lru-dict==1.2.0", + "lru-dict==1.3.0", "PyJWT==2.8.0", # PyJWT has loose dependency. We want the latest one. "cryptography==41.0.7", @@ -52,11 +52,15 @@ dependencies = [ "python-slugify==4.0.1", "PyYAML==6.0.1", "requests==2.31.0", - "typing-extensions>=4.8.0,<5.0", + "typing-extensions>=4.9.0,<5.0", "ulid-transform==0.9.0", + # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503 + # 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", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", - "yarl==1.9.2", + "yarl==1.9.4", ] [project.urls] @@ -435,13 +439,13 @@ filterwarnings = [ "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met", # -- design choice 3rd party - # https://github.com/gwww/elkm1/blob/2.2.5/elkm1_lib/util.py#L8-L19 + # https://github.com/gwww/elkm1/blob/2.2.6/elkm1_lib/util.py#L8-L19 "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", # https://github.com/michaeldavie/env_canada/blob/v0.6.0/env_canada/ec_cache.py "ignore:Inheritance class CacheClientSession from ClientSession is discouraged:DeprecationWarning:env_canada.ec_cache", # https://github.com/allenporter/ical/pull/215 - v5.0.0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", - # https://github.com/bachya/regenmaschine/blob/2023.08.0/regenmaschine/client.py#L51 + # https://github.com/bachya/regenmaschine/blob/2023.12.0/regenmaschine/client.py#L57 "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", # -- Setuptools DeprecationWarnings @@ -451,13 +455,6 @@ filterwarnings = [ "ignore:Deprecated call to `pkg_resources.declare_namespace\\('google.*'\\)`:DeprecationWarning:google.rpc", # -- tracked upstream / open PRs - # https://github.com/caronc/apprise/issues/659 - v1.4.5 - "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:apprise.AppriseLocal", - # https://github.com/kiorky/croniter/issues/49 - v1.4.1 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:croniter.croniter", - # https://github.com/spulec/freezegun/issues/508 - v1.2.2 - # https://github.com/spulec/freezegun/pull/511 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:freezegun.api", # https://github.com/influxdata/influxdb-client-python/issues/603 - v1.37.0 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", # https://github.com/beetbox/mediafile/issues/67 - v0.12.0 @@ -467,9 +464,8 @@ filterwarnings = [ "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", # https://github.com/PythonCharmers/python-future/issues/488 - v0.18.3 "ignore:the imp module is deprecated in favour of importlib and slated for removal in Python 3.12:DeprecationWarning:future.standard_library", - # https://github.com/frenck/python-toonapi/pull/9 - v0.2.1 - 2021-09-23 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:toonapi.models", - # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.2 + # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.3 + # https://github.com/foxel/python_ndms2_client/pull/8 "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", # https://github.com/pytest-dev/pytest-cov/issues/557 - v4.1.0 # Should resolve itself once pytest-xdist 4.0 is released and the option is removed @@ -478,19 +474,15 @@ filterwarnings = [ # -- fixed, waiting for release / update # https://github.com/ludeeus/aiogithubapi/pull/208 - >=23.9.0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiogithubapi.namespaces.events", - # https://github.com/bachya/aiopurpleair/pull/200 - >2023.08.0 + # https://github.com/bachya/aiopurpleair/pull/200 - >=2023.10.0 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators", - # https://github.com/scrapinghub/dateparser/pull/1179 - >1.1.8 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:dateparser.timezone_parser", - # https://github.com/zopefoundation/DateTime/pull/55 - >5.2 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:DateTime.pytz_support", - # https://github.com/kurtmckee/feedparser/issues/330 - >6.0.10 - "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:feedparser.encodings", + # https://github.com/kiorky/croniter/pull/52 - >=2.0.0 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:croniter.croniter", # https://github.com/jaraco/jaraco.abode/commit/9e3e789efc96cddcaa15f920686bbeb79a7469e0 - update jaraco.abode to >=5.1.0 "ignore:`jaraco.functools.call_aside` is deprecated, use `jaraco.functools.invoke` instead:DeprecationWarning:jaraco.abode.helpers.timeline", # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", - # https://github.com/bachya/pytile/pull/280 - >2023.08.0 + # https://github.com/bachya/pytile/pull/280 - >=2023.10.0 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pytile.tile", # https://github.com/rytilahti/python-miio/pull/1809 - >0.5.12 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", @@ -499,12 +491,12 @@ filterwarnings = [ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:onvif.client", # Fixed upstream in python-telegram-bot - >=20.0 "ignore:python-telegram-bot is using upstream urllib3:UserWarning:telegram.utils.request", - # https://github.com/ludeeus/pytraccar/pull/15 - >1.0.0 - "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pytraccar.client", # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - >0.5.3 "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", # https://github.com/Bluetooth-Devices/xiaomi-ble/pull/59 - >0.21.1 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:xiaomi_ble.parser", + # https://github.com/mvantellingen/python-zeep/pull/1364 - >4.2.1 + "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:zeep.utils", # -- not helpful # pyatmo.__init__ imports deprecated moduls from itself - v7.5.0 @@ -512,10 +504,8 @@ filterwarnings = [ # -- other # Locale changes might take some time to resolve upstream - "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:homematicip.base.base_connection", - "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:micloud.micloud", - # https://github.com/protocolbuffers/protobuf - v4.24.4 - "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:google.protobuf.internal.well_known_types", + "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud", + # https://github.com/protocolbuffers/protobuf - v4.25.1 "ignore:Type google._upb._message.(Message|Scalar)MapContainer uses PyType_Spec with a metaclass that has custom tp_new. .* Python 3.14:DeprecationWarning", # https://github.com/googleapis/google-auth-library-python/blob/v2.23.3/google/auth/_helpers.py#L95 - v2.23.3 "ignore:datetime.*utcnow\\(\\) is deprecated:DeprecationWarning:google.auth._helpers", @@ -523,6 +513,12 @@ filterwarnings = [ "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated:DeprecationWarning:proto.datetime_helpers", # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", + # https://github.com/lidatong/dataclasses-json/issues/328 + # https://github.com/lidatong/dataclasses-json/pull/351 + "ignore:The 'default' argument to fields is deprecated. Use 'dump_default' instead:DeprecationWarning:dataclasses_json.mm", + # Fixed for Python 3.12 + # https://github.com/lextudio/pysnmp/issues/10 + "ignore:The asyncore module is deprecated and will be removed in Python 3.12:DeprecationWarning:pysnmp.carrier.asyncore.base", # Wrong stacklevel # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:bs4.builder", @@ -587,7 +583,6 @@ select = [ "G", # flake8-logging-format "I", # isort "ICN001", # import concentions; {name} should be imported as {asname} - "ISC001", # Implicitly concatenated string literals on one line "N804", # First argument of a class method should be named cls "N805", # First argument of a method should be named self "N815", # Variable {name} in class scope should not be mixedCase @@ -663,6 +658,21 @@ ignore = [ # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q000", + "Q001", + "Q002", + "Q003", + "COM812", + "COM819", + "ISC001", + "ISC002", ] [tool.ruff.flake8-import-conventions.extend-aliases] diff --git a/requirements.txt b/requirements.txt index aa9a0ab0e5a..55cbdc31730 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ aiohttp==3.9.1 aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0 -aiohttp-zlib-ng==0.1.1 +aiohttp-zlib-ng==0.1.3 astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 @@ -14,11 +14,11 @@ awesomeversion==23.11.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 -httpx==0.25.0 -home-assistant-bluetooth==1.10.4 +httpx==0.26.0 +home-assistant-bluetooth==1.11.0 ifaddr==0.2.0 Jinja2==3.1.2 -lru-dict==1.2.0 +lru-dict==1.3.0 PyJWT==2.8.0 cryptography==41.0.7 pyOpenSSL==23.2.0 @@ -28,8 +28,9 @@ pip>=21.3.1 python-slugify==4.0.1 PyYAML==6.0.1 requests==2.31.0 -typing-extensions>=4.8.0,<5.0 +typing-extensions>=4.9.0,<5.0 ulid-transform==0.9.0 +urllib3>=1.26.5,<2 voluptuous==0.13.1 voluptuous-serialize==2.6.0 -yarl==1.9.2 +yarl==1.9.4 diff --git a/requirements_all.txt b/requirements_all.txt index b65e6f60338..46b89f491a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -45,7 +45,7 @@ Mastodon.py==1.5.1 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.4 +PlexAPI==4.15.7 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 @@ -96,7 +96,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.40.1 +PySwitchbot==0.43.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -112,7 +112,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.29.0 +PyViCare==2.32.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -185,7 +185,7 @@ aio-geojson-usgs-earthquakes==0.2 aio-georss-gdacs==0.8 # homeassistant.components.airq -aioairq==0.3.1 +aioairq==0.3.2 # homeassistant.components.airzone_cloud aioairzone-cloud==0.3.6 @@ -196,6 +196,9 @@ aioairzone==0.7.2 # homeassistant.components.ambient_station aioambient==2023.04.0 +# homeassistant.components.apcupsd +aioapcaccess==0.4.2 + # homeassistant.components.aseko_pool_live aioaseko==0.0.2 @@ -212,10 +215,10 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.6.2 +aiocomelit==0.7.0 # homeassistant.components.dhcp -aiodiscover==1.5.1 +aiodiscover==1.6.0 # homeassistant.components.dnsip aiodns==3.0.0 @@ -236,7 +239,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.2.1 +aioesphomeapi==21.0.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -254,13 +257,13 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.9 +aiohomekit==3.1.1 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.1.1 +aiohttp-zlib-ng==0.1.3 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -353,7 +356,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==6.1.0 +aioshelly==7.0.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -374,7 +377,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==67 +aiounifi==68 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -433,9 +436,6 @@ anova-wifi==0.10.0 # homeassistant.components.anthemav anthemav==1.4.1 -# homeassistant.components.apcupsd -apcaccess==0.0.13 - # homeassistant.components.weatherkit apple_weatherkit==1.1.2 @@ -469,7 +469,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.36.2 +async-upnp-client==0.38.0 # homeassistant.components.keyboard_remote asyncinotify==4.0.2 @@ -478,7 +478,7 @@ asyncinotify==4.0.2 asyncpysupla==0.0.5 # homeassistant.components.sleepiq -asyncsleepiq==1.3.7 +asyncsleepiq==1.4.1 # homeassistant.components.aten_pe # atenpdu==0.3.2 @@ -504,6 +504,9 @@ azure-eventhub==5.11.1 # homeassistant.components.azure_service_bus azure-servicebus==7.10.0 +# homeassistant.components.holiday +babel==2.13.1 + # homeassistant.components.baidu baidu-aip==1.6.6 @@ -523,7 +526,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.37.4 +bellows==0.37.6 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 @@ -531,8 +534,11 @@ bimmer-connected[china]==0.14.6 # homeassistant.components.bizkaibus bizkaibus==0.1.1 +# homeassistant.components.esphome +bleak-esphome==0.4.0 + # homeassistant.components.bluetooth -bleak-retry-connector==3.3.0 +bleak-retry-connector==3.4.0 # homeassistant.components.bluetooth bleak==0.21.1 @@ -546,6 +552,9 @@ blinkpy==0.22.4 # homeassistant.components.bitcoin blockchain==1.4.4 +# homeassistant.components.blue_current +bluecurrent-api==1.0.6 + # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 @@ -554,17 +563,16 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.1 +bluetooth-adapters==0.16.2 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.2.3 # homeassistant.components.bluetooth -# homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.15.0 +bluetooth-data-tools==1.19.0 # homeassistant.components.bond bond-async==0.2.1 @@ -580,10 +588,10 @@ boto3==1.28.17 broadlink==0.18.3 # homeassistant.components.brother -brother==2.3.0 +brother==3.0.0 # homeassistant.components.brottsplatskartan -brottsplatskartan==0.0.1 +brottsplatskartan==1.0.5 # homeassistant.components.brunt brunt==1.2.0 @@ -592,7 +600,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.2.0 +bthome-ble==3.3.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -603,6 +611,9 @@ btsmarthub-devicelist==0.2.3 # homeassistant.components.buienradar buienradar==1.0.5 +# homeassistant.components.dhcp +cached_ipaddress==0.3.0 + # homeassistant.components.caldav caldav==1.3.8 @@ -655,7 +666,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.14.0 +dbus-fast==2.21.0 # homeassistant.components.debugpy debugpy==1.8.0 @@ -684,7 +695,7 @@ denonavr==0.11.4 devialet==1.4.5 # homeassistant.components.devolo_home_control -devolo-home-control-api==0.18.2 +devolo-home-control-api==0.18.3 # homeassistant.components.devolo_home_network devolo-plc-api==1.4.1 @@ -704,6 +715,9 @@ dovado==0.4.1 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 +# homeassistant.components.drop_connect +dropmqttapi==1.0.1 + # homeassistant.components.dsmr dsmr-parser==1.3.1 @@ -723,7 +737,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==1.0.0 +easyenergy==2.1.0 # homeassistant.components.ebusd ebusdpy==0.0.17 @@ -756,7 +770,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==1.0.0 +energyzero==2.1.0 # homeassistant.components.enocean enocean==0.50 @@ -789,7 +803,7 @@ eufylife-ble-client==0.1.8 # evdev==1.6.1 # homeassistant.components.evohome -evohome-async==0.4.6 +evohome-async==0.4.15 # homeassistant.components.faa_delays faadelays==2023.9.1 @@ -802,7 +816,7 @@ faadelays==2023.9.1 fastdotcom==0.0.3 # homeassistant.components.feedreader -feedparser==6.0.10 +feedparser==6.0.11 # homeassistant.components.file file-read-backwards==2.0.0 @@ -822,6 +836,9 @@ fixerio==1.0.0a0 # homeassistant.components.fjaraskupan fjaraskupan==2.2.0 +# homeassistant.components.flexit_bacnet +flexit_bacnet==2.1.0 + # homeassistant.components.flipr flipr-api==1.5.0 @@ -855,7 +872,7 @@ fritzconnection[qr]==1.13.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.4.0 +gardena-bluetooth==1.4.1 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -886,7 +903,7 @@ georss-qld-bushfire-alert-client==0.5 # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp -getmac==0.8.2 +getmac==0.9.4 # homeassistant.components.gios gios==3.2.2 @@ -895,7 +912,7 @@ gios==3.2.2 gitterpy==0.1.7 # homeassistant.components.glances -glances-api==0.4.3 +glances-api==0.5.0 # homeassistant.components.goalzero goalzero==0.2.2 @@ -914,7 +931,7 @@ google-cloud-pubsub==2.13.11 google-cloud-texttospeech==2.12.3 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.1.0 +google-generativeai==0.3.1 # homeassistant.components.nest google-nest-sdm==3.0.3 @@ -925,6 +942,9 @@ googlemaps==2.5.1 # homeassistant.components.slide goslide-api==0.5.1 +# homeassistant.components.tailwind +gotailwind==0.2.2 + # homeassistant.components.govee_ble govee-ble==0.24.0 @@ -977,8 +997,11 @@ ha-philipsjs==3.1.1 # homeassistant.components.habitica habitipy==0.2.0 +# homeassistant.components.bluetooth +habluetooth==2.0.1 + # homeassistant.components.cloud -hass-nabucasa==0.74.0 +hass-nabucasa==0.75.1 # homeassistant.components.splunk hass-splunk==0.1.1 @@ -1010,14 +1033,15 @@ hlk-sw16==0.0.9 # homeassistant.components.pi_hole hole==0.8.0 +# homeassistant.components.holiday # homeassistant.components.workday -holidays==0.36 +holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20231208.2 +home-assistant-frontend==20240103.3 # homeassistant.components.conversation -home-assistant-intents==2023.12.05 +home-assistant-intents==2024.1.2 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -1041,7 +1065,7 @@ huawei-lte-api==1.7.3 hyperion-py==0.7.5 # homeassistant.components.iammeter -iammeter==0.1.7 +iammeter==0.2.1 # homeassistant.components.iaqualink iaqualink==0.5.0 @@ -1201,7 +1225,7 @@ lupupy==0.3.1 lw12==0.9.2 # homeassistant.components.scrape -lxml==4.9.3 +lxml==4.9.4 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 @@ -1246,7 +1270,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.7 +millheater==0.11.8 # homeassistant.components.minio minio==7.1.12 @@ -1383,7 +1407,7 @@ open-garage==0.2.0 open-meteo==0.3.1 # homeassistant.components.openai_conversation -openai==0.27.2 +openai==1.3.8 # homeassistant.components.opencv # opencv-python-headless==4.6.0.66 @@ -1401,7 +1425,7 @@ openhomedevice==2.2.0 opensensemap-api==0.2.0 # homeassistant.components.enigma2 -openwebifpy==3.2.7 +openwebifpy==4.0.2 # homeassistant.components.luci openwrt-luci-rpc==1.1.16 @@ -1410,7 +1434,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.39 +opower==0.1.0 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1454,7 +1478,6 @@ pescea==1.0.12 # homeassistant.components.aruba # homeassistant.components.cisco_ios # homeassistant.components.pandora -# homeassistant.components.unifi_direct pexpect==4.6.0 # homeassistant.components.modem_callerid @@ -1507,7 +1530,7 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==5.9.6 +psutil==5.9.7 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 @@ -1524,9 +1547,15 @@ pushover_complete==1.1.1 # homeassistant.components.pvoutput pvo==2.1.1 +# homeassistant.components.aosmith +py-aosmith==1.0.1 + # homeassistant.components.canary py-canary==0.5.3 +# homeassistant.components.ccm15 +py-ccm15==0.0.9 + # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 @@ -1570,7 +1599,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.11.1 +pyDuotecno==2024.1.1 # homeassistant.components.electrasmart pyElectra==1.2.0 @@ -1613,13 +1642,13 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.asuswrt -pyasuswrt==0.1.20 +pyasuswrt==0.1.21 # homeassistant.components.atag pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.1 +pyatmo==8.0.2 # homeassistant.components.apple_tv pyatv==0.14.3 @@ -1859,7 +1888,7 @@ pylgnetcast==0.3.7 pylibrespot-java==0.1.1 # homeassistant.components.litejet -pylitejet==0.5.0 +pylitejet==0.6.2 # homeassistant.components.litterrobot pylitterbot==2023.4.9 @@ -1945,6 +1974,9 @@ pyopnsense==0.4.0 # homeassistant.components.opple pyoppleio-legacy==1.0.8 +# homeassistant.components.osoenergy +pyosoenergyapi==1.1.3 + # homeassistant.components.opentherm_gw pyotgw==2.1.3 @@ -1984,7 +2016,7 @@ pyprof2calltree==1.4.5 pyprosegur==0.0.9 # homeassistant.components.prusalink -pyprusalink==1.1.0 +pyprusalink==2.0.0 # homeassistant.components.ps4 pyps4-2ndscreen==1.3.1 @@ -2026,7 +2058,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.12.0 +pyschlage==2023.12.1 # homeassistant.components.sensibo pysensibo==1.0.36 @@ -2073,7 +2105,7 @@ pysmartthings==0.7.8 pysml==0.0.12 # homeassistant.components.snmp -pysnmplib==5.0.21 +pysnmp-lextudio==5.0.31 # homeassistant.components.snooz pysnooz==0.8.6 @@ -2085,7 +2117,7 @@ pysoma==0.0.12 pyspcwebgw==0.7.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.3 +pysqueezebox==0.7.1 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 @@ -2108,6 +2140,9 @@ pytfiac==0.4 # homeassistant.components.thinkingcleaner pythinkingcleaner==0.0.3 +# homeassistant.components.motionmount +python-MotionMount==0.3.1 + # homeassistant.components.awair python-awair==0.2.4 @@ -2166,7 +2201,7 @@ python-kasa[speedups]==0.5.4 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==5.0.0 +python-matter-server==5.1.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -2178,10 +2213,10 @@ python-mpd2==3.0.5 python-mystrom==2.2.0 # homeassistant.components.swiss_public_transport -python-opendata-transport==0.3.0 +python-opendata-transport==0.4.0 # homeassistant.components.opensky -python-opensky==0.2.1 +python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread @@ -2197,7 +2232,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.36.2 +python-roborock==0.38.0 # homeassistant.components.smarttub python-smarttub==0.0.36 @@ -2206,7 +2241,7 @@ python-smarttub==0.0.36 python-songpal==0.16 # homeassistant.components.tado -python-tado==0.15.0 +python-tado==0.17.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 @@ -2236,7 +2271,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.9.1 +pytrafikverket==0.3.9.2 # homeassistant.components.v2c pytrydan==0.4.0 @@ -2245,7 +2280,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.3 +pyunifiprotect==4.22.5 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2266,7 +2301,7 @@ pyvesync==2.1.10 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.20 +pyvlx==0.2.21 # homeassistant.components.volumio pyvolumio==0.1.5 @@ -2305,7 +2340,7 @@ pyzbar==0.1.7 pyzerproc==0.4.8 # homeassistant.components.qingping -qingping-ble==0.8.2 +qingping-ble==0.9.0 # homeassistant.components.qnap qnapstats==0.4.0 @@ -2328,6 +2363,9 @@ rapt-ble==0.1.2 # homeassistant.components.raspyrfm raspyrfm-client==1.2.8 +# homeassistant.components.refoss +refoss-ha==1.2.0 + # homeassistant.components.rainmachine regenmaschine==2023.06.0 @@ -2335,10 +2373,10 @@ regenmaschine==2023.06.0 renault-api==0.2.1 # homeassistant.components.renson -renson-endura-delta==1.6.0 +renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.4 +reolink-aio==0.8.5 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -2362,10 +2400,10 @@ rocketchat-API==0.6.1 rokuecp==0.18.1 # homeassistant.components.roomba -roombapy==1.6.8 +roombapy==1.6.10 # homeassistant.components.roon -roonapi==0.1.5 +roonapi==0.1.6 # homeassistant.components.rova rova==0.3.0 @@ -2401,7 +2439,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.4 +screenlogicpy==0.10.0 # homeassistant.components.scsgate scsgate==0.1.0 @@ -2468,7 +2506,7 @@ smhi-pkg==1.0.16 snapcast==2.3.3 # homeassistant.components.sonos -soco==0.29.1 +soco==0.30.0 # homeassistant.components.solaredge_local solaredge-local==0.2.3 @@ -2497,6 +2535,9 @@ spiderpy==1.6.1 # homeassistant.components.spotify spotipy==2.23.0 +# homeassistant.components.sql +sqlparse==0.4.4 + # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -2536,6 +2577,9 @@ subarulink==0.7.9 # homeassistant.components.solarlog sunwatcher==0.2.1 +# homeassistant.components.sunweg +sunweg==2.0.3 + # homeassistant.components.surepetcare surepy==0.9.0 @@ -2543,7 +2587,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.2.1 +switchbot-api==1.3.0 # homeassistant.components.synology_srm synology-srm==0.2.0 @@ -2573,7 +2617,7 @@ tellduslive==0.10.11 temescal==0.5 # homeassistant.components.temper -temperusb==1.6.0 +temperusb==1.6.1 # homeassistant.components.tensorflow # tensorflow==2.5.0 @@ -2584,11 +2628,14 @@ tesla-powerwall==0.3.19 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 +# homeassistant.components.tessie +tessie-api==0.0.9 + # homeassistant.components.tensorflow # tf-models-official==2.5.0 # homeassistant.components.thermobeacon -thermobeacon-ble==0.6.0 +thermobeacon-ble==0.6.2 # homeassistant.components.thermopro thermopro-ble==0.5.0 @@ -2653,6 +2700,9 @@ ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 +# homeassistant.components.unifi_direct +unifi_ap==0.0.1 + # homeassistant.components.unifiled unifiled==0.11 @@ -2780,7 +2830,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.2 +yalexs-ble==2.4.0 # homeassistant.components.august yalexs==1.10.0 @@ -2810,13 +2860,13 @@ zamg==0.3.3 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.128.5 +zeroconf==0.131.0 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.108 +zha-quirks==0.0.109 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 @@ -2825,7 +2875,7 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.22.3 +zigpy-deconz==0.22.4 # homeassistant.components.zha zigpy-xbee==0.20.1 @@ -2837,13 +2887,13 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.2 +zigpy==0.60.4 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.54.0 +zwave-js-server-python==0.55.2 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index ee45a757669..3a552741812 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,11 +8,11 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.0.1 -coverage==7.3.2 -freezegun==1.2.2 +coverage==7.3.4 +freezegun==1.3.1 mock-open==1.4.0 -mypy==1.7.1 -pre-commit==3.5.0 +mypy==1.8.0 +pre-commit==3.6.0 pydantic==1.10.12 pylint==3.0.3 pylint-per-file-ignores==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cbe772165d9..ee1a9b2ac35 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -39,7 +39,7 @@ HATasmota==0.8.0 Pillow==10.1.0 # homeassistant.components.plex -PlexAPI==4.15.4 +PlexAPI==4.15.7 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 @@ -84,7 +84,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.40.1 +PySwitchbot==0.43.0 # homeassistant.components.syncthru PySyncThru==0.7.10 @@ -97,7 +97,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.29.0 +PyViCare==2.32.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 @@ -164,7 +164,7 @@ aio-geojson-usgs-earthquakes==0.2 aio-georss-gdacs==0.8 # homeassistant.components.airq -aioairq==0.3.1 +aioairq==0.3.2 # homeassistant.components.airzone_cloud aioairzone-cloud==0.3.6 @@ -175,6 +175,9 @@ aioairzone==0.7.2 # homeassistant.components.ambient_station aioambient==2023.04.0 +# homeassistant.components.apcupsd +aioapcaccess==0.4.2 + # homeassistant.components.aseko_pool_live aioaseko==0.0.2 @@ -191,10 +194,10 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.6.2 +aiocomelit==0.7.0 # homeassistant.components.dhcp -aiodiscover==1.5.1 +aiodiscover==1.6.0 # homeassistant.components.dnsip aiodns==3.0.0 @@ -215,7 +218,7 @@ aioelectricitymaps==0.1.5 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==19.2.1 +aioesphomeapi==21.0.1 # homeassistant.components.flo aioflo==2021.11.0 @@ -230,13 +233,13 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.9 +aiohomekit==3.1.1 # homeassistant.components.http aiohttp-fast-url-dispatcher==0.3.0 # homeassistant.components.http -aiohttp-zlib-ng==0.1.1 +aiohttp-zlib-ng==0.1.3 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -326,7 +329,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==6.1.0 +aioshelly==7.0.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -347,7 +350,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==67 +aiounifi==68 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -397,9 +400,6 @@ anova-wifi==0.10.0 # homeassistant.components.anthemav anthemav==1.4.1 -# homeassistant.components.apcupsd -apcaccess==0.0.13 - # homeassistant.components.weatherkit apple_weatherkit==1.1.2 @@ -421,10 +421,10 @@ arcam-fmj==1.4.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.36.2 +async-upnp-client==0.38.0 # homeassistant.components.sleepiq -asyncsleepiq==1.3.7 +asyncsleepiq==1.4.1 # homeassistant.components.aurora auroranoaa==0.0.3 @@ -438,6 +438,9 @@ axis==48 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 +# homeassistant.components.holiday +babel==2.13.1 + # homeassistant.components.homekit base36==0.1.1 @@ -445,13 +448,16 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.37.4 +bellows==0.37.6 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.14.6 +# homeassistant.components.esphome +bleak-esphome==0.4.0 + # homeassistant.components.bluetooth -bleak-retry-connector==3.3.0 +bleak-retry-connector==3.4.0 # homeassistant.components.bluetooth bleak==0.21.1 @@ -462,21 +468,23 @@ blebox-uniapi==2.2.0 # homeassistant.components.blink blinkpy==0.22.4 +# homeassistant.components.blue_current +bluecurrent-api==1.0.6 + # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.1 +bluetooth-adapters==0.16.2 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.2.3 # homeassistant.components.bluetooth -# homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.15.0 +bluetooth-data-tools==1.19.0 # homeassistant.components.bond bond-async==0.2.1 @@ -488,20 +496,23 @@ boschshcpy==0.2.75 broadlink==0.18.3 # homeassistant.components.brother -brother==2.3.0 +brother==3.0.0 # homeassistant.components.brottsplatskartan -brottsplatskartan==0.0.1 +brottsplatskartan==1.0.5 # homeassistant.components.brunt brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.2.0 +bthome-ble==3.3.1 # homeassistant.components.buienradar buienradar==1.0.5 +# homeassistant.components.dhcp +cached_ipaddress==0.3.0 + # homeassistant.components.caldav caldav==1.3.8 @@ -536,7 +547,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.14.0 +dbus-fast==2.21.0 # homeassistant.components.debugpy debugpy==1.8.0 @@ -559,7 +570,7 @@ denonavr==0.11.4 devialet==1.4.5 # homeassistant.components.devolo_home_control -devolo-home-control-api==0.18.2 +devolo-home-control-api==0.18.3 # homeassistant.components.devolo_home_network devolo-plc-api==1.4.1 @@ -573,6 +584,9 @@ discovery30303==0.2.1 # homeassistant.components.dremel_3d_printer dremel3dpy==2.1.1 +# homeassistant.components.drop_connect +dropmqttapi==1.0.1 + # homeassistant.components.dsmr dsmr-parser==1.3.1 @@ -589,7 +603,7 @@ dynalite-panel==0.0.4 eagle100==0.1.1 # homeassistant.components.easyenergy -easyenergy==1.0.0 +easyenergy==2.1.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.8.5 @@ -610,7 +624,7 @@ emulated-roku==0.2.1 energyflip-client==0.2.2 # homeassistant.components.energyzero -energyzero==1.0.0 +energyzero==2.1.0 # homeassistant.components.enocean enocean==0.50 @@ -627,6 +641,9 @@ epson-projector==0.5.1 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 +# homeassistant.components.netgear_lte +eternalegypt==0.0.16 + # homeassistant.components.eufylife_ble eufylife-ble-client==0.1.8 @@ -637,11 +654,14 @@ faadelays==2023.9.1 fastdotcom==0.0.3 # homeassistant.components.feedreader -feedparser==6.0.10 +feedparser==6.0.11 # homeassistant.components.file file-read-backwards==2.0.0 +# homeassistant.components.fints +fints==3.1.0 + # homeassistant.components.fitbit fitbit==0.3.1 @@ -651,6 +671,9 @@ fivem-api==0.1.2 # homeassistant.components.fjaraskupan fjaraskupan==2.2.0 +# homeassistant.components.flexit_bacnet +flexit_bacnet==2.1.0 + # homeassistant.components.flipr flipr-api==1.5.0 @@ -678,7 +701,7 @@ fritzconnection[qr]==1.13.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena-bluetooth==1.4.0 +gardena-bluetooth==1.4.1 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -706,13 +729,13 @@ georss-qld-bushfire-alert-client==0.5 # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp -getmac==0.8.2 +getmac==0.9.4 # homeassistant.components.gios gios==3.2.2 # homeassistant.components.glances -glances-api==0.4.3 +glances-api==0.5.0 # homeassistant.components.goalzero goalzero==0.2.2 @@ -728,7 +751,7 @@ google-api-python-client==2.71.0 google-cloud-pubsub==2.13.11 # homeassistant.components.google_generative_ai_conversation -google-generativeai==0.1.0 +google-generativeai==0.3.1 # homeassistant.components.nest google-nest-sdm==3.0.3 @@ -736,6 +759,9 @@ google-nest-sdm==3.0.3 # homeassistant.components.google_travel_time googlemaps==2.5.1 +# homeassistant.components.tailwind +gotailwind==0.2.2 + # homeassistant.components.govee_ble govee-ble==0.24.0 @@ -776,8 +802,11 @@ ha-philipsjs==3.1.1 # homeassistant.components.habitica habitipy==0.2.0 +# homeassistant.components.bluetooth +habluetooth==2.0.1 + # homeassistant.components.cloud -hass-nabucasa==0.74.0 +hass-nabucasa==0.75.1 # homeassistant.components.conversation hassil==1.5.1 @@ -797,14 +826,15 @@ hlk-sw16==0.0.9 # homeassistant.components.pi_hole hole==0.8.0 +# homeassistant.components.holiday # homeassistant.components.workday -holidays==0.36 +holidays==0.39 # homeassistant.components.frontend -home-assistant-frontend==20231208.2 +home-assistant-frontend==20240103.3 # homeassistant.components.conversation -home-assistant-intents==2023.12.05 +home-assistant-intents==2024.1.2 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -931,7 +961,7 @@ loqedAPI==2.1.8 luftdaten==0.7.4 # homeassistant.components.scrape -lxml==4.9.3 +lxml==4.9.4 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 @@ -970,7 +1000,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.7 +millheater==0.11.8 # homeassistant.components.minio minio==7.1.12 @@ -1077,7 +1107,7 @@ open-garage==0.2.0 open-meteo==0.3.1 # homeassistant.components.openai_conversation -openai==0.27.2 +openai==1.3.8 # homeassistant.components.openerz openerz-api==0.2.0 @@ -1086,7 +1116,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.39 +opower==0.1.0 # homeassistant.components.oralb oralb-ble==0.17.6 @@ -1115,12 +1145,6 @@ peco==0.0.29 # homeassistant.components.escea pescea==1.0.12 -# homeassistant.components.aruba -# homeassistant.components.cisco_ios -# homeassistant.components.pandora -# homeassistant.components.unifi_direct -pexpect==4.6.0 - # homeassistant.components.modem_callerid phone-modem==0.1.1 @@ -1155,6 +1179,9 @@ prometheus-client==0.17.1 # homeassistant.components.recorder psutil-home-assistant==0.0.1 +# homeassistant.components.systemmonitor +psutil==5.9.7 + # homeassistant.components.androidtv pure-python-adb[async]==0.3.0.dev0 @@ -1167,9 +1194,15 @@ pushover_complete==1.1.1 # homeassistant.components.pvoutput pvo==2.1.1 +# homeassistant.components.aosmith +py-aosmith==1.0.1 + # homeassistant.components.canary py-canary==0.5.3 +# homeassistant.components.ccm15 +py-ccm15==0.0.9 + # homeassistant.components.cpuspeed py-cpuinfo==9.0.0 @@ -1201,7 +1234,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.11.1 +pyDuotecno==2024.1.1 # homeassistant.components.electrasmart pyElectra==1.2.0 @@ -1229,13 +1262,13 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.asuswrt -pyasuswrt==0.1.20 +pyasuswrt==0.1.21 # homeassistant.components.atag pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==8.0.1 +pyatmo==8.0.2 # homeassistant.components.apple_tv pyatv==0.14.3 @@ -1403,7 +1436,7 @@ pylaunches==1.4.0 pylibrespot-java==0.1.1 # homeassistant.components.litejet -pylitejet==0.5.0 +pylitejet==0.6.2 # homeassistant.components.litterrobot pylitterbot==2023.4.9 @@ -1468,6 +1501,9 @@ pyopenuv==2023.02.0 # homeassistant.components.opnsense pyopnsense==0.4.0 +# homeassistant.components.osoenergy +pyosoenergyapi==1.1.3 + # homeassistant.components.opentherm_gw pyotgw==2.1.3 @@ -1504,7 +1540,7 @@ pyprof2calltree==1.4.5 pyprosegur==0.0.9 # homeassistant.components.prusalink -pyprusalink==1.1.0 +pyprusalink==2.0.0 # homeassistant.components.ps4 pyps4-2ndscreen==1.3.1 @@ -1531,7 +1567,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.12.0 +pyschlage==2023.12.1 # homeassistant.components.sensibo pysensibo==1.0.36 @@ -1572,7 +1608,7 @@ pysmartthings==0.7.8 pysml==0.0.12 # homeassistant.components.snmp -pysnmplib==5.0.21 +pysnmp-lextudio==5.0.31 # homeassistant.components.snooz pysnooz==0.8.6 @@ -1584,7 +1620,10 @@ pysoma==0.0.12 pyspcwebgw==0.7.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.3 +pysqueezebox==0.7.1 + +# homeassistant.components.suez_water +pysuez==0.2.0 # homeassistant.components.switchbee pyswitchbee==1.8.0 @@ -1595,6 +1634,9 @@ pytankerkoenig==0.0.6 # homeassistant.components.tautulli pytautulli==23.1.1 +# homeassistant.components.motionmount +python-MotionMount==0.3.1 + # homeassistant.components.awair python-awair==0.2.4 @@ -1620,7 +1662,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.5.4 # homeassistant.components.matter -python-matter-server==5.0.0 +python-matter-server==5.1.1 # homeassistant.components.xiaomi_miio python-miio==0.5.12 @@ -1628,8 +1670,11 @@ python-miio==0.5.12 # homeassistant.components.mystrom python-mystrom==2.2.0 +# homeassistant.components.swiss_public_transport +python-opendata-transport==0.4.0 + # homeassistant.components.opensky -python-opensky==0.2.1 +python-opensky==1.0.0 # homeassistant.components.otbr # homeassistant.components.thread @@ -1642,7 +1687,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.36.2 +python-roborock==0.38.0 # homeassistant.components.smarttub python-smarttub==0.0.36 @@ -1651,7 +1696,7 @@ python-smarttub==0.0.36 python-songpal==0.16 # homeassistant.components.tado -python-tado==0.15.0 +python-tado==0.17.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 @@ -1672,7 +1717,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.9.1 +pytrafikverket==0.3.9.2 # homeassistant.components.v2c pytrydan==0.4.0 @@ -1681,7 +1726,7 @@ pytrydan==0.4.0 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.22.3 +pyunifiprotect==4.22.5 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1726,7 +1771,7 @@ pyyardian==1.1.1 pyzerproc==0.4.8 # homeassistant.components.qingping -qingping-ble==0.8.2 +qingping-ble==0.9.0 # homeassistant.components.qnap qnapstats==0.4.0 @@ -1740,6 +1785,9 @@ radiotherm==2.1.0 # homeassistant.components.rapt_ble rapt-ble==0.1.2 +# homeassistant.components.refoss +refoss-ha==1.2.0 + # homeassistant.components.rainmachine regenmaschine==2023.06.0 @@ -1747,10 +1795,10 @@ regenmaschine==2023.06.0 renault-api==0.2.1 # homeassistant.components.renson -renson-endura-delta==1.6.0 +renson-endura-delta==1.7.1 # homeassistant.components.reolink -reolink-aio==0.8.4 +reolink-aio==0.8.5 # homeassistant.components.rflink rflink==0.0.65 @@ -1762,10 +1810,10 @@ ring-doorbell[listen]==0.8.5 rokuecp==0.18.1 # homeassistant.components.roomba -roombapy==1.6.8 +roombapy==1.6.10 # homeassistant.components.roon -roonapi==0.1.5 +roonapi==0.1.6 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 @@ -1789,7 +1837,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.4 +screenlogicpy==0.10.0 # homeassistant.components.backup securetar==2023.3.0 @@ -1838,7 +1886,7 @@ smhi-pkg==1.0.16 snapcast==2.3.3 # homeassistant.components.sonos -soco==0.29.1 +soco==0.30.0 # homeassistant.components.solaredge solaredge==0.0.2 @@ -1864,6 +1912,9 @@ spiderpy==1.6.1 # homeassistant.components.spotify spotipy==2.23.0 +# homeassistant.components.sql +sqlparse==0.4.4 + # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -1885,6 +1936,9 @@ stookalert==0.1.4 # homeassistant.components.stookwijzer stookwijzer==1.3.0 +# homeassistant.components.streamlabswater +streamlabswater==1.0.1 + # homeassistant.components.huawei_lte # homeassistant.components.solaredge # homeassistant.components.thermoworks_smoke @@ -1897,11 +1951,14 @@ subarulink==0.7.9 # homeassistant.components.solarlog sunwatcher==0.2.1 +# homeassistant.components.sunweg +sunweg==2.0.3 + # homeassistant.components.surepetcare surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.2.1 +switchbot-api==1.3.0 # homeassistant.components.system_bridge systembridgeconnector==3.10.0 @@ -1916,7 +1973,7 @@ tellduslive==0.10.11 temescal==0.5 # homeassistant.components.temper -temperusb==1.6.0 +temperusb==1.6.1 # homeassistant.components.powerwall tesla-powerwall==0.3.19 @@ -1924,8 +1981,11 @@ tesla-powerwall==0.3.19 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.2 +# homeassistant.components.tessie +tessie-api==0.0.9 + # homeassistant.components.thermobeacon -thermobeacon-ble==0.6.0 +thermobeacon-ble==0.6.2 # homeassistant.components.thermopro thermopro-ble==0.5.0 @@ -2081,7 +2141,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.2 +yalexs-ble==2.4.0 # homeassistant.components.august yalexs==1.10.0 @@ -2105,16 +2165,16 @@ yt-dlp==2023.11.16 zamg==0.3.3 # homeassistant.components.zeroconf -zeroconf==0.128.5 +zeroconf==0.131.0 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.108 +zha-quirks==0.0.109 # homeassistant.components.zha -zigpy-deconz==0.22.3 +zigpy-deconz==0.22.4 # homeassistant.components.zha zigpy-xbee==0.20.1 @@ -2126,10 +2186,10 @@ zigpy-zigate==0.12.0 zigpy-znp==0.12.1 # homeassistant.components.zha -zigpy==0.60.2 +zigpy==0.60.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.54.0 +zwave-js-server-python==0.55.2 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index c797db4b7a3..a02eed66ffa 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.2.2 -ruff==0.1.6 +ruff==0.1.8 yamllint==1.32.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f6835fdbaf1..7f652b14302 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -59,11 +59,6 @@ CONSTRAINT_BASE = """ # 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 -# 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 httplib2>=0.19.0 @@ -103,9 +98,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==4.0.0 +anyio==4.1.0 h11==0.14.0 -httpcore==0.18.0 +httpcore==1.0.2 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation @@ -179,6 +174,14 @@ get-mac==1000000000.0.0 # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. charset-normalizer==3.2.0 + +# lxml 5.0.0 currently does not build on alpine 3.18 +# https://bugs.launchpad.net/lxml/+bug/2047718 +lxml==4.9.4 + +# dacite: Ensure we have a version that is able to handle type unions for +# Roborock, NAM, Brother, and GIOS. +dacite>=1.7.0 """ GENERATED_MESSAGE = ( diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 3bd44736038..c9d81424229 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -59,9 +59,10 @@ WORKDIR /config def _generate_dockerfile() -> str: timeout = ( - core.STAGE_1_SHUTDOWN_TIMEOUT - + core.STAGE_2_SHUTDOWN_TIMEOUT - + core.STAGE_3_SHUTDOWN_TIMEOUT + core.STOPPING_STAGE_SHUTDOWN_TIMEOUT + + core.STOP_STAGE_SHUTDOWN_TIMEOUT + + core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT + + core.CLOSE_STAGE_SHUTDOWN_TIMEOUT + executor.EXECUTOR_SHUTDOWN_TIMEOUT + thread.THREADING_SHUTDOWN_TIMEOUT + 10 diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 4a826f7cad9..580294705cf 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -13,7 +13,7 @@ from voluptuous.humanize import humanize_error from homeassistant.const import CONF_SELECTOR from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, selector, service -from homeassistant.util.yaml import load_yaml +from homeassistant.util.yaml import load_yaml_dict from .model import Config, Integration @@ -107,7 +107,7 @@ def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool def validate_services(config: Config, integration: Integration) -> None: """Validate services.""" try: - data = load_yaml(str(integration.path / "services.yaml")) + data = load_yaml_dict(str(integration.path / "services.yaml")) except FileNotFoundError: # Find if integration uses services has_services = grep_dir( @@ -122,7 +122,7 @@ def validate_services(config: Config, integration: Integration) -> None: ) return except HomeAssistantError: - integration.add_error("services", "Unable to load services.yaml") + integration.add_error("services", "Invalid services.yaml") return try: diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py index 3dd60b51296..caef6c2e729 100644 --- a/script/scaffold/templates/config_flow/integration/config_flow.py +++ b/script/scaffold/templates/config_flow/integration/config_flow.py @@ -7,6 +7,7 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -18,9 +19,9 @@ _LOGGER = logging.getLogger(__name__) # TODO adjust the data schema to the data that you need STEP_USER_DATA_SCHEMA = vol.Schema( { - vol.Required("host"): str, - vol.Required("username"): str, - vol.Required("password"): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, } ) @@ -50,12 +51,12 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, # If your PyPI package is not built with async, pass your methods # to the executor: # await hass.async_add_executor_job( - # your_validate_func, data["username"], data["password"] + # your_validate_func, data[CONF_USERNAME], data[CONF_PASSWORD] # ) - hub = PlaceholderHub(data["host"]) + hub = PlaceholderHub(data[CONF_HOST]) - if not await hub.authenticate(data["username"], data["password"]): + if not await hub.authenticate(data[CONF_USERNAME], data[CONF_PASSWORD]): raise InvalidAuth # If you cannot connect: diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py index cbc1449378c..bb9e6380cdc 100644 --- a/script/scaffold/templates/config_flow/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -1,16 +1,13 @@ """Test the NEW_NAME config flow.""" from unittest.mock import AsyncMock, patch -import pytest - from homeassistant import config_entries from homeassistant.components.NEW_DOMAIN.config_flow import CannotConnect, InvalidAuth from homeassistant.components.NEW_DOMAIN.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME 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.""" @@ -18,33 +15,35 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == FlowResultType.FORM - assert result["errors"] is None + assert result["errors"] == {} with patch( "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", return_value=True, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) await hass.async_block_till_done() - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Name of the device" - assert result2["data"] == { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", } assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +async def test_form_invalid_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -54,20 +53,48 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", side_effect=InvalidAuth, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -77,14 +104,41 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", side_effect=CannotConnect, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { - "host": "1.1.1.1", - "username": "test-username", - "password": "test-password", + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", }, ) - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.PlaceholderHub.authenticate", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Name of the device" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index ef7beab488b..9e9b48a07f6 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -894,10 +894,7 @@ async def test_auth_module_expired_session(mock_hass) -> None: assert step["type"] == data_entry_flow.FlowResultType.FORM assert step["step_id"] == "mfa" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() + MFA_SESSION_EXPIRATION, - ): + with freeze_time(dt_util.utcnow() + MFA_SESSION_EXPIRATION): step = await manager.login_flow.async_configure( step["flow_id"], {"pin": "test-pin"} ) diff --git a/tests/common.py b/tests/common.py index b2fa53d28fb..b07788dc3d7 100644 --- a/tests/common.py +++ b/tests/common.py @@ -6,6 +6,7 @@ from collections import OrderedDict from collections.abc import Generator, Mapping, Sequence from contextlib import contextmanager from datetime import UTC, datetime, timedelta +from enum import Enum import functools as ft from functools import lru_cache from io import StringIO @@ -15,10 +16,12 @@ import os import pathlib import threading import time +from types import ModuleType from typing import Any, NoReturn from unittest.mock import AsyncMock, Mock, patch from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401 +import pytest import voluptuous as vol from homeassistant import auth, bootstrap, config_entries, loader @@ -88,6 +91,10 @@ from homeassistant.util.unit_system import METRIC_SYSTEM import homeassistant.util.uuid as uuid_util import homeassistant.util.yaml.loader as yaml_loader +from tests.testing_config.custom_components.test_constant_deprecation import ( + import_deprecated_costant, +) + _LOGGER = logging.getLogger(__name__) INSTANCES = [] CLIENT_ID = "https://example.com/app" @@ -890,6 +897,7 @@ class MockConfigEntry(config_entries.ConfigEntry): domain="test", data=None, version=1, + minor_version=1, entry_id=None, source=config_entries.SOURCE_USER, title="Mock Title", @@ -910,6 +918,7 @@ class MockConfigEntry(config_entries.ConfigEntry): "pref_disable_polling": pref_disable_polling, "options": options, "version": version, + "minor_version": minor_version, "title": title, "unique_id": unique_id, "disabled_by": disabled_by, @@ -1216,7 +1225,7 @@ class MockEntity(entity.Entity): @contextmanager def mock_storage( - data: dict[str, Any] | None = None + data: dict[str, Any] | None = None, ) -> Generator[dict[str, Any], None, None]: """Mock storage. @@ -1427,7 +1436,7 @@ ANY = _HA_ANY() def raise_contains_mocks(val: Any) -> None: """Raise for mocks.""" if isinstance(val, Mock): - raise TypeError + raise TypeError(val) if isinstance(val, dict): for dict_value in val.values(): @@ -1458,3 +1467,59 @@ def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> else: state = CloudConnectionState.CLOUD_DISCONNECTED async_dispatcher_send(hass, SIGNAL_CLOUD_CONNECTION_STATE, state) + + +def import_and_test_deprecated_constant_enum( + caplog: pytest.LogCaptureFixture, + module: ModuleType, + replacement: Enum, + constant_prefix: str, + breaks_in_ha_version: str, +) -> None: + """Import and test deprecated constant replaced by a enum. + + - Import deprecated enum + - Assert value is the same as the replacement + - Assert a warning is logged + - Assert the deprecated constant is included in the modules.__dir__() + """ + import_and_test_deprecated_constant( + caplog, + module, + constant_prefix + replacement.name, + f"{replacement.__class__.__name__}.{replacement.name}", + replacement, + breaks_in_ha_version, + ) + + +def import_and_test_deprecated_constant( + caplog: pytest.LogCaptureFixture, + module: ModuleType, + constant_name: str, + replacement_name: str, + replacement: Any, + breaks_in_ha_version: str, +) -> None: + """Import and test deprecated constant replaced by a value. + + - Import deprecated constant + - Assert value is the same as the replacement + - Assert a warning is logged + - Assert the deprecated constant is included in the modules.__dir__() + """ + value = import_deprecated_costant(module, constant_name) + assert value == replacement + assert ( + module.__name__, + logging.WARNING, + ( + f"{constant_name} was used from test_constant_deprecation," + f" this is a deprecated constant which will be removed in HA Core {breaks_in_ha_version}. " + f"Use {replacement_name} instead, please report " + "it to the author of the 'test_constant_deprecation' custom integration" + ), + ) in caplog.record_tuples + + # verify deprecated constant is included in dir() + assert constant_name in dir(module) diff --git a/tests/components/advantage_air/__init__.py b/tests/components/advantage_air/__init__.py index b826e3ac7ce..05d98e957bb 100644 --- a/tests/components/advantage_air/__init__.py +++ b/tests/components/advantage_air/__init__.py @@ -1,12 +1,14 @@ """Tests for the Advantage Air component.""" +from unittest.mock import AsyncMock, patch + from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_json_object_fixture -TEST_SYSTEM_DATA = load_fixture("advantage_air/getSystemData.json") -TEST_SET_RESPONSE = load_fixture("advantage_air/setAircon.json") +TEST_SYSTEM_DATA = load_json_object_fixture("getSystemData.json", DOMAIN) +TEST_SET_RESPONSE = None USER_INPUT = { CONF_IP_ADDRESS: "1.2.3.4", @@ -25,6 +27,22 @@ TEST_SET_THING_URL = ( ) +def patch_get(return_value=TEST_SYSTEM_DATA, side_effect=None): + """Patch the Advantage Air async_get method.""" + return patch( + "homeassistant.components.advantage_air.advantage_air.async_get", + new=AsyncMock(return_value=return_value, side_effect=side_effect), + ) + + +def patch_update(return_value=True, side_effect=None): + """Patch the Advantage Air async_set method.""" + return patch( + "homeassistant.components.advantage_air.advantage_air._endpoint.async_update", + new=AsyncMock(return_value=return_value, side_effect=side_effect), + ) + + async def add_mock_config(hass): """Create a fake Advantage Air Config Entry.""" entry = MockConfigEntry( @@ -33,6 +51,7 @@ async def add_mock_config(hass): unique_id="0123456", data=USER_INPUT, ) + entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/advantage_air/conftest.py b/tests/components/advantage_air/conftest.py new file mode 100644 index 00000000000..9da0a176309 --- /dev/null +++ b/tests/components/advantage_air/conftest.py @@ -0,0 +1,20 @@ +"""Fixtures for advantage_air.""" +from __future__ import annotations + +import pytest + +from . import patch_get, patch_update + + +@pytest.fixture +def mock_get(): + """Fixture to patch the Advantage Air async_get method.""" + with patch_get() as mock_get: + yield mock_get + + +@pytest.fixture +def mock_update(): + """Fixture to patch the Advantage Air async_get method.""" + with patch_update() as mock_get: + yield mock_get diff --git a/tests/components/advantage_air/snapshots/test_climate.ambr b/tests/components/advantage_air/snapshots/test_climate.ambr new file mode 100644 index 00000000000..9e21d0ede17 --- /dev/null +++ b/tests/components/advantage_air/snapshots/test_climate.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_climate_myauto_main[climate.myauto-fanmode] + dict({ + 'ac3': dict({ + 'info': dict({ + 'fan': 'autoAA', + }), + }), + }) +# --- +# name: test_climate_myauto_main[climate.myauto-settemp] + dict({ + 'ac3': dict({ + 'info': dict({ + 'myAutoCoolTargetTemp': 23.0, + 'myAutoHeatTargetTemp': 21.0, + }), + }), + }) +# --- +# name: test_climate_myauto_main[climate.myauto] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': None, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'low', + 'medium', + 'high', + 'auto', + ]), + 'friendly_name': 'myauto', + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 32, + 'min_temp': 16, + 'supported_features': , + 'target_temp_high': 24, + 'target_temp_low': 20, + 'target_temp_step': 1, + 'temperature': 24, + }), + 'context': , + 'entity_id': 'climate.myauto', + 'last_changed': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- diff --git a/tests/components/advantage_air/snapshots/test_switch.ambr b/tests/components/advantage_air/snapshots/test_switch.ambr new file mode 100644 index 00000000000..2060c0798ed --- /dev/null +++ b/tests/components/advantage_air/snapshots/test_switch.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_cover_async_setup_entry[switch.myzone_myfan-turnoff] + dict({ + 'ac1': dict({ + 'info': dict({ + 'aaAutoFanModeEnabled': False, + }), + }), + }) +# --- +# name: test_cover_async_setup_entry[switch.myzone_myfan-turnon] + dict({ + 'ac1': dict({ + 'info': dict({ + 'aaAutoFanModeEnabled': True, + }), + }), + }) +# --- +# name: test_cover_async_setup_entry[switch.myzone_myfan] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'myzone MyFan', + 'icon': 'mdi:fan-auto', + }), + 'context': , + 'entity_id': 'switch.myzone_myfan', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index c6d055f396a..19b0dba2eda 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -1,5 +1,6 @@ """Test the Advantage Air Binary Sensor Platform.""" from datetime import timedelta +from unittest.mock import AsyncMock from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON @@ -7,37 +8,20 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) +from . import add_mock_config from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker async def test_binary_sensor_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, ) -> None: """Test binary sensor setup.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test First Air Filter entity_id = "binary_sensor.myzone_filter" state = hass.states.get(entity_id) @@ -83,6 +67,7 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -91,6 +76,7 @@ async def test_binary_sensor_async_setup_entry( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state @@ -105,6 +91,7 @@ async def test_binary_sensor_async_setup_entry( assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -113,6 +100,7 @@ async def test_binary_sensor_async_setup_entry( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index a1eb886cbd0..704e25c0473 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -1,20 +1,11 @@ """Test the Advantage Air Climate Platform.""" -from json import loads +from unittest.mock import AsyncMock + +from advantage_air import ApiError import pytest +from syrupy import SnapshotAssertion -from homeassistant.components.advantage_air.climate import ( - ADVANTAGE_AIR_COOL_TARGET, - ADVANTAGE_AIR_HEAT_TARGET, - HASS_FAN_MODES, - HASS_HVAC_MODES, -) -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_CLOSE, - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, - ADVANTAGE_AIR_STATE_OPEN, -) from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, @@ -24,6 +15,7 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, FAN_LOW, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, @@ -37,35 +29,20 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config -async def test_climate_async_setup_entry( +async def test_climate_myzone_main( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: - """Test climate platform.""" + """Test climate platform main entity.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - # Test MyZone Climate Entity + # Test MyZone main climate entity entity_id = "climate.myzone" state = hass.states.get(entity_id) assert state @@ -80,19 +57,24 @@ async def test_climate_async_setup_entry( assert entry.unique_id == "uniqueid-ac1" # Test setting HVAC Mode + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + mock_update.assert_called_once() + mock_update.reset_mock() + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_ON - assert data["ac1"]["info"]["mode"] == HASS_HVAC_MODES[HVACMode.FAN_ONLY] - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning Off with HVAC Mode await hass.services.async_call( @@ -101,26 +83,17 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() - # Test changing Fan Mode await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {ATTR_ENTITY_ID: [entity_id], ATTR_FAN_MODE: FAN_LOW}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["fan"] == HASS_FAN_MODES[FAN_LOW] - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test changing Temperature await hass.services.async_call( @@ -129,12 +102,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["setTemp"] == 25 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning On await hass.services.async_call( @@ -143,12 +112,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Turning Off await hass.services.async_call( @@ -157,12 +122,19 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() + + +async def test_climate_myzone_zone( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, +) -> None: + """Test climate platform myzone zone entity.""" + + await add_mock_config(hass) # Test Climate Zone Entity entity_id = "climate.myzone_zone_open_with_sensor" @@ -184,14 +156,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, blocking=True, ) - - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - - assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Climate Zone Off await hass.services.async_call( @@ -200,13 +166,8 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, blocking=True, ) - - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( CLIMATE_DOMAIN, @@ -214,18 +175,24 @@ async def test_climate_async_setup_entry( {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 25}, blocking=True, ) + mock_update.assert_called_once() + mock_update.reset_mock() - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + +async def test_climate_myauto_main( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test climate platform zone entity.""" + + await add_mock_config(hass) # Test MyAuto Climate Entity entity_id = "climate.myauto" - state = hass.states.get(entity_id) - assert state - assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 20 - assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 24 + assert hass.states.get(entity_id) == snapshot(name=entity_id) entry = entity_registry.async_get(entity_id) assert entry @@ -241,34 +208,35 @@ async def test_climate_async_setup_entry( }, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["info"][ADVANTAGE_AIR_HEAT_TARGET] == 21 - assert data["ac3"]["info"][ADVANTAGE_AIR_COOL_TARGET] == 23 + mock_update.assert_called_once() + assert mock_update.call_args[0][0] == snapshot(name=f"{entity_id}-settemp") + mock_update.reset_mock() + + # Test AutoFanMode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_FAN_MODE: FAN_AUTO}, + blocking=True, + ) + mock_update.assert_called_once() + assert mock_update.call_args[0][0] == snapshot(name=f"{entity_id}-fanmode") async def test_climate_async_failed_update( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test climate change failure.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - exc=SyntaxError, - ) - await add_mock_config(hass) - with pytest.raises(HomeAssistantError): + mock_update.side_effect = ApiError + await add_mock_config(hass) await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {ATTR_ENTITY_ID: ["climate.myzone"], ATTR_TEMPERATURE: 25}, blocking=True, ) - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/setAircon" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_config_flow.py b/tests/components/advantage_air/test_config_flow.py index fc74df5538b..64d445a0b20 100644 --- a/tests/components/advantage_air/test_config_flow.py +++ b/tests/components/advantage_air/test_config_flow.py @@ -1,23 +1,18 @@ """Test the Advantage Air config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch + +from advantage_air import ApiError from homeassistant import config_entries, data_entry_flow from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, USER_INPUT - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import TEST_SYSTEM_DATA, USER_INPUT -async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +async def test_form(hass: HomeAssistant) -> None: """Test that form shows up.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - result1 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -26,6 +21,9 @@ async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> assert result1["errors"] == {} with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(return_value=TEST_SYSTEM_DATA), + ) as mock_get, patch( "homeassistant.components.advantage_air.async_setup_entry", return_value=True, ) as mock_setup_entry: @@ -34,43 +32,44 @@ async def test_form(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> USER_INPUT, ) await hass.async_block_till_done() + mock_setup_entry.assert_called_once() + mock_get.assert_called_once() - assert len(aioclient_mock.mock_calls) == 1 assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result2["title"] == "testname" assert result2["data"] == USER_INPUT - assert len(mock_setup_entry.mock_calls) == 1 # Test Duplicate Config Flow result3 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - USER_INPUT, - ) + with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(return_value=TEST_SYSTEM_DATA), + ) as mock_get: + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + USER_INPUT, + ) assert result4["type"] == data_entry_flow.FlowResultType.ABORT -async def test_form_cannot_connect( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_form_cannot_connect(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - exc=SyntaxError, - ) - 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"], - USER_INPUT, - ) + with patch( + "homeassistant.components.advantage_air.config_flow.advantage_air.async_get", + new=AsyncMock(side_effect=ApiError), + ) as mock_get: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + mock_get.assert_called_once() assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["step_id"] == "user" assert result2["errors"] == {"base": "cannot_connect"} - assert len(aioclient_mock.mock_calls) == 1 diff --git a/tests/components/advantage_air/test_cover.py b/tests/components/advantage_air/test_cover.py index af516d16e6e..8166b5da941 100644 --- a/tests/components/advantage_air/test_cover.py +++ b/tests/components/advantage_air/test_cover.py @@ -1,10 +1,6 @@ """Test the Advantage Air Cover Platform.""" -from json import loads +from unittest.mock import AsyncMock -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_CLOSE, - ADVANTAGE_AIR_STATE_OPEN, -) from homeassistant.components.cover import ( ATTR_POSITION, DOMAIN as COVER_DOMAIN, @@ -17,34 +13,17 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OPEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_ac_cover( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test cover platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Cover Zone Entity @@ -65,12 +44,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -78,13 +53,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert data["ac3"]["zones"]["z01"]["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -92,12 +62,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id], ATTR_POSITION: 50}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["value"] == 50 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -105,12 +71,8 @@ async def test_ac_cover( {ATTR_ENTITY_ID: [entity_id], ATTR_POSITION: 0}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test controlling multiple Cover Zone Entity await hass.services.async_call( @@ -124,9 +86,9 @@ async def test_ac_cover( }, blocking=True, ) - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE - assert data["ac3"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_CLOSE + assert len(mock_update.mock_calls) == 2 + mock_update.reset_mock() + await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, @@ -138,27 +100,18 @@ async def test_ac_cover( }, blocking=True, ) - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac3"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN - assert data["ac3"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_OPEN + + assert len(mock_update.mock_calls) == 2 async def test_things_cover( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test cover platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Blind 1 Entity @@ -171,7 +124,7 @@ async def test_things_cover( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-200" + assert entry.unique_id == f"uniqueid-{thing_id}" await hass.services.async_call( COVER_DOMAIN, @@ -179,13 +132,8 @@ async def test_things_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( COVER_DOMAIN, @@ -193,10 +141,4 @@ async def test_things_cover( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_diagnostics.py b/tests/components/advantage_air/test_diagnostics.py index 01f6d809a49..80de9019715 100644 --- a/tests/components/advantage_air/test_diagnostics.py +++ b/tests/components/advantage_air/test_diagnostics.py @@ -1,28 +1,24 @@ """Test the Advantage Air Diagnostics.""" +from unittest.mock import AsyncMock + from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, add_mock_config +from . import add_mock_config from tests.components.diagnostics import get_diagnostics_for_config_entry -from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator async def test_select_async_setup_entry( hass: HomeAssistant, hass_client: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, snapshot: SnapshotAssertion, + mock_get: AsyncMock, ) -> None: """Test select platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - entry = await add_mock_config(hass) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert diag == snapshot diff --git a/tests/components/advantage_air/test_init.py b/tests/components/advantage_air/test_init.py index c665d038878..21cadbc4b3d 100644 --- a/tests/components/advantage_air/test_init.py +++ b/tests/components/advantage_air/test_init.py @@ -1,22 +1,17 @@ """Test the Advantage Air Initialization.""" +from unittest.mock import AsyncMock + +from advantage_air import ApiError + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import TEST_SYSTEM_DATA, TEST_SYSTEM_URL, add_mock_config - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config, patch_get -async def test_async_setup_entry( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_async_setup_entry(hass: HomeAssistant, mock_get: AsyncMock) -> None: """Test a successful setup entry and unload.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - entry = await add_mock_config(hass) assert entry.state is ConfigEntryState.LOADED @@ -25,15 +20,9 @@ async def test_async_setup_entry( assert entry.state is ConfigEntryState.NOT_LOADED -async def test_async_setup_entry_failure( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +async def test_async_setup_entry_failure(hass: HomeAssistant) -> None: """Test a unsuccessful setup entry.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - exc=SyntaxError, - ) - - entry = await add_mock_config(hass) + with patch_get(side_effect=ApiError): + entry = await add_mock_config(hass) assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/advantage_air/test_light.py b/tests/components/advantage_air/test_light.py index 0e27b8aec73..4d21781772d 100644 --- a/tests/components/advantage_air/test_light.py +++ b/tests/components/advantage_air/test_light.py @@ -1,10 +1,8 @@ """Test the Advantage Air Switch Platform.""" -from json import loads -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, -) + +from unittest.mock import AsyncMock + from homeassistant.components.light import ( ATTR_BRIGHTNESS, DOMAIN as LIGHT_DOMAIN, @@ -15,34 +13,17 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_LIGHT_URL, - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_light( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test light setup.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_LIGHT_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Light Entity @@ -62,13 +43,9 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -76,13 +53,8 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test Dimmable Light Entity entity_id = "light.light_b" @@ -98,13 +70,8 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -112,32 +79,17 @@ async def test_light( {ATTR_ENTITY_ID: [entity_id], ATTR_BRIGHTNESS: 128}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setLights" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 50 - assert data["state"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() async def test_things_light( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test things lights.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Switch Entity @@ -149,7 +101,7 @@ async def test_things_light( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-204" + assert entry.unique_id == f"uniqueid-{light_id}" await hass.services.async_call( LIGHT_DOMAIN, @@ -157,13 +109,8 @@ async def test_things_light( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( LIGHT_DOMAIN, @@ -171,10 +118,4 @@ async def test_things_light( {ATTR_ENTITY_ID: [entity_id], ATTR_BRIGHTNESS: 128}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(light_id) - assert data["id"] == light_id - assert data["value"] == 50 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_select.py b/tests/components/advantage_air/test_select.py index 553c2e60180..3367595d777 100644 --- a/tests/components/advantage_air/test_select.py +++ b/tests/components/advantage_air/test_select.py @@ -1,5 +1,7 @@ """Test the Advantage Air Select Platform.""" -from json import loads + + +from unittest.mock import AsyncMock from homeassistant.components.select import ( ATTR_OPTION, @@ -10,37 +12,19 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_select_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test select platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test MyZone Select Entity entity_id = "select.myzone_myzone" state = hass.states.get(entity_id) @@ -57,10 +41,4 @@ async def test_select_async_setup_entry( {ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Zone 3"}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["myZone"] == 3 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index e4fab12291d..0099e1844c6 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -1,6 +1,6 @@ """Test the Advantage Air Sensor Platform.""" from datetime import timedelta -from json import loads +from unittest.mock import AsyncMock from homeassistant.components.advantage_air.const import DOMAIN as ADVANTAGE_AIR_DOMAIN from homeassistant.components.advantage_air.sensor import ( @@ -13,37 +13,21 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from . import ( - TEST_SET_RESPONSE, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) +from . import add_mock_config from tests.common import async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker async def test_sensor_platform( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test sensor platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) await add_mock_config(hass) - assert len(aioclient_mock.mock_calls) == 1 - # Test First TimeToOn Sensor entity_id = "sensor.myzone_time_to_on" state = hass.states.get(entity_id) @@ -55,19 +39,15 @@ async def test_sensor_platform( assert entry.unique_id == "uniqueid-ac1-timetoOn" value = 20 + await hass.services.async_call( ADVANTAGE_AIR_DOMAIN, ADVANTAGE_AIR_SERVICE_SET_TIME_TO, {ATTR_ENTITY_ID: [entity_id], ADVANTAGE_AIR_SET_COUNTDOWN_VALUE: value}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 3 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["countDownToOn"] == value - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test First TimeToOff Sensor entity_id = "sensor.myzone_time_to_off" @@ -86,13 +66,8 @@ async def test_sensor_platform( {ATTR_ENTITY_ID: [entity_id], ADVANTAGE_AIR_SET_COUNTDOWN_VALUE: value}, blocking=True, ) - assert len(aioclient_mock.mock_calls) == 5 - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["countDownToOff"] == value - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() # Test First Zone Vent Sensor entity_id = "sensor.myzone_zone_open_with_sensor_vent" @@ -134,11 +109,20 @@ async def test_sensor_platform( assert entry assert entry.unique_id == "uniqueid-ac1-z02-signal" + +async def test_sensor_platform_disabled_entity( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_get: AsyncMock +) -> None: + """Test sensor platform disabled entity.""" + + await add_mock_config(hass) + # Test First Zone Temp Sensor (disabled by default) entity_id = "sensor.myzone_zone_open_with_sensor_temperature" assert not hass.states.get(entity_id) + mock_get.reset_mock() entity_registry.async_update_entity(entity_id=entity_id, disabled_by=None) await hass.async_block_till_done() @@ -147,6 +131,7 @@ async def test_sensor_platform( dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() + assert len(mock_get.mock_calls) == 2 state = hass.states.get(entity_id) assert state diff --git a/tests/components/advantage_air/test_switch.py b/tests/components/advantage_air/test_switch.py index 99e4c645e71..4977a4cc31f 100644 --- a/tests/components/advantage_air/test_switch.py +++ b/tests/components/advantage_air/test_switch.py @@ -1,10 +1,9 @@ """Test the Advantage Air Switch Platform.""" -from json import loads -from homeassistant.components.advantage_air.const import ( - ADVANTAGE_AIR_STATE_OFF, - ADVANTAGE_AIR_STATE_ON, -) +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -14,37 +13,23 @@ from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import ( - TEST_SET_RESPONSE, - TEST_SET_THING_URL, - TEST_SET_URL, - TEST_SYSTEM_DATA, - TEST_SYSTEM_URL, - add_mock_config, -) - -from tests.test_util.aiohttp import AiohttpClientMocker +from . import add_mock_config async def test_cover_async_setup_entry( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, + snapshot: SnapshotAssertion, ) -> None: """Test switch platform.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) - # Test Switch Entity + registry = er.async_get(hass) + + # Test Fresh Air Switch Entity entity_id = "switch.myzone_fresh_air" state = hass.states.get(entity_id) assert state @@ -60,12 +45,8 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["freshAirStatus"] == ADVANTAGE_AIR_STATE_ON - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, @@ -73,30 +54,45 @@ async def test_cover_async_setup_entry( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setAircon" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) - assert data["ac1"]["info"]["freshAirStatus"] == ADVANTAGE_AIR_STATE_OFF - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() + + # Test MyFan Switch Entity + entity_id = "switch.myzone_myfan" + assert hass.states.get(entity_id) == snapshot(name=entity_id) + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "uniqueid-ac1-myfan" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_update.assert_called_once() + assert mock_update.call_args[0][0] == snapshot(name=f"{entity_id}-turnon") + mock_update.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_update.assert_called_once() + assert mock_update.call_args[0][0] == snapshot(name=f"{entity_id}-turnoff") async def test_things_switch( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, + mock_update: AsyncMock, ) -> None: """Test things switches.""" - aioclient_mock.get( - TEST_SYSTEM_URL, - text=TEST_SYSTEM_DATA, - ) - aioclient_mock.get( - TEST_SET_THING_URL, - text=TEST_SET_RESPONSE, - ) - await add_mock_config(hass) # Test Switch Entity @@ -108,7 +104,7 @@ async def test_things_switch( entry = entity_registry.async_get(entity_id) assert entry - assert entry.unique_id == "uniqueid-205" + assert entry.unique_id == f"uniqueid-{thing_id}" await hass.services.async_call( SWITCH_DOMAIN, @@ -116,13 +112,8 @@ async def test_things_switch( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 0 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() + mock_update.reset_mock() await hass.services.async_call( SWITCH_DOMAIN, @@ -130,10 +121,4 @@ async def test_things_switch( {ATTR_ENTITY_ID: [entity_id]}, blocking=True, ) - assert aioclient_mock.mock_calls[-2][0] == "GET" - assert aioclient_mock.mock_calls[-2][1].path == "/setThings" - data = loads(aioclient_mock.mock_calls[-2][1].query["json"]).get(thing_id) - assert data["id"] == thing_id - assert data["value"] == 100 - assert aioclient_mock.mock_calls[-1][0] == "GET" - assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + mock_update.assert_called_once() diff --git a/tests/components/advantage_air/test_update.py b/tests/components/advantage_air/test_update.py index 985641b923b..cb180d73f39 100644 --- a/tests/components/advantage_air/test_update.py +++ b/tests/components/advantage_air/test_update.py @@ -1,25 +1,26 @@ """Test the Advantage Air Update Platform.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.advantage_air.const import DOMAIN from homeassistant.const import STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import TEST_SYSTEM_URL, add_mock_config +from . import add_mock_config -from tests.common import load_fixture -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.common import load_json_object_fixture + +TEST_NEEDS_UPDATE = load_json_object_fixture("needsUpdate.json", DOMAIN) async def test_update_platform( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + mock_get: AsyncMock, ) -> None: """Test update platform.""" - - aioclient_mock.get( - TEST_SYSTEM_URL, - text=load_fixture("advantage_air/needsUpdate.json"), - ) + mock_get.return_value = TEST_NEEDS_UPDATE await add_mock_config(hass) entity_id = "update.testname_app" diff --git a/tests/components/airly/snapshots/test_diagnostics.ambr b/tests/components/airly/snapshots/test_diagnostics.ambr index a224ea07d46..c22e96a2082 100644 --- a/tests/components/airly/snapshots/test_diagnostics.ambr +++ b/tests/components/airly/snapshots/test_diagnostics.ambr @@ -11,6 +11,7 @@ 'disabled_by': None, 'domain': 'airly', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 80c6de427ca..71fda040c1d 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -26,6 +26,7 @@ 'disabled_by': None, 'domain': 'airnow', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, 'options': dict({ 'radius': 150, }), diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 52fc8d2300b..1619440a6f7 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -1,7 +1,7 @@ """Test the air-Q config flow.""" from unittest.mock import patch -from aioairq import DeviceInfo, InvalidAuth, InvalidInput +from aioairq import DeviceInfo, InvalidAuth from aiohttp.client_exceptions import ClientConnectionError import pytest @@ -80,21 +80,6 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_invalid_input(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch("aioairq.AirQ.validate", side_effect=InvalidInput): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_USER_DATA | {CONF_IP_ADDRESS: "invalid_ip"} - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_input"} - - async def test_duplicate_error(hass: HomeAssistant) -> None: """Test that errors are shown when duplicates are added.""" MockConfigEntry( diff --git a/tests/components/airthings/test_config_flow.py b/tests/components/airthings/test_config_flow.py index 3a0c852535e..3228b3c7229 100644 --- a/tests/components/airthings/test_config_flow.py +++ b/tests/components/airthings/test_config_flow.py @@ -4,7 +4,8 @@ from unittest.mock import patch import airthings from homeassistant import config_entries -from homeassistant.components.airthings.const import CONF_ID, CONF_SECRET, DOMAIN +from homeassistant.components.airthings.const import CONF_SECRET, DOMAIN +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/airvisual/snapshots/test_diagnostics.ambr b/tests/components/airvisual/snapshots/test_diagnostics.ambr index c805c5f9cb7..cb9d25b8790 100644 --- a/tests/components/airvisual/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual/snapshots/test_diagnostics.ambr @@ -38,6 +38,7 @@ 'disabled_by': None, 'domain': 'airvisual', 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, 'options': dict({ 'show_on_map': True, }), diff --git a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr index 96cda8e012f..be709621e31 100644 --- a/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr +++ b/tests/components/airvisual_pro/snapshots/test_diagnostics.ambr @@ -93,6 +93,7 @@ 'disabled_by': None, 'domain': 'airvisual_pro', 'entry_id': '6a2b3770e53c28dc1eeb2515e906b0ce', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/airzone/snapshots/test_diagnostics.ambr b/tests/components/airzone/snapshots/test_diagnostics.ambr index 0eab9ffe81b..adf0176765c 100644 --- a/tests/components/airzone/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone/snapshots/test_diagnostics.ambr @@ -240,6 +240,7 @@ 'disabled_by': None, 'domain': 'airzone', 'entry_id': '6e7a0798c1734ba81d26ced0e690eaec', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/airzone/test_select.py b/tests/components/airzone/test_select.py index c7c32022123..01617eab175 100644 --- a/tests/components/airzone/test_select.py +++ b/tests/components/airzone/test_select.py @@ -15,6 +15,7 @@ import pytest from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, SERVICE_SELECT_OPTION from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .util import async_init_integration @@ -85,7 +86,7 @@ async def test_airzone_select_sleep(hass: HomeAssistant) -> None: ] } - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 594a5e6765a..4a7217a08c5 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -93,6 +93,7 @@ 'disabled_by': None, 'domain': 'airzone_cloud', 'entry_id': 'd186e31edb46d64d14b9b2f11f1ebd9f', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py index 294ec81b970..2fc09d1641d 100644 --- a/tests/components/aladdin_connect/test_init.py +++ b/tests/components/aladdin_connect/test_init.py @@ -7,10 +7,14 @@ from aiohttp import ClientConnectionError from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import DEVICE_CONFIG_OPEN from tests.common import AsyncMock, MockConfigEntry CONFIG = {"username": "test-user", "password": "test-password"} +ID = "533255-1" async def test_setup_get_doors_errors(hass: HomeAssistant) -> None: @@ -40,7 +44,7 @@ async def test_setup_login_error( config_entry = MockConfigEntry( domain=DOMAIN, data=CONFIG, - unique_id="test-id", + unique_id=ID, ) config_entry.add_to_hass(hass) mock_aladdinconnect_api.login.return_value = False @@ -59,7 +63,7 @@ async def test_setup_connection_error( config_entry = MockConfigEntry( domain=DOMAIN, data=CONFIG, - unique_id="test-id", + unique_id=ID, ) config_entry.add_to_hass(hass) mock_aladdinconnect_api.login.side_effect = ClientConnectionError @@ -75,7 +79,7 @@ async def test_setup_component_no_error(hass: HomeAssistant) -> None: config_entry = MockConfigEntry( domain=DOMAIN, data=CONFIG, - unique_id="test-id", + unique_id=ID, ) config_entry.add_to_hass(hass) with patch( @@ -116,7 +120,7 @@ async def test_load_and_unload( config_entry = MockConfigEntry( domain=DOMAIN, data=CONFIG, - unique_id="test-id", + unique_id=ID, ) config_entry.add_to_hass(hass) @@ -133,3 +137,119 @@ async def test_load_and_unload( assert await config_entry.async_unload(hass) await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_stale_device_removal( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: + """Test component setup missing door device is removed.""" + DEVICE_CONFIG_DOOR_2 = { + "device_id": 533255, + "door_number": 2, + "name": "home 2", + "status": "open", + "link_status": "Connected", + "serial": "12346", + "model": "02", + } + config_entry = MockConfigEntry( + domain=DOMAIN, + data=CONFIG, + unique_id=ID, + ) + config_entry.add_to_hass(hass) + mock_aladdinconnect_api.get_doors = AsyncMock( + return_value=[DEVICE_CONFIG_OPEN, DEVICE_CONFIG_DOOR_2] + ) + config_entry_other = MockConfigEntry( + domain="OtherDomain", + data=CONFIG, + unique_id="unique_id", + ) + config_entry_other.add_to_hass(hass) + + device_registry = dr.async_get(hass) + device_entry_other = device_registry.async_get_or_create( + config_entry_id=config_entry_other.entry_id, + identifiers={("OtherDomain", "533255-2")}, + ) + device_registry.async_update_device( + device_entry_other.id, + add_config_entry_id=config_entry.entry_id, + merge_identifiers={(DOMAIN, "533255-2")}, + ) + + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + device_registry = dr.async_get(hass) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + + assert len(device_entries) == 2 + assert any((DOMAIN, "533255-1") in device.identifiers for device in device_entries) + assert any((DOMAIN, "533255-2") in device.identifiers for device in device_entries) + assert any( + ("OtherDomain", "533255-2") in device.identifiers for device in device_entries + ) + + device_entries_other = dr.async_entries_for_config_entry( + device_registry, config_entry_other.entry_id + ) + assert len(device_entries_other) == 1 + assert any( + (DOMAIN, "533255-2") in device.identifiers for device in device_entries_other + ) + assert any( + ("OtherDomain", "533255-2") in device.identifiers + for device in device_entries_other + ) + + assert await config_entry.async_unload(hass) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + mock_aladdinconnect_api.get_doors = AsyncMock(return_value=[DEVICE_CONFIG_OPEN]) + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(device_entries) == 1 + assert any((DOMAIN, "533255-1") in device.identifiers for device in device_entries) + assert not any( + (DOMAIN, "533255-2") in device.identifiers for device in device_entries + ) + assert not any( + ("OtherDomain", "533255-2") in device.identifiers for device in device_entries + ) + + device_entries_other = dr.async_entries_for_config_entry( + device_registry, config_entry_other.entry_id + ) + + assert len(device_entries_other) == 1 + assert any( + ("OtherDomain", "533255-2") in device.identifiers + for device in device_entries_other + ) + assert any( + (DOMAIN, "533255-2") in device.identifiers for device in device_entries_other + ) diff --git a/tests/components/alarm_control_panel/test_init.py b/tests/components/alarm_control_panel/test_init.py new file mode 100644 index 00000000000..1e6fce6def6 --- /dev/null +++ b/tests/components/alarm_control_panel/test_init.py @@ -0,0 +1,70 @@ +"""Test for the alarm control panel const module.""" + +from types import ModuleType + +import pytest + +from homeassistant.components import alarm_control_panel + +from tests.common import import_and_test_deprecated_constant_enum + + +@pytest.mark.parametrize( + "code_format", + list(alarm_control_panel.CodeFormat), +) +@pytest.mark.parametrize( + "module", + [alarm_control_panel, alarm_control_panel.const], +) +def test_deprecated_constant_code_format( + caplog: pytest.LogCaptureFixture, + code_format: alarm_control_panel.CodeFormat, + module: ModuleType, +) -> None: + """Test deprecated format constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, code_format, "FORMAT_", "2025.1" + ) + + +@pytest.mark.parametrize( + "entity_feature", + list(alarm_control_panel.AlarmControlPanelEntityFeature), +) +@pytest.mark.parametrize( + "module", + [alarm_control_panel, alarm_control_panel.const], +) +def test_deprecated_support_alarm_constants( + caplog: pytest.LogCaptureFixture, + entity_feature: alarm_control_panel.AlarmControlPanelEntityFeature, + module: ModuleType, +) -> None: + """Test deprecated support alarm constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, entity_feature, "SUPPORT_ALARM_", "2025.1" + ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockAlarmControlPanelEntity(alarm_control_panel.AlarmControlPanelEntity): + _attr_supported_features = 1 + + entity = MockAlarmControlPanelEntity() + assert ( + entity.supported_features + is alarm_control_panel.AlarmControlPanelEntityFeature(1) + ) + assert "MockAlarmControlPanelEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "AlarmControlPanelEntityFeature.ARM_HOME" in caplog.text + caplog.clear() + assert ( + entity.supported_features + is alarm_control_panel.AlarmControlPanelEntityFeature(1) + ) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/alarm_control_panel/test_significant_change.py b/tests/components/alarm_control_panel/test_significant_change.py new file mode 100644 index 00000000000..d65a1d5cb00 --- /dev/null +++ b/tests/components/alarm_control_panel/test_significant_change.py @@ -0,0 +1,51 @@ +"""Test the Alarm Control Panel significant change platform.""" +import pytest + +from homeassistant.components.alarm_control_panel import ( + ATTR_CHANGED_BY, + ATTR_CODE_ARM_REQUIRED, + ATTR_CODE_FORMAT, +) +from homeassistant.components.alarm_control_panel.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Alarm Control Panel significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_CHANGED_BY: "old_value"}, {ATTR_CHANGED_BY: "old_value"}, False), + ({ATTR_CHANGED_BY: "old_value"}, {ATTR_CHANGED_BY: "new_value"}, True), + ( + {ATTR_CODE_ARM_REQUIRED: "old_value"}, + {ATTR_CODE_ARM_REQUIRED: "new_value"}, + True, + ), + # multiple attributes + ( + {ATTR_CHANGED_BY: "old_value", ATTR_CODE_ARM_REQUIRED: "old_value"}, + {ATTR_CHANGED_BY: "new_value", ATTR_CODE_ARM_REQUIRED: "old_value"}, + True, + ), + # insignificant attributes + ({ATTR_CODE_FORMAT: "old_value"}, {ATTR_CODE_FORMAT: "old_value"}, False), + ({ATTR_CODE_FORMAT: "old_value"}, {ATTR_CODE_FORMAT: "new_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Humidifier significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 11e39c40cb1..b83bdb794a8 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -8,6 +8,14 @@ from homeassistant.components.alexa import smart_home from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE, HVACMode from homeassistant.components.lock import STATE_JAMMED, STATE_LOCKING, STATE_UNLOCKING from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.valve import ValveEntityFeature +from homeassistant.components.water_heater import ( + ATTR_OPERATION_LIST, + ATTR_OPERATION_MODE, + STATE_ECO, + STATE_GAS, + STATE_HEAT_PUMP, +) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_ALARM_ARMED_AWAY, @@ -16,6 +24,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_LOCKED, + STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNLOCKED, @@ -645,6 +654,143 @@ async def test_report_cover_range_value(hass: HomeAssistant) -> None: properties.assert_equal("Alexa.RangeController", "rangeValue", 0) +async def test_report_valve_range_value(hass: HomeAssistant) -> None: + """Test RangeController reports valve position correctly.""" + all_valve_features = ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP + | ValveEntityFeature.SET_POSITION + ) + hass.states.async_set( + "valve.fully_open", + "open", + { + "friendly_name": "Fully open valve", + "current_position": 100, + "supported_features": all_valve_features, + }, + ) + hass.states.async_set( + "valve.half_open", + "open", + { + "friendly_name": "Half open valve", + "current_position": 50, + "supported_features": all_valve_features, + }, + ) + hass.states.async_set( + "valve.closed", + "closed", + { + "friendly_name": "Closed valve", + "current_position": 0, + "supported_features": all_valve_features, + }, + ) + + properties = await reported_properties(hass, "valve.fully_open") + properties.assert_equal("Alexa.RangeController", "rangeValue", 100) + + properties = await reported_properties(hass, "valve.half_open") + properties.assert_equal("Alexa.RangeController", "rangeValue", 50) + + properties = await reported_properties(hass, "valve.closed") + properties.assert_equal("Alexa.RangeController", "rangeValue", 0) + + +@pytest.mark.parametrize( + ( + "supported_features", + "has_mode_controller", + "has_range_controller", + "has_toggle_controller", + ), + [ + (ValveEntityFeature(0), False, False, False), + ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + True, + False, + True, + ), + ( + ValveEntityFeature.OPEN, + True, + False, + False, + ), + ( + ValveEntityFeature.CLOSE, + True, + False, + False, + ), + ( + ValveEntityFeature.STOP, + False, + False, + True, + ), + ( + ValveEntityFeature.SET_POSITION, + False, + True, + False, + ), + ( + ValveEntityFeature.STOP | ValveEntityFeature.SET_POSITION, + False, + True, + True, + ), + ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.SET_POSITION, + False, + True, + False, + ), + ], +) +async def test_report_valve_controllers( + hass: HomeAssistant, + supported_features: ValveEntityFeature, + has_mode_controller: bool, + has_range_controller: bool, + has_toggle_controller: bool, +) -> None: + """Test valve controllers are reported correctly.""" + hass.states.async_set( + "valve.custom", + "opening", + { + "friendly_name": "Custom valve", + "current_position": 0, + "supported_features": supported_features, + }, + ) + + properties = await reported_properties(hass, "valve.custom") + + if has_mode_controller: + properties.assert_equal("Alexa.ModeController", "mode", "state.opening") + else: + properties.assert_not_has_property("Alexa.ModeController", "mode") + if has_range_controller: + properties.assert_equal("Alexa.RangeController", "rangeValue", 0) + else: + properties.assert_not_has_property("Alexa.RangeController", "rangeValue") + if has_toggle_controller: + properties.assert_equal("Alexa.ToggleController", "toggleState", "OFF") + else: + properties.assert_not_has_property("Alexa.ToggleController", "toggleState") + + async def test_report_climate_state(hass: HomeAssistant) -> None: """Test ThermostatController reports state correctly.""" for auto_modes in (HVACMode.AUTO, HVACMode.HEAT_COOL): @@ -777,6 +923,96 @@ async def test_report_climate_state(hass: HomeAssistant) -> None: assert msg["event"]["payload"]["type"] == "INTERNAL_ERROR" +async def test_report_water_heater_state(hass: HomeAssistant) -> None: + """Test ThermostatController also reports state correctly for water heaters.""" + for operation_mode in (STATE_ECO, STATE_GAS, STATE_HEAT_PUMP): + hass.states.async_set( + "water_heater.boyler", + operation_mode, + { + "friendly_name": "Boyler", + "supported_features": 11, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + ATTR_OPERATION_LIST: [STATE_ECO, STATE_GAS, STATE_HEAT_PUMP], + ATTR_OPERATION_MODE: operation_mode, + }, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property( + "Alexa.ThermostatController", "thermostatMode" + ) + properties.assert_equal( + "Alexa.ModeController", "mode", f"operation_mode.{operation_mode}" + ) + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + for off_mode in [STATE_OFF]: + hass.states.async_set( + "water_heater.boyler", + off_mode, + { + "friendly_name": "Boyler", + "supported_features": 11, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + }, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property( + "Alexa.ThermostatController", "thermostatMode" + ) + properties.assert_not_has_property("Alexa.ModeController", "mode") + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + for state in "unavailable", "unknown": + hass.states.async_set( + f"water_heater.{state}", + state, + {"friendly_name": f"Boyler {state}", "supported_features": 11}, + ) + properties = await reported_properties(hass, f"water_heater.{state}") + properties.assert_not_has_property( + "Alexa.ThermostatController", "thermostatMode" + ) + properties.assert_not_has_property("Alexa.ModeController", "mode") + + +async def test_report_singe_mode_water_heater(hass: HomeAssistant) -> None: + """Test ThermostatController also reports state correctly for water heaters.""" + operation_mode = STATE_ECO + hass.states.async_set( + "water_heater.boyler", + operation_mode, + { + "friendly_name": "Boyler", + "supported_features": 11, + ATTR_CURRENT_TEMPERATURE: 34, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + ATTR_OPERATION_LIST: [STATE_ECO], + ATTR_OPERATION_MODE: operation_mode, + }, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property("Alexa.ThermostatController", "thermostatMode") + properties.assert_equal( + "Alexa.ModeController", "mode", f"operation_mode.{operation_mode}" + ) + properties.assert_equal( + "Alexa.TemperatureSensor", + "temperature", + {"value": 34.0, "scale": "CELSIUS"}, + ) + + async def test_temperature_sensor_sensor(hass: HomeAssistant) -> None: """Test TemperatureSensor reports sensor temperature correctly.""" for bad_value in (STATE_UNKNOWN, STATE_UNAVAILABLE, "not-number"): @@ -823,6 +1059,29 @@ async def test_temperature_sensor_climate(hass: HomeAssistant) -> None: ) +async def test_temperature_sensor_water_heater(hass: HomeAssistant) -> None: + """Test TemperatureSensor reports climate temperature correctly.""" + for bad_value in (STATE_UNKNOWN, STATE_UNAVAILABLE, "not-number"): + hass.states.async_set( + "water_heater.boyler", + STATE_ECO, + {"supported_features": 11, ATTR_CURRENT_TEMPERATURE: bad_value}, + ) + + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_not_has_property("Alexa.TemperatureSensor", "temperature") + + hass.states.async_set( + "water_heater.boyler", + STATE_ECO, + {"supported_features": 11, ATTR_CURRENT_TEMPERATURE: 34}, + ) + properties = await reported_properties(hass, "water_heater.boyler") + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 34.0, "scale": "CELSIUS"} + ) + + async def test_report_alarm_control_panel_state(hass: HomeAssistant) -> None: """Test SecurityPanelController implements armState property.""" hass.states.async_set("alarm_control_panel.armed_away", STATE_ALARM_ARMED_AWAY, {}) diff --git a/tests/components/alexa/test_common.py b/tests/components/alexa/test_common.py index 4cbe112af49..d3ea1bcda3e 100644 --- a/tests/components/alexa/test_common.py +++ b/tests/components/alexa/test_common.py @@ -128,12 +128,14 @@ async def assert_request_calls_service( async def assert_request_fails( - namespace, name, endpoint, service_not_called, hass, payload=None + namespace, name, endpoint, service_not_called, hass, payload=None, instance=None ): """Assert an API request returns an ErrorResponse.""" request = get_new_request(namespace, name, endpoint) if payload: request["directive"]["payload"] = payload + if instance: + request["directive"]["header"]["instance"] = instance domain, service_name = service_not_called.split(".") call = async_mock_service(hass, domain, service_name) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 0a5b1f79f72..ff8fef43a66 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -9,8 +9,14 @@ import homeassistant.components.camera as camera from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.valve import SERVICE_STOP_VALVE, ValveEntityFeature from homeassistant.config import async_process_ha_core_config -from homeassistant.const import STATE_UNKNOWN, UnitOfTemperature +from homeassistant.const import ( + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + STATE_UNKNOWN, + UnitOfTemperature, +) from homeassistant.core import Context, Event, HomeAssistant from homeassistant.helpers import entityfilter from homeassistant.setup import async_setup_component @@ -156,7 +162,7 @@ def assert_endpoint_capabilities(endpoint, *interfaces): capabilities = endpoint["capabilities"] supported = {feature["interface"] for feature in capabilities} - assert supported == set(interfaces) + assert supported == {interface for interface in interfaces if interface is not None} return capabilities @@ -2069,6 +2075,216 @@ async def test_cover_position( assert properties["value"] == position +@pytest.mark.parametrize( + ( + "position", + "position_attr_in_service_call", + "supported_features", + "service_call", + "has_toggle_controller", + ), + [ + ( + 30, + 30, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + "valve.set_valve_position", + True, + ), + ( + 0, + None, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + "valve.close_valve", + False, + ), + ( + 99, + 99, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + "valve.set_valve_position", + False, + ), + ( + 100, + None, + ValveEntityFeature.SET_POSITION + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + "valve.open_valve", + False, + ), + ( + 0, + 0, + ValveEntityFeature.SET_POSITION, + "valve.set_valve_position", + False, + ), + ( + 60, + 60, + ValveEntityFeature.SET_POSITION, + "valve.set_valve_position", + False, + ), + ( + 60, + 60, + ValveEntityFeature.SET_POSITION | ValveEntityFeature.STOP, + "valve.set_valve_position", + True, + ), + ( + 100, + 100, + ValveEntityFeature.SET_POSITION, + "valve.set_valve_position", + False, + ), + ( + 0, + 0, + ValveEntityFeature.SET_POSITION | ValveEntityFeature.OPEN, + "valve.set_valve_position", + False, + ), + ( + 100, + 100, + ValveEntityFeature.SET_POSITION | ValveEntityFeature.CLOSE, + "valve.set_valve_position", + False, + ), + ], + ids=[ + "position_30_open_close_stop", + "position_0_open_close", + "position_99_open_close", + "position_100_open_close", + "position_0_no_open_close", + "position_60_no_open_close", + "position_60_stop_no_open_close", + "position_100_no_open_close", + "position_0_no_close", + "position_100_no_open", + ], +) +async def test_valve_position( + hass: HomeAssistant, + position: int, + position_attr_in_service_call: int | None, + supported_features: CoverEntityFeature, + service_call: str, + has_toggle_controller: bool, +) -> None: + """Test cover discovery and position using rangeController.""" + device = ( + "valve.test_range", + "open", + { + "friendly_name": "Test valve range", + "device_class": "water", + "supported_features": supported_features, + "position": position, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test_range" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa.ToggleController" if has_toggle_controller else None, + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "valve.position" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "rangeValue"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Opening", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Opening"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent" + + supported_range = configuration["supportedRange"] + assert supported_range["minimumValue"] == 0 + assert supported_range["maximumValue"] == 100 + assert supported_range["precision"] == 1 + + # Assert for Position Semantics + position_semantics = range_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in position_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in position_state_mappings + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "SetRangeValue", + "valve#test_range", + service_call, + hass, + payload={"rangeValue": position}, + instance="valve.position", + ) + assert call.data.get("position") == position_attr_in_service_call + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == position + + async def test_cover_position_range( hass: HomeAssistant, ) -> None: @@ -2186,6 +2402,208 @@ async def test_cover_position_range( ) +async def test_valve_position_range( + hass: HomeAssistant, +) -> None: + """Test valve discovery and position range using rangeController. + + Also tests an invalid valve position being handled correctly. + """ + + device = ( + "valve.test_range", + "open", + { + "friendly_name": "Test valve range", + "device_class": "water", + "supported_features": 15, + "position": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test_range" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve range" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.RangeController", + "Alexa.EndpointHealth", + "Alexa.ToggleController", + "Alexa", + ) + + range_capability = get_capability(capabilities, "Alexa.RangeController") + assert range_capability is not None + assert range_capability["instance"] == "valve.position" + + properties = range_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "rangeValue"} in properties["supported"] + + capability_resources = range_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Opening", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Opening"}, + } in capability_resources["friendlyNames"] + + configuration = range_capability["configuration"] + assert configuration is not None + assert configuration["unitOfMeasure"] == "Alexa.Unit.Percent" + + supported_range = configuration["supportedRange"] + assert supported_range["minimumValue"] == 0 + assert supported_range["maximumValue"] == 100 + assert supported_range["precision"] == 1 + + # Assert for Position Semantics + position_semantics = range_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Close"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 0}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Open"], + "directive": {"name": "SetRangeValue", "payload": {"rangeValue": 100}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": 0, + } in position_state_mappings + assert { + "@type": "StatesToRange", + "states": ["Alexa.States.Open"], + "range": {"minimumValue": 1, "maximumValue": 100}, + } in position_state_mappings + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "AdjustRangeValue", + "valve#test_range", + "valve.open_valve", + hass, + payload={"rangeValueDelta": 101, "rangeValueDeltaDefault": False}, + instance="valve.position", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == 100 + assert call.service == SERVICE_OPEN_VALVE + + call, msg = await assert_request_calls_service( + "Alexa.RangeController", + "AdjustRangeValue", + "valve#test_range", + "valve.close_valve", + hass, + payload={"rangeValueDelta": -99, "rangeValueDeltaDefault": False}, + instance="valve.position", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "rangeValue" + assert properties["namespace"] == "Alexa.RangeController" + assert properties["value"] == 0 + assert call.service == SERVICE_CLOSE_VALVE + + await assert_range_changes( + hass, + [(25, -5, False), (35, 5, False), (50, 1, True), (10, -1, True)], + "Alexa.RangeController", + "AdjustRangeValue", + "valve#test_range", + "valve.set_valve_position", + "position", + instance="valve.position", + ) + + +@pytest.mark.parametrize( + ("supported_features", "state_controller"), + [ + ( + ValveEntityFeature.SET_POSITION | ValveEntityFeature.STOP, + "Alexa.RangeController", + ), + ( + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + "Alexa.ModeController", + ), + ], +) +async def test_stop_valve( + hass: HomeAssistant, supported_features: ValveEntityFeature, state_controller: str +) -> None: + """Test stop valve ToggleController.""" + device = ( + "valve.test", + "opening", + { + "friendly_name": "Test valve", + "supported_features": supported_features, + "current_position": 30, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve" + capabilities = assert_endpoint_capabilities( + appliance, + state_controller, + "Alexa.ToggleController", + "Alexa.EndpointHealth", + "Alexa", + ) + + toggle_capability = get_capability(capabilities, "Alexa.ToggleController") + assert toggle_capability is not None + assert toggle_capability["instance"] == "valve.stop" + + properties = toggle_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "toggleState"} in properties["supported"] + + capability_resources = toggle_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Stop", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + call, _ = await assert_request_calls_service( + "Alexa.ToggleController", + "TurnOn", + "valve#test", + "valve.stop_valve", + hass, + payload={}, + instance="valve.stop", + ) + assert call.data["entity_id"] == "valve.test" + assert call.service == SERVICE_STOP_VALVE + + async def assert_percentage_changes( hass, adjustments, namespace, name, endpoint, parameter, service, changed_parameter ): @@ -2700,6 +3118,181 @@ async def test_thermostat(hass: HomeAssistant) -> None: assert call.data["preset_mode"] == "eco" +async def test_water_heater(hass: HomeAssistant) -> None: + """Test water_heater discovery.""" + hass.config.units = US_CUSTOMARY_SYSTEM + device = ( + "water_heater.boyler", + "gas", + { + "temperature": 70.0, + "target_temp_high": None, + "target_temp_low": None, + "current_temperature": 75.0, + "friendly_name": "Test water heater", + "supported_features": 1 | 2 | 8, + "operation_list": ["off", "gas", "eco"], + "operation_mode": "gas", + "min_temp": 50, + "max_temp": 90, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "water_heater#boyler" + assert appliance["displayCategories"][0] == "WATER_HEATER" + assert appliance["friendlyName"] == "Test water heater" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.ThermostatController", + "Alexa.ModeController", + "Alexa.TemperatureSensor", + "Alexa.EndpointHealth", + "Alexa", + ) + + properties = await reported_properties(hass, "water_heater#boyler") + properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.gas") + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 70.0, "scale": "FAHRENHEIT"}, + ) + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 75.0, "scale": "FAHRENHEIT"} + ) + + modes_capability = get_capability(capabilities, "Alexa.ModeController") + assert modes_capability is not None + configuration = modes_capability["configuration"] + + supported_modes = ["operation_mode.off", "operation_mode.gas", "operation_mode.eco"] + for mode in supported_modes: + assert mode in [item["value"] for item in configuration["supportedModes"]] + + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "SetTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpoint": {"value": 69.0, "scale": "FAHRENHEIT"}}, + ) + assert call.data["temperature"] == 69.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 69.0, "scale": "FAHRENHEIT"}, + ) + + msg = await assert_request_fails( + "Alexa.ThermostatController", + "SetTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpoint": {"value": 0.0, "scale": "CELSIUS"}}, + ) + assert msg["event"]["payload"]["type"] == "TEMPERATURE_VALUE_OUT_OF_RANGE" + + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "SetTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={ + "targetSetpoint": {"value": 30.0, "scale": "CELSIUS"}, + }, + ) + assert call.data["temperature"] == 86.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 86.0, "scale": "FAHRENHEIT"}, + ) + + call, msg = await assert_request_calls_service( + "Alexa.ThermostatController", + "AdjustTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpointDelta": {"value": -10.0, "scale": "KELVIN"}}, + ) + assert call.data["temperature"] == 52.0 + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal( + "Alexa.ThermostatController", + "targetSetpoint", + {"value": 52.0, "scale": "FAHRENHEIT"}, + ) + + msg = await assert_request_fails( + "Alexa.ThermostatController", + "AdjustTargetTemperature", + "water_heater#boyler", + "water_heater.set_temperature", + hass, + payload={"targetSetpointDelta": {"value": 20.0, "scale": "CELSIUS"}}, + ) + assert msg["event"]["payload"]["type"] == "TEMPERATURE_VALUE_OUT_OF_RANGE" + + # Setting mode, the payload can be an object with a value attribute... + call, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.eco"}, + instance="water_heater.operation_mode", + ) + assert call.data["operation_mode"] == "eco" + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.eco") + + call, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.gas"}, + instance="water_heater.operation_mode", + ) + assert call.data["operation_mode"] == "gas" + properties = ReportedProperties(msg["context"]["properties"]) + properties.assert_equal("Alexa.ModeController", "mode", "operation_mode.gas") + + # assert unsupported mode + msg = await assert_request_fails( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.invalid"}, + instance="water_heater.operation_mode", + ) + assert msg["event"]["payload"]["type"] == "INVALID_VALUE" + + call, _ = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "water_heater#boyler", + "water_heater.set_operation_mode", + hass, + payload={"mode": "operation_mode.off"}, + instance="water_heater.operation_mode", + ) + assert call.data["operation_mode"] == "off" + + async def test_no_current_target_temp_adjusting_temp(hass: HomeAssistant) -> None: """Test thermostat adjusting temp with no initial target temperature.""" hass.config.units = US_CUSTOMARY_SYSTEM @@ -3492,6 +4085,137 @@ async def test_cover_position_mode(hass: HomeAssistant) -> None: assert properties["value"] == "position.custom" +async def test_valve_position_mode(hass: HomeAssistant) -> None: + """Test valve discovery and position using modeController.""" + device = ( + "valve.test_mode", + "open", + { + "friendly_name": "Test valve mode", + "device_class": "water", + "supported_features": ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "valve#test_mode" + assert appliance["displayCategories"][0] == "OTHER" + assert appliance["friendlyName"] == "Test valve mode" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.ModeController", + "Alexa.EndpointHealth", + "Alexa.ToggleController", + "Alexa", + ) + + mode_capability = get_capability(capabilities, "Alexa.ModeController") + assert mode_capability is not None + assert mode_capability["instance"] == "valve.state" + + properties = mode_capability["properties"] + assert properties["nonControllable"] is False + assert {"name": "mode"} in properties["supported"] + + capability_resources = mode_capability["capabilityResources"] + assert capability_resources is not None + assert { + "@type": "text", + "value": {"text": "Preset", "locale": "en-US"}, + } in capability_resources["friendlyNames"] + + assert { + "@type": "asset", + "value": {"assetId": "Alexa.Setting.Preset"}, + } in capability_resources["friendlyNames"] + + configuration = mode_capability["configuration"] + assert configuration is not None + assert configuration["ordered"] is False + + supported_modes = configuration["supportedModes"] + assert supported_modes is not None + assert { + "value": "state.open", + "modeResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "Open", "locale": "en-US"}}, + {"@type": "asset", "value": {"assetId": "Alexa.Setting.Preset"}}, + ] + }, + } in supported_modes + assert { + "value": "state.closed", + "modeResources": { + "friendlyNames": [ + {"@type": "text", "value": {"text": "Closed", "locale": "en-US"}}, + {"@type": "asset", "value": {"assetId": "Alexa.Setting.Preset"}}, + ] + }, + } in supported_modes + + # Assert for Position Semantics + position_semantics = mode_capability["semantics"] + assert position_semantics is not None + + position_action_mappings = position_semantics["actionMappings"] + assert position_action_mappings is not None + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Close"], + "directive": {"name": "SetMode", "payload": {"mode": "state.closed"}}, + } in position_action_mappings + assert { + "@type": "ActionsToDirective", + "actions": ["Alexa.Actions.Open"], + "directive": {"name": "SetMode", "payload": {"mode": "state.open"}}, + } in position_action_mappings + + position_state_mappings = position_semantics["stateMappings"] + assert position_state_mappings is not None + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Closed"], + "value": "state.closed", + } in position_state_mappings + assert { + "@type": "StatesToValue", + "states": ["Alexa.States.Open"], + "value": "state.open", + } in position_state_mappings + + _, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "valve#test_mode", + "valve.close_valve", + hass, + payload={"mode": "state.closed"}, + instance="valve.state", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "mode" + assert properties["namespace"] == "Alexa.ModeController" + assert properties["value"] == "state.closed" + + _, msg = await assert_request_calls_service( + "Alexa.ModeController", + "SetMode", + "valve#test_mode", + "valve.open_valve", + hass, + payload={"mode": "state.open"}, + instance="valve.state", + ) + properties = msg["context"]["properties"][0] + assert properties["name"] == "mode" + assert properties["namespace"] == "Alexa.ModeController" + assert properties["value"] == "state.open" + + async def test_image_processing(hass: HomeAssistant) -> None: """Test image_processing discovery as event detection.""" device = ( diff --git a/tests/components/ambient_station/snapshots/test_diagnostics.ambr b/tests/components/ambient_station/snapshots/test_diagnostics.ambr index 4b231660c4b..b4aede7948c 100644 --- a/tests/components/ambient_station/snapshots/test_diagnostics.ambr +++ b/tests/components/ambient_station/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'ambient_station', 'entry_id': '382cf7643f016fd48b3fe52163fe8877', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/anthemav/conftest.py b/tests/components/anthemav/conftest.py index 7797f08872f..4c1abdd3c9b 100644 --- a/tests/components/anthemav/conftest.py +++ b/tests/components/anthemav/conftest.py @@ -4,8 +4,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from homeassistant.components.anthemav.const import CONF_MODEL, DOMAIN -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT +from homeassistant.components.anthemav.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_PORT from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry diff --git a/tests/components/aosmith/__init__.py b/tests/components/aosmith/__init__.py new file mode 100644 index 00000000000..89845dda42e --- /dev/null +++ b/tests/components/aosmith/__init__.py @@ -0,0 +1 @@ +"""Tests for the A. O. Smith integration.""" diff --git a/tests/components/aosmith/conftest.py b/tests/components/aosmith/conftest.py new file mode 100644 index 00000000000..61c1fc9a562 --- /dev/null +++ b/tests/components/aosmith/conftest.py @@ -0,0 +1,82 @@ +"""Common fixtures for the A. O. Smith tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from py_aosmith import AOSmithAPIClient +import pytest + +from homeassistant.components.aosmith.const import DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + +FIXTURE_USER_INPUT = { + CONF_EMAIL: "testemail@example.com", + CONF_PASSWORD: "test-password", +} + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=FIXTURE_USER_INPUT, + unique_id=FIXTURE_USER_INPUT[CONF_EMAIL], + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.aosmith.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def get_devices_fixture() -> str: + """Return the name of the fixture to use for get_devices.""" + return "get_devices" + + +@pytest.fixture +async def mock_client(get_devices_fixture: str) -> Generator[MagicMock, None, None]: + """Return a mocked client.""" + get_devices_fixture = load_json_array_fixture(f"{get_devices_fixture}.json", DOMAIN) + get_energy_use_fixture = load_json_object_fixture( + "get_energy_use_data.json", DOMAIN + ) + + client_mock = MagicMock(AOSmithAPIClient) + client_mock.get_devices = AsyncMock(return_value=get_devices_fixture) + client_mock.get_energy_use_data = AsyncMock(return_value=get_energy_use_fixture) + + return client_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> MockConfigEntry: + """Set up the integration for testing.""" + hass.config.units = US_CUSTOMARY_SYSTEM + + with patch( + "homeassistant.components.aosmith.AOSmithAPIClient", return_value=mock_client + ): + 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/aosmith/fixtures/get_devices.json b/tests/components/aosmith/fixtures/get_devices.json new file mode 100644 index 00000000000..e34c50cd270 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_mode_pending.json b/tests/components/aosmith/fixtures/get_devices_mode_pending.json new file mode 100644 index 00000000000..a12f1d95f13 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_mode_pending.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": true, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json b/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json new file mode 100644 index 00000000000..249024e1f1e --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_no_vacation_mode.json @@ -0,0 +1,42 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": false, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json b/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json new file mode 100644 index 00000000000..4d6e7613cf2 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_devices_setpoint_pending.json @@ -0,0 +1,46 @@ +[ + { + "brand": "aosmith", + "model": "HPTS-50 200 202172000", + "deviceType": "NEXT_GEN_HEAT_PUMP", + "dsn": "dsn", + "junctionId": "junctionId", + "name": "My water heater", + "serial": "serial", + "install": { + "location": "Basement" + }, + "data": { + "__typename": "NextGenHeatPump", + "temperatureSetpoint": 130, + "temperatureSetpointPending": true, + "temperatureSetpointPrevious": 130, + "temperatureSetpointMaximum": 130, + "modes": [ + { + "mode": "HYBRID", + "controls": null + }, + { + "mode": "HEAT_PUMP", + "controls": null + }, + { + "mode": "ELECTRIC", + "controls": "SELECT_DAYS" + }, + { + "mode": "VACATION", + "controls": "SELECT_DAYS" + } + ], + "isOnline": true, + "firmwareVersion": "2.14", + "hotWaterStatus": "LOW", + "mode": "HEAT_PUMP", + "modePending": false, + "vacationModeRemainingDays": 0, + "electricModeRemainingDays": 0 + } + } +] diff --git a/tests/components/aosmith/fixtures/get_energy_use_data.json b/tests/components/aosmith/fixtures/get_energy_use_data.json new file mode 100644 index 00000000000..989ddab5399 --- /dev/null +++ b/tests/components/aosmith/fixtures/get_energy_use_data.json @@ -0,0 +1,19 @@ +{ + "average": 2.7552000000000003, + "graphData": [ + { + "date": "2023-10-30T04:00:00.000Z", + "kwh": 2.01 + }, + { + "date": "2023-10-31T04:00:00.000Z", + "kwh": 1.542 + }, + { + "date": "2023-11-01T04:00:00.000Z", + "kwh": 1.908 + } + ], + "lifetimeKwh": 132.825, + "startDate": "Oct 30" +} diff --git a/tests/components/aosmith/snapshots/test_device.ambr b/tests/components/aosmith/snapshots/test_device.ambr new file mode 100644 index 00000000000..fb80dc06917 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_device.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': 'basement', + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'aosmith', + 'junctionId', + ), + }), + 'is_new': False, + 'manufacturer': 'A. O. Smith', + 'model': 'HPTS-50 200 202172000', + 'name': 'My water heater', + 'name_by_user': None, + 'serial_number': 'serial', + 'suggested_area': 'Basement', + 'sw_version': '2.14', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/aosmith/snapshots/test_sensor.ambr b/tests/components/aosmith/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..d4376c64a01 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_sensor.ambr @@ -0,0 +1,35 @@ +# serializer version: 1 +# name: test_state[sensor.my_water_heater_energy_usage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'My water heater Energy usage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_water_heater_energy_usage', + 'last_changed': , + 'last_updated': , + 'state': '132.825', + }) +# --- +# name: test_state[sensor.my_water_heater_hot_water_availability] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'My water heater Hot water availability', + 'icon': 'mdi:water-thermometer', + 'options': list([ + 'low', + 'medium', + 'high', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_water_heater_hot_water_availability', + 'last_changed': , + 'last_updated': , + 'state': 'low', + }) +# --- diff --git a/tests/components/aosmith/snapshots/test_water_heater.ambr b/tests/components/aosmith/snapshots/test_water_heater.ambr new file mode 100644 index 00000000000..2293a6c7b65 --- /dev/null +++ b/tests/components/aosmith/snapshots/test_water_heater.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_state + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'away_mode': 'off', + 'current_temperature': None, + 'friendly_name': 'My water heater', + 'max_temp': 130, + 'min_temp': 95, + 'operation_list': list([ + 'eco', + 'heat_pump', + 'electric', + ]), + 'operation_mode': 'heat_pump', + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 130, + }), + 'context': , + 'entity_id': 'water_heater.my_water_heater', + 'last_changed': , + 'last_updated': , + 'state': 'heat_pump', + }) +# --- diff --git a/tests/components/aosmith/test_config_flow.py b/tests/components/aosmith/test_config_flow.py new file mode 100644 index 00000000000..d6cf1655b14 --- /dev/null +++ b/tests/components/aosmith/test_config_flow.py @@ -0,0 +1,191 @@ +"""Test the A. O. Smith config flow.""" +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from py_aosmith import AOSmithInvalidCredentialsException +import pytest + +from homeassistant import config_entries +from homeassistant.components.aosmith.const import ( + DOMAIN, + ENERGY_USAGE_INTERVAL, + REGULAR_INTERVAL, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.aosmith.conftest import FIXTURE_USER_INPUT + + +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( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] + assert result2["data"] == FIXTURE_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "expected_error_key"), + [ + (AOSmithInvalidCredentialsException("Invalid credentials"), "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_form_exception( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, + expected_error_key: str, +) -> None: + """Test handling an exception and then recovering on the second attempt.""" + 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( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + FIXTURE_USER_INPUT, + ) + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": expected_error_key} + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + FIXTURE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] + assert result3["data"] == FIXTURE_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("api_method", "wait_interval"), + [ + ("get_devices", REGULAR_INTERVAL), + ("get_energy_use_data", ENERGY_USAGE_INTERVAL), + ], +) +async def test_reauth_flow( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, + api_method: str, + wait_interval: timedelta, +) -> None: + """Test reauth works.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + getattr(mock_client, api_method).side_effect = AOSmithInvalidCredentialsException( + "Authentication error" + ) + freezer.tick(wait_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ), patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_energy_use_data", + return_value=[], + ), patch("homeassistant.components.aosmith.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_reauth_flow_retry( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test reauth works with retry.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + mock_client.get_devices.side_effect = AOSmithInvalidCredentialsException( + "Authentication error" + ) + freezer.tick(REGULAR_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + # First attempt at reauth - authentication fails again + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=AOSmithInvalidCredentialsException("Authentication error"), + ): + result2 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + # Second attempt at reauth - authentication succeeds + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=[], + ), patch("homeassistant.components.aosmith.async_setup_entry", return_value=True): + result3 = await hass.config_entries.flow.async_configure( + flows[0]["flow_id"], + {CONF_PASSWORD: FIXTURE_USER_INPUT[CONF_PASSWORD]}, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" diff --git a/tests/components/aosmith/test_device.py b/tests/components/aosmith/test_device.py new file mode 100644 index 00000000000..596f380290e --- /dev/null +++ b/tests/components/aosmith/test_device.py @@ -0,0 +1,23 @@ +"""Tests for the device created by the A. O. Smith integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.aosmith.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test creation of the device.""" + reg_device = device_registry.async_get_device( + identifiers={(DOMAIN, "junctionId")}, + ) + + assert reg_device == snapshot diff --git a/tests/components/aosmith/test_init.py b/tests/components/aosmith/test_init.py new file mode 100644 index 00000000000..463932e930a --- /dev/null +++ b/tests/components/aosmith/test_init.py @@ -0,0 +1,97 @@ +"""Tests for the initialization of the A. O. Smith integration.""" + +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from py_aosmith import AOSmithUnknownException +import pytest + +from homeassistant.components.aosmith.const import ( + DOMAIN, + FAST_INTERVAL, + REGULAR_INTERVAL, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_array_fixture, +) + + +async def test_config_entry_setup(init_integration: MockConfigEntry) -> None: + """Test setup of the config entry.""" + mock_config_entry = init_integration + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_config_entry_not_ready_get_devices_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test the config entry not ready when get_devices fails.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + side_effect=AOSmithUnknownException("Unknown error"), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_not_ready_get_energy_use_data_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the config entry not ready when get_energy_use_data fails.""" + mock_config_entry.add_to_hass(hass) + + get_devices_fixture = load_json_array_fixture("get_devices.json", DOMAIN) + + with patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_devices", + return_value=get_devices_fixture, + ), patch( + "homeassistant.components.aosmith.config_flow.AOSmithAPIClient.get_energy_use_data", + side_effect=AOSmithUnknownException("Unknown error"), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("get_devices_fixture", "time_to_wait", "expected_call_count"), + [ + ("get_devices", REGULAR_INTERVAL, 1), + ("get_devices", FAST_INTERVAL, 0), + ("get_devices_mode_pending", FAST_INTERVAL, 1), + ("get_devices_setpoint_pending", FAST_INTERVAL, 1), + ], +) +async def test_update( + freezer: FrozenDateTimeFactory, + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + time_to_wait: timedelta, + expected_call_count: int, +) -> None: + """Test data update with differing intervals depending on device status.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert mock_client.get_devices.call_count == 1 + + freezer.tick(time_to_wait) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert mock_client.get_devices.call_count == 1 + expected_call_count diff --git a/tests/components/aosmith/test_sensor.py b/tests/components/aosmith/test_sensor.py new file mode 100644 index 00000000000..f94dfdb710c --- /dev/null +++ b/tests/components/aosmith/test_sensor.py @@ -0,0 +1,50 @@ +"""Tests for the sensor platform of the A. O. Smith integration.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_id", "unique_id"), + [ + ( + "sensor.my_water_heater_hot_water_availability", + "hot_water_availability_junctionId", + ), + ("sensor.my_water_heater_energy_usage", "energy_usage_junctionId"), + ], +) +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, + entity_id: str, + unique_id: str, +) -> None: + """Test the setup of the sensor entities.""" + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == unique_id + + +@pytest.mark.parametrize( + ("entity_id"), + [ + "sensor.my_water_heater_hot_water_availability", + "sensor.my_water_heater_energy_usage", + ], +) +async def test_state( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_id: str, +) -> None: + """Test the state of the sensor entities.""" + state = hass.states.get(entity_id) + assert state == snapshot diff --git a/tests/components/aosmith/test_water_heater.py b/tests/components/aosmith/test_water_heater.py new file mode 100644 index 00000000000..61cb159c82a --- /dev/null +++ b/tests/components/aosmith/test_water_heater.py @@ -0,0 +1,147 @@ +"""Tests for the water heater platform of the A. O. Smith integration.""" + +from unittest.mock import MagicMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.aosmith.const import ( + AOSMITH_MODE_ELECTRIC, + AOSMITH_MODE_HEAT_PUMP, + AOSMITH_MODE_HYBRID, + AOSMITH_MODE_VACATION, +) +from homeassistant.components.water_heater import ( + ATTR_AWAY_MODE, + ATTR_OPERATION_MODE, + ATTR_TEMPERATURE, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + SERVICE_SET_OPERATION_MODE, + SERVICE_SET_TEMPERATURE, + STATE_ECO, + STATE_ELECTRIC, + STATE_HEAT_PUMP, + WaterHeaterEntityFeature, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + init_integration: MockConfigEntry, +) -> None: + """Test the setup of the water heater entity.""" + entry = entity_registry.async_get("water_heater.my_water_heater") + assert entry + assert entry.unique_id == "junctionId" + + state = hass.states.get("water_heater.my_water_heater") + assert state + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My water heater" + + +async def test_state( + hass: HomeAssistant, init_integration: MockConfigEntry, snapshot: SnapshotAssertion +) -> None: + """Test the state of the water heater entity.""" + state = hass.states.get("water_heater.my_water_heater") + assert state == snapshot + + +@pytest.mark.parametrize( + ("get_devices_fixture"), + ["get_devices_no_vacation_mode"], +) +async def test_state_away_mode_unsupported( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Test that away mode is not supported if the water heater does not support vacation mode.""" + state = hass.states.get("water_heater.my_water_heater") + assert ( + state.attributes.get(ATTR_SUPPORTED_FEATURES) + == WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.OPERATION_MODE + ) + + +@pytest.mark.parametrize( + ("hass_mode", "aosmith_mode"), + [ + (STATE_HEAT_PUMP, AOSMITH_MODE_HEAT_PUMP), + (STATE_ECO, AOSMITH_MODE_HYBRID), + (STATE_ELECTRIC, AOSMITH_MODE_ELECTRIC), + ], +) +async def test_set_operation_mode( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + hass_mode: str, + aosmith_mode: str, +) -> None: + """Test setting the operation mode.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.my_water_heater", + ATTR_OPERATION_MODE: hass_mode, + }, + ) + await hass.async_block_till_done() + + mock_client.update_mode.assert_called_once_with("junctionId", aosmith_mode) + + +async def test_set_temperature( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test setting the target temperature.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: "water_heater.my_water_heater", ATTR_TEMPERATURE: 120}, + ) + await hass.async_block_till_done() + + mock_client.update_setpoint.assert_called_once_with("junctionId", 120) + + +@pytest.mark.parametrize( + ("hass_away_mode", "aosmith_mode"), + [ + (True, AOSMITH_MODE_VACATION), + (False, AOSMITH_MODE_HYBRID), + ], +) +async def test_away_mode( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + hass_away_mode: bool, + aosmith_mode: str, +) -> None: + """Test turning away mode on/off.""" + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_AWAY_MODE, + { + ATTR_ENTITY_ID: "water_heater.my_water_heater", + ATTR_AWAY_MODE: hass_away_mode, + }, + ) + await hass.async_block_till_done() + + mock_client.update_mode.assert_called_once_with("junctionId", aosmith_mode) diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index b0eee051331..4c4e0af8705 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -95,10 +95,7 @@ async def async_init_integration( entry.add_to_hass(hass) - with ( - patch("apcaccess.status.parse", return_value=status), - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status", return_value=status): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/apcupsd/test_config_flow.py b/tests/components/apcupsd/test_config_flow.py index 48d57890320..6a69d4e974e 100644 --- a/tests/components/apcupsd/test_config_flow.py +++ b/tests/components/apcupsd/test_config_flow.py @@ -24,7 +24,7 @@ def _patch_setup(): async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: """Test config flow setup with connection error.""" - with patch("apcaccess.status.get") as mock_get: + with patch("aioapcaccess.request_status") as mock_get: mock_get.side_effect = OSError() result = await hass.config_entries.flow.async_init( @@ -38,10 +38,7 @@ async def test_config_flow_cannot_connect(hass: HomeAssistant) -> None: async def test_config_flow_no_status(hass: HomeAssistant) -> None: """Test config flow setup with successful connection but no status is reported.""" - with ( - patch("apcaccess.status.parse", return_value={}), # Returns no status. - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status", return_value={}): # Returns no status. result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -64,11 +61,10 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: mock_entry.add_to_hass(hass) with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b""), + patch("aioapcaccess.request_status") as mock_request_status, _patch_setup(), ): - mock_parse.return_value = MOCK_STATUS + mock_request_status.return_value = MOCK_STATUS # Now, create the integration again using the same config data, we should reject # the creation due same host / port. @@ -98,7 +94,7 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: # Now we change the serial number and add it again. This should be successful. another_device_status = copy(MOCK_STATUS) another_device_status["SERIALNO"] = MOCK_STATUS["SERIALNO"] + "ZZZ" - mock_parse.return_value = another_device_status + mock_request_status.return_value = another_device_status result = await hass.config_entries.flow.async_init( DOMAIN, @@ -112,8 +108,7 @@ async def test_config_flow_duplicate(hass: HomeAssistant) -> None: async def test_flow_works(hass: HomeAssistant) -> None: """Test successful creation of config entries via user configuration.""" with ( - patch("apcaccess.status.parse", return_value=MOCK_STATUS), - patch("apcaccess.status.get", return_value=b""), + patch("aioapcaccess.request_status", return_value=MOCK_STATUS), _patch_setup() as mock_setup, ): result = await hass.config_entries.flow.async_init( @@ -152,12 +147,11 @@ async def test_flow_minimal_status( integration will vary. """ with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b""), + patch("aioapcaccess.request_status") as mock_request_status, _patch_setup() as mock_setup, ): status = MOCK_MINIMAL_STATUS | extra_status - mock_parse.return_value = status + mock_request_status.return_value = status result = await hass.config_entries.flow.async_init( DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA diff --git a/tests/components/apcupsd/test_init.py b/tests/components/apcupsd/test_init.py index 756fa07f120..c65efe25bb9 100644 --- a/tests/components/apcupsd/test_init.py +++ b/tests/components/apcupsd/test_init.py @@ -1,4 +1,5 @@ """Test init of APCUPSd integration.""" +import asyncio from collections import OrderedDict from unittest.mock import patch @@ -97,7 +98,11 @@ async def test_multiple_integrations(hass: HomeAssistant) -> None: assert state1.state != state2.state -async def test_connection_error(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "error", + (OSError(), asyncio.IncompleteReadError(partial=b"", expected=0)), +) +async def test_connection_error(hass: HomeAssistant, error: Exception) -> None: """Test connection error during integration setup.""" entry = MockConfigEntry( version=1, @@ -109,10 +114,7 @@ async def test_connection_error(hass: HomeAssistant) -> None: entry.add_to_hass(hass) - with ( - patch("apcaccess.status.parse", side_effect=OSError()), - patch("apcaccess.status.get"), - ): + with patch("aioapcaccess.request_status", side_effect=error): await hass.config_entries.async_setup(entry.entry_id) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -156,12 +158,9 @@ async def test_availability(hass: HomeAssistant) -> None: assert state.state != STATE_UNAVAILABLE assert pytest.approx(float(state.state)) == 14.0 - with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status") as mock_request_status: # Mock a network error and then trigger an auto-polling event. - mock_parse.side_effect = OSError() + mock_request_status.side_effect = OSError() future = utcnow() + UPDATE_INTERVAL async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -172,8 +171,8 @@ async def test_availability(hass: HomeAssistant) -> None: assert state.state == STATE_UNAVAILABLE # Reset the API to return a new status and update. - mock_parse.side_effect = None - mock_parse.return_value = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} + mock_request_status.side_effect = None + mock_request_status.return_value = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} future = future + UPDATE_INTERVAL async_fire_time_changed(hass, future) await hass.async_block_till_done() diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index bff1b858216..24aae1d3937 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -127,10 +127,7 @@ async def test_state_update(hass: HomeAssistant) -> None: assert state.state == "14.0" new_status = MOCK_STATUS | {"LOADPCT": "15.0 Percent"} - with ( - patch("apcaccess.status.parse", return_value=new_status), - patch("apcaccess.status.get", return_value=b""), - ): + with patch("aioapcaccess.request_status", return_value=new_status): future = utcnow() + timedelta(minutes=2) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -154,11 +151,8 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: # Setup HASS for calling the update_entity service. await async_setup_component(hass, "homeassistant", {}) - with ( - patch("apcaccess.status.parse") as mock_parse, - patch("apcaccess.status.get", return_value=b"") as mock_get, - ): - mock_parse.return_value = MOCK_STATUS | { + with patch("aioapcaccess.request_status") as mock_request_status: + mock_request_status.return_value = MOCK_STATUS | { "LOADPCT": "15.0 Percent", "BCHARGE": "99.0 Percent", } @@ -174,8 +168,7 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: ) # Even if we requested updates for two entities, our integration should smartly # group the API calls to just one. - assert mock_parse.call_count == 1 - assert mock_get.call_count == 1 + assert mock_request_status.call_count == 1 # The new state should be effective. state = hass.states.get("sensor.ups_load") @@ -194,10 +187,9 @@ async def test_multiple_manual_update_entity(hass: HomeAssistant) -> None: # Setup HASS for calling the update_entity service. await async_setup_component(hass, "homeassistant", {}) - with ( - patch("apcaccess.status.parse", return_value=MOCK_STATUS) as mock_parse, - patch("apcaccess.status.get", return_value=b"") as mock_get, - ): + with patch( + "aioapcaccess.request_status", return_value=MOCK_STATUS + ) as mock_request_status: # Fast-forward time to just pass the initial debouncer cooldown. future = utcnow() + timedelta(seconds=REQUEST_REFRESH_COOLDOWN) async_fire_time_changed(hass, future) @@ -207,5 +199,4 @@ async def test_multiple_manual_update_entity(hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: ["sensor.ups_load", "sensor.ups_input_voltage"]}, blocking=True, ) - assert mock_parse.call_count == 1 - assert mock_get.call_count == 1 + assert mock_request_status.call_count == 1 diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 5a84f4c2716..35913df7400 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1,6 +1,7 @@ """Websocket tests for Voice Assistant integration.""" +from collections.abc import AsyncGenerator from typing import Any -from unittest.mock import ANY, AsyncMock, patch +from unittest.mock import ANY, patch import pytest @@ -16,14 +17,22 @@ from homeassistant.components.assist_pipeline.pipeline import ( async_create_default_pipeline, async_get_pipeline, async_get_pipelines, + async_update_pipeline, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from . import MANY_LANGUAGES -from .conftest import MockSttPlatform, MockSttProvider, MockTTSPlatform, MockTTSProvider +from .conftest import MockSttProvider, MockTTSProvider -from tests.common import MockModule, flush_store, mock_integration, mock_platform +from tests.common import flush_store + + +@pytest.fixture(autouse=True) +async def delay_save_fixture() -> AsyncGenerator[None, None]: + """Load the homeassistant integration.""" + with patch("homeassistant.helpers.collection.SAVE_DELAY", new=0): + yield @pytest.fixture(autouse=True) @@ -237,13 +246,26 @@ async def test_create_default_pipeline( store = pipeline_data.pipeline_store assert len(store.data) == 1 - assert await async_create_default_pipeline(hass, "bla", "bla") is None - assert await async_create_default_pipeline(hass, "test", "test") == Pipeline( + assert ( + await async_create_default_pipeline( + hass, + stt_engine_id="bla", + tts_engine_id="bla", + pipeline_name="Bla pipeline", + ) + is None + ) + assert await async_create_default_pipeline( + hass, + stt_engine_id="test", + tts_engine_id="test", + pipeline_name="Test pipeline", + ) == Pipeline( conversation_engine="homeassistant", conversation_language="en", id=ANY, language="en", - name="Home Assistant", + name="Test pipeline", stt_engine="test", stt_language="en-US", tts_engine="test", @@ -467,51 +489,123 @@ async def test_default_pipeline_unsupported_tts_language( ) -async def test_default_pipeline_cloud( +async def test_update_pipeline( hass: HomeAssistant, - mock_stt_provider: MockSttProvider, - mock_tts_provider: MockTTSProvider, + hass_storage: dict[str, Any], ) -> None: - """Test async_get_pipeline.""" - - mock_integration(hass, MockModule("cloud")) - mock_platform( - hass, - "cloud.tts", - MockTTSPlatform( - async_get_engine=AsyncMock(return_value=mock_tts_provider), - ), - ) - mock_platform( - hass, - "cloud.stt", - MockSttPlatform( - async_get_engine=AsyncMock(return_value=mock_stt_provider), - ), - ) - mock_platform(hass, "test.config_flow") - - assert await async_setup_component(hass, "tts", {"tts": {"platform": "cloud"}}) - assert await async_setup_component(hass, "stt", {"stt": {"platform": "cloud"}}) + """Test async_update_pipeline.""" assert await async_setup_component(hass, "assist_pipeline", {}) - pipeline_data: PipelineData = hass.data[DOMAIN] - store = pipeline_data.pipeline_store - assert len(store.data) == 1 + pipelines = async_get_pipelines(hass) + pipelines = list(pipelines) + assert pipelines == [ + Pipeline( + conversation_engine="homeassistant", + conversation_language="en", + id=ANY, + language="en", + name="Home Assistant", + stt_engine=None, + stt_language=None, + tts_engine=None, + tts_language=None, + tts_voice=None, + wake_word_entity=None, + wake_word_id=None, + ) + ] - # Check the default pipeline - pipeline = async_get_pipeline(hass, None) - assert pipeline == Pipeline( - conversation_engine="homeassistant", - conversation_language="en", - id=pipeline.id, - language="en", - name="Home Assistant Cloud", - stt_engine="cloud", - stt_language="en-US", - tts_engine="cloud", - tts_language="en-US", - tts_voice="james_earl_jones", - wake_word_entity=None, - wake_word_id=None, + pipeline = pipelines[0] + await async_update_pipeline( + hass, + pipeline, + conversation_engine="homeassistant_1", + conversation_language="de", + language="de", + name="Home Assistant 1", + stt_engine="stt.test_1", + stt_language="de", + tts_engine="test_1", + tts_language="de", + tts_voice="test_voice", + wake_word_entity="wake_work.test_1", + wake_word_id="wake_word_id_1", ) + + pipelines = async_get_pipelines(hass) + pipelines = list(pipelines) + pipeline = pipelines[0] + assert pipelines == [ + Pipeline( + conversation_engine="homeassistant_1", + conversation_language="de", + id=pipeline.id, + language="de", + name="Home Assistant 1", + stt_engine="stt.test_1", + stt_language="de", + tts_engine="test_1", + tts_language="de", + tts_voice="test_voice", + wake_word_entity="wake_work.test_1", + wake_word_id="wake_word_id_1", + ) + ] + assert len(hass_storage[STORAGE_KEY]["data"]["items"]) == 1 + assert hass_storage[STORAGE_KEY]["data"]["items"][0] == { + "conversation_engine": "homeassistant_1", + "conversation_language": "de", + "id": pipeline.id, + "language": "de", + "name": "Home Assistant 1", + "stt_engine": "stt.test_1", + "stt_language": "de", + "tts_engine": "test_1", + "tts_language": "de", + "tts_voice": "test_voice", + "wake_word_entity": "wake_work.test_1", + "wake_word_id": "wake_word_id_1", + } + + await async_update_pipeline( + hass, + pipeline, + stt_engine="stt.test_2", + stt_language="en", + tts_engine="test_2", + tts_language="en", + ) + + pipelines = async_get_pipelines(hass) + pipelines = list(pipelines) + assert pipelines == [ + Pipeline( + conversation_engine="homeassistant_1", + conversation_language="de", + id=pipeline.id, + language="de", + name="Home Assistant 1", + stt_engine="stt.test_2", + stt_language="en", + tts_engine="test_2", + tts_language="en", + tts_voice="test_voice", + wake_word_entity="wake_work.test_1", + wake_word_id="wake_word_id_1", + ) + ] + assert len(hass_storage[STORAGE_KEY]["data"]["items"]) == 1 + assert hass_storage[STORAGE_KEY]["data"]["items"][0] == { + "conversation_engine": "homeassistant_1", + "conversation_language": "de", + "id": pipeline.id, + "language": "de", + "name": "Home Assistant 1", + "stt_engine": "stt.test_2", + "stt_language": "en", + "tts_engine": "test_2", + "tts_language": "en", + "tts_voice": "test_voice", + "wake_word_entity": "wake_work.test_1", + "wake_word_id": "wake_word_id_1", + } diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index 72cbc37d571..7710e26707c 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -20,8 +20,8 @@ MOCK_CURRENT_TRANSFER_RATES = 20000000, 10000000 MOCK_CURRENT_TRANSFER_RATES_HTTP = dict(enumerate(MOCK_CURRENT_TRANSFER_RATES)) MOCK_LOAD_AVG_HTTP = {"load_avg_1": 1.1, "load_avg_5": 1.2, "load_avg_15": 1.3} MOCK_LOAD_AVG = list(MOCK_LOAD_AVG_HTTP.values()) -MOCK_TEMPERATURES_HTTP = {"2.4GHz": 40.2, "CPU": 71.2} -MOCK_TEMPERATURES = {**MOCK_TEMPERATURES_HTTP, "5.0GHz": 0} +MOCK_TEMPERATURES = {"2.4GHz": 40.2, "5.0GHz": 0, "CPU": 71.2} +MOCK_TEMPERATURES_HTTP = {**MOCK_TEMPERATURES, "5.0GHz_2": 40.3, "6.0GHz": 40.4} @pytest.fixture(name="patch_setup_entry") @@ -118,9 +118,9 @@ def mock_controller_connect_http(mock_devices_http): MOCK_CURRENT_TRANSFER_RATES_HTTP ) service_mock.return_value.async_get_loadavg.return_value = MOCK_LOAD_AVG_HTTP - service_mock.return_value.async_get_temperatures.return_value = ( - MOCK_TEMPERATURES_HTTP - ) + service_mock.return_value.async_get_temperatures.return_value = { + k: v for k, v in MOCK_TEMPERATURES_HTTP.items() if k != "5.0GHz" + } yield service_mock @@ -140,6 +140,6 @@ def mock_controller_connect_http_sens_detect(): """Mock a successful sensor detection using http library.""" with patch( f"{ASUSWRT_BASE}.bridge.AsusWrtHttpBridge._get_available_temperature_sensors", - return_value=[*MOCK_TEMPERATURES], + return_value=[*MOCK_TEMPERATURES_HTTP], ) as mock_sens_detect: yield mock_sens_detect diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py index a7b19bb3785..e3122f1dfef 100644 --- a/tests/components/asuswrt/test_sensor.py +++ b/tests/components/asuswrt/test_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.asuswrt.const import ( SENSORS_LOAD_AVG, SENSORS_RATES, SENSORS_TEMPERATURES, + SENSORS_TEMPERATURES_LEGACY, ) from homeassistant.components.device_tracker import CONF_CONSIDER_HOME from homeassistant.config_entries import ConfigEntryState @@ -39,7 +40,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed SENSORS_DEFAULT = [*SENSORS_BYTES, *SENSORS_RATES] -SENSORS_ALL_LEGACY = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES] +SENSORS_ALL_LEGACY = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES_LEGACY] SENSORS_ALL_HTTP = [*SENSORS_DEFAULT, *SENSORS_LOAD_AVG, *SENSORS_TEMPERATURES] @@ -242,11 +243,13 @@ async def test_temperature_sensors_http_fail( assert not hass.states.get(f"{sensor_prefix}_2_4ghz") assert not hass.states.get(f"{sensor_prefix}_5_0ghz") assert not hass.states.get(f"{sensor_prefix}_cpu") + assert not hass.states.get(f"{sensor_prefix}_5_0ghz_2") + assert not hass.states.get(f"{sensor_prefix}_6_0ghz") -async def _test_temperature_sensors(hass: HomeAssistant, config) -> None: +async def _test_temperature_sensors(hass: HomeAssistant, config, sensors) -> str: """Test creating a AsusWRT temperature sensors.""" - config_entry, sensor_prefix = _setup_entry(hass, config, SENSORS_TEMPERATURES) + config_entry, sensor_prefix = _setup_entry(hass, config, sensors) config_entry.add_to_hass(hass) # initial devices setup @@ -255,20 +258,31 @@ async def _test_temperature_sensors(hass: HomeAssistant, config) -> None: async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() - # assert temperature sensor available - assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.2" - assert not hass.states.get(f"{sensor_prefix}_5_0ghz") - assert hass.states.get(f"{sensor_prefix}_cpu").state == "71.2" + return sensor_prefix async def test_temperature_sensors_legacy(hass: HomeAssistant, connect_legacy) -> None: """Test creating a AsusWRT temperature sensors.""" - await _test_temperature_sensors(hass, CONFIG_DATA_TELNET) + sensor_prefix = await _test_temperature_sensors( + hass, CONFIG_DATA_TELNET, SENSORS_TEMPERATURES_LEGACY + ) + # assert temperature sensor available + assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.2" + assert hass.states.get(f"{sensor_prefix}_cpu").state == "71.2" + assert not hass.states.get(f"{sensor_prefix}_5_0ghz") async def test_temperature_sensors_http(hass: HomeAssistant, connect_http) -> None: """Test creating a AsusWRT temperature sensors.""" - await _test_temperature_sensors(hass, CONFIG_DATA_HTTP) + sensor_prefix = await _test_temperature_sensors( + hass, CONFIG_DATA_HTTP, SENSORS_TEMPERATURES + ) + # assert temperature sensor available + assert hass.states.get(f"{sensor_prefix}_2_4ghz").state == "40.2" + assert hass.states.get(f"{sensor_prefix}_cpu").state == "71.2" + assert hass.states.get(f"{sensor_prefix}_5_0ghz_2").state == "40.3" + assert hass.states.get(f"{sensor_prefix}_6_0ghz").state == "40.4" + assert not hass.states.get(f"{sensor_prefix}_5_0ghz") @pytest.mark.parametrize( @@ -416,7 +430,7 @@ async def test_decorator_errors( hass: HomeAssistant, connect_legacy, mock_available_temps ) -> None: """Test AsusWRT sensors are unavailable on decorator type check error.""" - sensors = [*SENSORS_BYTES, *SENSORS_TEMPERATURES] + sensors = [*SENSORS_BYTES, *SENSORS_TEMPERATURES_LEGACY] config_entry, sensor_prefix = _setup_entry(hass, CONFIG_DATA_TELNET, sensors) config_entry.add_to_hass(hass) diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 61521c49b79..a78682ced6d 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -62,6 +62,8 @@ async def test_sensors(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns, + ), patch( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch( "aurorapy.client.AuroraSerialClient.serial_number", return_value="9876543", @@ -102,6 +104,8 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) # sun is up with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=_simulated_returns + ), patch( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=_simulated_returns, @@ -133,7 +137,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=AuroraTimeoutError("No response after 3 tries"), - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 2) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -145,7 +149,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=_simulated_returns, - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 4) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -159,7 +163,7 @@ async def test_sensor_dark(hass: HomeAssistant, freezer: FrozenDateTimeFactory) ), patch( "aurorapy.client.AuroraSerialClient.cumulated_energy", side_effect=AuroraError("No response after 10 seconds"), - ): + ), patch("aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"]): freezer.tick(SCAN_INTERVAL * 6) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -174,6 +178,8 @@ async def test_sensor_unknown_error(hass: HomeAssistant) -> None: with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", side_effect=AuroraError("another error"), + ), patch( + "aurorapy.client.AuroraSerialClient.alarms", return_value=["No alarm"] ), patch("serial.Serial.isOpen", return_value=True): mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index a33ca702bcf..4088b1819fa 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -4,6 +4,7 @@ from http import HTTPStatus import logging from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.auth import InvalidAuthError @@ -167,28 +168,25 @@ async def test_auth_code_checks_local_only_user( assert error["error"] == "access_denied" -def test_auth_code_store_expiration(mock_credential) -> None: +def test_auth_code_store_expiration( + mock_credential, freezer: FrozenDateTimeFactory +) -> None: """Test that the auth code store will not return expired tokens.""" store, retrieve = auth._create_auth_code_store() client_id = "bla" now = utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - code = store(client_id, mock_credential) + freezer.move_to(now) + code = store(client_id, mock_credential) - with patch( - "homeassistant.util.dt.utcnow", return_value=now + timedelta(minutes=10) - ): - assert retrieve(client_id, code) is None + freezer.move_to(now + timedelta(minutes=10)) + assert retrieve(client_id, code) is None - with patch("homeassistant.util.dt.utcnow", return_value=now): - code = store(client_id, mock_credential) + freezer.move_to(now) + code = store(client_id, mock_credential) - with patch( - "homeassistant.util.dt.utcnow", - return_value=now + timedelta(minutes=9, seconds=59), - ): - assert retrieve(client_id, code) == mock_credential + freezer.move_to(now + timedelta(minutes=9, seconds=59)) + assert retrieve(client_id, code) == mock_credential def test_auth_code_store_requires_credentials(mock_credential) -> None: diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 6d83b00517d..235ca48f095 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta import logging +from typing import Any from unittest.mock import Mock, patch import pytest @@ -46,6 +47,7 @@ from homeassistant.helpers.script import ( SCRIPT_MODE_SINGLE, _async_stop_scripts_at_shutdown, ) +from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo from homeassistant.setup import async_setup_component from homeassistant.util import yaml import homeassistant.util.dt as dt_util @@ -57,6 +59,7 @@ from tests.common import ( async_capture_events, async_fire_time_changed, async_mock_service, + import_and_test_deprecated_constant, mock_restore_cache, ) from tests.components.logbook.common import MockRow, mock_humanify @@ -1102,7 +1105,7 @@ async def test_reload_automation_when_blueprint_changes( autospec=True, return_value=config, ), patch( - "homeassistant.components.blueprint.models.yaml.load_yaml", + "homeassistant.components.blueprint.models.yaml.load_yaml_dict", autospec=True, return_value=blueprint_config, ): @@ -2564,3 +2567,22 @@ async def test_websocket_config( msg = await client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "not_found" + + +@pytest.mark.parametrize( + ("constant_name", "replacement"), + [ + ("AutomationActionType", TriggerActionType), + ("AutomationTriggerData", TriggerData), + ("AutomationTriggerInfo", TriggerInfo), + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + constant_name: str, + replacement: Any, +) -> None: + """Test deprecated automation constants.""" + import_and_test_deprecated_constant( + caplog, automation, constant_name, replacement.__name__, replacement, "2025.1" + ) diff --git a/tests/components/axis/snapshots/test_diagnostics.ambr b/tests/components/axis/snapshots/test_diagnostics.ambr index 74a1f110c14..9960fc9bfd2 100644 --- a/tests/components/axis/snapshots/test_diagnostics.ambr +++ b/tests/components/axis/snapshots/test_diagnostics.ambr @@ -41,6 +41,7 @@ 'disabled_by': None, 'domain': 'axis', 'entry_id': '676abe5b73621446e6550a2e86ffe3dd', + 'minor_version': 1, 'options': dict({ 'events': True, }), diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index 90ef6c75e5f..6ba0661ae55 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -26,6 +26,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import ServiceValidationError from . import init_integration @@ -146,7 +147,7 @@ async def test_spa_preset_modes( assert state assert state.attributes[ATTR_PRESET_MODE] == mode - with pytest.raises(KeyError): + with pytest.raises(ServiceValidationError): await common.async_set_preset_mode(hass, 2, ENTITY_CLIMATE) # put it in RNR and test assertion diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py index 437a2e1efa6..014722d94a4 100644 --- a/tests/components/binary_sensor/test_init.py +++ b/tests/components/binary_sensor/test_init.py @@ -14,6 +14,7 @@ from tests.common import ( MockConfigEntry, MockModule, MockPlatform, + import_and_test_deprecated_constant_enum, mock_config_flow, mock_integration, mock_platform, @@ -102,7 +103,7 @@ async def test_name(hass: HomeAssistant) -> None: config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up test stt platform via config entry.""" + """Set up test binary_sensor platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) mock_platform( @@ -172,7 +173,7 @@ async def test_entity_category_config_raises_error( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up test stt platform via config entry.""" + """Set up test binary_sensor platform via config entry.""" async_add_entities([entity1, entity2]) mock_platform( @@ -194,3 +195,17 @@ async def test_entity_category_config_raises_error( "Entity binary_sensor.test2 cannot be added as the entity category is set to config" in caplog.text ) + + +@pytest.mark.parametrize( + "device_class", + list(binary_sensor.BinarySensorDeviceClass), +) +def test_deprecated_constant_device_class( + caplog: pytest.LogCaptureFixture, + device_class: binary_sensor.BinarySensorDeviceClass, +) -> None: + """Test deprecated binary sensor device classes.""" + import_and_test_deprecated_constant_enum( + caplog, binary_sensor, device_class, "DEVICE_CLASS_", "2025.1" + ) diff --git a/tests/components/blink/snapshots/test_diagnostics.ambr b/tests/components/blink/snapshots/test_diagnostics.ambr index b572aae0a00..44554dad1e3 100644 --- a/tests/components/blink/snapshots/test_diagnostics.ambr +++ b/tests/components/blink/snapshots/test_diagnostics.ambr @@ -39,6 +39,7 @@ }), 'disabled_by': None, 'domain': 'blink', + 'minor_version': 1, 'options': dict({ 'scan_interval': 300, }), diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py index ccc326dac1f..1c2faa32d04 100644 --- a/tests/components/blink/test_services.py +++ b/tests/components/blink/test_services.py @@ -4,22 +4,15 @@ from unittest.mock import AsyncMock, MagicMock, Mock import pytest from homeassistant.components.blink.const import ( + ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_REFRESH, - SERVICE_SAVE_RECENT_CLIPS, - SERVICE_SAVE_VIDEO, SERVICE_SEND_PIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_DEVICE_ID, - CONF_FILE_PATH, - CONF_FILENAME, - CONF_NAME, - CONF_PIN, -) +from homeassistant.const import ATTR_DEVICE_ID, CONF_PIN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -43,7 +36,6 @@ async def test_refresh_service_calls( await hass.async_block_till_done() device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) - assert device_entry assert mock_config_entry.state is ConfigEntryState.LOADED @@ -67,163 +59,8 @@ async def test_refresh_service_calls( ) -async def test_video_service_calls( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_blink_api: MagicMock, - mock_blink_auth_api: MagicMock, - mock_config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test video service calls.""" - - 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, "12345")}) - - assert device_entry - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert mock_blink_api.refresh.call_count == 1 - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - - hass.config.is_allowed_path = Mock(return_value=True) - caplog.clear() - mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - mock_blink_api.cameras[CAMERA_NAME].video_to_file.assert_awaited_once() - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: ["bad-device_id"], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - - mock_blink_api.cameras[CAMERA_NAME].video_to_file = AsyncMock(side_effect=OSError) - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_VIDEO, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - blocking=True, - ) - - hass.config.is_allowed_path = Mock(return_value=False) - - -async def test_picture_service_calls( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_blink_api: MagicMock, - mock_blink_auth_api: MagicMock, - mock_config_entry: MockConfigEntry, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test picture servcie calls.""" - - 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, "12345")}) - - assert device_entry - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert mock_blink_api.refresh.call_count == 1 - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - - hass.config.is_allowed_path = Mock(return_value=True) - mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} - - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - mock_blink_api.cameras[CAMERA_NAME].save_recent_clips.assert_awaited_once() - - mock_blink_api.cameras[CAMERA_NAME].save_recent_clips = AsyncMock( - side_effect=OSError - ) - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: [device_entry.id], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - SERVICE_SAVE_RECENT_CLIPS, - { - ATTR_DEVICE_ID: ["bad-device_id"], - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - blocking=True, - ) - - async def test_pin_service_calls( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, mock_blink_api: MagicMock, mock_blink_auth_api: MagicMock, mock_config_entry: MockConfigEntry, @@ -234,17 +71,13 @@ async def test_pin_service_calls( 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, "12345")}) - - assert device_entry - assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_blink_api.refresh.call_count == 1 await hass.services.async_call( DOMAIN, SERVICE_SEND_PIN, - {ATTR_DEVICE_ID: [device_entry.id], CONF_PIN: PIN}, + {ATTR_CONFIG_ENTRY_ID: [mock_config_entry.entry_id], CONF_PIN: PIN}, blocking=True, ) assert mock_blink_api.auth.send_auth_key.assert_awaited_once @@ -253,41 +86,18 @@ async def test_pin_service_calls( await hass.services.async_call( DOMAIN, SERVICE_SEND_PIN, - {ATTR_DEVICE_ID: ["bad-device_id"], CONF_PIN: PIN}, + {ATTR_CONFIG_ENTRY_ID: ["bad-config_id"], CONF_PIN: PIN}, blocking=True, ) -@pytest.mark.parametrize( - ("service", "params"), - [ - (SERVICE_SEND_PIN, {CONF_PIN: PIN}), - ( - SERVICE_SAVE_RECENT_CLIPS, - { - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - ), - ( - SERVICE_SAVE_VIDEO, - { - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - ), - ], -) -async def test_service_called_with_non_blink_device( +async def test_service_pin_called_with_non_blink_device( hass: HomeAssistant, - device_registry: dr.DeviceRegistry, mock_blink_api: MagicMock, mock_blink_auth_api: MagicMock, mock_config_entry: MockConfigEntry, - service, - params, ) -> None: - """Test service calls with non blink device.""" + """Test pin service calls with non blink device.""" mock_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -295,11 +105,48 @@ async def test_service_called_with_non_blink_device( other_domain = "NotBlink" other_config_id = "555" - await hass.config_entries.async_add( - MockConfigEntry( - title="Not Blink", domain=other_domain, entry_id=other_config_id - ) + other_mock_config_entry = MockConfigEntry( + title="Not Blink", domain=other_domain, entry_id=other_config_id ) + await hass.config_entries.async_add(other_mock_config_entry) + + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + parameters = { + ATTR_CONFIG_ENTRY_ID: [other_mock_config_entry.entry_id], + CONF_PIN: PIN, + } + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_PIN, + parameters, + blocking=True, + ) + + +async def test_service_update_called_with_non_blink_device( + hass: HomeAssistant, + mock_blink_api: MagicMock, + device_registry: dr.DeviceRegistry, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test update service calls with non blink device.""" + + 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() + + other_domain = "NotBlink" + other_config_id = "555" + other_mock_config_entry = MockConfigEntry( + title="Not Blink", domain=other_domain, entry_id=other_config_id + ) + await hass.config_entries.async_add(other_mock_config_entry) + device_entry = device_registry.async_get_or_create( config_entry_id=other_config_id, identifiers={ @@ -311,67 +158,68 @@ async def test_service_called_with_non_blink_device( mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} parameters = {ATTR_DEVICE_ID: [device_entry.id]} - parameters.update(params) - with pytest.raises(ServiceValidationError): + with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, - service, + SERVICE_REFRESH, parameters, blocking=True, ) -@pytest.mark.parametrize( - ("service", "params"), - [ - (SERVICE_SEND_PIN, {CONF_PIN: PIN}), - ( - SERVICE_SAVE_RECENT_CLIPS, - { - CONF_NAME: CAMERA_NAME, - CONF_FILE_PATH: FILENAME, - }, - ), - ( - SERVICE_SAVE_VIDEO, - { - CONF_NAME: CAMERA_NAME, - CONF_FILENAME: FILENAME, - }, - ), - ], -) -async def test_service_called_with_unloaded_entry( +async def test_service_pin_called_with_unloaded_entry( + hass: HomeAssistant, + mock_blink_api: MagicMock, + mock_blink_auth_api: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pin service calls with not ready config entry.""" + + 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() + mock_config_entry.state = ConfigEntryState.SETUP_ERROR + hass.config.is_allowed_path = Mock(return_value=True) + mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + + parameters = {ATTR_CONFIG_ENTRY_ID: [mock_config_entry.entry_id], CONF_PIN: PIN} + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_SEND_PIN, + parameters, + blocking=True, + ) + + +async def test_service_update_called_with_unloaded_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_blink_api: MagicMock, mock_blink_auth_api: MagicMock, mock_config_entry: MockConfigEntry, - service, - params, ) -> None: - """Test service calls with unloaded config entry.""" + """Test update service calls with not ready config entry.""" 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() - await mock_config_entry.async_unload(hass) - - device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) - - assert device_entry + mock_config_entry.state = ConfigEntryState.SETUP_ERROR hass.config.is_allowed_path = Mock(return_value=True) mock_blink_api.cameras = {CAMERA_NAME: AsyncMock()} + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "12345")}) + assert device_entry + parameters = {ATTR_DEVICE_ID: [device_entry.id]} - parameters.update(params) with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, - service, + SERVICE_REFRESH, parameters, blocking=True, ) diff --git a/tests/components/blue_current/__init__.py b/tests/components/blue_current/__init__.py new file mode 100644 index 00000000000..901c776a894 --- /dev/null +++ b/tests/components/blue_current/__init__.py @@ -0,0 +1,52 @@ +"""Tests for the Blue Current integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from bluecurrent_api import Client + +from homeassistant.components.blue_current import DOMAIN, Connector +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import MockConfigEntry + + +async def init_integration( + hass: HomeAssistant, platform, data: dict, grid=None +) -> MockConfigEntry: + """Set up the Blue Current integration in Home Assistant.""" + + if grid is None: + grid = {} + + def init( + self: Connector, hass: HomeAssistant, config: ConfigEntry, client: Client + ) -> None: + """Mock grid and charge_points.""" + + self.config = config + self.hass = hass + self.client = client + self.charge_points = data + self.grid = grid + self.available = True + + with patch( + "homeassistant.components.blue_current.PLATFORMS", [platform] + ), patch.object(Connector, "__init__", init), patch( + "homeassistant.components.blue_current.Client", autospec=True + ): + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + async_dispatcher_send(hass, "blue_current_value_update_101") + return config_entry diff --git a/tests/components/blue_current/test_config_flow.py b/tests/components/blue_current/test_config_flow.py new file mode 100644 index 00000000000..c510aeada4f --- /dev/null +++ b/tests/components/blue_current/test_config_flow.py @@ -0,0 +1,89 @@ +"""Test the Blue Current config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.blue_current import DOMAIN +from homeassistant.components.blue_current.config_flow import ( + AlreadyConnected, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) +from homeassistant.core import HomeAssistant + + +async def test_form(hass: HomeAssistant) -> None: + """Test if the form is created.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["errors"] == {} + + +async def test_user(hass: HomeAssistant) -> None: + """Test if the api token is set.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["errors"] == {} + + with patch("bluecurrent_api.Client.validate_api_token", return_value=True), patch( + "bluecurrent_api.Client.get_email", return_value="test@email.com" + ), patch( + "homeassistant.components.blue_current.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_token": "123", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "test@email.com" + assert result2["data"] == {"api_token": "123"} + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (InvalidApiToken(), "invalid_token"), + (RequestLimitReached(), "limit_reached"), + (AlreadyConnected(), "already_connected"), + (Exception(), "unknown"), + (WebsocketError(), "cannot_connect"), + ], +) +async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) -> None: + """Test user initialized flow with invalid username.""" + with patch( + "bluecurrent_api.Client.validate_api_token", + side_effect=error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"api_token": "123"}, + ) + assert result["errors"]["base"] == message + + with patch("bluecurrent_api.Client.validate_api_token", return_value=True), patch( + "bluecurrent_api.Client.get_email", return_value="test@email.com" + ), patch( + "homeassistant.components.blue_current.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_token": "123", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "test@email.com" + assert result2["data"] == {"api_token": "123"} diff --git a/tests/components/blue_current/test_init.py b/tests/components/blue_current/test_init.py new file mode 100644 index 00000000000..fe40f58077f --- /dev/null +++ b/tests/components/blue_current/test_init.py @@ -0,0 +1,185 @@ +"""Test Blue Current Init Component.""" + +from datetime import timedelta +from unittest.mock import patch + +from bluecurrent_api.client import Client +from bluecurrent_api.exceptions import RequestLimitReached, WebsocketError +import pytest + +from homeassistant.components.blue_current import DOMAIN, Connector, async_setup_entry +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry(hass: HomeAssistant) -> None: + """Test load and unload entry.""" + config_entry = await init_integration(hass, "sensor", {}) + assert config_entry.state == ConfigEntryState.LOADED + assert isinstance(hass.data[DOMAIN][config_entry.entry_id], Connector) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert hass.data[DOMAIN] == {} + + +async def test_config_not_ready(hass: HomeAssistant) -> None: + """Tests if ConfigEntryNotReady is raised when connect raises a WebsocketError.""" + with patch( + "bluecurrent_api.Client.connect", + side_effect=WebsocketError, + ), pytest.raises(ConfigEntryNotReady): + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + config_entry.add_to_hass(hass) + + await async_setup_entry(hass, config_entry) + + +async def test_on_data(hass: HomeAssistant) -> None: + """Test on_data.""" + + await init_integration(hass, "sensor", {}) + + with patch( + "homeassistant.components.blue_current.async_dispatcher_send" + ) as test_async_dispatcher_send: + connector: Connector = hass.data[DOMAIN]["uuid"] + + # test CHARGE_POINTS + data = { + "object": "CHARGE_POINTS", + "data": [{"evse_id": "101", "model_type": "hidden", "name": ""}], + } + await connector.on_data(data) + assert connector.charge_points == {"101": {"model_type": "hidden", "name": ""}} + + # test CH_STATUS + data2 = { + "object": "CH_STATUS", + "data": { + "actual_v1": 12, + "actual_v2": 14, + "actual_v3": 15, + "actual_p1": 12, + "actual_p2": 14, + "actual_p3": 15, + "activity": "charging", + "start_datetime": "2021-11-18T14:12:23", + "stop_datetime": "2021-11-18T14:32:23", + "offline_since": "2021-11-18T14:32:23", + "total_cost": 10.52, + "vehicle_status": "standby", + "actual_kwh": 10, + "evse_id": "101", + }, + } + await connector.on_data(data2) + assert connector.charge_points == { + "101": { + "model_type": "hidden", + "name": "", + "actual_v1": 12, + "actual_v2": 14, + "actual_v3": 15, + "actual_p1": 12, + "actual_p2": 14, + "actual_p3": 15, + "activity": "charging", + "start_datetime": "2021-11-18T14:12:23", + "stop_datetime": "2021-11-18T14:32:23", + "offline_since": "2021-11-18T14:32:23", + "total_cost": 10.52, + "vehicle_status": "standby", + "actual_kwh": 10, + } + } + + test_async_dispatcher_send.assert_called_with( + hass, "blue_current_value_update_101" + ) + + # test GRID_STATUS + data3 = { + "object": "GRID_STATUS", + "data": { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + }, + } + await connector.on_data(data3) + assert connector.grid == { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + } + test_async_dispatcher_send.assert_called_with(hass, "blue_current_grid_update") + + +async def test_start_loop(hass: HomeAssistant) -> None: + """Tests start_loop.""" + + with patch( + "homeassistant.components.blue_current.async_call_later" + ) as test_async_call_later: + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + + connector = Connector(hass, config_entry, Client) + + with patch( + "bluecurrent_api.Client.start_loop", + side_effect=WebsocketError("unknown command"), + ): + await connector.start_loop() + test_async_call_later.assert_called_with(hass, 1, connector.reconnect) + + with patch( + "bluecurrent_api.Client.start_loop", side_effect=RequestLimitReached + ): + await connector.start_loop() + test_async_call_later.assert_called_with(hass, 1, connector.reconnect) + + +async def test_reconnect(hass: HomeAssistant) -> None: + """Tests reconnect.""" + + with patch("bluecurrent_api.Client.connect"), patch( + "bluecurrent_api.Client.connect", side_effect=WebsocketError + ), patch( + "bluecurrent_api.Client.get_next_reset_delta", return_value=timedelta(hours=1) + ), patch( + "homeassistant.components.blue_current.async_call_later" + ) as test_async_call_later: + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + + connector = Connector(hass, config_entry, Client) + await connector.reconnect() + + test_async_call_later.assert_called_with(hass, 20, connector.reconnect) + + with patch("bluecurrent_api.Client.connect", side_effect=RequestLimitReached): + await connector.reconnect() + test_async_call_later.assert_called_with( + hass, timedelta(hours=1), connector.reconnect + ) diff --git a/tests/components/blue_current/test_sensor.py b/tests/components/blue_current/test_sensor.py new file mode 100644 index 00000000000..a4bcbfcda00 --- /dev/null +++ b/tests/components/blue_current/test_sensor.py @@ -0,0 +1,181 @@ +"""The tests for Blue current sensors.""" +from datetime import datetime +from typing import Any + +from homeassistant.components.blue_current import Connector +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from . import init_integration + +TIMESTAMP_KEYS = ("start_datetime", "stop_datetime", "offline_since") + + +charge_point = { + "actual_v1": 14, + "actual_v2": 18, + "actual_v3": 15, + "actual_p1": 19, + "actual_p2": 14, + "actual_p3": 15, + "activity": "available", + "start_datetime": datetime.strptime("20211118 14:12:23+08:00", "%Y%m%d %H:%M:%S%z"), + "stop_datetime": datetime.strptime("20211118 14:32:23+00:00", "%Y%m%d %H:%M:%S%z"), + "offline_since": datetime.strptime("20211118 14:32:23+00:00", "%Y%m%d %H:%M:%S%z"), + "total_cost": 13.32, + "avg_current": 16, + "avg_voltage": 15.7, + "total_kw": 251.2, + "vehicle_status": "standby", + "actual_kwh": 11, + "max_usage": 10, + "max_offline": 7, + "smartcharging_max_usage": 6, + "current_left": 10, +} + +data: dict[str, Any] = { + "101": { + "model_type": "hidden", + "evse_id": "101", + "name": "", + **charge_point, + } +} + + +charge_point_entity_ids = { + "voltage_phase_1": "actual_v1", + "voltage_phase_2": "actual_v2", + "voltage_phase_3": "actual_v3", + "current_phase_1": "actual_p1", + "current_phase_2": "actual_p2", + "current_phase_3": "actual_p3", + "activity": "activity", + "started_on": "start_datetime", + "stopped_on": "stop_datetime", + "offline_since": "offline_since", + "total_cost": "total_cost", + "average_current": "avg_current", + "average_voltage": "avg_voltage", + "total_power": "total_kw", + "vehicle_status": "vehicle_status", + "energy_usage": "actual_kwh", + "max_usage": "max_usage", + "offline_max_usage": "max_offline", + "smart_charging_max_usage": "smartcharging_max_usage", + "remaining_current": "current_left", +} + +grid = { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + "grid_max_current": 15, + "grid_avg_current": 13.7, +} + +grid_entity_ids = { + "grid_current_phase_1": "grid_actual_p1", + "grid_current_phase_2": "grid_actual_p2", + "grid_current_phase_3": "grid_actual_p3", + "max_grid_current": "grid_max_current", + "average_grid_current": "grid_avg_current", +} + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test the underlying sensors.""" + await init_integration(hass, "sensor", data, grid) + + entity_registry = er.async_get(hass) + for entity_id, key in charge_point_entity_ids.items(): + entry = entity_registry.async_get(f"sensor.101_{entity_id}") + assert entry + assert entry.unique_id == f"{key}_101" + + # skip sensors that are disabled by default. + if not entry.disabled: + state = hass.states.get(f"sensor.101_{entity_id}") + assert state is not None + + value = charge_point[key] + + if key in TIMESTAMP_KEYS: + assert datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") == value + else: + assert state.state == str(value) + + for entity_id, key in grid_entity_ids.items(): + entry = entity_registry.async_get(f"sensor.{entity_id}") + assert entry + assert entry.unique_id == key + + # skip sensors that are disabled by default. + if not entry.disabled: + state = hass.states.get(f"sensor.{entity_id}") + assert state is not None + assert state.state == str(grid[key]) + + sensors = er.async_entries_for_config_entry(entity_registry, "uuid") + assert len(charge_point.keys()) + len(grid.keys()) == len(sensors) + + +async def test_sensor_update(hass: HomeAssistant) -> None: + """Test if the sensors get updated when there is new data.""" + await init_integration(hass, "sensor", data, grid) + key = "avg_voltage" + entity_id = "average_voltage" + timestamp_key = "start_datetime" + timestamp_entity_id = "started_on" + grid_key = "grid_avg_current" + grid_entity_id = "average_grid_current" + + connector: Connector = hass.data["blue_current"]["uuid"] + + connector.charge_points = {"101": {key: 20, timestamp_key: None}} + connector.grid = {grid_key: 20} + async_dispatcher_send(hass, "blue_current_value_update_101") + await hass.async_block_till_done() + async_dispatcher_send(hass, "blue_current_grid_update") + await hass.async_block_till_done() + + # test data updated + state = hass.states.get(f"sensor.101_{entity_id}") + assert state is not None + assert state.state == str(20) + + # grid + state = hass.states.get(f"sensor.{grid_entity_id}") + assert state + assert state.state == str(20) + + # test unavailable + state = hass.states.get("sensor.101_energy_usage") + assert state + assert state.state == "unavailable" + + # test if timestamp keeps old value + state = hass.states.get(f"sensor.101_{timestamp_entity_id}") + assert state + assert ( + datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") + == charge_point[timestamp_key] + ) + + # test if older timestamp is ignored + connector.charge_points = { + "101": { + timestamp_key: datetime.strptime( + "20211118 14:11:23+08:00", "%Y%m%d %H:%M:%S%z" + ) + } + } + async_dispatcher_send(hass, "blue_current_value_update_101") + state = hass.states.get(f"sensor.101_{timestamp_entity_id}") + assert state + assert ( + datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") + == charge_point[timestamp_key] + ) diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 55d995dd63c..5ad4b5a6c31 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -5,11 +5,12 @@ from contextlib import contextmanager import itertools import time from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from bleak import BleakClient from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import BaseHaScanner, BluetoothManager, get_manager from homeassistant.components.bluetooth import ( DOMAIN, @@ -17,10 +18,7 @@ from homeassistant.components.bluetooth import ( BluetoothServiceInfo, BluetoothServiceInfoBleak, async_get_advertisement_callback, - models, ) -from homeassistant.components.bluetooth.base_scanner import BaseHaScanner -from homeassistant.components.bluetooth.manager import BluetoothManager from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -37,6 +35,7 @@ __all__ = ( "generate_advertisement_data", "generate_ble_device", "MockBleakClient", + "patch_bluetooth_time", ) ADVERTISEMENT_DATA_DEFAULTS = { @@ -56,6 +55,19 @@ BLE_DEVICE_DEFAULTS = { } +@contextmanager +def patch_bluetooth_time(mock_time: float) -> None: + """Patch the bluetooth time.""" + with patch( + "homeassistant.components.bluetooth.MONOTONIC_TIME", return_value=mock_time + ), patch( + "habluetooth.base_scanner.monotonic_time_coarse", return_value=mock_time + ), patch( + "habluetooth.manager.monotonic_time_coarse", return_value=mock_time + ), patch("habluetooth.scanner.monotonic_time_coarse", return_value=mock_time): + yield + + def generate_advertisement_data(**kwargs: Any) -> AdvertisementData: """Generate advertisement data with defaults.""" new = kwargs.copy() @@ -88,7 +100,7 @@ def generate_ble_device( def _get_manager() -> BluetoothManager: """Return the bluetooth manager.""" - return models.MANAGER + return get_manager() def inject_advertisement( diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 5f166a3fca2..4ec6c4e5388 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -50,7 +50,7 @@ def macos_adapter(): "homeassistant.components.bluetooth.platform.system", return_value="Darwin", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Darwin", ), patch( "bluetooth_adapters.systems.platform.system", @@ -76,7 +76,7 @@ def no_adapter_fixture(): "homeassistant.components.bluetooth.platform.system", return_value="Linux", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch( "bluetooth_adapters.systems.platform.system", @@ -97,7 +97,7 @@ def one_adapter_fixture(): "homeassistant.components.bluetooth.platform.system", return_value="Linux", ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch( "bluetooth_adapters.systems.platform.system", @@ -128,7 +128,7 @@ def two_adapters_fixture(): with patch( "homeassistant.components.bluetooth.platform.system", return_value="Linux" ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" @@ -168,7 +168,7 @@ def one_adapter_old_bluez(): with patch( "homeassistant.components.bluetooth.platform.system", return_value="Linux" ), patch( - "homeassistant.components.bluetooth.scanner.platform.system", + "habluetooth.scanner.platform.system", return_value="Linux", ), patch("bluetooth_adapters.systems.platform.system", return_value="Linux"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.refresh" diff --git a/tests/components/bluetooth/test_advertisement_tracker.py b/tests/components/bluetooth/test_advertisement_tracker.py index f04ea2873f0..f90b82fc379 100644 --- a/tests/components/bluetooth/test_advertisement_tracker.py +++ b/tests/components/bluetooth/test_advertisement_tracker.py @@ -1,8 +1,8 @@ """Tests for the Bluetooth integration advertisement tracking.""" from datetime import timedelta import time -from unittest.mock import patch +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED import pytest from homeassistant.components.bluetooth import ( @@ -10,9 +10,6 @@ from homeassistant.components.bluetooth import ( async_register_scanner, async_track_unavailable, ) -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) from homeassistant.components.bluetooth.const import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, SOURCE_LOCAL, @@ -27,6 +24,7 @@ from . import ( generate_ble_device, inject_advertisement_with_time_and_source, inject_advertisement_with_time_and_source_connectable, + patch_bluetooth_time, ) from tests.common import async_fire_time_changed @@ -72,9 +70,8 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout( ) monotonic_now = start_monotonic_time + ((ADVERTISING_TIMES_NEEDED - 1) * 2) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -125,9 +122,8 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_connectab monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -191,9 +187,8 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -247,9 +242,8 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_not_conne monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -323,9 +317,8 @@ async def test_advertisment_interval_shorter_than_adapter_stack_timeout_adapter_ monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -352,8 +345,8 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c ) switchbot_device_went_unavailable = False - scanner = FakeScanner(hass, "new", "fake_adapter") - cancel_scanner = async_register_scanner(hass, scanner, False) + scanner = FakeScanner("new", "fake_adapter") + cancel_scanner = async_register_scanner(hass, scanner) @callback def _switchbot_device_unavailable_callback(_address: str) -> None: @@ -404,9 +397,8 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c monotonic_now = start_monotonic_time + ( (ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -417,9 +409,8 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c cancel_scanner() # Now that the scanner is gone we should go back to the stack default timeout - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -429,9 +420,8 @@ async def test_advertisment_interval_longer_than_adapter_stack_timeout_adapter_c assert switchbot_device_went_unavailable is False # Now that the scanner is gone we should go back to the stack default timeout - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, @@ -486,9 +476,8 @@ async def test_advertisment_interval_longer_increasing_than_adapter_stack_timeou ) monotonic_now = start_monotonic_time + UNAVAILABLE_TRACK_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 63b60c8f487..a42752dcfc7 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -27,8 +27,8 @@ from . import ( async def test_scanner_by_source(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test we can get a scanner by source.""" - hci2_scanner = FakeScanner(hass, "hci2", "hci2") - cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True) + hci2_scanner = FakeScanner("hci2", "hci2") + cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner) assert async_scanner_by_source(hass, "hci2") is hci2_scanner cancel_hci2() @@ -40,6 +40,14 @@ async def test_monotonic_time() -> None: assert MONOTONIC_TIME() == pytest.approx(time.monotonic(), abs=0.1) +async def test_async_get_advertisement_callback( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test getting advertisement callback.""" + callback = bluetooth.async_get_advertisement_callback(hass) + assert callback is not None + + async def test_async_scanner_devices_by_address_connectable( hass: HomeAssistant, enable_bluetooth: None ) -> None: @@ -63,15 +71,12 @@ async def test_async_scanner_devices_by_address_connectable( MONOTONIC_TIME(), ) - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeInjectableScanner( - hass, "esp32", "esp32", new_info_callback, connector, False - ) + scanner = FakeInjectableScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) switchbot_device = generate_ble_device( "44:44:33:11:23:45", "wohand", @@ -135,8 +140,8 @@ async def test_async_scanner_devices_by_address_non_connectable( connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeStaticScanner(hass, "esp32", "esp32", connector) - cancel = manager.async_register_scanner(scanner, False) + scanner = FakeStaticScanner("esp32", "esp32", connector) + cancel = manager.async_register_scanner(scanner) assert scanner.discovered_devices_and_advertisement_data == { switchbot_device.address: (switchbot_device, switchbot_device_adv) diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 31d90a6e93d..e1d64115e86 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -8,6 +8,7 @@ from unittest.mock import patch from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData +from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest from homeassistant.components import bluetooth @@ -17,9 +18,6 @@ from homeassistant.components.bluetooth import ( HaBluetoothConnector, storage, ) -from homeassistant.components.bluetooth.advertisement_tracker import ( - TRACKER_BUFFERING_WOBBLE_SECONDS, -) from homeassistant.components.bluetooth.const import ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -37,11 +35,35 @@ from . import ( _get_manager, generate_advertisement_data, generate_ble_device, + patch_bluetooth_time, ) from tests.common import async_fire_time_changed, load_fixture +class FakeScanner(BaseHaRemoteScanner): + """Fake scanner.""" + + def inject_advertisement( + self, + device: BLEDevice, + advertisement_data: AdvertisementData, + now: float | None = None, + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + now or MONOTONIC_TIME(), + ) + + @pytest.mark.parametrize("name_2", [None, "w"]) async def test_remote_scanner( hass: HomeAssistant, enable_bluetooth: None, name_2: str | None @@ -89,30 +111,12 @@ async def test_remote_scanner( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, True) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) scanner.inject_advertisement(switchbot_device, switchbot_device_adv) @@ -173,30 +177,12 @@ async def test_remote_scanner_expires_connectable( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, True) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) start_time_monotonic = time.monotonic() scanner.inject_advertisement(switchbot_device, switchbot_device_adv) @@ -214,10 +200,7 @@ async def test_remote_scanner_expires_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -248,30 +231,12 @@ async def test_remote_scanner_expires_non_connectable( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) start_time_monotonic = time.monotonic() scanner.inject_advertisement(switchbot_device, switchbot_device_adv) @@ -297,10 +262,7 @@ async def test_remote_scanner_expires_non_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -313,10 +275,7 @@ async def test_remote_scanner_expires_non_connectable( expire_utc = dt_util.utcnow() + timedelta( seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=expire_monotonic, - ): + with patch_bluetooth_time(expire_monotonic): async_fire_time_changed(hass, expire_utc) await hass.async_block_till_done() @@ -346,30 +305,12 @@ async def test_base_scanner_connecting_behavior( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) with scanner.connecting(): assert scanner.scanning is False @@ -419,15 +360,13 @@ async def test_restore_history_remote_adapter( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) scanner = BaseHaRemoteScanner( - hass, "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", - lambda adv: None, connector, True, ) unsetup = scanner.async_setup() - cancel = _get_manager().async_register_scanner(scanner, True) + cancel = _get_manager().async_register_scanner(scanner) assert "EB:0B:36:35:6F:A4" in scanner.discovered_devices_and_advertisement_data assert "E3:A5:63:3E:5E:23" not in scanner.discovered_devices_and_advertisement_data @@ -435,15 +374,13 @@ async def test_restore_history_remote_adapter( unsetup() scanner = BaseHaRemoteScanner( - hass, "atom-bluetooth-proxy-ceaac4", "atom-bluetooth-proxy-ceaac4", - lambda adv: None, connector, True, ) unsetup = scanner.async_setup() - cancel = _get_manager().async_register_scanner(scanner, True) + cancel = _get_manager().async_register_scanner(scanner) assert "EB:0B:36:35:6F:A4" in scanner.discovered_devices_and_advertisement_data assert "E3:A5:63:3E:5E:23" not in scanner.discovered_devices_and_advertisement_data @@ -470,30 +407,12 @@ async def test_device_with_ten_minute_advertising_interval( rssi=-100, ) - class FakeScanner(BaseHaRemoteScanner): - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) monotonic_now = time.monotonic() new_time = monotonic_now @@ -514,11 +433,8 @@ async def test_device_with_ten_minute_advertising_interval( connectable=False, ) - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=new_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(new_time): + scanner.inject_advertisement(bparasite_device, bparasite_device_adv, new_time) original_device = scanner.discovered_devices_and_advertisement_data[ bparasite_device.address @@ -527,11 +443,10 @@ async def test_device_with_ten_minute_advertising_interval( for _ in range(1, 20): new_time += advertising_interval - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=new_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(new_time): + scanner.inject_advertisement( + bparasite_device, bparasite_device_adv, new_time + ) # Make sure the BLEDevice object gets updated # and not replaced @@ -545,10 +460,7 @@ async def test_device_with_ten_minute_advertising_interval( bluetooth.async_address_present(hass, bparasite_device.address, False) is True ) assert bparasite_device_went_unavailable is False - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=new_time, - ): + with patch_bluetooth_time(new_time): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=future_time)) await hass.async_block_till_done() @@ -558,13 +470,7 @@ async def test_device_with_ten_minute_advertising_interval( future_time + advertising_interval + TRACKER_BUFFERING_WOBBLE_SECONDS + 1 ) - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=missed_advertisement_future_time, - ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=missed_advertisement_future_time, - ): + with patch_bluetooth_time(missed_advertisement_future_time): # Fire once for the scanner to expire the device async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -592,32 +498,12 @@ async def test_scanner_stops_responding( """Test we mark a scanner are not scanning when it stops responding.""" manager = _get_manager() - class FakeScanner(BaseHaRemoteScanner): - """A fake remote scanner.""" - - def inject_advertisement( - self, device: BLEDevice, advertisement_data: AdvertisementData - ) -> None: - """Inject an advertisement.""" - self._async_on_advertisement( - device.address, - advertisement_data.rssi, - device.name, - advertisement_data.service_uuids, - advertisement_data.service_data, - advertisement_data.manufacturer_data, - advertisement_data.tx_power, - {"scanner_specific_data": "test"}, - MONOTONIC_TIME(), - ) - - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner(hass, "esp32", "esp32", new_info_callback, connector, False) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) start_time_monotonic = time.monotonic() @@ -628,10 +514,7 @@ async def test_scanner_stops_responding( + SCANNER_WATCHDOG_INTERVAL.total_seconds() ) # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=failure_reached_time, - ): + with patch_bluetooth_time(failure_reached_time): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -652,11 +535,10 @@ async def test_scanner_stops_responding( failure_reached_time += 1 - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=failure_reached_time, - ): - scanner.inject_advertisement(bparasite_device, bparasite_device_adv) + with patch_bluetooth_time(failure_reached_time): + scanner.inject_advertisement( + bparasite_device, bparasite_device_adv, failure_reached_time + ) # As soon as we get a detection, we know the scanner is working again assert scanner.scanning is True diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 0e8b2b54f06..a8e693c3f99 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -3,6 +3,7 @@ from unittest.mock import ANY, MagicMock, patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import HaScanner from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -25,6 +26,21 @@ from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator +class FakeHaScanner(HaScanner): + """Fake HaScanner.""" + + @property + def discovered_devices_and_advertisement_data(self): + """Return the discovered devices and advertisement data.""" + return { + "44:44:33:11:23:45": ( + generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), + generate_advertisement_data(local_name="x"), + ) + } + + +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -38,15 +54,8 @@ async def test_diagnostics( # because we cannot import the scanner class directly without it throwing an # error if the test is not running on linux since we won't have the correct # deps installed when testing on MacOS. + with patch( - "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", - { - "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), - generate_advertisement_data(local_name="x"), - ) - }, - ), patch( "homeassistant.components.bluetooth.diagnostics.platform.system", return_value="Linux", ), patch( @@ -88,25 +97,25 @@ async def test_diagnostics( "adapters": { "hci0": { "address": "00:00:00:00:00:01", + "connection_slots": 1, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": False, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": ANY, "vendor_id": "cc01", - "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", + "connection_slots": 2, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": True, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": True, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": ANY, "vendor_id": "cc01", - "connection_slots": 2, }, }, "dbus": { @@ -126,63 +135,42 @@ async def test_diagnostics( } }, "manager": { - "slot_manager": { - "adapter_slots": {"hci0": 5, "hci1": 2}, - "allocations_by_adapter": {"hci0": [], "hci1": []}, - "manager": False, - }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", + "connection_slots": 1, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": False, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": "homeassistant", "vendor_id": "cc01", - "connection_slots": 1, }, "hci1": { "address": "00:00:00:00:00:02", + "connection_slots": 2, "hw_version": "usb:v1D6Bp0246d053F", - "passive_scan": True, - "sw_version": "homeassistant", "manufacturer": "ACME", + "passive_scan": True, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", + "sw_version": "homeassistant", "vendor_id": "cc01", - "connection_slots": 2, }, }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {}, "timings": {}, }, - "connectable_history": [], "all_history": [], + "connectable_history": [], "scanners": [ { "adapter": "hci0", - "discovered_devices_and_advertisement_data": [ - { - "address": "44:44:33:11:23:45", - "advertisement_data": [ - "x", - {}, - {}, - [], - -127, - -127, - [[]], - ], - "details": None, - "name": "x", - "rssi": -127, - } - ], + "discovered_devices_and_advertisement_data": [], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (00:00:00:00:00:01)", @@ -216,7 +204,7 @@ async def test_diagnostics( "scanning": True, "source": "00:00:00:00:00:01", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, { "adapter": "hci1", @@ -243,13 +231,19 @@ async def test_diagnostics( "scanning": True, "source": "00:00:00:00:00:02", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, ], + "slot_manager": { + "adapter_slots": {"hci0": 5, "hci1": 2}, + "allocations_by_adapter": {"hci0": [], "hci1": []}, + "manager": False, + }, }, } +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics_macos( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -269,14 +263,6 @@ async def test_diagnostics_macos( ) with patch( - "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices_and_advertisement_data", - { - "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), - switchbot_adv, - ) - }, - ), patch( "homeassistant.components.bluetooth.diagnostics.platform.system", return_value="Darwin", ), patch( @@ -297,70 +283,36 @@ async def test_diagnostics_macos( inject_advertisement(hass, switchbot_device, switchbot_adv) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) - assert diag == { "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", - "passive_scan": False, - "sw_version": ANY, "manufacturer": "Apple", + "passive_scan": False, "product": "Unknown MacOS Model", "product_id": "Unknown", + "sw_version": ANY, "vendor_id": "Unknown", } }, "manager": { - "slot_manager": { - "adapter_slots": {"Core Bluetooth": 5}, - "allocations_by_adapter": {"Core Bluetooth": []}, - "manager": False, - }, "adapters": { "Core Bluetooth": { "address": "00:00:00:00:00:00", - "passive_scan": False, - "sw_version": ANY, "manufacturer": "Apple", + "passive_scan": False, "product": "Unknown MacOS Model", "product_id": "Unknown", + "sw_version": ANY, "vendor_id": "Unknown", } }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {"44:44:33:11:23:45": "local"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, - "connectable_history": [ - { - "address": "44:44:33:11:23:45", - "advertisement": [ - "wohand", - {"1": {"__type": "", "repr": "b'\\x01'"}}, - {}, - [], - -127, - -127, - [[]], - ], - "device": { - "__type": "", - "repr": "BLEDevice(44:44:33:11:23:45, wohand)", - }, - "connectable": True, - "manufacturer_data": { - "1": {"__type": "", "repr": "b'\\x01'"} - }, - "name": "wohand", - "rssi": -127, - "service_data": {}, - "service_uuids": [], - "source": "local", - "time": ANY, - } - ], "all_history": [ { "address": "44:44:33:11:23:45", @@ -373,11 +325,39 @@ async def test_diagnostics_macos( -127, [[]], ], + "connectable": True, "device": { "__type": "", "repr": "BLEDevice(44:44:33:11:23:45, wohand)", }, + "manufacturer_data": { + "1": {"__type": "", "repr": "b'\\x01'"} + }, + "name": "wohand", + "rssi": -127, + "service_data": {}, + "service_uuids": [], + "source": "local", + "time": ANY, + } + ], + "connectable_history": [ + { + "address": "44:44:33:11:23:45", + "advertisement": [ + "wohand", + {"1": {"__type": "", "repr": "b'\\x01'"}}, + {}, + [], + -127, + -127, + [[]], + ], "connectable": True, + "device": { + "__type": "", + "repr": "BLEDevice(44:44:33:11:23:45, wohand)", + }, "manufacturer_data": { "1": {"__type": "", "repr": "b'\\x01'"} }, @@ -396,13 +376,8 @@ async def test_diagnostics_macos( { "address": "44:44:33:11:23:45", "advertisement_data": [ - "wohand", - { - "1": { - "__type": "", - "repr": "b'\\x01'", - } - }, + "x", + {}, {}, [], -127, @@ -420,13 +395,19 @@ async def test_diagnostics_macos( "scanning": True, "source": "Core Bluetooth", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", } ], + "slot_manager": { + "adapter_slots": {"Core Bluetooth": 5}, + "allocations_by_adapter": {"Core Bluetooth": []}, + "manager": False, + }, }, } +@patch("homeassistant.components.bluetooth.HaScanner", FakeHaScanner) async def test_diagnostics_remote_adapter( hass: HomeAssistant, hass_client: ClientSessionGenerator, @@ -473,15 +454,12 @@ async def test_diagnostics_remote_adapter( assert await hass.config_entries.async_setup(entry1.entry_id) await hass.async_block_till_done() - new_info_callback = manager.scanner_adv_received connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) - scanner = FakeScanner( - hass, "esp32", "esp32", new_info_callback, connector, False - ) + scanner = FakeScanner("esp32", "esp32", connector, True) unsetup = scanner.async_setup() - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) scanner.inject_advertisement(switchbot_device, switchbot_adv) inject_advertisement(hass, switchbot_device, switchbot_adv) @@ -497,17 +475,12 @@ async def test_diagnostics_remote_adapter( "passive_scan": False, "product": "Bluetooth Adapter 5.0", "product_id": "aa01", - "sw_version": "homeassistant", + "sw_version": ANY, "vendor_id": "cc01", } }, "dbus": {}, "manager": { - "slot_manager": { - "adapter_slots": {"hci0": 5}, - "allocations_by_adapter": {"hci0": []}, - "manager": False, - }, "adapters": { "hci0": { "address": "00:00:00:00:00:01", @@ -521,8 +494,8 @@ async def test_diagnostics_remote_adapter( } }, "advertisement_tracker": { - "intervals": {}, "fallback_intervals": {}, + "intervals": {}, "sources": {"44:44:33:11:23:45": "esp32"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, @@ -538,7 +511,7 @@ async def test_diagnostics_remote_adapter( -127, [], ], - "connectable": False, + "connectable": True, "device": { "__type": "", "repr": "BLEDevice(44:44:33:11:23:45, wohand)", @@ -564,7 +537,7 @@ async def test_diagnostics_remote_adapter( [], -127, -127, - [[]], + [], ], "connectable": True, "device": { @@ -578,7 +551,7 @@ async def test_diagnostics_remote_adapter( "rssi": -127, "service_data": {}, "service_uuids": [], - "source": "local", + "source": "esp32", "time": ANY, } ], @@ -596,19 +569,34 @@ async def test_diagnostics_remote_adapter( }, { "adapter": "hci0", - "discovered_devices_and_advertisement_data": [], + "discovered_devices_and_advertisement_data": [ + { + "address": "44:44:33:11:23:45", + "advertisement_data": [ + "x", + {}, + {}, + [], + -127, + -127, + [[]], + ], + "details": None, + "name": "x", + "rssi": -127, + } + ], "last_detection": ANY, "monotonic_time": ANY, "name": "hci0 (00:00:00:00:00:01)", "scanning": True, "source": "00:00:00:00:00:01", "start_time": ANY, - "type": "HaScanner", + "type": "FakeHaScanner", }, { - "connectable": False, + "connectable": True, "discovered_device_timestamps": {"44:44:33:11:23:45": ANY}, - "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, "discovered_devices_and_advertisement_data": [ { "address": "44:44:33:11:23:45", @@ -639,11 +627,16 @@ async def test_diagnostics_remote_adapter( "name": "esp32", "scanning": True, "source": "esp32", - "storage": None, - "type": "FakeScanner", "start_time": ANY, + "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, + "type": "FakeScanner", }, ], + "slot_manager": { + "adapter_slots": {"hci0": 5}, + "allocations_by_adapter": {"hci0": []}, + "manager": False, + }, }, } diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index 21fade843f5..1659b989af0 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -7,6 +7,8 @@ from unittest.mock import ANY, MagicMock, Mock, patch from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS +from habluetooth import scanner +from habluetooth.wrappers import HaBleakScannerWrapper import pytest from homeassistant.components import bluetooth @@ -17,7 +19,6 @@ from homeassistant.components.bluetooth import ( async_process_advertisements, async_rediscover_address, async_track_unavailable, - scanner, ) from homeassistant.components.bluetooth.const import ( BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, @@ -35,7 +36,6 @@ from homeassistant.components.bluetooth.match import ( SERVICE_DATA_UUID, SERVICE_UUID, ) -from homeassistant.components.bluetooth.wrappers import HaBleakScannerWrapper from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback @@ -107,7 +107,7 @@ async def test_setup_and_stop_passive( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockPassiveBleakScanner, ): assert await async_setup_component( @@ -158,7 +158,7 @@ async def test_setup_and_stop_old_bluez( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): assert await async_setup_component( @@ -185,7 +185,7 @@ async def test_setup_and_stop_no_bluetooth( {"domain": "switchbot", "service_uuid": "cba20d00-224d-11e6-9fb8-0002a5d5c51b"} ] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", side_effect=BleakError, ) as mock_ha_bleak_scanner, patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -206,7 +206,7 @@ async def test_setup_and_stop_broken_bluetooth( """Test we fail gracefully when bluetooth/dbus is broken.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -231,7 +231,7 @@ async def test_setup_and_stop_broken_bluetooth_hanging( await asyncio.sleep(1) with patch.object(scanner, "START_TIMEOUT", 0), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=_mock_hang, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -251,7 +251,7 @@ async def test_setup_and_retry_adapter_not_yet_available( """Test we retry if the adapter is not yet available.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -267,14 +267,14 @@ async def test_setup_and_retry_adapter_not_yet_available( assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -286,7 +286,7 @@ async def test_no_race_during_manual_reload_in_retry_state( """Test we can successfully reload when the entry is in a retry state.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BleakError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -302,7 +302,7 @@ async def test_no_race_during_manual_reload_in_retry_state( assert entry.state == ConfigEntryState.SETUP_RETRY with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ): await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() @@ -310,7 +310,7 @@ async def test_no_race_during_manual_reload_in_retry_state( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", ): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() @@ -322,7 +322,7 @@ async def test_calling_async_discovered_devices_no_bluetooth( """Test we fail gracefully when asking for discovered devices and there is no blueooth.""" mock_bt = [] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", side_effect=FileNotFoundError, ), patch( "homeassistant.components.bluetooth.async_get_bluetooth", return_value=mock_bt @@ -2815,16 +2815,16 @@ async def test_scanner_count_connectable( hass: HomeAssistant, enable_bluetooth: None ) -> None: """Test getting the connectable scanner count.""" - scanner = FakeScanner(hass, "any", "any") - cancel = bluetooth.async_register_scanner(hass, scanner, False) + scanner = FakeScanner("any", "any") + cancel = bluetooth.async_register_scanner(hass, scanner) assert bluetooth.async_scanner_count(hass, connectable=True) == 1 cancel() async def test_scanner_count(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test getting the connectable and non-connectable scanner count.""" - scanner = FakeScanner(hass, "any", "any") - cancel = bluetooth.async_register_scanner(hass, scanner, False) + scanner = FakeScanner("any", "any") + cancel = bluetooth.async_register_scanner(hass, scanner) assert bluetooth.async_scanner_count(hass, connectable=False) == 2 cancel() diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 6c89074e471..4726c12f681 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -7,10 +7,12 @@ from unittest.mock import patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory +from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS import pytest from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, MONOTONIC_TIME, BaseHaRemoteScanner, BluetoothChange, @@ -19,7 +21,6 @@ from homeassistant.components.bluetooth import ( BluetoothServiceInfoBleak, HaBluetoothConnector, async_ble_device_from_address, - async_get_advertisement_callback, async_get_fallback_availability_interval, async_get_learned_advertising_interval, async_scanner_count, @@ -31,9 +32,6 @@ from homeassistant.components.bluetooth.const import ( SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS, ) -from homeassistant.components.bluetooth.manager import ( - FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, -) from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -48,6 +46,7 @@ from . import ( inject_advertisement_with_source, inject_advertisement_with_time_and_source, inject_advertisement_with_time_and_source_connectable, + patch_bluetooth_time, ) from tests.common import async_fire_time_changed, load_fixture @@ -56,8 +55,8 @@ from tests.common import async_fire_time_changed, load_fixture @pytest.fixture def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: """Register an hci0 scanner.""" - hci0_scanner = FakeScanner(hass, "hci0", "hci0") - cancel = bluetooth.async_register_scanner(hass, hci0_scanner, True) + hci0_scanner = FakeScanner("hci0", "hci0") + cancel = bluetooth.async_register_scanner(hass, hci0_scanner) yield cancel() @@ -65,8 +64,8 @@ def register_hci0_scanner(hass: HomeAssistant) -> Generator[None, None, None]: @pytest.fixture def register_hci1_scanner(hass: HomeAssistant) -> Generator[None, None, None]: """Register an hci1 scanner.""" - hci1_scanner = FakeScanner(hass, "hci1", "hci1") - cancel = bluetooth.async_register_scanner(hass, hci1_scanner, True) + hci1_scanner = FakeScanner("hci1", "hci1") + cancel = bluetooth.async_register_scanner(hass, hci1_scanner) yield cancel() @@ -317,6 +316,89 @@ async def test_switching_adapters_based_on_stale( ) +async def test_switching_adapters_based_on_stale_with_discovered_interval( + hass: HomeAssistant, + enable_bluetooth: None, + register_hci0_scanner: None, + register_hci1_scanner: None, +) -> None: + """Test switching with discovered interval.""" + + address = "44:44:33:11:23:41" + start_time_monotonic = 50.0 + + switchbot_device_poor_signal_hci0 = generate_ble_device( + address, "wohand_poor_signal_hci0" + ) + switchbot_adv_poor_signal_hci0 = generate_advertisement_data( + local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci0, + switchbot_adv_poor_signal_hci0, + start_time_monotonic, + "hci0", + ) + + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci0 + ) + + bluetooth.async_set_fallback_availability_interval(hass, address, 10) + + switchbot_device_poor_signal_hci1 = generate_ble_device( + address, "wohand_poor_signal_hci1" + ) + switchbot_adv_poor_signal_hci1 = generate_advertisement_data( + local_name="wohand_poor_signal_hci1", service_uuids=[], rssi=-99 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci1, + switchbot_adv_poor_signal_hci1, + start_time_monotonic, + "hci1", + ) + + # Should not switch adapters until the advertisement is stale + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci0 + ) + + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci1, + switchbot_adv_poor_signal_hci1, + start_time_monotonic + 10 + 1, + "hci1", + ) + + # Should not switch yet since we are not within the + # wobble period + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci0 + ) + + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci1, + switchbot_adv_poor_signal_hci1, + start_time_monotonic + 10 + TRACKER_BUFFERING_WOBBLE_SECONDS + 1, + "hci1", + ) + # Should switch to hci1 since the previous advertisement is stale + # even though the signal is poor because the device is now + # likely unreachable via hci0 + assert ( + bluetooth.async_ble_device_from_address(hass, address) + is switchbot_device_poor_signal_hci1 + ) + + async def test_restore_history_from_dbus( hass: HomeAssistant, one_adapter: None, disable_new_discovery_flows ) -> None: @@ -561,9 +643,7 @@ async def test_switching_adapters_when_one_goes_away( hass: HomeAssistant, enable_bluetooth: None, register_hci0_scanner: None ) -> None: """Test switching adapters when one goes away.""" - cancel_hci2 = bluetooth.async_register_scanner( - hass, FakeScanner(hass, "hci2", "hci2"), True - ) + cancel_hci2 = bluetooth.async_register_scanner(hass, FakeScanner("hci2", "hci2")) address = "44:44:33:11:23:45" @@ -612,8 +692,8 @@ async def test_switching_adapters_when_one_stop_scanning( hass: HomeAssistant, enable_bluetooth: None, register_hci0_scanner: None ) -> None: """Test switching adapters when stops scanning.""" - hci2_scanner = FakeScanner(hass, "hci2", "hci2") - cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner, True) + hci2_scanner = FakeScanner("hci2", "hci2") + cancel_hci2 = bluetooth.async_register_scanner(hass, hci2_scanner) address = "44:44:33:11:23:45" @@ -721,21 +801,18 @@ async def test_goes_unavailable_connectable_only_and_recovers( MONOTONIC_TIME(), ) - new_info_callback = async_get_advertisement_callback(hass) connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) connectable_scanner = FakeScanner( - hass, "connectable", "connectable", - new_info_callback, connector, True, ) unsetup_connectable_scanner = connectable_scanner.async_setup() cancel_connectable_scanner = _get_manager().async_register_scanner( - connectable_scanner, True + connectable_scanner ) connectable_scanner.inject_advertisement( switchbot_device_connectable, switchbot_device_adv @@ -750,16 +827,14 @@ async def test_goes_unavailable_connectable_only_and_recovers( ) not_connectable_scanner = FakeScanner( - hass, "not_connectable", "not_connectable", - new_info_callback, connector, False, ) unsetup_not_connectable_scanner = not_connectable_scanner.async_setup() cancel_not_connectable_scanner = _get_manager().async_register_scanner( - not_connectable_scanner, False + not_connectable_scanner ) not_connectable_scanner.inject_advertisement( switchbot_device_non_connectable, switchbot_device_adv @@ -801,16 +876,14 @@ async def test_goes_unavailable_connectable_only_and_recovers( cancel_unavailable() connectable_scanner_2 = FakeScanner( - hass, "connectable", "connectable", - new_info_callback, connector, True, ) unsetup_connectable_scanner_2 = connectable_scanner_2.async_setup() cancel_connectable_scanner_2 = _get_manager().async_register_scanner( - connectable_scanner, True + connectable_scanner ) connectable_scanner_2.inject_advertisement( switchbot_device_connectable, switchbot_device_adv @@ -898,22 +971,20 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( """Clear all devices.""" self._discovered_device_advertisement_datas.clear() self._discovered_device_timestamps.clear() + self._previous_service_info.clear() - new_info_callback = async_get_advertisement_callback(hass) connector = ( HaBluetoothConnector(MockBleakClient, "mock_bleak_client", lambda: False), ) non_connectable_scanner = FakeScanner( - hass, "connectable", "connectable", - new_info_callback, connector, False, ) unsetup_connectable_scanner = non_connectable_scanner.async_setup() cancel_connectable_scanner = _get_manager().async_register_scanner( - non_connectable_scanner, True + non_connectable_scanner ) with patch.object(hass.config_entries.flow, "async_init") as mock_config_flow: non_connectable_scanner.inject_advertisement( @@ -925,7 +996,7 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( assert mock_config_flow.mock_calls[0][1][0] == "switchbot" assert async_ble_device_from_address(hass, "44:44:33:11:23:45", False) is not None - assert async_scanner_count(hass, connectable=True) == 1 + assert async_scanner_count(hass, connectable=False) == 1 assert len(callbacks) == 1 assert ( @@ -962,9 +1033,8 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( return_value=[{"flow_id": "mock_flow_id"}], ) as mock_async_progress_by_init_data_type, patch.object( hass.config_entries.flow, "async_abort" - ) as mock_async_abort, patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + ) as mock_async_abort, patch_bluetooth_time( + monotonic_now + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -1105,9 +1175,8 @@ async def test_set_fallback_interval_small( ) monotonic_now = start_monotonic_time + 2 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -1170,9 +1239,8 @@ async def test_set_fallback_interval_big( # Check that device hasn't expired after a day monotonic_now = start_monotonic_time + 86400 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -1184,9 +1252,8 @@ async def test_set_fallback_interval_big( # Try again after it has expired monotonic_now = start_monotonic_time + 604800 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now + UNAVAILABLE_TRACK_SECONDS, + with patch_bluetooth_time( + monotonic_now + UNAVAILABLE_TRACK_SECONDS, ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index 746f52537cb..9b513ed2197 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -7,6 +7,7 @@ import bleak from bleak import BleakError from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData +from habluetooth.wrappers import HaBleakClientWrapper, HaBleakScannerWrapper import pytest from homeassistant.components.bluetooth import ( @@ -14,10 +15,6 @@ from homeassistant.components.bluetooth import ( BaseHaScanner, HaBluetoothConnector, ) -from homeassistant.components.bluetooth.wrappers import ( - HaBleakClientWrapper, - HaBleakScannerWrapper, -) from homeassistant.core import HomeAssistant from . import ( @@ -107,11 +104,11 @@ async def test_wrapped_bleak_client_local_adapter_only( return None scanner = FakeScanner( - hass, "00:00:00:00:00:01", "hci0", ) - cancel = manager.async_register_scanner(scanner, True) + scanner.connectable = True + cancel = manager.async_register_scanner(scanner) inject_advertisement_with_source( hass, switchbot_device, switchbot_adv, "00:00:00:00:00:01" ) @@ -186,14 +183,12 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( MockBleakClient, "esp32_has_connection_slot", lambda: True ) scanner = FakeScanner( - hass, "esp32_has_connection_slot", "esp32_has_connection_slot", - lambda info: None, connector, True, ) - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) inject_advertisement_with_source( hass, switchbot_device, switchbot_adv, "00:00:00:00:00:01" ) @@ -296,8 +291,8 @@ async def test_ble_device_with_proxy_client_out_of_connections( return None connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: False) - scanner = FakeScanner(hass, "esp32", "esp32", lambda info: None, connector, True) - cancel = manager.async_register_scanner(scanner, True) + scanner = FakeScanner("esp32", "esp32", connector, True) + cancel = manager.async_register_scanner(scanner) inject_advertisement_with_source( hass, switchbot_proxy_device_no_connection_slot, switchbot_adv, "esp32" ) @@ -361,8 +356,8 @@ async def test_ble_device_with_proxy_clear_cache( return None connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: True) - scanner = FakeScanner(hass, "esp32", "esp32", lambda info: None, connector, True) - cancel = manager.async_register_scanner(scanner, True) + scanner = FakeScanner("esp32", "esp32", connector, True) + cancel = manager.async_register_scanner(scanner) inject_advertisement_with_source( hass, switchbot_proxy_device_with_connection_slot, switchbot_adv, "esp32" ) @@ -467,14 +462,12 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab MockBleakClient, "esp32_has_connection_slot", lambda: True ) scanner = FakeScanner( - hass, "esp32_has_connection_slot", "esp32_has_connection_slot", - lambda info: None, connector, True, ) - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) assert manager.async_discovered_devices(True) == [ switchbot_proxy_device_no_connection_slot ] @@ -581,14 +574,12 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab MockBleakClient, "esp32_has_connection_slot", lambda: True ) scanner = FakeScanner( - hass, "esp32_has_connection_slot", "esp32_has_connection_slot", - lambda info: None, connector, True, ) - cancel = manager.async_register_scanner(scanner, True) + cancel = manager.async_register_scanner(scanner) assert manager.async_discovered_devices(True) == [ switchbot_proxy_device_no_connection_slot ] diff --git a/tests/components/bluetooth/test_passive_update_coordinator.py b/tests/components/bluetooth/test_passive_update_coordinator.py index 86f0ee4b5de..b6e50ebc565 100644 --- a/tests/components/bluetooth/test_passive_update_coordinator.py +++ b/tests/components/bluetooth/test_passive_update_coordinator.py @@ -22,7 +22,11 @@ from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import inject_bluetooth_service_info, patch_all_discovered_devices +from . import ( + inject_bluetooth_service_info, + patch_all_discovered_devices, + patch_bluetooth_time, +) from tests.common import async_fire_time_changed @@ -159,10 +163,9 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable( 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([MagicMock(address="44:44:33:11:23:45")]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices( + [MagicMock(address="44:44:33:11:23:45")] + ): async_fire_time_changed( hass, dt_util.utcnow() @@ -176,9 +179,8 @@ async def test_unavailable_callbacks_mark_the_coordinator_unavailable( monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 2 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): async_fire_time_changed( hass, diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 8cc76e01d8c..345c4b62b7e 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -48,6 +48,7 @@ from . import ( inject_bluetooth_service_info, inject_bluetooth_service_info_bleak, patch_all_discovered_devices, + patch_bluetooth_time, ) from tests.common import ( @@ -471,9 +472,8 @@ async def test_unavailable_after_no_data( assert processor.available is True monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) @@ -490,9 +490,8 @@ async def test_unavailable_after_no_data( monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 2 - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([MagicMock(address="44:44:33:11:23:45")]): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=UNAVAILABLE_TRACK_SECONDS) diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index bc32a5b302d..7673acb80dc 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -14,7 +14,6 @@ from homeassistant.components.bluetooth.const import ( SCANNER_WATCHDOG_INTERVAL, SCANNER_WATCHDOG_TIMEOUT, ) -from homeassistant.components.bluetooth.scanner import NEED_RESET_ERRORS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant @@ -26,10 +25,19 @@ from . import ( async_setup_with_one_adapter, generate_advertisement_data, generate_ble_device, + patch_bluetooth_time, ) from tests.common import MockConfigEntry, async_fire_time_changed +# If the adapter is in a stuck state the following errors are raised: +NEED_RESET_ERRORS = [ + "org.bluez.Error.Failed", + "org.bluez.Error.InProgress", + "org.bluez.Error.NotReady", + "not found", +] + async def test_config_entry_can_be_reloaded_when_stop_raises( hass: HomeAssistant, @@ -42,7 +50,7 @@ async def test_config_entry_can_be_reloaded_when_stop_raises( assert entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.stop", + "habluetooth.scanner.OriginalBleakScanner.stop", side_effect=BleakError, ): await hass.config_entries.async_reload(entry.entry_id) @@ -57,10 +65,8 @@ async def test_dbus_socket_missing_in_container( ) -> None: """Test we handle dbus being missing in the container.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=True - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=True), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ): await async_setup_with_one_adapter(hass) @@ -79,10 +85,8 @@ async def test_dbus_socket_missing( ) -> None: """Test we handle dbus being missing.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=False - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=False), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=FileNotFoundError, ): await async_setup_with_one_adapter(hass) @@ -101,10 +105,8 @@ async def test_dbus_broken_pipe_in_container( ) -> None: """Test we handle dbus broken pipe in the container.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=True - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=True), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ): await async_setup_with_one_adapter(hass) @@ -124,10 +126,8 @@ async def test_dbus_broken_pipe( ) -> None: """Test we handle dbus broken pipe.""" - with patch( - "homeassistant.components.bluetooth.scanner.is_docker_env", return_value=False - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + with patch("habluetooth.scanner.is_docker_env", return_value=False), patch( + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=BrokenPipeError, ): await async_setup_with_one_adapter(hass) @@ -148,7 +148,7 @@ async def test_invalid_dbus_message( """Test we handle invalid dbus message.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=InvalidMessageError, ): await async_setup_with_one_adapter(hass) @@ -168,10 +168,10 @@ async def test_adapter_needs_reset_at_start( """Test we cycle the adapter when it needs a restart.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", side_effect=[BleakError(error), None], ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: await async_setup_with_one_adapter(hass) @@ -216,7 +216,7 @@ async def test_recovery_from_dbus_restart( return mock_discovered with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): await async_setup_with_one_adapter(hass) @@ -227,9 +227,8 @@ async def test_recovery_from_dbus_restart( mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -237,9 +236,8 @@ async def test_recovery_from_dbus_restart( assert called_start == 1 # Fire a callback to reset the timer - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ): _callback( generate_ble_device("44:44:33:11:23:42", "any_name"), @@ -247,9 +245,8 @@ async def test_recovery_from_dbus_restart( ) # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -257,9 +254,8 @@ async def test_recovery_from_dbus_restart( assert called_start == 1 # We hit the timer, so we restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + 20, ): async_fire_time_changed( hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL + timedelta(seconds=20) @@ -302,11 +298,10 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: scanner = MockBleakScanner() start_time_monotonic = time.monotonic() - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ): await async_setup_with_one_adapter(hass) @@ -317,9 +312,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -327,9 +321,8 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: assert called_start == 1 # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -337,13 +330,12 @@ async def test_adapter_recovery(hass: HomeAssistant, one_adapter: None) -> None: assert called_start == 1 # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -391,11 +383,10 @@ async def test_adapter_scanner_fails_to_start_first_time( scanner = MockBleakScanner() start_time_monotonic = time.monotonic() - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, + with patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ): await async_setup_with_one_adapter(hass) @@ -406,9 +397,8 @@ async def test_adapter_scanner_fails_to_start_first_time( mock_discovered = [MagicMock()] # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 10, + with patch_bluetooth_time( + start_time_monotonic + 10, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -416,9 +406,8 @@ async def test_adapter_scanner_fails_to_start_first_time( assert called_start == 1 # Ensure we don't restart the scanner if we don't need to - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + 20, + with patch_bluetooth_time( + start_time_monotonic + 20, ): async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -426,13 +415,12 @@ async def test_adapter_scanner_fails_to_start_first_time( assert called_start == 1 # We hit the timer with no detections, so we reset the adapter and restart the scanner - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -442,13 +430,12 @@ async def test_adapter_scanner_fails_to_start_first_time( # We hit the timer again the previous start call failed, make sure # we try again - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: async_fire_time_changed(hass, dt_util.utcnow() + SCANNER_WATCHDOG_INTERVAL) await hass.async_block_till_done() @@ -503,16 +490,15 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + "habluetooth.scanner.ADAPTER_INIT_TIME", 0, + ), patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True + "habluetooth.util.recover_adapter", return_value=True ) as mock_recover_adapter: await async_setup_with_one_adapter(hass) @@ -554,26 +540,22 @@ async def test_restart_takes_longer_than_watchdog_time( start_time_monotonic = time.monotonic() with patch( - "homeassistant.components.bluetooth.scanner.ADAPTER_INIT_TIME", + "habluetooth.scanner.ADAPTER_INIT_TIME", 0, + ), patch_bluetooth_time( + start_time_monotonic, ), patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic, - ), patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", return_value=scanner, - ), patch( - "homeassistant.components.bluetooth.util.recover_adapter", return_value=True - ): + ), patch("habluetooth.util.recover_adapter", return_value=True): await async_setup_with_one_adapter(hass) assert called_start == 1 # Now force a recover adapter 2x for _ in range(2): - with patch( - "homeassistant.components.bluetooth.base_scanner.MONOTONIC_TIME", - return_value=start_time_monotonic + with patch_bluetooth_time( + start_time_monotonic + SCANNER_WATCHDOG_TIMEOUT + SCANNER_WATCHDOG_INTERVAL.total_seconds(), ): @@ -617,7 +599,7 @@ async def test_setup_and_stop_macos( """Register a callback.""" with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner", + "habluetooth.scanner.OriginalBleakScanner", MockBleakScanner, ): assert await async_setup_component( diff --git a/tests/components/bluetooth/test_usage.py b/tests/components/bluetooth/test_usage.py index 12bdba66d75..0edff02aa0e 100644 --- a/tests/components/bluetooth/test_usage.py +++ b/tests/components/bluetooth/test_usage.py @@ -2,17 +2,12 @@ from unittest.mock import patch import bleak -import bleak_retry_connector -import pytest - -from homeassistant.components.bluetooth.usage import ( +from habluetooth.usage import ( install_multiple_bleak_catcher, uninstall_multiple_bleak_catcher, ) -from homeassistant.components.bluetooth.wrappers import ( - HaBleakClientWrapper, - HaBleakScannerWrapper, -) +from habluetooth.wrappers import HaBleakClientWrapper, HaBleakScannerWrapper + from homeassistant.core import HomeAssistant from . import generate_ble_device @@ -57,47 +52,3 @@ async def test_wrapping_bleak_client( instance = bleak.BleakClient(MOCK_BLE_DEVICE) assert not isinstance(instance, HaBleakClientWrapper) - - -async def test_bleak_client_reports_with_address( - hass: HomeAssistant, enable_bluetooth: None, caplog: pytest.LogCaptureFixture -) -> None: - """Test we report when we pass an address to BleakClient.""" - install_multiple_bleak_catcher() - - instance = bleak.BleakClient("00:00:00:00:00:00") - - assert "BleakClient with an address instead of a BLEDevice" in caplog.text - - assert isinstance(instance, HaBleakClientWrapper) - - uninstall_multiple_bleak_catcher() - - caplog.clear() - - instance = bleak.BleakClient("00:00:00:00:00:00") - - assert not isinstance(instance, HaBleakClientWrapper) - assert "BleakClient with an address instead of a BLEDevice" not in caplog.text - - -async def test_bleak_retry_connector_client_reports_with_address( - hass: HomeAssistant, enable_bluetooth: None, caplog: pytest.LogCaptureFixture -) -> None: - """Test we report when we pass an address to BleakClientWithServiceCache.""" - install_multiple_bleak_catcher() - - instance = bleak_retry_connector.BleakClientWithServiceCache("00:00:00:00:00:00") - - assert "BleakClient with an address instead of a BLEDevice" in caplog.text - - assert isinstance(instance, HaBleakClientWrapper) - - uninstall_multiple_bleak_catcher() - - caplog.clear() - - instance = bleak_retry_connector.BleakClientWithServiceCache("00:00:00:00:00:00") - - assert not isinstance(instance, HaBleakClientWrapper) - assert "BleakClient with an address instead of a BLEDevice" not in caplog.text diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index f69f8971479..cc837f381d4 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -1,47 +1,50 @@ """Tests for the Bluetooth integration.""" from __future__ import annotations -from collections.abc import Callable +from contextlib import contextmanager from unittest.mock import patch import bleak from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData from bleak.exc import BleakError +from habluetooth.usage import ( + install_multiple_bleak_catcher, + uninstall_multiple_bleak_catcher, +) import pytest from homeassistant.components.bluetooth import ( MONOTONIC_TIME, BaseHaRemoteScanner, - BluetoothServiceInfoBleak, HaBluetoothConnector, - async_get_advertisement_callback, -) -from homeassistant.components.bluetooth.usage import ( - install_multiple_bleak_catcher, - uninstall_multiple_bleak_catcher, + HomeAssistantBluetoothManager, ) from homeassistant.core import HomeAssistant from . import _get_manager, generate_advertisement_data, generate_ble_device +@contextmanager +def mock_shutdown(manager: HomeAssistantBluetoothManager) -> None: + """Mock shutdown of the HomeAssistantBluetoothManager.""" + manager.shutdown = True + yield + manager.shutdown = False + + class FakeScanner(BaseHaRemoteScanner): """Fake scanner.""" def __init__( self, - hass: HomeAssistant, scanner_id: str, name: str, - new_info_callback: Callable[[BluetoothServiceInfoBleak], None], connector: None, connectable: bool, ) -> None: """Initialize the scanner.""" - super().__init__( - hass, scanner_id, name, new_info_callback, connector, connectable - ) + super().__init__(scanner_id, name, connector, connectable) self._details: dict[str, str | HaBluetoothConnector] = {} def __repr__(self) -> str: @@ -133,7 +136,7 @@ def install_bleak_catcher_fixture(): def mock_platform_client_fixture(): """Fixture that mocks the platform client.""" with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClient, ): yield @@ -143,7 +146,7 @@ def mock_platform_client_fixture(): def mock_platform_client_that_fails_to_connect_fixture(): """Fixture that mocks the platform client that fails to connect.""" with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsToConnect, ): yield @@ -153,7 +156,7 @@ def mock_platform_client_that_fails_to_connect_fixture(): def mock_platform_client_that_raises_on_connect_fixture(): """Fixture that mocks the platform client that fails to connect.""" with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientRaisesOnConnect, ): yield @@ -175,13 +178,8 @@ def _generate_scanners_with_fake_devices(hass): ) hci1_device_advs[device.address] = (device, adv_data) - new_info_callback = async_get_advertisement_callback(hass) - scanner_hci0 = FakeScanner( - hass, "00:00:00:00:00:01", "hci0", new_info_callback, None, True - ) - scanner_hci1 = FakeScanner( - hass, "00:00:00:00:00:02", "hci1", new_info_callback, None, True - ) + scanner_hci0 = FakeScanner("00:00:00:00:00:01", "hci0", None, True) + scanner_hci1 = FakeScanner("00:00:00:00:00:02", "hci1", None, True) for device, adv_data in hci0_device_advs.values(): scanner_hci0.inject_advertisement(device, adv_data) @@ -189,8 +187,8 @@ def _generate_scanners_with_fake_devices(hass): for device, adv_data in hci1_device_advs.values(): scanner_hci1.inject_advertisement(device, adv_data) - cancel_hci0 = manager.async_register_scanner(scanner_hci0, True, 2) - cancel_hci1 = manager.async_register_scanner(scanner_hci1, True, 1) + cancel_hci0 = manager.async_register_scanner(scanner_hci0, connection_slots=2) + cancel_hci1 = manager.async_register_scanner(scanner_hci1, connection_slots=1) return hci0_device_advs, cancel_hci0, cancel_hci1 @@ -332,27 +330,27 @@ async def test_we_switch_adapters_on_failure( return True with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is False with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is False # After two tries we should switch to hci1 with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is True # ..and we remember that hci1 works as long as the client doesn't change with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is True @@ -361,7 +359,7 @@ async def test_we_switch_adapters_on_failure( client = bleak.BleakClient(ble_device) with patch( - "homeassistant.components.bluetooth.wrappers.get_platform_client_backend_type", + "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClientFailsHCI0Only, ): assert await client.connect() is False @@ -382,7 +380,7 @@ async def test_raise_after_shutdown( hass ) # hci0 has 2 slots, hci1 has 1 slot - with patch.object(manager, "shutdown", True): + with mock_shutdown(manager): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) with pytest.raises(BleakError, match="shutdown"): diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index 2dbe66139b2..1860ed19720 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -8,7 +8,7 @@ import respx from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from . import check_remote_service_call, setup_mocked_integration @@ -92,7 +92,7 @@ async def test_service_call_invalid_input( old_value = hass.states.get(entity_id).state # Test - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( "select", "select_option", @@ -108,7 +108,7 @@ async def test_service_call_invalid_input( [ (MyBMWRemoteServiceError, HomeAssistantError), (MyBMWAPIError, HomeAssistantError), - (ValueError, ValueError), + (ServiceValidationError, ServiceValidationError), ], ) async def test_service_call_fail( diff --git a/tests/components/braviatv/snapshots/test_diagnostics.ambr b/tests/components/braviatv/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..2fd515b24e5 --- /dev/null +++ b/tests/components/braviatv/snapshots/test_diagnostics.ambr @@ -0,0 +1,37 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': 'localhost', + 'mac': '**REDACTED**', + 'pin': '**REDACTED**', + 'use_psk': True, + }), + 'disabled_by': None, + 'domain': 'braviatv', + 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': 'very_unique_string', + 'version': 1, + }), + 'device_info': dict({ + 'area': 'POL', + 'cid': 'very_unique_string', + 'generation': '5.2.0', + 'language': 'pol', + 'macAddr': '**REDACTED**', + 'model': 'TV-Model', + 'name': 'BRAVIA', + 'product': 'TV', + 'region': 'XEU', + 'serial': 'serial_number', + }), + }) +# --- diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index 1ac1fcd4bea..0f1d08792fa 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -12,14 +12,13 @@ import pytest from homeassistant import data_entry_flow from homeassistant.components import ssdp from homeassistant.components.braviatv.const import ( - CONF_CLIENT_ID, CONF_NICKNAME, CONF_USE_PSK, DOMAIN, NICKNAME_PREFIX, ) from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN +from homeassistant.const import CONF_CLIENT_ID, CONF_HOST, CONF_MAC, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.helpers import instance_id diff --git a/tests/components/braviatv/test_diagnostics.py b/tests/components/braviatv/test_diagnostics.py new file mode 100644 index 00000000000..d0974774e7b --- /dev/null +++ b/tests/components/braviatv/test_diagnostics.py @@ -0,0 +1,72 @@ +"""Test the BraviaTV diagnostics.""" +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.braviatv.const import CONF_USE_PSK, DOMAIN +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +BRAVIA_SYSTEM_INFO = { + "product": "TV", + "region": "XEU", + "language": "pol", + "model": "TV-Model", + "serial": "serial_number", + "macAddr": "AA:BB:CC:DD:EE:FF", + "name": "BRAVIA", + "generation": "5.2.0", + "area": "POL", + "cid": "very_unique_string", +} +INPUTS = [ + { + "uri": "extInput:hdmi?port=1", + "title": "HDMI 1", + "connection": False, + "label": "", + "icon": "meta:hdmi", + } +] + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "localhost", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_USE_PSK: True, + CONF_PIN: "12345qwerty", + }, + unique_id="very_unique_string", + entry_id="3bd2acb0e4f0476d40865546d0d91921", + ) + + config_entry.add_to_hass(hass) + with patch("pybravia.BraviaClient.connect"), patch( + "pybravia.BraviaClient.pair" + ), patch("pybravia.BraviaClient.set_wol_mode"), patch( + "pybravia.BraviaClient.get_system_info", return_value=BRAVIA_SYSTEM_INFO + ), patch("pybravia.BraviaClient.get_power_status", return_value="active"), patch( + "pybravia.BraviaClient.get_external_status", return_value=INPUTS + ), patch("pybravia.BraviaClient.get_volume_info", return_value={}), patch( + "pybravia.BraviaClient.get_playing_info", return_value={} + ), patch("pybravia.BraviaClient.get_app_list", return_value=[]), patch( + "pybravia.BraviaClient.get_content_list_all", return_value=[] + ): + assert await async_setup_component(hass, DOMAIN, {}) + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot diff --git a/tests/components/brother/__init__.py b/tests/components/brother/__init__.py index 3176fa7fc28..8e24c2d8058 100644 --- a/tests/components/brother/__init__.py +++ b/tests/components/brother/__init__.py @@ -1,16 +1,13 @@ """Tests for Brother Printer integration.""" import json -import sys from unittest.mock import patch +from homeassistant.components.brother.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture -if sys.version_info < (3, 12): - from homeassistant.components.brother.const import DOMAIN - async def init_integration( hass: HomeAssistant, skip_setup: bool = False diff --git a/tests/components/brother/conftest.py b/tests/components/brother/conftest.py index 558b3b8ac3e..9e81cce9d12 100644 --- a/tests/components/brother/conftest.py +++ b/tests/components/brother/conftest.py @@ -1,13 +1,9 @@ """Test fixtures for brother.""" from collections.abc import Generator -import sys from unittest.mock import AsyncMock, patch import pytest -if sys.version_info >= (3, 12): - collect_ignore_glob = ["test_*.py"] - @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: diff --git a/tests/components/bthome/test_binary_sensor.py b/tests/components/bthome/test_binary_sensor.py index 168988e510f..c38bec3ba44 100644 --- a/tests/components/bthome/test_binary_sensor.py +++ b/tests/components/bthome/test_binary_sensor.py @@ -2,7 +2,6 @@ from datetime import timedelta import logging import time -from unittest.mock import patch import pytest @@ -25,6 +24,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) _LOGGER = logging.getLogger(__name__) @@ -236,10 +236,7 @@ async def test_unavailable(hass: HomeAssistant) -> None: # 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([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() @@ -290,10 +287,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: # 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([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() @@ -344,10 +338,7 @@ async def test_sleepy_device_restores_state(hass: HomeAssistant) -> None: # 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([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index c1f8e26ccb2..0b6e7a42cfb 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -2,7 +2,6 @@ from datetime import timedelta import logging import time -from unittest.mock import patch import pytest @@ -25,6 +24,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) _LOGGER = logging.getLogger(__name__) @@ -1150,10 +1150,7 @@ async def test_unavailable(hass: HomeAssistant) -> None: # 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([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() @@ -1206,10 +1203,7 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: # 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([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() @@ -1262,10 +1256,7 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: # 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([]): + with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]): async_fire_time_changed( hass, dt_util.utcnow() diff --git a/tests/components/buienradar/test_camera.py b/tests/components/buienradar/test_camera.py index 027fde853c1..f048f8d69a7 100644 --- a/tests/components/buienradar/test_camera.py +++ b/tests/components/buienradar/test_camera.py @@ -6,8 +6,8 @@ from http import HTTPStatus from aiohttp.client_exceptions import ClientResponseError -from homeassistant.components.buienradar.const import CONF_COUNTRY, CONF_DELTA, DOMAIN -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.components.buienradar.const import CONF_DELTA, DOMAIN +from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util @@ -144,7 +144,7 @@ async def test_belgium_country( aioclient_mock.get(radar_map_url(country_code="BE"), text="hello world") data = copy.deepcopy(TEST_CFG_DATA) - data[CONF_COUNTRY] = "BE" + data[CONF_COUNTRY_CODE] = "BE" mock_entry = MockConfigEntry(domain=DOMAIN, unique_id="TEST_ID", data=data) diff --git a/tests/components/caldav/test_todo.py b/tests/components/caldav/test_todo.py index a90529297be..6056cac5fa9 100644 --- a/tests/components/caldav/test_todo.py +++ b/tests/components/caldav/test_todo.py @@ -69,6 +69,19 @@ STATUS:NEEDS-ACTION END:VTODO END:VCALENDAR""" +TODO_ALL_FIELDS = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//E-Corp.//CalDAV Client//EN +BEGIN:VTODO +UID:2 +DTSTAMP:20171125T000000Z +SUMMARY:Cheese +DESCRIPTION:Any kind will do +STATUS:NEEDS-ACTION +DUE:20171126 +END:VTODO +END:VCALENDAR""" + @pytest.fixture def platforms() -> list[Platform]: @@ -132,6 +145,18 @@ async def mock_add_to_hass( config_entry.add_to_hass(hass) +IGNORE_COMPONENTS = ["BEGIN", "END", "DTSTAMP", "PRODID", "UID", "VERSION"] + + +def compact_ics(ics: str) -> list[str]: + """Pull out parts of the rfc5545 content useful for assertions in tests.""" + return [ + line + for line in ics.split("\n") + if line and not any(filter(line.startswith, IGNORE_COMPONENTS)) + ] + + @pytest.mark.parametrize( ("todos", "expected_state"), [ @@ -292,45 +317,148 @@ async def test_add_item_failure( [ ( {"rename": "Swiss Cheese"}, - ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Swiss Cheese", + ], "1", - {**RESULT_ITEM, "summary": "Swiss Cheese"}, + { + "uid": "2", + "summary": "Swiss Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2017-11-26", + }, ), ( {"status": "needs_action"}, - ["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], "1", - RESULT_ITEM, + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2017-11-26", + }, ), ( {"status": "completed"}, - ["SUMMARY:Cheese", "STATUS:COMPLETED"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:COMPLETED", + "SUMMARY:Cheese", + ], "0", - {**RESULT_ITEM, "status": "completed"}, + { + "uid": "2", + "summary": "Cheese", + "status": "completed", + "description": "Any kind will do", + "due": "2017-11-26", + }, ), ( {"rename": "Swiss Cheese", "status": "needs_action"}, - ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Swiss Cheese", + ], "1", - {**RESULT_ITEM, "summary": "Swiss Cheese"}, + { + "uid": "2", + "summary": "Swiss Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2017-11-26", + }, ), ( {"due_date": "2023-11-18"}, - ["SUMMARY:Cheese", "DUE;VALUE=DATE:20231118"], + [ + "DESCRIPTION:Any kind will do", + "DUE;VALUE=DATE:20231118", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], "1", - {**RESULT_ITEM, "due": "2023-11-18"}, + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2023-11-18", + }, ), ( {"due_datetime": "2023-11-18T08:30:00-06:00"}, - ["SUMMARY:Cheese", "DUE;TZID=America/Regina:20231118T083000"], + [ + "DESCRIPTION:Any kind will do", + "DUE;TZID=America/Regina:20231118T083000", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], "1", - {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + "due": "2023-11-18T08:30:00-06:00", + }, + ), + ( + {"due_datetime": None}, + [ + "DESCRIPTION:Any kind will do", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], + "1", + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "description": "Any kind will do", + }, ), ( {"description": "Make sure to get Swiss"}, - ["SUMMARY:Cheese", "DESCRIPTION:Make sure to get Swiss"], + [ + "DESCRIPTION:Make sure to get Swiss", + "DUE;VALUE=DATE:20171126", + "STATUS:NEEDS-ACTION", + "SUMMARY:Cheese", + ], "1", - {**RESULT_ITEM, "description": "Make sure to get Swiss"}, + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "due": "2017-11-26", + "description": "Make sure to get Swiss", + }, + ), + ( + {"description": None}, + ["DUE;VALUE=DATE:20171126", "STATUS:NEEDS-ACTION", "SUMMARY:Cheese"], + "1", + { + "uid": "2", + "summary": "Cheese", + "status": "needs_action", + "due": "2017-11-26", + }, ), ], ids=[ @@ -340,7 +468,9 @@ async def test_add_item_failure( "rename_status", "due_date", "due_datetime", + "clear_due_date", "description", + "clear_description", ], ) async def test_update_item( @@ -355,7 +485,7 @@ async def test_update_item( ) -> None: """Test updating an item on the list.""" - item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2") + item = Todo(dav_client, None, TODO_ALL_FIELDS, calendar, "2") calendar.search = MagicMock(return_value=[item]) await config_entry.async_setup(hass) @@ -381,8 +511,7 @@ async def test_update_item( assert dav_client.put.call_args ics = dav_client.put.call_args.args[1] - for expected in expected_ics: - assert expected in ics + assert compact_ics(ics) == expected_ics state = hass.states.get(TEST_ENTITY) assert state diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 8e49e00e498..0e761f2f437 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -2,6 +2,7 @@ import asyncio from http import HTTPStatus import io +from types import ModuleType from unittest.mock import AsyncMock, Mock, PropertyMock, mock_open, patch import pytest @@ -26,6 +27,7 @@ from homeassistant.setup import async_setup_component from .common import EMPTY_8_6_JPEG, WEBRTC_ANSWER, mock_turbo_jpeg +from tests.common import import_and_test_deprecated_constant_enum from tests.typing import ClientSessionGenerator, WebSocketGenerator STREAM_SOURCE = "rtsp://127.0.0.1/stream" @@ -56,10 +58,7 @@ async def mock_stream_source_fixture(): with patch( "homeassistant.components.camera.Camera.stream_source", return_value=STREAM_SOURCE, - ) as mock_stream_source, patch( - "homeassistant.components.camera.Camera.supported_features", - return_value=camera.CameraEntityFeature.STREAM, - ): + ) as mock_stream_source: yield mock_stream_source @@ -69,10 +68,7 @@ async def mock_hls_stream_source_fixture(): with patch( "homeassistant.components.camera.Camera.stream_source", return_value=HLS_STREAM_SOURCE, - ) as mock_hls_stream_source, patch( - "homeassistant.components.camera.Camera.supported_features", - return_value=camera.CameraEntityFeature.STREAM, - ): + ) as mock_hls_stream_source: yield mock_hls_stream_source @@ -932,19 +928,15 @@ async def test_use_stream_for_stills( return_value=True, ): # First test when the integration does not support stream should fail - resp = await client.get("/api/camera_proxy/camera.demo_camera") + resp = await client.get("/api/camera_proxy/camera.demo_camera_without_stream") await hass.async_block_till_done() mock_stream_source.assert_not_called() assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR # Test when the integration does not provide a stream_source should fail - with patch( - "homeassistant.components.demo.camera.DemoCamera.supported_features", - return_value=camera.SUPPORT_STREAM, - ): - resp = await client.get("/api/camera_proxy/camera.demo_camera") - await hass.async_block_till_done() - mock_stream_source.assert_called_once() - assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR + resp = await client.get("/api/camera_proxy/camera.demo_camera") + await hass.async_block_till_done() + mock_stream_source.assert_called_once() + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR with patch( "homeassistant.components.demo.camera.DemoCamera.stream_source", @@ -952,9 +944,6 @@ async def test_use_stream_for_stills( ) as mock_stream_source, patch( "homeassistant.components.camera.create_stream" ) as mock_create_stream, patch( - "homeassistant.components.demo.camera.DemoCamera.supported_features", - return_value=camera.SUPPORT_STREAM, - ), patch( "homeassistant.components.demo.camera.DemoCamera.use_stream_for_stills", return_value=True, ): @@ -971,3 +960,56 @@ async def test_use_stream_for_stills( mock_stream.async_get_image.assert_called_once() assert resp.status == HTTPStatus.OK assert await resp.read() == b"stream_keyframe_image" + + +@pytest.mark.parametrize( + "enum", + list(camera.const.StreamType), +) +@pytest.mark.parametrize( + "module", + [camera, camera.const], +) +def test_deprecated_stream_type_constants( + caplog: pytest.LogCaptureFixture, + enum: camera.const.StreamType, + module: ModuleType, +) -> None: + """Test deprecated stream type constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, "STREAM_TYPE_", "2025.1" + ) + + +@pytest.mark.parametrize( + "entity_feature", + list(camera.CameraEntityFeature), +) +def test_deprecated_support_constants( + caplog: pytest.LogCaptureFixture, + entity_feature: camera.CameraEntityFeature, +) -> None: + """Test deprecated support constants.""" + import_and_test_deprecated_constant_enum( + caplog, camera, entity_feature, "SUPPORT_", "2025.1" + ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockCamera(camera.Camera): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockCamera() + assert entity.supported_features_compat is camera.CameraEntityFeature(1) + assert "MockCamera" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "CameraEntityFeature.ON_OFF" in caplog.text + caplog.clear() + assert entity.supported_features_compat is camera.CameraEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/camera/test_media_source.py b/tests/components/camera/test_media_source.py index bbeef35b6f3..7aa41b98efa 100644 --- a/tests/components/camera/test_media_source.py +++ b/tests/components/camera/test_media_source.py @@ -22,14 +22,14 @@ async def test_browsing_hls(hass: HomeAssistant, mock_camera_hls) -> None: assert item is not None assert item.title == "Camera" assert len(item.children) == 0 - assert item.not_shown == 2 + assert item.not_shown == 3 # Adding stream enables HLS camera hass.config.components.add("stream") item = await media_source.async_browse_media(hass, "media-source://camera") assert item.not_shown == 0 - assert len(item.children) == 2 + assert len(item.children) == 3 assert item.children[0].media_content_type == FORMAT_CONTENT_TYPE["hls"] @@ -38,10 +38,9 @@ async def test_browsing_mjpeg(hass: HomeAssistant, mock_camera) -> None: item = await media_source.async_browse_media(hass, "media-source://camera") assert item is not None assert item.title == "Camera" - assert len(item.children) == 2 - assert item.not_shown == 0 + assert len(item.children) == 1 + assert item.not_shown == 2 assert item.children[0].media_content_type == "image/jpg" - assert item.children[1].media_content_type == "image/png" async def test_browsing_filter_web_rtc( @@ -52,7 +51,7 @@ async def test_browsing_filter_web_rtc( assert item is not None assert item.title == "Camera" assert len(item.children) == 0 - assert item.not_shown == 2 + assert item.not_shown == 3 async def test_resolving(hass: HomeAssistant, mock_camera_hls) -> None: diff --git a/tests/components/camera/test_significant_change.py b/tests/components/camera/test_significant_change.py new file mode 100644 index 00000000000..b1e1eb66589 --- /dev/null +++ b/tests/components/camera/test_significant_change.py @@ -0,0 +1,19 @@ +"""Test the Camera significant change platform.""" +from homeassistant.components.camera import STATE_IDLE, STATE_RECORDING +from homeassistant.components.camera.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_change() -> None: + """Detect Camera significant changes.""" + attrs = {} + assert not async_check_significant_change( + None, STATE_IDLE, attrs, STATE_IDLE, attrs + ) + assert not async_check_significant_change( + None, STATE_IDLE, attrs, STATE_IDLE, {"dummy": "dummy"} + ) + assert async_check_significant_change( + None, STATE_IDLE, attrs, STATE_RECORDING, attrs + ) diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 2af5e67f845..55e4d8d5c65 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -2285,7 +2285,6 @@ async def test_cast_platform_play_media_local_media( quick_play_mock.assert_called() app_data = quick_play_mock.call_args[0][2] # No authSig appended - assert ( - app_data["media_id"] - == f"{network.get_url(hass)}/api/hls/bla/master_playlist.m3u8?token=bla" - ) + assert app_data[ + "media_id" + ] == f"{network.get_url(hass)}/api/hls/bla/master_playlist.m3u8?token=bla" diff --git a/tests/components/ccm15/__init__.py b/tests/components/ccm15/__init__.py new file mode 100644 index 00000000000..fe6be699c4d --- /dev/null +++ b/tests/components/ccm15/__init__.py @@ -0,0 +1 @@ +"""Tests for the Midea ccm15 AC Controller integration.""" diff --git a/tests/components/ccm15/conftest.py b/tests/components/ccm15/conftest.py new file mode 100644 index 00000000000..910a74fa0bc --- /dev/null +++ b/tests/components/ccm15/conftest.py @@ -0,0 +1,41 @@ +"""Common fixtures for the Midea ccm15 AC Controller tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from ccm15 import CCM15DeviceState, CCM15SlaveDevice +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ccm15.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def ccm15_device() -> Generator[AsyncMock, None, None]: + """Mock ccm15 device.""" + ccm15_devices = { + 0: CCM15SlaveDevice(bytes.fromhex("000000b0b8001b")), + 1: CCM15SlaveDevice(bytes.fromhex("00000041c0001a")), + } + device_state = CCM15DeviceState(devices=ccm15_devices) + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.get_status_async", + return_value=device_state, + ): + yield + + +@pytest.fixture +def network_failure_ccm15_device() -> Generator[AsyncMock, None, None]: + """Mock empty set of ccm15 device.""" + device_state = CCM15DeviceState(devices={}) + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.get_status_async", + return_value=device_state, + ): + yield diff --git a/tests/components/ccm15/snapshots/test_climate.ambr b/tests/components/ccm15/snapshots/test_climate.ambr new file mode 100644 index 00000000000..0d4ce32fb8b --- /dev/null +++ b/tests/components/ccm15/snapshots/test_climate.ambr @@ -0,0 +1,351 @@ +# serializer version: 1 +# name: test_climate_state + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 27, + 'error_code': 0, + 'fan_mode': 'off', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 0', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + 'temperature': 23, + }), + 'context': , + 'entity_id': 'climate.midea_0', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_climate_state.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 26, + 'error_code': 0, + 'fan_mode': 'low', + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 1', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_mode': 'off', + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + 'temperature': 24, + }), + 'context': , + 'entity_id': 'climate.midea_1', + 'last_changed': , + 'last_updated': , + 'state': 'cool', + }) +# --- +# name: test_climate_state.4 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.0', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.5 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.midea_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'ccm15', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '1.1.1.1.1', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_state.6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 0', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'context': , + 'entity_id': 'climate.midea_0', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_climate_state.7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'fan_modes': list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + 'friendly_name': 'Midea 1', + 'hvac_modes': list([ + , + , + , + , + , + ]), + 'max_temp': 35, + 'min_temp': 7, + 'supported_features': , + 'swing_modes': list([ + 'off', + 'on', + ]), + 'target_temp_step': 1, + }), + 'context': , + 'entity_id': 'climate.midea_1', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/ccm15/snapshots/test_diagnostics.ambr b/tests/components/ccm15/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c6b2f9c371e --- /dev/null +++ b/tests/components/ccm15/snapshots/test_diagnostics.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + '0': dict({ + 'ac_mode': 4, + 'error_code': 0, + 'fan_locked': False, + 'fan_mode': 5, + 'is_ac_mode_locked': False, + 'is_celsius': True, + 'is_remote_locked': False, + 'locked_ac_mode': 0, + 'locked_cool_temperature': 0, + 'locked_heat_temperature': 0, + 'temperature': 27, + 'temperature_setpoint': 23, + }), + '1': dict({ + 'ac_mode': 0, + 'error_code': 0, + 'fan_locked': False, + 'fan_mode': 2, + 'is_ac_mode_locked': False, + 'is_celsius': True, + 'is_remote_locked': False, + 'locked_ac_mode': 0, + 'locked_cool_temperature': 0, + 'locked_heat_temperature': 0, + 'temperature': 26, + 'temperature_setpoint': 24, + }), + }) +# --- diff --git a/tests/components/ccm15/test_climate.py b/tests/components/ccm15/test_climate.py new file mode 100644 index 00000000000..36a77aa15ab --- /dev/null +++ b/tests/components/ccm15/test_climate.py @@ -0,0 +1,130 @@ +"""Unit test for CCM15 coordinator component.""" +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from ccm15 import CCM15DeviceState +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + FAN_HIGH, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_ON, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, SERVICE_TURN_OFF +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_climate_state( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + ccm15_device: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the coordinator.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_get("climate.midea_0") == snapshot + assert entity_registry.async_get("climate.midea_1") == snapshot + + assert hass.states.get("climate.midea_0") == snapshot + assert hass.states.get("climate.midea_1") == snapshot + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ["climate.midea_0"], ATTR_FAN_MODE: FAN_HIGH}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ["climate.midea_0"], ATTR_HVAC_MODE: HVACMode.COOL}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ["climate.midea_0"], ATTR_TEMPERATURE: 25}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ["climate.midea_0"]}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + with patch( + "homeassistant.components.ccm15.coordinator.CCM15Device.async_set_state" + ) as mock_set_state: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ["climate.midea_0"]}, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_state.assert_called_once() + + # Create an instance of the CCM15DeviceState class + device_state = CCM15DeviceState(devices={}) + with patch( + "ccm15.CCM15Device.CCM15Device.get_status_async", + return_value=device_state, + ): + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert entity_registry.async_get("climate.midea_0") == snapshot + assert entity_registry.async_get("climate.midea_1") == snapshot + + assert hass.states.get("climate.midea_0") == snapshot + assert hass.states.get("climate.midea_1") == snapshot diff --git a/tests/components/ccm15/test_config_flow.py b/tests/components/ccm15/test_config_flow.py new file mode 100644 index 00000000000..9b6314228cc --- /dev/null +++ b/tests/components/ccm15/test_config_flow.py @@ -0,0 +1,171 @@ +"""Test the Midea ccm15 AC Controller config flow.""" +from unittest.mock import AsyncMock, patch + +from homeassistant import config_entries +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +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( + "ccm15.CCM15Device.CCM15Device.async_test_connection", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "1.1.1.1" + assert result2["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_host( + 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( + "ccm15.CCM15Device.CCM15Device.async_test_connection", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + assert len(mock_setup_entry.mock_calls) == 0 + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.0.0.1", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=False + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.0.0.1", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +async def test_form_unexpected_error(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", + side_effect=Exception(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + with patch( + "ccm15.CCM15Device.CCM15Device.async_test_connection", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.0.0.1", + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + + +async def test_duplicate_host(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we handle cannot connect error.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + 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"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/ccm15/test_diagnostics.py b/tests/components/ccm15/test_diagnostics.py new file mode 100644 index 00000000000..3700faa51ce --- /dev/null +++ b/tests/components/ccm15/test_diagnostics.py @@ -0,0 +1,37 @@ +"""Test CCM15 diagnostics.""" +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + ccm15_device: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result == snapshot diff --git a/tests/components/ccm15/test_init.py b/tests/components/ccm15/test_init.py new file mode 100644 index 00000000000..b65f170a656 --- /dev/null +++ b/tests/components/ccm15/test_init.py @@ -0,0 +1,32 @@ +"""Tests for the ccm15 component.""" +from unittest.mock import AsyncMock + +from homeassistant.components.ccm15.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload(hass: HomeAssistant, ccm15_device: AsyncMock) -> None: + """Test options flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1.1.1.1", + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 80, + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index 6c1d593560e..00f8a34fb0c 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -2,6 +2,8 @@ from datetime import timedelta from unittest.mock import patch +from freezegun import freeze_time + from homeassistant.components.cert_expiry.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -73,8 +75,8 @@ async def test_update_unique_id(hass: HomeAssistant) -> None: assert entry.unique_id == f"{HOST}:{PORT}" -@patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) -async def test_unload_config_entry(mock_now, hass: HomeAssistant) -> None: +@freeze_time(static_datetime()) +async def test_unload_config_entry(hass: HomeAssistant) -> None: """Test unloading a config entry.""" assert hass.state is CoreState.running diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index 48421f5c41f..18a70fa9ab6 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -4,6 +4,8 @@ import socket import ssl from unittest.mock import patch +from freezegun import freeze_time + from homeassistant.components.cert_expiry.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import CoreState, HomeAssistant @@ -15,8 +17,8 @@ from .helpers import future_timestamp, static_datetime from tests.common import MockConfigEntry, async_fire_time_changed -@patch("homeassistant.util.dt.utcnow", return_value=static_datetime()) -async def test_async_setup_entry(mock_now, hass: HomeAssistant) -> None: +@freeze_time(static_datetime()) +async def test_async_setup_entry(hass: HomeAssistant) -> None: """Test async_setup_entry.""" assert hass.state is CoreState.running @@ -82,7 +84,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: starting_time = static_datetime() timestamp = future_timestamp(100) - with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( + with freeze_time(starting_time), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -98,7 +100,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: assert state.attributes.get("is_valid") next_update = starting_time + timedelta(hours=24) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -126,7 +128,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: starting_time = static_datetime() timestamp = future_timestamp(100) - with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( + with freeze_time(starting_time), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -143,7 +145,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=24) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=socket.gaierror, ): @@ -155,7 +157,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.example_com_cert_expiry") assert state.state == STATE_UNAVAILABLE - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): @@ -171,7 +173,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=72) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=ssl.SSLError("something bad"), ): @@ -186,7 +188,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=96) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( + with freeze_time(next_update), patch( "homeassistant.components.cert_expiry.helper.get_cert", side_effect=Exception() ): async_fire_time_changed(hass, utcnow() + timedelta(hours=96)) diff --git a/tests/components/climate/conftest.py b/tests/components/climate/conftest.py new file mode 100644 index 00000000000..2db96a20a0b --- /dev/null +++ b/tests/components/climate/conftest.py @@ -0,0 +1,22 @@ +"""Fixtures for Climate platform tests.""" +from collections.abc import Generator + +import pytest + +from homeassistant.config_entries import ConfigFlow +from homeassistant.core import HomeAssistant + +from tests.common import mock_config_flow, mock_platform + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, "test.config_flow") + + with mock_config_flow("test", MockFlow): + yield diff --git a/tests/components/climate/test_device_action.py b/tests/components/climate/test_device_action.py index 8ef73ed4e51..1fc379487ed 100644 --- a/tests/components/climate/test_device_action.py +++ b/tests/components/climate/test_device_action.py @@ -220,7 +220,7 @@ async def test_action( assert set_hvac_mode_calls[0].service == "set_hvac_mode" assert set_hvac_mode_calls[0].data == { "entity_id": entry.entity_id, - "hvac_mode": const.HVAC_MODE_OFF, + "hvac_mode": const.HVACMode.OFF, } assert set_preset_mode_calls[0].domain == DOMAIN assert set_preset_mode_calls[0].service == "set_preset_mode" @@ -287,7 +287,7 @@ async def test_action_legacy( assert set_hvac_mode_calls[0].service == "set_hvac_mode" assert set_hvac_mode_calls[0].data == { "entity_id": entry.entity_id, - "hvac_mode": const.HVAC_MODE_OFF, + "hvac_mode": const.HVACMode.OFF, } diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 897a7316c95..8fc82365c23 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -1,19 +1,46 @@ """The tests for the climate component.""" from __future__ import annotations +from enum import Enum +from types import ModuleType from unittest.mock import MagicMock import pytest import voluptuous as vol +from homeassistant.components import climate from homeassistant.components.climate import ( + DOMAIN, SET_TEMPERATURE_SCHEMA, ClimateEntity, HVACMode, ) +from homeassistant.components.climate.const import ( + ATTR_FAN_MODE, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + SERVICE_SET_FAN_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_MODE, + ClimateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from tests.common import async_mock_service +from tests.common import ( + MockConfigEntry, + MockEntity, + MockModule, + MockPlatform, + async_mock_service, + import_and_test_deprecated_constant, + import_and_test_deprecated_constant_enum, + mock_integration, + mock_platform, +) async def test_set_temp_schema_no_req( @@ -50,9 +77,22 @@ async def test_set_temp_schema( assert calls[-1].data == data -class MockClimateEntity(ClimateEntity): +class MockClimateEntity(MockEntity, ClimateEntity): """Mock Climate device to use in tests.""" + _attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.SWING_MODE + ) + _attr_preset_mode = "home" + _attr_preset_modes = ["home", "away"] + _attr_fan_mode = "auto" + _attr_fan_modes = ["auto", "off"] + _attr_swing_mode = "auto" + _attr_swing_modes = ["auto", "off"] + _attr_temperature_unit = UnitOfTemperature.CELSIUS + @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode. @@ -75,6 +115,18 @@ class MockClimateEntity(ClimateEntity): def turn_off(self) -> None: """Turn off.""" + def set_preset_mode(self, preset_mode: str) -> None: + """Set preset mode.""" + self._attr_preset_mode = preset_mode + + def set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + self._attr_fan_mode = fan_mode + + def set_swing_mode(self, swing_mode: str) -> None: + """Set swing mode.""" + self._attr_swing_mode = swing_mode + async def test_sync_turn_on(hass: HomeAssistant) -> None: """Test if async turn_on calls sync turn_on.""" @@ -96,3 +148,208 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: await climate.async_turn_off() assert climate.turn_off.called + + +def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: + result = [] + for enum in enum: + result.append((enum, constant_prefix)) + return result + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(climate.ClimateEntityFeature, "SUPPORT_") + + _create_tuples(climate.HVACMode, "HVAC_MODE_"), +) +@pytest.mark.parametrize( + "module", + [climate, climate.const], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, constant_prefix, "2025.1" + ) + + +@pytest.mark.parametrize( + ("enum", "constant_postfix"), + [ + (climate.HVACAction.OFF, "OFF"), + (climate.HVACAction.HEATING, "HEAT"), + (climate.HVACAction.COOLING, "COOL"), + (climate.HVACAction.DRYING, "DRY"), + (climate.HVACAction.IDLE, "IDLE"), + (climate.HVACAction.FAN, "FAN"), + ], +) +def test_deprecated_current_constants( + caplog: pytest.LogCaptureFixture, + enum: climate.HVACAction, + constant_postfix: str, +) -> None: + """Test deprecated current constants.""" + import_and_test_deprecated_constant( + caplog, + climate.const, + "CURRENT_HVAC_" + constant_postfix, + f"{enum.__class__.__name__}.{enum.name}", + enum, + "2025.1", + ) + + +async def test_preset_mode_validation( + hass: HomeAssistant, config_flow_fixture: None +) -> None: + """Test mode validation for fan, swing and preset.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) + return True + + async def async_setup_entry_climate_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test climate platform via config entry.""" + async_add_entities([MockClimateEntity(name="test", entity_id="climate.test")]) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry_init, + ), + built_in=False, + ) + mock_platform( + hass, + "test.climate", + MockPlatform(async_setup_entry=async_setup_entry_climate_platform), + ) + + config_entry = MockConfigEntry(domain="test") + 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("climate.test") + assert state.attributes.get(ATTR_PRESET_MODE) == "home" + assert state.attributes.get(ATTR_FAN_MODE) == "auto" + assert state.attributes.get(ATTR_SWING_MODE) == "auto" + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + { + "entity_id": "climate.test", + "preset_mode": "away", + }, + blocking=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_SWING_MODE, + { + "entity_id": "climate.test", + "swing_mode": "off", + }, + blocking=True, + ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + { + "entity_id": "climate.test", + "fan_mode": "off", + }, + blocking=True, + ) + state = hass.states.get("climate.test") + assert state.attributes.get(ATTR_PRESET_MODE) == "away" + assert state.attributes.get(ATTR_FAN_MODE) == "off" + assert state.attributes.get(ATTR_SWING_MODE) == "off" + + with pytest.raises( + ServiceValidationError, + match="The preset_mode invalid is not a valid preset_mode: home, away", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PRESET_MODE, + { + "entity_id": "climate.test", + "preset_mode": "invalid", + }, + blocking=True, + ) + assert ( + str(exc.value) + == "The preset_mode invalid is not a valid preset_mode: home, away" + ) + assert exc.value.translation_key == "not_valid_preset_mode" + + with pytest.raises( + ServiceValidationError, + match="The swing_mode invalid is not a valid swing_mode: auto, off", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_SWING_MODE, + { + "entity_id": "climate.test", + "swing_mode": "invalid", + }, + blocking=True, + ) + assert ( + str(exc.value) == "The swing_mode invalid is not a valid swing_mode: auto, off" + ) + assert exc.value.translation_key == "not_valid_swing_mode" + + with pytest.raises( + ServiceValidationError, + match="The fan_mode invalid is not a valid fan_mode: auto, off", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_FAN_MODE, + { + "entity_id": "climate.test", + "fan_mode": "invalid", + }, + blocking=True, + ) + assert str(exc.value) == "The fan_mode invalid is not a valid fan_mode: auto, off" + assert exc.value.translation_key == "not_valid_fan_mode" + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockClimateEntity(ClimateEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockClimateEntity() + assert entity.supported_features_compat is ClimateEntityFeature(1) + assert "MockClimateEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "ClimateEntityFeature.TARGET_TEMPERATURE" in caplog.text + caplog.clear() + assert entity.supported_features_compat is ClimateEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/climate/test_significant_change.py b/tests/components/climate/test_significant_change.py new file mode 100644 index 00000000000..369e5e67004 --- /dev/null +++ b/tests/components/climate/test_significant_change.py @@ -0,0 +1,129 @@ +"""Test the Climate significant change platform.""" +import pytest + +from homeassistant.components.climate import ( + ATTR_AUX_HEAT, + ATTR_CURRENT_HUMIDITY, + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_HUMIDITY, + ATTR_HVAC_ACTION, + ATTR_PRESET_MODE, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +) +from homeassistant.components.climate.significant_change import ( + async_check_significant_change, +) +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import ( + METRIC_SYSTEM as METRIC, + US_CUSTOMARY_SYSTEM as IMPERIAL, + UnitSystem, +) + + +async def test_significant_state_change(hass: HomeAssistant) -> None: + """Detect Climate significant state_changes.""" + attrs = {} + assert not async_check_significant_change(hass, "on", attrs, "on", attrs) + assert async_check_significant_change(hass, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("unit_system", "old_attrs", "new_attrs", "expected_result"), + [ + (METRIC, {ATTR_AUX_HEAT: "old_value"}, {ATTR_AUX_HEAT: "old_value"}, False), + (METRIC, {ATTR_AUX_HEAT: "old_value"}, {ATTR_AUX_HEAT: "new_value"}, True), + (METRIC, {ATTR_FAN_MODE: "old_value"}, {ATTR_FAN_MODE: "old_value"}, False), + (METRIC, {ATTR_FAN_MODE: "old_value"}, {ATTR_FAN_MODE: "new_value"}, True), + ( + METRIC, + {ATTR_HVAC_ACTION: "old_value"}, + {ATTR_HVAC_ACTION: "old_value"}, + False, + ), + ( + METRIC, + {ATTR_HVAC_ACTION: "old_value"}, + {ATTR_HVAC_ACTION: "new_value"}, + True, + ), + ( + METRIC, + {ATTR_PRESET_MODE: "old_value"}, + {ATTR_PRESET_MODE: "old_value"}, + False, + ), + ( + METRIC, + {ATTR_PRESET_MODE: "old_value"}, + {ATTR_PRESET_MODE: "new_value"}, + True, + ), + (METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "old_value"}, False), + (METRIC, {ATTR_SWING_MODE: "old_value"}, {ATTR_SWING_MODE: "new_value"}, True), + # multiple attributes + ( + METRIC, + {ATTR_HVAC_ACTION: "old_value", ATTR_PRESET_MODE: "old_value"}, + {ATTR_HVAC_ACTION: "new_value", ATTR_PRESET_MODE: "old_value"}, + True, + ), + # float attributes + (METRIC, {ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: 61}, True), + (METRIC, {ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: 60.9}, False), + ( + METRIC, + {ATTR_CURRENT_HUMIDITY: "invalid"}, + {ATTR_CURRENT_HUMIDITY: 60.0}, + True, + ), + ( + METRIC, + {ATTR_CURRENT_HUMIDITY: 60.0}, + {ATTR_CURRENT_HUMIDITY: "invalid"}, + False, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 22.0}, + {ATTR_CURRENT_TEMPERATURE: 22.5}, + True, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 22.0}, + {ATTR_CURRENT_TEMPERATURE: 22.4}, + False, + ), + (METRIC, {ATTR_HUMIDITY: 60.0}, {ATTR_HUMIDITY: 61.0}, True), + (METRIC, {ATTR_HUMIDITY: 60.0}, {ATTR_HUMIDITY: 60.9}, False), + (METRIC, {ATTR_TARGET_TEMP_HIGH: 31.0}, {ATTR_TARGET_TEMP_HIGH: 31.5}, True), + (METRIC, {ATTR_TARGET_TEMP_HIGH: 31.0}, {ATTR_TARGET_TEMP_HIGH: 31.4}, False), + (METRIC, {ATTR_TARGET_TEMP_LOW: 8.0}, {ATTR_TARGET_TEMP_LOW: 8.5}, True), + (METRIC, {ATTR_TARGET_TEMP_LOW: 8.0}, {ATTR_TARGET_TEMP_LOW: 8.4}, False), + (METRIC, {ATTR_TEMPERATURE: 22.0}, {ATTR_TEMPERATURE: 22.5}, True), + (METRIC, {ATTR_TEMPERATURE: 22.0}, {ATTR_TEMPERATURE: 22.4}, False), + (IMPERIAL, {ATTR_TEMPERATURE: 70.0}, {ATTR_TEMPERATURE: 71.0}, True), + (IMPERIAL, {ATTR_TEMPERATURE: 70.0}, {ATTR_TEMPERATURE: 70.9}, False), + # insignificant attributes + (METRIC, {"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + (METRIC, {"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + hass: HomeAssistant, + unit_system: UnitSystem, + old_attrs: dict, + new_attrs: dict, + expected_result: bool, +) -> None: + """Detect Climate significant attribute changes.""" + hass.config.units = unit_system + assert ( + async_check_significant_change(hass, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index ea8c09706c5..22b84f032f6 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -1,7 +1,8 @@ """Tests for the cloud component.""" - from unittest.mock import AsyncMock, patch +from hass_nabucasa import Cloud + from homeassistant.components import cloud from homeassistant.components.cloud import const, prefs as cloud_prefs from homeassistant.setup import async_setup_component @@ -14,7 +15,7 @@ async def mock_cloud(hass, config=None): assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, cloud.DOMAIN, {"cloud": config or {}}) - cloud_inst = hass.data["cloud"] + cloud_inst: Cloud = hass.data["cloud"] with patch("hass_nabucasa.Cloud.run_executor", AsyncMock(return_value=None)): await cloud_inst.initialize() diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 221267c59fb..ef8cb037cdb 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -1,20 +1,162 @@ """Fixtures for cloud tests.""" -from unittest.mock import patch +from collections.abc import AsyncGenerator, Callable, Coroutine +from typing import Any +from unittest.mock import DEFAULT, MagicMock, PropertyMock, patch +from hass_nabucasa import Cloud +from hass_nabucasa.auth import CognitoAuth +from hass_nabucasa.cloudhooks import Cloudhooks +from hass_nabucasa.const import DEFAULT_SERVERS, DEFAULT_VALUES, STATE_CONNECTED +from hass_nabucasa.google_report_state import GoogleReportState +from hass_nabucasa.iot import CloudIoT +from hass_nabucasa.remote import RemoteUI +from hass_nabucasa.voice import Voice import jwt import pytest -from homeassistant.components.cloud import const, prefs +from homeassistant.components.cloud import CloudClient, const, prefs +from homeassistant.util.dt import utcnow from . import mock_cloud, mock_cloud_prefs +@pytest.fixture(name="cloud") +async def cloud_fixture() -> AsyncGenerator[MagicMock, None]: + """Mock the cloud object. + + See the real hass_nabucasa.Cloud class for how to configure the mock. + """ + with patch( + "homeassistant.components.cloud.Cloud", autospec=True + ) as mock_cloud_class: + mock_cloud = mock_cloud_class.return_value + + # Attributes set in the constructor without parameters. + # We spec the mocks with the real classes + # and set constructor attributes or mock properties as needed. + mock_cloud.google_report_state = MagicMock(spec=GoogleReportState) + mock_cloud.cloudhooks = MagicMock(spec=Cloudhooks) + mock_cloud.remote = MagicMock( + spec=RemoteUI, + certificate=None, + certificate_status=None, + instance_domain=None, + is_connected=False, + ) + mock_cloud.auth = MagicMock(spec=CognitoAuth) + mock_cloud.iot = MagicMock( + spec=CloudIoT, last_disconnect_reason=None, state=STATE_CONNECTED + ) + mock_cloud.voice = MagicMock(spec=Voice) + mock_cloud.started = None + + def set_up_mock_cloud( + cloud_client: CloudClient, mode: str, **kwargs: Any + ) -> DEFAULT: + """Set up mock cloud with a mock constructor.""" + + # Attributes set in the constructor with parameters. + cloud_client.cloud = mock_cloud + mock_cloud.client = cloud_client + default_values = DEFAULT_VALUES[mode] + servers = { + f"{name}_server": server + for name, server in DEFAULT_SERVERS[mode].items() + } + mock_cloud.configure_mock(**default_values, **servers) + mock_cloud.configure_mock(**kwargs) + mock_cloud.mode = mode + + # Properties that we mock as attributes from the constructor. + mock_cloud.websession = cloud_client.websession + + return DEFAULT + + mock_cloud_class.side_effect = set_up_mock_cloud + + # Attributes that we mock with default values. + + mock_cloud.id_token = jwt.encode( + { + "email": "hello@home-assistant.io", + "custom:sub-exp": "2018-01-03", + "cognito:username": "abcdefghjkl", + }, + "test", + ) + mock_cloud.access_token = "test_access_token" + mock_cloud.refresh_token = "test_refresh_token" + + # Properties that we keep as properties. + + def mock_is_logged_in() -> bool: + """Mock is logged in.""" + return mock_cloud.id_token is not None + + is_logged_in = PropertyMock(side_effect=mock_is_logged_in) + type(mock_cloud).is_logged_in = is_logged_in + + def mock_claims() -> dict[str, Any]: + """Mock claims.""" + return Cloud._decode_claims(mock_cloud.id_token) + + claims = PropertyMock(side_effect=mock_claims) + type(mock_cloud).claims = claims + + def mock_is_connected() -> bool: + """Return True if we are connected.""" + return mock_cloud.iot.state == STATE_CONNECTED + + is_connected = PropertyMock(side_effect=mock_is_connected) + type(mock_cloud).is_connected = is_connected + + # Properties that we mock as attributes. + mock_cloud.expiration_date = utcnow() + mock_cloud.subscription_expired = False + + # Methods that we mock with a custom side effect. + + async def mock_login(email: str, password: str) -> None: + """Mock login. + + When called, it should call the on_start callback. + """ + on_start_callback = mock_cloud.register_on_start.call_args[0][0] + await on_start_callback() + + mock_cloud.login.side_effect = mock_login + + yield mock_cloud + + +@pytest.fixture(name="set_cloud_prefs") +def set_cloud_prefs_fixture( + cloud: MagicMock, +) -> Callable[[dict[str, Any]], Coroutine[Any, Any, None]]: + """Fixture for cloud component.""" + + async def set_cloud_prefs(prefs_settings: dict[str, Any]) -> None: + """Set cloud prefs.""" + prefs_to_set = cloud.client.prefs.as_dict() + prefs_to_set.pop(prefs.PREF_ALEXA_DEFAULT_EXPOSE) + prefs_to_set.pop(prefs.PREF_GOOGLE_DEFAULT_EXPOSE) + prefs_to_set.update(prefs_settings) + await cloud.client.prefs.async_update(**prefs_to_set) + + return set_cloud_prefs + + @pytest.fixture(autouse=True) def mock_tts_cache_dir_autouse(mock_tts_cache_dir): """Mock the TTS cache dir with empty dir.""" return mock_tts_cache_dir +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): + """Mock writing tags.""" + + @pytest.fixture(autouse=True) def mock_user_data(): """Mock os module.""" diff --git a/tests/components/cloud/test_binary_sensor.py b/tests/components/cloud/test_binary_sensor.py index 7b090bd5eca..6505be1fe10 100644 --- a/tests/components/cloud/test_binary_sensor.py +++ b/tests/components/cloud/test_binary_sensor.py @@ -1,44 +1,68 @@ """Tests for the cloud binary sensor.""" -from unittest.mock import Mock, patch +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +from hass_nabucasa.const import DISPATCH_REMOTE_CONNECT, DISPATCH_REMOTE_DISCONNECT +import pytest -from homeassistant.components.cloud.const import DISPATCHER_REMOTE_UPDATE from homeassistant.core import HomeAssistant -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component -async def test_remote_connection_sensor(hass: HomeAssistant) -> None: +@pytest.fixture(autouse=True) +def mock_wait_until() -> Generator[None, None, None]: + """Mock WAIT_UNTIL_CHANGE to execute callback immediately.""" + with patch("homeassistant.components.cloud.binary_sensor.WAIT_UNTIL_CHANGE", 0): + yield + + +async def test_remote_connection_sensor( + hass: HomeAssistant, + cloud: MagicMock, + entity_registry: EntityRegistry, +) -> None: """Test the remote connection sensor.""" + entity_id = "binary_sensor.remote_ui" + cloud.remote.certificate = None + assert await async_setup_component(hass, "cloud", {"cloud": {}}) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.remote_ui") is None + assert hass.states.get(entity_id) is None - # Fake connection/discovery - await async_load_platform(hass, "binary_sensor", "cloud", {}, {"cloud": {}}) + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() - # Mock test env - cloud = hass.data["cloud"] = Mock() - cloud.remote.certificate = None - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.remote_ui") + state = hass.states.get(entity_id) assert state is not None assert state.state == "unavailable" - with patch("homeassistant.components.cloud.binary_sensor.WAIT_UNTIL_CHANGE", 0): - cloud.remote.is_connected = False - cloud.remote.certificate = object() - async_dispatcher_send(hass, DISPATCHER_REMOTE_UPDATE, {}) - await hass.async_block_till_done() + cloud.remote.is_connected = False + cloud.remote.certificate = object() + cloud.client.dispatcher_message(DISPATCH_REMOTE_DISCONNECT) + await hass.async_block_till_done() - state = hass.states.get("binary_sensor.remote_ui") - assert state.state == "off" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "off" - cloud.remote.is_connected = True - async_dispatcher_send(hass, DISPATCHER_REMOTE_UPDATE, {}) - await hass.async_block_till_done() + cloud.remote.is_connected = True + cloud.client.dispatcher_message(DISPATCH_REMOTE_CONNECT) + await hass.async_block_till_done() - state = hass.states.get("binary_sensor.remote_ui") - assert state.state == "on" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" + + # Test that a state is not set if the entity is removed. + entity_registry.async_remove(entity_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is None + + cloud.remote.is_connected = False + cloud.client.dispatcher_message(DISPATCH_REMOTE_DISCONNECT) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is None diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index ff718262b10..0cd605fd755 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -7,7 +7,10 @@ from aiohttp import web import pytest from homeassistant.components.cloud import DOMAIN -from homeassistant.components.cloud.client import CloudClient +from homeassistant.components.cloud.client import ( + VALID_REPAIR_TRANSLATION_KEYS, + CloudClient, +) from homeassistant.components.cloud.const import ( PREF_ALEXA_REPORT_STATE, PREF_ENABLE_ALEXA, @@ -21,6 +24,7 @@ from homeassistant.components.homeassistant.exposed_entities import ( from homeassistant.const import CONTENT_TYPE_JSON, __version__ as HA_VERSION from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.issue_registry import IssueRegistry from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -381,3 +385,46 @@ async def test_cloud_connection_info(hass: HomeAssistant) -> None: "version": HA_VERSION, "instance_id": "12345678901234567890", } + + +@pytest.mark.parametrize( + "translation_key", + sorted(VALID_REPAIR_TRANSLATION_KEYS), +) +async def test_async_create_repair_issue_known( + cloud: MagicMock, + mock_cloud_setup: None, + issue_registry: IssueRegistry, + translation_key: str, +) -> None: + """Test create repair issue for known repairs.""" + identifier = f"test_identifier_{translation_key}" + await cloud.client.async_create_repair_issue( + identifier=identifier, + translation_key=translation_key, + placeholders={"custom_domains": "example.com"}, + severity="warning", + ) + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) + assert issue is not None + + +async def test_async_create_repair_issue_unknown( + cloud: MagicMock, + mock_cloud_setup: None, + issue_registry: IssueRegistry, +) -> None: + """Test not creating repair issue for unknown repairs.""" + identifier = "abc123" + with pytest.raises( + ValueError, + match="Invalid translation key unknown_translation_key", + ): + await cloud.client.async_create_repair_issue( + identifier=identifier, + translation_key="unknown_translation_key", + placeholders={"custom_domains": "example.com"}, + severity="error", + ) + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=identifier) + assert issue is None diff --git a/tests/components/cloud/test_config_flow.py b/tests/components/cloud/test_config_flow.py new file mode 100644 index 00000000000..ee4e37276dc --- /dev/null +++ b/tests/components/cloud/test_config_flow.py @@ -0,0 +1,40 @@ +"""Test the Home Assistant Cloud config flow.""" +from unittest.mock import patch + +from homeassistant.components.cloud.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_config_flow(hass: HomeAssistant) -> None: + """Test create cloud entry.""" + + with patch( + "homeassistant.components.cloud.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.cloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + assert result["type"] == "create_entry" + assert result["title"] == "Home Assistant Cloud" + assert result["data"] == {} + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_multiple_entries(hass: HomeAssistant) -> None: + """Test creating multiple cloud entries.""" + config_entry = MockConfigEntry(domain=DOMAIN) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index fc6861f2b49..29930632691 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1,5 +1,6 @@ """Tests for the HTTP API for the cloud component.""" import asyncio +from copy import deepcopy from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch @@ -8,12 +9,12 @@ import aiohttp from hass_nabucasa import thingtalk, voice from hass_nabucasa.auth import Unauthenticated, UnknownError from hass_nabucasa.const import STATE_CONNECTED -from jose import jwt import pytest from homeassistant.components.alexa import errors as alexa_errors from homeassistant.components.alexa.entities import LightCapabilities -from homeassistant.components.cloud.const import DOMAIN +from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY +from homeassistant.components.cloud.const import DEFAULT_EXPOSED_DOMAINS, DOMAIN from homeassistant.components.google_assistant.helpers import GoogleEntity from homeassistant.components.homeassistant import exposed_entities from homeassistant.core import HomeAssistant, State @@ -21,39 +22,87 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.location import LocationInfo -from . import mock_cloud, mock_cloud_prefs - from tests.components.google_assistant import MockConfig from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator, WebSocketGenerator +PIPELINE_DATA_LEGACY = { + "items": [ + { + "conversation_engine": "homeassistant", + "conversation_language": "language_1", + "id": "12345", + "language": "language_1", + "name": "Home Assistant Cloud", + "stt_engine": "cloud", + "stt_language": "language_1", + "tts_engine": "cloud", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + }, + ], + "preferred_item": "12345", +} + +PIPELINE_DATA = { + "items": [ + { + "conversation_engine": "homeassistant", + "conversation_language": "language_1", + "id": "12345", + "language": "language_1", + "name": "Home Assistant Cloud", + "stt_engine": "stt.home_assistant_cloud", + "stt_language": "language_1", + "tts_engine": "cloud", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + }, + ], + "preferred_item": "12345", +} + +PIPELINE_DATA_OTHER = { + "items": [ + { + "conversation_engine": "other", + "conversation_language": "language_1", + "id": "12345", + "language": "language_1", + "name": "Home Assistant", + "stt_engine": "stt.other", + "stt_language": "language_1", + "tts_engine": "other", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + }, + ], + "preferred_item": "12345", +} + SUBSCRIPTION_INFO_URL = "https://api-test.hass.io/payments/subscription_info" -@pytest.fixture(name="mock_cloud_login") -def mock_cloud_login_fixture(hass, setup_api): - """Mock cloud is logged in.""" - hass.data[DOMAIN].id_token = jwt.encode( +@pytest.fixture(name="setup_cloud") +async def setup_cloud_fixture(hass: HomeAssistant, cloud: MagicMock) -> None: + """Fixture that sets up cloud.""" + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component( + hass, + DOMAIN, { - "email": "hello@home-assistant.io", - "custom:sub-exp": "2018-01-03", - "cognito:username": "abcdefghjkl", - }, - "test", - ) - - -@pytest.fixture(autouse=True, name="setup_api") -def setup_api_fixture(hass, aioclient_mock): - """Initialize HTTP API.""" - hass.loop.run_until_complete( - mock_cloud( - hass, - { + DOMAIN: { "mode": "development", "cognito_client_id": "cognito_client_id", "user_pool_id": "user_pool_id", "region": "region", + "alexa_server": "alexa-api.nabucasa.com", "relayer_server": "relayer", "accounts_server": "api-test.hass.io", "google_actions": {"filter": {"include_domains": "light"}}, @@ -61,69 +110,59 @@ def setup_api_fixture(hass, aioclient_mock): "filter": {"include_entities": ["light.kitchen", "switch.ac"]} }, }, - ) + }, ) - return mock_cloud_prefs(hass) - - -@pytest.fixture(name="cloud_client") -def cloud_client_fixture(hass, hass_client): - """Fixture that can fetch from the cloud client.""" - with patch("hass_nabucasa.Cloud._write_user_info"): - yield hass.loop.run_until_complete(hass_client()) - - -@pytest.fixture(name="mock_cognito") -def mock_cognito_fixture(): - """Mock warrant.""" - with patch("hass_nabucasa.auth.CognitoAuth._cognito") as mock_cog: - yield mock_cog() + await hass.async_block_till_done() + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() async def test_google_actions_sync( - mock_cognito, mock_cloud_login, cloud_client + setup_cloud: None, + hass_client: ClientSessionGenerator, ) -> None: """Test syncing Google Actions.""" + cloud_client = await hass_client() with patch( "hass_nabucasa.cloud_api.async_google_actions_request_sync", return_value=Mock(status=200), ) as mock_request_sync: req = await cloud_client.post("/api/cloud/google_actions/sync") assert req.status == HTTPStatus.OK - assert len(mock_request_sync.mock_calls) == 1 + assert mock_request_sync.call_count == 1 async def test_google_actions_sync_fails( - mock_cognito, mock_cloud_login, cloud_client + setup_cloud: None, + hass_client: ClientSessionGenerator, ) -> None: """Test syncing Google Actions gone bad.""" + cloud_client = await hass_client() with patch( "hass_nabucasa.cloud_api.async_google_actions_request_sync", return_value=Mock(status=HTTPStatus.INTERNAL_SERVER_ERROR), ) as mock_request_sync: req = await cloud_client.post("/api/cloud/google_actions/sync") assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR - assert len(mock_request_sync.mock_calls) == 1 + assert mock_request_sync.call_count == 1 -async def test_login_view(hass: HomeAssistant, cloud_client) -> None: - """Test logging in when an assist pipeline is available.""" - hass.data["cloud"] = MagicMock(login=AsyncMock()) - await async_setup_component(hass, "stt", {}) - await async_setup_component(hass, "tts", {}) +async def test_login_view_missing_stt_entity( + hass: HomeAssistant, + setup_cloud: None, + entity_registry: er.EntityRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test logging in when the cloud stt entity is missing.""" + # Make sure that the cloud stt entity does not exist. + entity_registry.async_remove("stt.home_assistant_cloud") + await hass.async_block_till_done() + cloud_client = await hass_client() + + # We assume the user needs to login again for some reason. with patch( - "homeassistant.components.cloud.http_api.assist_pipeline.async_get_pipelines", - return_value=[ - Mock( - conversation_engine="homeassistant", - id="12345", - stt_engine=DOMAIN, - tts_engine=DOMAIN, - ) - ], - ), patch( - "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", + "homeassistant.components.cloud.assist_pipeline.async_create_default_pipeline", ) as create_pipeline_mock: req = await cloud_client.post( "/api/cloud/login", json={"email": "my_username", "password": "my_password"} @@ -135,14 +174,63 @@ async def test_login_view(hass: HomeAssistant, cloud_client) -> None: create_pipeline_mock.assert_not_awaited() -async def test_login_view_create_pipeline(hass: HomeAssistant, cloud_client) -> None: - """Test logging in when no assist pipeline is available.""" - hass.data["cloud"] = MagicMock(login=AsyncMock()) - await async_setup_component(hass, "stt", {}) - await async_setup_component(hass, "tts", {}) +@pytest.mark.parametrize("pipeline_data", [PIPELINE_DATA, PIPELINE_DATA_LEGACY]) +async def test_login_view_existing_pipeline( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], + pipeline_data: dict[str, Any], +) -> None: + """Test logging in when an assist pipeline is available.""" + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": STORAGE_KEY, + "data": deepcopy(pipeline_data), + } + + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + cloud_client = await hass_client() with patch( - "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", + "homeassistant.components.cloud.assist_pipeline.async_create_default_pipeline", + ) as create_pipeline_mock: + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + + assert req.status == HTTPStatus.OK + result = await req.json() + assert result == {"success": True, "cloud_pipeline": None} + create_pipeline_mock.assert_not_awaited() + + +async def test_login_view_create_pipeline( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test logging in when no existing cloud assist pipeline is available.""" + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": STORAGE_KEY, + "data": deepcopy(PIPELINE_DATA_OTHER), + } + + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + cloud_client = await hass_client() + + with patch( + "homeassistant.components.cloud.assist_pipeline.async_create_default_pipeline", return_value=AsyncMock(id="12345"), ) as create_pipeline_mock: req = await cloud_client.post( @@ -152,19 +240,36 @@ async def test_login_view_create_pipeline(hass: HomeAssistant, cloud_client) -> assert req.status == HTTPStatus.OK result = await req.json() assert result == {"success": True, "cloud_pipeline": "12345"} - create_pipeline_mock.assert_awaited_once_with(hass, "cloud", "cloud") + create_pipeline_mock.assert_awaited_once_with( + hass, + stt_engine_id="stt.home_assistant_cloud", + tts_engine_id="cloud", + pipeline_name="Home Assistant Cloud", + ) async def test_login_view_create_pipeline_fail( - hass: HomeAssistant, cloud_client + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], ) -> None: """Test logging in when no assist pipeline is available.""" - hass.data["cloud"] = MagicMock(login=AsyncMock()) - await async_setup_component(hass, "stt", {}) - await async_setup_component(hass, "tts", {}) + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": STORAGE_KEY, + "data": deepcopy(PIPELINE_DATA_OTHER), + } + + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + cloud_client = await hass_client() with patch( - "homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline", + "homeassistant.components.cloud.assist_pipeline.async_create_default_pipeline", return_value=None, ) as create_pipeline_mock: req = await cloud_client.post( @@ -174,99 +279,161 @@ async def test_login_view_create_pipeline_fail( assert req.status == HTTPStatus.OK result = await req.json() assert result == {"success": True, "cloud_pipeline": None} - create_pipeline_mock.assert_awaited_once_with(hass, "cloud", "cloud") + create_pipeline_mock.assert_awaited_once_with( + hass, + stt_engine_id="stt.home_assistant_cloud", + tts_engine_id="cloud", + pipeline_name="Home Assistant Cloud", + ) -async def test_login_view_random_exception(cloud_client) -> None: - """Try logging in with invalid JSON.""" - with patch("hass_nabucasa.Cloud.login", side_effect=ValueError("Boom")): - req = await cloud_client.post( - "/api/cloud/login", json={"email": "my_username", "password": "my_password"} - ) +async def test_login_view_random_exception( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Try logging in with random exception.""" + cloud_client = await hass_client() + cloud.login.side_effect = ValueError("Boom") + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) + assert req.status == HTTPStatus.BAD_GATEWAY resp = await req.json() assert resp == {"code": "valueerror", "message": "Unexpected error: Boom"} -async def test_login_view_invalid_json(cloud_client) -> None: +async def test_login_view_invalid_json( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Try logging in with invalid JSON.""" - with patch("hass_nabucasa.auth.CognitoAuth.async_login") as mock_login: - req = await cloud_client.post("/api/cloud/login", data="Not JSON") + cloud_client = await hass_client() + mock_login = cloud.login + + req = await cloud_client.post("/api/cloud/login", data="Not JSON") + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_login.mock_calls) == 0 + assert mock_login.call_count == 0 -async def test_login_view_invalid_schema(cloud_client) -> None: +async def test_login_view_invalid_schema( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Try logging in with invalid schema.""" - with patch("hass_nabucasa.auth.CognitoAuth.async_login") as mock_login: - req = await cloud_client.post("/api/cloud/login", json={"invalid": "schema"}) + cloud_client = await hass_client() + mock_login = cloud.login + + req = await cloud_client.post("/api/cloud/login", json={"invalid": "schema"}) + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_login.mock_calls) == 0 + assert mock_login.call_count == 0 -async def test_login_view_request_timeout(cloud_client) -> None: +async def test_login_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test request timeout while trying to log in.""" - with patch( - "hass_nabucasa.auth.CognitoAuth.async_login", side_effect=asyncio.TimeoutError - ): - req = await cloud_client.post( - "/api/cloud/login", json={"email": "my_username", "password": "my_password"} - ) + cloud_client = await hass_client() + cloud.login.side_effect = asyncio.TimeoutError + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) assert req.status == HTTPStatus.BAD_GATEWAY -async def test_login_view_invalid_credentials(cloud_client) -> None: +async def test_login_view_invalid_credentials( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test logging in with invalid credentials.""" - with patch( - "hass_nabucasa.auth.CognitoAuth.async_login", side_effect=Unauthenticated - ): - req = await cloud_client.post( - "/api/cloud/login", json={"email": "my_username", "password": "my_password"} - ) + cloud_client = await hass_client() + cloud.login.side_effect = Unauthenticated + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) assert req.status == HTTPStatus.UNAUTHORIZED -async def test_login_view_unknown_error(cloud_client) -> None: +async def test_login_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test unknown error while logging in.""" - with patch("hass_nabucasa.auth.CognitoAuth.async_login", side_effect=UnknownError): - req = await cloud_client.post( - "/api/cloud/login", json={"email": "my_username", "password": "my_password"} - ) + cloud_client = await hass_client() + cloud.login.side_effect = UnknownError + + req = await cloud_client.post( + "/api/cloud/login", json={"email": "my_username", "password": "my_password"} + ) assert req.status == HTTPStatus.BAD_GATEWAY -async def test_logout_view(hass: HomeAssistant, cloud_client) -> None: +async def test_logout_view( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test logging out.""" - cloud = hass.data["cloud"] = MagicMock() - cloud.logout = AsyncMock(return_value=None) + cloud_client = await hass_client() req = await cloud_client.post("/api/cloud/logout") + assert req.status == HTTPStatus.OK data = await req.json() assert data == {"message": "ok"} - assert len(cloud.logout.mock_calls) == 1 + assert cloud.logout.call_count == 1 -async def test_logout_view_request_timeout(hass: HomeAssistant, cloud_client) -> None: +async def test_logout_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test timeout while logging out.""" - cloud = hass.data["cloud"] = MagicMock() + cloud_client = await hass_client() cloud.logout.side_effect = asyncio.TimeoutError + req = await cloud_client.post("/api/cloud/logout") + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_logout_view_unknown_error(hass: HomeAssistant, cloud_client) -> None: +async def test_logout_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test unknown error while logging out.""" - cloud = hass.data["cloud"] = MagicMock() + cloud_client = await hass_client() cloud.logout.side_effect = UnknownError + req = await cloud_client.post("/api/cloud/logout") + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_register_view_no_location(mock_cognito, cloud_client) -> None: +async def test_register_view_no_location( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test register without location.""" + cloud_client = await hass_client() + mock_cognito = cloud.auth with patch( "homeassistant.components.cloud.http_api.async_detect_location_info", return_value=None, @@ -275,17 +442,24 @@ async def test_register_view_no_location(mock_cognito, cloud_client) -> None: "/api/cloud/register", json={"email": "hello@bla.com", "password": "falcon42"}, ) + assert req.status == HTTPStatus.OK - assert len(mock_cognito.register.mock_calls) == 1 - call = mock_cognito.register.mock_calls[0] + assert mock_cognito.async_register.call_count == 1 + call = mock_cognito.async_register.mock_calls[0] result_email, result_pass = call.args assert result_email == "hello@bla.com" assert result_pass == "falcon42" assert call.kwargs["client_metadata"] is None -async def test_register_view_with_location(mock_cognito, cloud_client) -> None: +async def test_register_view_with_location( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: """Test register with location.""" + cloud_client = await hass_client() + mock_cognito = cloud.auth with patch( "homeassistant.components.cloud.http_api.async_detect_location_info", return_value=LocationInfo( @@ -308,9 +482,10 @@ async def test_register_view_with_location(mock_cognito, cloud_client) -> None: "/api/cloud/register", json={"email": "hello@bla.com", "password": "falcon42"}, ) + assert req.status == HTTPStatus.OK - assert len(mock_cognito.register.mock_calls) == 1 - call = mock_cognito.register.mock_calls[0] + assert mock_cognito.async_register.call_count == 1 + call = mock_cognito.async_register.mock_calls[0] result_email, result_pass = call.args assert result_email == "hello@bla.com" assert result_pass == "falcon42" @@ -321,124 +496,213 @@ async def test_register_view_with_location(mock_cognito, cloud_client) -> None: } -async def test_register_view_bad_data(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_register_view_bad_data( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test register bad data.""" + cloud_client = await hass_client() + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/register", json={"email": "hello@bla.com", "not_password": "falcon"} ) + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_cognito.logout.mock_calls) == 0 + assert mock_cognito.async_register.call_count == 0 -async def test_register_view_request_timeout(mock_cognito, cloud_client) -> None: - """Test timeout while logging out.""" - mock_cognito.register.side_effect = asyncio.TimeoutError +async def test_register_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test timeout while registering.""" + cloud_client = await hass_client() + cloud.auth.async_register.side_effect = asyncio.TimeoutError + req = await cloud_client.post( "/api/cloud/register", json={"email": "hello@bla.com", "password": "falcon42"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_register_view_unknown_error(mock_cognito, cloud_client) -> None: - """Test unknown error while logging out.""" - mock_cognito.register.side_effect = UnknownError +async def test_register_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test unknown error while registering.""" + cloud_client = await hass_client() + cloud.auth.async_register.side_effect = UnknownError + req = await cloud_client.post( "/api/cloud/register", json={"email": "hello@bla.com", "password": "falcon42"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_forgot_password_view(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_forgot_password_view( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test forgot password.""" + cloud_client = await hass_client() + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/forgot_password", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.OK - assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 + assert mock_cognito.async_forgot_password.call_count == 1 -async def test_forgot_password_view_bad_data(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_forgot_password_view_bad_data( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test forgot password bad data.""" + cloud_client = await hass_client() + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/forgot_password", json={"not_email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_cognito.initiate_forgot_password.mock_calls) == 0 + assert mock_cognito.async_forgot_password.call_count == 0 -async def test_forgot_password_view_request_timeout(mock_cognito, cloud_client) -> None: - """Test timeout while logging out.""" - mock_cognito.initiate_forgot_password.side_effect = asyncio.TimeoutError +async def test_forgot_password_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test timeout while forgot password.""" + cloud_client = await hass_client() + cloud.auth.async_forgot_password.side_effect = asyncio.TimeoutError + req = await cloud_client.post( "/api/cloud/forgot_password", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_forgot_password_view_unknown_error(mock_cognito, cloud_client) -> None: - """Test unknown error while logging out.""" - mock_cognito.initiate_forgot_password.side_effect = UnknownError +async def test_forgot_password_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test unknown error while forgot password.""" + cloud_client = await hass_client() + cloud.auth.async_forgot_password.side_effect = UnknownError + req = await cloud_client.post( "/api/cloud/forgot_password", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_forgot_password_view_aiohttp_error(mock_cognito, cloud_client) -> None: - """Test unknown error while logging out.""" - mock_cognito.initiate_forgot_password.side_effect = aiohttp.ClientResponseError( +async def test_forgot_password_view_aiohttp_error( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test unknown error while forgot password.""" + cloud_client = await hass_client() + cloud.auth.async_forgot_password.side_effect = aiohttp.ClientResponseError( Mock(), Mock() ) + req = await cloud_client.post( "/api/cloud/forgot_password", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR -async def test_resend_confirm_view(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_resend_confirm_view( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test resend confirm.""" + cloud_client = await hass_client() + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/resend_confirm", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.OK - assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1 + assert mock_cognito.async_resend_email_confirm.call_count == 1 -async def test_resend_confirm_view_bad_data(mock_cognito, cloud_client) -> None: - """Test logging out.""" +async def test_resend_confirm_view_bad_data( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test resend confirm bad data.""" + cloud_client = await hass_client() + mock_cognito = cloud.auth + req = await cloud_client.post( "/api/cloud/resend_confirm", json={"not_email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_REQUEST - assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 0 + assert mock_cognito.async_resend_email_confirm.call_count == 0 -async def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client) -> None: - """Test timeout while logging out.""" - mock_cognito.client.resend_confirmation_code.side_effect = asyncio.TimeoutError +async def test_resend_confirm_view_request_timeout( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test timeout while resend confirm.""" + cloud_client = await hass_client() + cloud.auth.async_resend_email_confirm.side_effect = asyncio.TimeoutError + req = await cloud_client.post( "/api/cloud/resend_confirm", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_GATEWAY -async def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client) -> None: - """Test unknown error while logging out.""" - mock_cognito.client.resend_confirmation_code.side_effect = UnknownError +async def test_resend_confirm_view_unknown_error( + cloud: MagicMock, + setup_cloud: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test unknown error while resend confirm.""" + cloud_client = await hass_client() + cloud.auth.async_resend_email_confirm.side_effect = UnknownError + req = await cloud_client.post( "/api/cloud/resend_confirm", json={"email": "hello@bla.com"} ) + assert req.status == HTTPStatus.BAD_GATEWAY async def test_websocket_status( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - mock_cloud_fixture, - mock_cloud_login, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test querying the status.""" - hass.data[DOMAIN].iot.state = STATE_CONNECTED + cloud.iot.state = STATE_CONNECTED client = await hass_ws_client(hass) with patch.dict( @@ -452,6 +716,7 @@ async def test_websocket_status( ): await client.send_json({"id": 5, "type": "cloud/status"}) response = await client.receive_json() + assert response["result"] == { "logged_in": True, "email": "hello@home-assistant.io", @@ -462,8 +727,8 @@ async def test_websocket_status( "cloudhooks": {}, "google_enabled": True, "google_secure_devices_pin": None, - "google_default_expose": None, - "alexa_default_expose": None, + "google_default_expose": DEFAULT_EXPOSED_DOMAINS, + "alexa_default_expose": DEFAULT_EXPOSED_DOMAINS, "alexa_report_state": True, "google_report_state": True, "remote_enabled": False, @@ -493,17 +758,23 @@ async def test_websocket_status( "remote_certificate_status": None, "remote_certificate": None, "http_use_ssl": False, - "active_subscription": False, + "active_subscription": True, } async def test_websocket_status_not_logged_in( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: - """Test querying the status.""" + """Test querying the status not logged in.""" + cloud.id_token = None client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "cloud/status"}) response = await client.receive_json() + assert response["result"] == { "logged_in": False, "cloud": "disconnected", @@ -515,30 +786,32 @@ async def test_websocket_subscription_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aioclient_mock: AiohttpClientMocker, - mock_auth, - mock_cloud_login, + cloud: MagicMock, + setup_cloud: None, ) -> None: - """Test querying the status and connecting because valid account.""" + """Test subscription info and connecting because valid account.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={"provider": "stripe"}) client = await hass_ws_client(hass) + mock_renew = cloud.auth.async_renew_access_token + + await client.send_json({"id": 5, "type": "cloud/subscription"}) + response = await client.receive_json() - with patch("hass_nabucasa.auth.CognitoAuth.async_renew_access_token") as mock_renew: - await client.send_json({"id": 5, "type": "cloud/subscription"}) - response = await client.receive_json() assert response["result"] == {"provider": "stripe"} - assert len(mock_renew.mock_calls) == 1 + assert mock_renew.call_count == 1 async def test_websocket_subscription_fail( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, aioclient_mock: AiohttpClientMocker, - mock_auth, - mock_cloud_login, + cloud: MagicMock, + setup_cloud: None, ) -> None: - """Test querying the status.""" + """Test subscription info fail.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, status=HTTPStatus.INTERNAL_SERVER_ERROR) client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "cloud/subscription"}) response = await client.receive_json() @@ -547,10 +820,15 @@ async def test_websocket_subscription_fail( async def test_websocket_subscription_not_logged_in( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: - """Test querying the status.""" + """Test subscription info not logged in.""" + cloud.id_token = None client = await hass_ws_client(hass) + with patch( "hass_nabucasa.cloud_api.async_subscription_info", return_value={"return": "value"}, @@ -565,15 +843,16 @@ async def test_websocket_subscription_not_logged_in( async def test_websocket_update_preferences( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - aioclient_mock: AiohttpClientMocker, - setup_api, - mock_cloud_login, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test updating preference.""" - assert setup_api.google_enabled - assert setup_api.alexa_enabled - assert setup_api.google_secure_devices_pin is None + assert cloud.client.prefs.google_enabled + assert cloud.client.prefs.alexa_enabled + assert cloud.client.prefs.google_secure_devices_pin is None + client = await hass_ws_client(hass) + await client.send_json( { "id": 5, @@ -587,18 +866,16 @@ async def test_websocket_update_preferences( response = await client.receive_json() assert response["success"] - assert not setup_api.google_enabled - assert not setup_api.alexa_enabled - assert setup_api.google_secure_devices_pin == "1234" - assert setup_api.tts_default_voice == ("en-GB", "male") + assert not cloud.client.prefs.google_enabled + assert not cloud.client.prefs.alexa_enabled + assert cloud.client.prefs.google_secure_devices_pin == "1234" + assert cloud.client.prefs.tts_default_voice == ("en-GB", "male") async def test_websocket_update_preferences_alexa_report_state( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - aioclient_mock: AiohttpClientMocker, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test updating alexa_report_state sets alexa authorized.""" client = await hass_ws_client(hass) @@ -612,10 +889,12 @@ async def test_websocket_update_preferences_alexa_report_state( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig.set_authorized" ) as set_authorized_mock: set_authorized_mock.assert_not_called() + await client.send_json( {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} ) response = await client.receive_json() + set_authorized_mock.assert_called_once_with(True) assert response["success"] @@ -624,9 +903,7 @@ async def test_websocket_update_preferences_alexa_report_state( async def test_websocket_update_preferences_require_relink( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - aioclient_mock: AiohttpClientMocker, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test updating preference requires relink.""" client = await hass_ws_client(hass) @@ -641,10 +918,12 @@ async def test_websocket_update_preferences_require_relink( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig.set_authorized" ) as set_authorized_mock: set_authorized_mock.assert_not_called() + await client.send_json( {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} ) response = await client.receive_json() + set_authorized_mock.assert_called_once_with(False) assert not response["success"] @@ -654,9 +933,7 @@ async def test_websocket_update_preferences_require_relink( async def test_websocket_update_preferences_no_token( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - aioclient_mock: AiohttpClientMocker, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test updating preference no token available.""" client = await hass_ws_client(hass) @@ -671,10 +948,12 @@ async def test_websocket_update_preferences_no_token( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig.set_authorized" ) as set_authorized_mock: set_authorized_mock.assert_not_called() + await client.send_json( {"id": 5, "type": "cloud/update_prefs", "alexa_report_state": True} ) response = await client.receive_json() + set_authorized_mock.assert_called_once_with(False) assert not response["success"] @@ -682,69 +961,79 @@ async def test_websocket_update_preferences_no_token( async def test_enabling_webhook( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test we call right code to enable webhooks.""" client = await hass_ws_client(hass) - with patch( - "hass_nabucasa.cloudhooks.Cloudhooks.async_create", return_value={} - ) as mock_enable: - await client.send_json( - {"id": 5, "type": "cloud/cloudhook/create", "webhook_id": "mock-webhook-id"} - ) - response = await client.receive_json() - assert response["success"] + mock_enable = cloud.cloudhooks.async_create + mock_enable.return_value = {} - assert len(mock_enable.mock_calls) == 1 + await client.send_json( + {"id": 5, "type": "cloud/cloudhook/create", "webhook_id": "mock-webhook-id"} + ) + response = await client.receive_json() + + assert response["success"] + assert mock_enable.call_count == 1 assert mock_enable.mock_calls[0][1][0] == "mock-webhook-id" async def test_disabling_webhook( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test we call right code to disable webhooks.""" client = await hass_ws_client(hass) - with patch("hass_nabucasa.cloudhooks.Cloudhooks.async_delete") as mock_disable: - await client.send_json( - {"id": 5, "type": "cloud/cloudhook/delete", "webhook_id": "mock-webhook-id"} - ) - response = await client.receive_json() - assert response["success"] + mock_disable = cloud.cloudhooks.async_delete - assert len(mock_disable.mock_calls) == 1 + await client.send_json( + {"id": 5, "type": "cloud/cloudhook/delete", "webhook_id": "mock-webhook-id"} + ) + response = await client.receive_json() + + assert response["success"] + assert mock_disable.call_count == 1 assert mock_disable.mock_calls[0][1][0] == "mock-webhook-id" async def test_enabling_remote( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + cloud: MagicMock, + setup_cloud: None, ) -> None: """Test we call right code to enable remote UI.""" client = await hass_ws_client(hass) - cloud = hass.data[DOMAIN] - - with patch("hass_nabucasa.remote.RemoteUI.connect") as mock_connect: - await client.send_json({"id": 5, "type": "cloud/remote/connect"}) - response = await client.receive_json() - assert response["success"] - assert cloud.client.remote_autostart - - assert len(mock_connect.mock_calls) == 1 - - with patch("hass_nabucasa.remote.RemoteUI.disconnect") as mock_disconnect: - await client.send_json({"id": 6, "type": "cloud/remote/disconnect"}) - response = await client.receive_json() - assert response["success"] + mock_connect = cloud.remote.connect assert not cloud.client.remote_autostart - assert len(mock_disconnect.mock_calls) == 1 + await client.send_json({"id": 5, "type": "cloud/remote/connect"}) + response = await client.receive_json() + + assert response["success"] + assert cloud.client.remote_autostart + assert mock_connect.call_count == 1 + + mock_disconnect = cloud.remote.disconnect + + await client.send_json({"id": 6, "type": "cloud/remote/disconnect"}) + response = await client.receive_json() + + assert response["success"] + assert not cloud.client.remote_autostart + assert mock_disconnect.call_count == 1 async def test_list_google_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can list Google entities.""" client = await hass_ws_client(hass) @@ -762,6 +1051,7 @@ async def test_list_google_entities( ): await client.send_json_auto_id({"type": "cloud/google_assistant/entities"}) response = await client.receive_json() + assert response["success"] assert len(response["result"]) == 2 assert response["result"][0] == { @@ -789,6 +1079,7 @@ async def test_list_google_entities( ): await client.send_json_auto_id({"type": "cloud/google_assistant/entities"}) response = await client.receive_json() + assert response["success"] assert len(response["result"]) == 2 assert response["result"][0] == { @@ -807,8 +1098,7 @@ async def test_get_google_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can get a Google entity.""" client = await hass_ws_client(hass) @@ -818,6 +1108,7 @@ async def test_get_google_entity( {"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_found", @@ -829,10 +1120,12 @@ async def test_get_google_entity( "group", "test", "unique", suggested_object_id="all_locks" ) hass.states.async_set("group.all_locks", "bla") + await client.send_json_auto_id( {"type": "cloud/google_assistant/entities/get", "entity_id": "group.all_locks"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_supported", @@ -849,6 +1142,7 @@ async def test_get_google_entity( {"type": "cloud/google_assistant/entities/get", "entity_id": "light.kitchen"} ) response = await client.receive_json() + assert response["success"] assert response["result"] == { "disable_2fa": None, @@ -861,6 +1155,7 @@ async def test_get_google_entity( {"type": "cloud/google_assistant/entities/get", "entity_id": "cover.garage"} ) response = await client.receive_json() + assert response["success"] assert response["result"] == { "disable_2fa": None, @@ -878,12 +1173,14 @@ async def test_get_google_entity( } ) response = await client.receive_json() + assert response["success"] await client.send_json_auto_id( {"type": "cloud/google_assistant/entities/get", "entity_id": "cover.garage"} ) response = await client.receive_json() + assert response["success"] assert response["result"] == { "disable_2fa": True, @@ -895,13 +1192,12 @@ async def test_get_google_entity( async def test_update_google_entity( hass: HomeAssistant, - entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can update config of a Google entity.""" client = await hass_ws_client(hass) + await client.send_json_auto_id( { "type": "cloud/google_assistant/entities/update", @@ -910,6 +1206,7 @@ async def test_update_google_entity( } ) response = await client.receive_json() + assert response["success"] await client.send_json_auto_id( @@ -921,8 +1218,8 @@ async def test_update_google_entity( } ) response = await client.receive_json() - assert response["success"] + assert response["success"] assert exposed_entities.async_get_entity_settings(hass, "light.kitchen") == { "cloud.google_assistant": {"disable_2fa": False, "should_expose": False} } @@ -932,8 +1229,7 @@ async def test_list_alexa_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can list Alexa entities.""" client = await hass_ws_client(hass) @@ -946,6 +1242,7 @@ async def test_list_alexa_entities( ): await client.send_json_auto_id({"id": 5, "type": "cloud/alexa/entities"}) response = await client.receive_json() + assert response["success"] assert len(response["result"]) == 1 assert response["result"][0] == { @@ -954,10 +1251,19 @@ async def test_list_alexa_entities( "interfaces": ["Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"], } - # Add the entity to the entity registry - entity_registry.async_get_or_create( - "light", "test", "unique", suggested_object_id="kitchen" - ) + with patch( + ( + "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" + ".async_get_access_token" + ), + ), patch( + "homeassistant.components.cloud.alexa_config.alexa_state_report.async_send_add_or_update_message" + ): + # Add the entity to the entity registry + entity_registry.async_get_or_create( + "light", "test", "unique", suggested_object_id="kitchen" + ) + await hass.async_block_till_done() with patch( "homeassistant.components.alexa.entities.async_get_entities", @@ -965,6 +1271,7 @@ async def test_list_alexa_entities( ): await client.send_json_auto_id({"type": "cloud/alexa/entities"}) response = await client.receive_json() + assert response["success"] assert len(response["result"]) == 1 assert response["result"][0] == { @@ -978,8 +1285,7 @@ async def test_get_alexa_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can get an Alexa entity.""" client = await hass_ws_client(hass) @@ -989,6 +1295,7 @@ async def test_get_alexa_entity( {"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"} ) response = await client.receive_json() + assert response["success"] assert response["result"] is None @@ -997,6 +1304,7 @@ async def test_get_alexa_entity( {"type": "cloud/alexa/entities/get", "entity_id": "sensor.temperature"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_supported", @@ -1008,10 +1316,12 @@ async def test_get_alexa_entity( "group", "test", "unique", suggested_object_id="all_locks" ) hass.states.async_set("group.all_locks", "bla") + await client.send_json_auto_id( {"type": "cloud/alexa/entities/get", "entity_id": "group.all_locks"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_supported", @@ -1029,6 +1339,7 @@ async def test_get_alexa_entity( {"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"} ) response = await client.receive_json() + assert response["success"] assert response["result"] is None @@ -1036,6 +1347,7 @@ async def test_get_alexa_entity( {"type": "cloud/alexa/entities/get", "entity_id": "water_heater.basement"} ) response = await client.receive_json() + assert not response["success"] assert response["error"] == { "code": "not_supported", @@ -1047,14 +1359,14 @@ async def test_update_alexa_entity( hass: HomeAssistant, entity_registry: er.EntityRegistry, hass_ws_client: WebSocketGenerator, - setup_api, - mock_cloud_login, + setup_cloud: None, ) -> None: """Test that we can update config of an Alexa entity.""" entry = entity_registry.async_get_or_create( "light", "test", "unique", suggested_object_id="kitchen" ) client = await hass_ws_client(hass) + await client.send_json_auto_id( { "type": "homeassistant/expose_entity", @@ -1072,10 +1384,13 @@ async def test_update_alexa_entity( async def test_sync_alexa_entities_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that timeout syncing Alexa entities.""" client = await hass_ws_client(hass) + with patch( ( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" @@ -1091,10 +1406,13 @@ async def test_sync_alexa_entities_timeout( async def test_sync_alexa_entities_no_token( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test sync Alexa entities when we have no token.""" client = await hass_ws_client(hass) + with patch( ( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" @@ -1110,10 +1428,13 @@ async def test_sync_alexa_entities_no_token( async def test_enable_alexa_state_report_fail( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api, mock_cloud_login + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test enable Alexa entities state reporting when no token available.""" client = await hass_ws_client(hass) + with patch( ( "homeassistant.components.cloud.alexa_config.CloudAlexaConfig" @@ -1129,7 +1450,9 @@ async def test_enable_alexa_state_report_fail( async def test_thingtalk_convert( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that we can convert a query.""" client = await hass_ws_client(hass) @@ -1148,7 +1471,9 @@ async def test_thingtalk_convert( async def test_thingtalk_convert_timeout( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that we can convert a query.""" client = await hass_ws_client(hass) @@ -1167,7 +1492,9 @@ async def test_thingtalk_convert_timeout( async def test_thingtalk_convert_internal( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that we can convert a query.""" client = await hass_ws_client(hass) @@ -1187,7 +1514,9 @@ async def test_thingtalk_convert_internal( async def test_tts_info( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_api + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_cloud: None, ) -> None: """Test that we can get TTS info.""" # Verify the format is as expected @@ -1223,6 +1552,7 @@ async def test_tts_info( ) async def test_api_calls_require_admin( hass: HomeAssistant, + setup_cloud: None, hass_client: ClientSessionGenerator, hass_read_only_access_token: str, endpoint: str, diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index f83de408bcc..0e662c30ee7 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -21,10 +21,9 @@ from tests.typing import ClientSessionGenerator async def test_do_not_create_repair_issues_at_startup_if_not_logged_in( hass: HomeAssistant, + issue_registry: ir.IssueRegistry, ) -> None: """Test that we create repair issue at startup if we are logged in.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) - with patch("homeassistant.components.cloud.Cloud.is_logged_in", False): await mock_cloud(hass) @@ -40,9 +39,9 @@ async def test_create_repair_issues_at_startup_if_logged_in( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_auth: Generator[None, AsyncMock, None], + issue_registry: ir.IssueRegistry, ): """Test that we create repair issue at startup if we are logged in.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) aioclient_mock.get( "https://accounts.nabucasa.com/payments/subscription_info", json={"provider": "legacy"}, @@ -61,9 +60,9 @@ async def test_create_repair_issues_at_startup_if_logged_in( async def test_legacy_subscription_delete_issue_if_no_longer_legacy( hass: HomeAssistant, + issue_registry: ir.IssueRegistry, ) -> None: """Test that we delete the legacy subscription issue if no longer legacy.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) cloud_repairs.async_manage_legacy_subscription_issue(hass, {"provider": "legacy"}) assert issue_registry.async_get_issue( domain="cloud", issue_id="legacy_subscription" @@ -80,9 +79,9 @@ async def test_legacy_subscription_repair_flow( aioclient_mock: AiohttpClientMocker, mock_auth: Generator[None, AsyncMock, None], hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, ): """Test desired flow of the fix flow for legacy subscription.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) aioclient_mock.get( "https://accounts.nabucasa.com/payments/subscription_info", json={"provider": None}, @@ -154,6 +153,7 @@ async def test_legacy_subscription_repair_flow( "handler": DOMAIN, "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue( @@ -166,6 +166,7 @@ async def test_legacy_subscription_repair_flow_timeout( hass_client: ClientSessionGenerator, mock_auth: Generator[None, AsyncMock, None], aioclient_mock: AiohttpClientMocker, + issue_registry: ir.IssueRegistry, ): """Test timeout flow of the fix flow for legacy subscription.""" aioclient_mock.post( @@ -173,8 +174,6 @@ async def test_legacy_subscription_repair_flow_timeout( status=403, ) - issue_registry: ir.IssueRegistry = ir.async_get(hass) - cloud_repairs.async_manage_legacy_subscription_issue(hass, {"provider": "legacy"}) repair_issue = issue_registry.async_get_issue( domain="cloud", issue_id="legacy_subscription" diff --git a/tests/components/cloud/test_stt.py b/tests/components/cloud/test_stt.py new file mode 100644 index 00000000000..666d8ae7d65 --- /dev/null +++ b/tests/components/cloud/test_stt.py @@ -0,0 +1,201 @@ +"""Test the speech-to-text platform for the cloud integration.""" +from collections.abc import AsyncGenerator +from copy import deepcopy +from http import HTTPStatus +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from hass_nabucasa.voice import STTResponse, VoiceError +import pytest + +from homeassistant.components.assist_pipeline.pipeline import STORAGE_KEY +from homeassistant.components.cloud import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import ClientSessionGenerator + +PIPELINE_DATA = { + "items": [ + { + "conversation_engine": "conversation_engine_1", + "conversation_language": "language_1", + "id": "01GX8ZWBAQYWNB1XV3EXEZ75DY", + "language": "language_1", + "name": "Home Assistant Cloud", + "stt_engine": "cloud", + "stt_language": "language_1", + "tts_engine": "cloud", + "tts_language": "language_1", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": None, + "wake_word_id": None, + }, + { + "conversation_engine": "conversation_engine_2", + "conversation_language": "language_2", + "id": "01GX8ZWBAQTKFQNK4W7Q4CTRCX", + "language": "language_2", + "name": "name_2", + "stt_engine": "stt_engine_2", + "stt_language": "language_2", + "tts_engine": "tts_engine_2", + "tts_language": "language_2", + "tts_voice": "The Voice", + "wake_word_entity": None, + "wake_word_id": None, + }, + { + "conversation_engine": "conversation_engine_3", + "conversation_language": "language_3", + "id": "01GX8ZWBAQSV1HP3WGJPFWEJ8J", + "language": "language_3", + "name": "name_3", + "stt_engine": None, + "stt_language": None, + "tts_engine": None, + "tts_language": None, + "tts_voice": None, + "wake_word_entity": None, + "wake_word_id": None, + }, + ], + "preferred_item": "01GX8ZWBAQYWNB1XV3EXEZ75DY", +} + + +@pytest.fixture(autouse=True) +async def load_homeassistant(hass: HomeAssistant) -> None: + """Load the homeassistant integration.""" + assert await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture(autouse=True) +async def delay_save_fixture() -> AsyncGenerator[None, None]: + """Load the homeassistant integration.""" + with patch("homeassistant.helpers.collection.SAVE_DELAY", new=0): + yield + + +@pytest.mark.parametrize( + ("mock_process_stt", "expected_response_data"), + [ + ( + AsyncMock(return_value=STTResponse(True, "Turn the Kitchen Lights on")), + {"text": "Turn the Kitchen Lights on", "result": "success"}, + ), + (AsyncMock(side_effect=VoiceError("Boom!")), {"text": None, "result": "error"}), + ], +) +async def test_cloud_speech( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + mock_process_stt: AsyncMock, + expected_response_data: dict[str, Any], +) -> None: + """Test cloud text-to-speech.""" + cloud.voice.process_stt = mock_process_stt + + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + + state = hass.states.get("stt.home_assistant_cloud") + assert state + assert state.state == STATE_UNKNOWN + + client = await hass_client() + + response = await client.post( + "/api/stt/stt.home_assistant_cloud", + headers={ + "X-Speech-Content": ( + "format=wav; codec=pcm; sample_rate=16000; bit_rate=16; channel=1;" + " language=de-DE" + ) + }, + data=b"Test", + ) + response_data = await response.json() + + assert mock_process_stt.call_count == 1 + assert ( + mock_process_stt.call_args.kwargs["content_type"] + == "audio/wav; codecs=audio/pcm; samplerate=16000" + ) + assert mock_process_stt.call_args.kwargs["language"] == "de-DE" + assert response.status == HTTPStatus.OK + assert response_data == expected_response_data + + state = hass.states.get("stt.home_assistant_cloud") + assert state + assert state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + + +async def test_migrating_pipelines( + hass: HomeAssistant, + cloud: MagicMock, + hass_client: ClientSessionGenerator, + hass_storage: dict[str, Any], +) -> None: + """Test migrating pipelines when cloud stt entity is added.""" + cloud.voice.process_stt = AsyncMock( + return_value=STTResponse(True, "Turn the Kitchen Lights on") + ) + hass_storage[STORAGE_KEY] = { + "version": 1, + "minor_version": 1, + "key": "assist_pipeline.pipelines", + "data": deepcopy(PIPELINE_DATA), + } + + assert await async_setup_component(hass, "assist_pipeline", {}) + assert await async_setup_component(hass, DOMAIN, {"cloud": {}}) + await hass.async_block_till_done() + + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + await hass.async_block_till_done() + + state = hass.states.get("stt.home_assistant_cloud") + assert state + assert state.state == STATE_UNKNOWN + + # The stt engine should be updated to the new cloud stt engine id. + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["stt_engine"] + == "stt.home_assistant_cloud" + ) + + # The other items should stay the same. + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["conversation_engine"] + == "conversation_engine_1" + ) + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["conversation_language"] + == "language_1" + ) + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["id"] + == "01GX8ZWBAQYWNB1XV3EXEZ75DY" + ) + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["language"] == "language_1" + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["name"] == "Home Assistant Cloud" + ) + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["stt_language"] == "language_1" + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_engine"] == "cloud" + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_language"] == "language_1" + assert ( + hass_storage[STORAGE_KEY]["data"]["items"][0]["tts_voice"] + == "Arnold Schwarzenegger" + ) + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["wake_word_entity"] is None + assert hass_storage[STORAGE_KEY]["data"]["items"][0]["wake_word_id"] is None + assert hass_storage[STORAGE_KEY]["data"]["items"][1] == PIPELINE_DATA["items"][1] + assert hass_storage[STORAGE_KEY]["data"]["items"][2] == PIPELINE_DATA["items"][2] diff --git a/tests/components/cloud/test_system_health.py b/tests/components/cloud/test_system_health.py index c540394b937..9f1af8aaeb4 100644 --- a/tests/components/cloud/test_system_health.py +++ b/tests/components/cloud/test_system_health.py @@ -1,20 +1,25 @@ """Test cloud system health.""" import asyncio -from unittest.mock import Mock +from collections.abc import Callable, Coroutine +from typing import Any +from unittest.mock import MagicMock from aiohttp import ClientError from hass_nabucasa.remote import CertificateStatus +from homeassistant.components.cloud import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow from tests.common import get_system_health_info from tests.test_util.aiohttp import AiohttpClientMocker async def test_cloud_system_health( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], ) -> None: """Test cloud system health.""" aioclient_mock.get("https://cloud.bla.com/status", text="") @@ -23,32 +28,27 @@ async def test_cloud_system_health( "https://cognito-idp.us-east-1.amazonaws.com/AAAA/.well-known/jwks.json", exc=ClientError, ) - hass.config.components.add("cloud") assert await async_setup_component(hass, "system_health", {}) - now = utcnow() + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "user_pool_id": "AAAA", + "region": "us-east-1", + "acme_server": "cert-server", + "relayer_server": "cloud.bla.com", + }, + }, + ) + await hass.async_block_till_done() - hass.data["cloud"] = Mock( - region="us-east-1", - user_pool_id="AAAA", - relayer_server="cloud.bla.com", - acme_server="cert-server", - is_logged_in=True, - remote=Mock( - is_connected=False, - snitun_server="us-west-1", - certificate_status=CertificateStatus.READY, - ), - expiration_date=now, - is_connected=True, - client=Mock( - relayer_region="xx-earth-616", - prefs=Mock( - remote_enabled=True, - alexa_enabled=True, - google_enabled=False, - instance_id="12345678901234567890", - ), - ), + cloud.remote.snitun_server = "us-west-1" + cloud.remote.certificate_status = CertificateStatus.READY + + await cloud.client.async_system_message({"region": "xx-earth-616"}) + await set_cloud_prefs( + {"alexa_enabled": True, "google_enabled": False, "remote_enabled": True} ) info = await get_system_health_info(hass, "cloud") @@ -59,8 +59,8 @@ async def test_cloud_system_health( assert info == { "logged_in": True, - "subscription_expiration": now, - "certificate_status": "ready", + "subscription_expiration": cloud.expiration_date, + "certificate_status": CertificateStatus.READY, "relayer_connected": True, "relayer_region": "xx-earth-616", "remote_enabled": True, @@ -71,5 +71,5 @@ async def test_cloud_system_health( "can_reach_cert_server": "ok", "can_reach_cloud_auth": {"type": "failed", "error": "unreachable"}, "can_reach_cloud": "ok", - "instance_id": "12345678901234567890", + "instance_id": cloud.client.prefs.instance_id, } diff --git a/tests/components/cloud/test_tts.py b/tests/components/cloud/test_tts.py index ba88ae2af2d..dc32747182d 100644 --- a/tests/components/cloud/test_tts.py +++ b/tests/components/cloud/test_tts.py @@ -1,23 +1,35 @@ """Tests for cloud tts.""" -from unittest.mock import Mock +from collections.abc import Callable, Coroutine +from http import HTTPStatus +from typing import Any +from unittest.mock import AsyncMock, MagicMock -from hass_nabucasa import voice +from hass_nabucasa.voice import MAP_VOICE, VoiceError import pytest import voluptuous as vol -from homeassistant.components.cloud import const, tts +from homeassistant.components.cloud import DOMAIN, const, tts +from homeassistant.components.tts import DOMAIN as TTS_DOMAIN +from homeassistant.components.tts.helper import get_engine_instance +from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.typing import ClientSessionGenerator -@pytest.fixture -def cloud_with_prefs(cloud_prefs): - """Return a cloud mock with prefs.""" - return Mock(client=Mock(prefs=cloud_prefs)) +@pytest.fixture(autouse=True) +async def internal_url_mock(hass: HomeAssistant) -> None: + """Mock internal URL of the instance.""" + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.local:8123"}, + ) def test_default_exists() -> None: """Test our default language exists.""" - assert const.DEFAULT_TTS_DEFAULT_VOICE in voice.MAP_VOICE + assert const.DEFAULT_TTS_DEFAULT_VOICE in MAP_VOICE def test_schema() -> None: @@ -42,54 +54,138 @@ def test_schema() -> None: tts.PLATFORM_SCHEMA({"platform": "cloud"}) +@pytest.mark.parametrize( + ("engine_id", "platform_config"), + [ + ( + DOMAIN, + None, + ), + ( + DOMAIN, + { + "platform": DOMAIN, + "service_name": "yaml", + "language": "fr-FR", + "gender": "female", + }, + ), + ], +) async def test_prefs_default_voice( - hass: HomeAssistant, cloud_with_prefs, cloud_prefs + hass: HomeAssistant, + cloud: MagicMock, + set_cloud_prefs: Callable[[dict[str, Any]], Coroutine[Any, Any, None]], + engine_id: str, + platform_config: dict[str, Any] | None, ) -> None: """Test cloud provider uses the preferences.""" - assert cloud_prefs.tts_default_voice == ("en-US", "female") - - tts_info = {"platform_loaded": Mock()} - provider_pref = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), None, tts_info - ) - provider_conf = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), - {"language": "fr-FR", "gender": "female"}, - None, - ) - - assert provider_pref.default_language == "en-US" - assert provider_pref.default_options == {"gender": "female", "audio_output": "mp3"} - assert provider_conf.default_language == "fr-FR" - assert provider_conf.default_options == {"gender": "female", "audio_output": "mp3"} - - await cloud_prefs.async_update(tts_default_voice=("nl-NL", "male")) + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, TTS_DOMAIN, {TTS_DOMAIN: platform_config}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() - assert provider_pref.default_language == "nl-NL" - assert provider_pref.default_options == {"gender": "male", "audio_output": "mp3"} - assert provider_conf.default_language == "fr-FR" - assert provider_conf.default_options == {"gender": "female", "audio_output": "mp3"} + assert cloud.client.prefs.tts_default_voice == ("en-US", "female") + + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + + engine = get_engine_instance(hass, engine_id) + + assert engine is not None + # The platform config provider will be overridden by the discovery info provider. + assert engine.default_language == "en-US" + assert engine.default_options == {"gender": "female", "audio_output": "mp3"} + + await set_cloud_prefs({"tts_default_voice": ("nl-NL", "male")}) + await hass.async_block_till_done() + + assert engine.default_language == "nl-NL" + assert engine.default_options == {"gender": "male", "audio_output": "mp3"} -async def test_provider_properties(cloud_with_prefs) -> None: +async def test_provider_properties( + hass: HomeAssistant, + cloud: MagicMock, +) -> None: """Test cloud provider.""" - tts_info = {"platform_loaded": Mock()} - provider = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), None, tts_info - ) - assert provider.supported_options == ["gender", "voice", "audio_output"] - assert "nl-NL" in provider.supported_languages - assert tts.Voice( - "ColetteNeural", "ColetteNeural" - ) in provider.async_get_supported_voices("nl-NL") + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + + engine = get_engine_instance(hass, DOMAIN) + + assert engine is not None + assert engine.supported_options == ["gender", "voice", "audio_output"] + assert "nl-NL" in engine.supported_languages + supported_voices = engine.async_get_supported_voices("nl-NL") + assert supported_voices is not None + assert tts.Voice("ColetteNeural", "ColetteNeural") in supported_voices + supported_voices = engine.async_get_supported_voices("missing_language") + assert supported_voices is None -async def test_get_tts_audio(cloud_with_prefs) -> None: +@pytest.mark.parametrize( + ("data", "expected_url_suffix"), + [ + ({"platform": DOMAIN}, DOMAIN), + ({"engine_id": DOMAIN}, DOMAIN), + ], +) +@pytest.mark.parametrize( + ("mock_process_tts_return_value", "mock_process_tts_side_effect"), + [ + (b"", None), + (None, VoiceError("Boom!")), + ], +) +async def test_get_tts_audio( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + cloud: MagicMock, + data: dict[str, Any], + expected_url_suffix: str, + mock_process_tts_return_value: bytes | None, + mock_process_tts_side_effect: Exception | None, +) -> None: """Test cloud provider.""" - tts_info = {"platform_loaded": Mock()} - provider = await tts.async_get_engine( - Mock(data={const.DOMAIN: cloud_with_prefs}), None, tts_info + mock_process_tts = AsyncMock( + return_value=mock_process_tts_return_value, + side_effect=mock_process_tts_side_effect, ) - assert provider.supported_options == ["gender", "voice", "audio_output"] - assert "nl-NL" in provider.supported_languages + cloud.voice.process_tts = mock_process_tts + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + on_start_callback = cloud.register_on_start.call_args[0][0] + await on_start_callback() + client = await hass_client() + + url = "/api/tts_get_url" + data |= {"message": "There is someone at the door."} + + req = await client.post(url, json=data) + assert req.status == HTTPStatus.OK + response = await req.json() + + assert response == { + "url": ( + "http://example.local:8123/api/tts_proxy/" + "42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" + ), + "path": ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_e09b5a0968_{expected_url_suffix}.mp3" + ), + } + await hass.async_block_till_done() + + assert mock_process_tts.call_count == 1 + assert mock_process_tts.call_args is not None + assert mock_process_tts.call_args.kwargs["text"] == "There is someone at the door." + assert mock_process_tts.call_args.kwargs["language"] == "en-US" + assert mock_process_tts.call_args.kwargs["gender"] == "female" + assert mock_process_tts.call_args.kwargs["output"] == "mp3" diff --git a/tests/components/co2signal/snapshots/test_diagnostics.ambr b/tests/components/co2signal/snapshots/test_diagnostics.ambr index 53a0f000f28..645e0bd87e9 100644 --- a/tests/components/co2signal/snapshots/test_diagnostics.ambr +++ b/tests/components/co2signal/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'co2signal', 'entry_id': '904a74160aa6f335526706bee85dfb83', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/coinbase/snapshots/test_diagnostics.ambr b/tests/components/coinbase/snapshots/test_diagnostics.ambr index 38224a9992f..9079a7682c8 100644 --- a/tests/components/coinbase/snapshots/test_diagnostics.ambr +++ b/tests/components/coinbase/snapshots/test_diagnostics.ambr @@ -47,6 +47,7 @@ 'disabled_by': None, 'domain': 'coinbase', 'entry_id': '080272b77a4f80c41b94d7cdc86fd826', + 'minor_version': 1, 'options': dict({ 'account_balance_currencies': list([ ]), diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 10999b04bea..998c12c09b7 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -1,7 +1,9 @@ """Common stuff for Comelit SimpleHome tests.""" +from aiocomelit.const import VEDO + from homeassistant.components.comelit.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN, CONF_PORT +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN, CONF_PORT, CONF_TYPE MOCK_CONFIG = { DOMAIN: { @@ -10,11 +12,18 @@ MOCK_CONFIG = { CONF_HOST: "fake_host", CONF_PORT: 80, CONF_PIN: 1234, - } + }, + { + CONF_HOST: "fake_vedo_host", + CONF_PORT: 8080, + CONF_PIN: 1234, + CONF_TYPE: VEDO, + }, ] } } -MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] +MOCK_USER_BRIDGE_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] +MOCK_USER_VEDO_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][1] FAKE_PIN = 5678 diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index dd15eca05cd..f17c46c6f5b 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for Comelit SimpleHome config flow.""" +from typing import Any from unittest.mock import patch from aiocomelit import CannotAuthenticate, CannotConnect @@ -10,24 +11,27 @@ from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import FAKE_PIN, MOCK_USER_DATA +from .const import FAKE_PIN, MOCK_USER_BRIDGE_DATA, MOCK_USER_VEDO_DATA from tests.common import MockConfigEntry -async def test_user(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("class_api", "user_input"), + [ + ("ComeliteSerialBridgeApi", MOCK_USER_BRIDGE_DATA), + ("ComelitVedoApi", MOCK_USER_VEDO_DATA), + ], +) +async def test_full_flow( + hass: HomeAssistant, class_api: str, user_input: dict[str, Any] +) -> None: """Test starting a flow by user.""" with patch( - "aiocomelit.api.ComeliteSerialBridgeApi.login", + f"aiocomelit.api.{class_api}.login", ), patch( - "aiocomelit.api.ComeliteSerialBridgeApi.logout", - ), patch( - "homeassistant.components.comelit.async_setup_entry" - ) as mock_setup_entry, patch( - "requests.get", - ) as mock_request_get: - mock_request_get.return_value.status_code = 200 - + f"aiocomelit.api.{class_api}.logout", + ), patch("homeassistant.components.comelit.async_setup_entry") as mock_setup_entry: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -35,12 +39,12 @@ async def test_user(hass: HomeAssistant) -> None: assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_DATA + result["flow_id"], user_input=user_input ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_PORT] == 80 - assert result["data"][CONF_PIN] == 1234 + assert result["data"][CONF_HOST] == user_input[CONF_HOST] + assert result["data"][CONF_PORT] == user_input[CONF_PORT] + assert result["data"][CONF_PIN] == user_input[CONF_PIN] assert not result["result"].unique_id await hass.async_block_till_done() @@ -73,7 +77,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> "homeassistant.components.comelit.async_setup_entry", ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=MOCK_USER_DATA + result["flow_id"], user_input=MOCK_USER_BRIDGE_DATA ) assert result["type"] == FlowResultType.FORM @@ -84,7 +88,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> async def test_reauth_successful(hass: HomeAssistant) -> None: """Test starting a reauthentication flow.""" - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) mock_config.add_to_hass(hass) with patch( @@ -128,7 +132,7 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> None: """Test starting a reauthentication flow but no connection found.""" - mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + mock_config = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_BRIDGE_DATA) mock_config.add_to_hass(hass) with patch( diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index bfee7551cff..414f4eb39f2 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -532,6 +532,7 @@ async def test_create_account( "description": None, "description_placeholders": None, "options": {}, + "minor_version": 1, } @@ -609,6 +610,7 @@ async def test_two_step_flow( "description": None, "description_placeholders": None, "options": {}, + "minor_version": 1, } @@ -942,6 +944,7 @@ async def test_two_step_options_flow(hass: HomeAssistant, client) -> None: "version": 1, "description": None, "description_placeholders": None, + "minor_version": 1, } diff --git a/tests/components/conftest.py b/tests/components/conftest.py index c985565b1be..adf79a2ef96 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -16,7 +16,7 @@ def patch_zeroconf_multiple_catcher() -> Generator[None, None, None]: yield -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def prevent_io() -> Generator[None, None, None]: """Fixture to prevent certain I/O from happening.""" with patch( @@ -91,3 +91,12 @@ def tts_mutagen_mock_fixture(): from tests.components.tts.common import tts_mutagen_mock_fixture_helper yield from tts_mutagen_mock_fixture_helper() + + +@pytest.fixture(scope="session", autouse=True) +def prevent_ffmpeg_subprocess() -> Generator[None, None, None]: + """Prevent ffmpeg from creating a subprocess.""" + with patch( + "homeassistant.components.ffmpeg.FFVersion.get_version", return_value="6.0" + ): + yield diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index f7145a9ab56..35d967f37da 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -249,7 +249,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -279,7 +279,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -309,7 +309,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), @@ -339,7 +339,7 @@ 'speech': dict({ 'plain': dict({ 'extra_data': None, - 'speech': 'Turned on light', + 'speech': 'Turned on the light', }), }), }), diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index fdbf10b0c7f..0f47f9ac3d9 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -85,7 +85,7 @@ async def test_http_processing_intent( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -135,7 +135,7 @@ async def test_http_processing_intent_target_ha_agent( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -186,7 +186,7 @@ async def test_http_processing_intent_entity_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -222,7 +222,7 @@ async def test_http_processing_intent_entity_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -255,7 +255,7 @@ async def test_http_processing_intent_entity_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -331,7 +331,7 @@ async def test_http_processing_intent_alias_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -364,7 +364,7 @@ async def test_http_processing_intent_alias_added_removed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -449,7 +449,7 @@ async def test_http_processing_intent_entity_renamed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -483,7 +483,7 @@ async def test_http_processing_intent_entity_renamed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -540,7 +540,7 @@ async def test_http_processing_intent_entity_renamed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -624,7 +624,7 @@ async def test_http_processing_intent_entity_exposed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -656,7 +656,7 @@ async def test_http_processing_intent_entity_exposed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -740,7 +740,7 @@ async def test_http_processing_intent_entity_exposed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -769,7 +769,7 @@ async def test_http_processing_intent_entity_exposed( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, @@ -855,7 +855,7 @@ async def test_http_processing_intent_conversion_not_expose_new( "speech": { "plain": { "extra_data": None, - "speech": "Turned on light", + "speech": "Turned on the light", } }, "language": hass.config.language, diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index 802bf759d81..1b08658d983 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -1,4 +1,8 @@ """The tests for Cover.""" +from enum import Enum + +import pytest + import homeassistant.components.cover as cover from homeassistant.const import ( ATTR_ENTITY_ID, @@ -12,6 +16,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import import_and_test_deprecated_constant_enum + async def test_services(hass: HomeAssistant, enable_custom_integrations: None) -> None: """Test the provided services.""" @@ -112,3 +118,43 @@ def is_closed(hass, ent): def is_closing(hass, ent): """Return if the cover is closed based on the statemachine.""" return hass.states.is_state(ent.entity_id, STATE_CLOSING) + + +def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: + result = [] + for enum in enum: + result.append((enum, constant_prefix)) + return result + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(cover.CoverEntityFeature, "SUPPORT_") + + _create_tuples(cover.CoverDeviceClass, "DEVICE_CLASS_"), +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, cover, enum, constant_prefix, "2025.1" + ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockCoverEntity(cover.CoverEntity): + _attr_supported_features = 1 + + entity = MockCoverEntity() + assert entity.supported_features is cover.CoverEntityFeature(1) + assert "MockCoverEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "CoverEntityFeature.OPEN" in caplog.text + caplog.clear() + assert entity.supported_features is cover.CoverEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/cover/test_significant_change.py b/tests/components/cover/test_significant_change.py new file mode 100644 index 00000000000..9ddb2cb9498 --- /dev/null +++ b/tests/components/cover/test_significant_change.py @@ -0,0 +1,65 @@ +"""Test the Cover significant change platform.""" +import pytest + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, +) +from homeassistant.components.cover.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Cover significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # float attributes + ({ATTR_CURRENT_POSITION: 60.0}, {ATTR_CURRENT_POSITION: 61.0}, True), + ({ATTR_CURRENT_POSITION: 60.0}, {ATTR_CURRENT_POSITION: 60.9}, False), + ({ATTR_CURRENT_POSITION: "invalid"}, {ATTR_CURRENT_POSITION: 60.0}, True), + ({ATTR_CURRENT_POSITION: 60.0}, {ATTR_CURRENT_POSITION: "invalid"}, False), + ({ATTR_CURRENT_TILT_POSITION: 60.0}, {ATTR_CURRENT_TILT_POSITION: 61.0}, True), + ({ATTR_CURRENT_TILT_POSITION: 60.0}, {ATTR_CURRENT_TILT_POSITION: 60.9}, False), + # multiple attributes + ( + { + ATTR_CURRENT_POSITION: 60, + ATTR_CURRENT_TILT_POSITION: 60, + }, + { + ATTR_CURRENT_POSITION: 60, + ATTR_CURRENT_TILT_POSITION: 61, + }, + True, + ), + ( + { + ATTR_CURRENT_POSITION: 60, + ATTR_CURRENT_TILT_POSITION: 59.1, + }, + { + ATTR_CURRENT_POSITION: 60, + ATTR_CURRENT_TILT_POSITION: 60.9, + }, + True, + ), + # insignificant attributes + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Cover significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/deconz/snapshots/test_diagnostics.ambr b/tests/components/deconz/snapshots/test_diagnostics.ambr index bbd96f1751c..911f2e134f2 100644 --- a/tests/components/deconz/snapshots/test_diagnostics.ambr +++ b/tests/components/deconz/snapshots/test_diagnostics.ambr @@ -12,6 +12,7 @@ 'disabled_by': None, 'domain': 'deconz', 'entry_id': '1', + 'minor_version': 1, 'options': dict({ 'master': True, }), diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index 5a3952e16db..dd0de559ba8 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -41,6 +41,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .test_gateway import ( DECONZ_WEB_REQUEST, @@ -602,7 +603,7 @@ async def test_climate_device_with_fan_support( # Service set fan mode to unsupported value - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, @@ -725,7 +726,7 @@ async def test_climate_device_with_preset( # Service set preset to unsupported value - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, diff --git a/tests/components/demo/test_button.py b/tests/components/demo/test_button.py index bcaddab433b..6049de12570 100644 --- a/tests/components/demo/test_button.py +++ b/tests/components/demo/test_button.py @@ -2,6 +2,7 @@ from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.button import DOMAIN, SERVICE_PRESS @@ -37,20 +38,20 @@ def test_setup_params(hass: HomeAssistant) -> None: assert state.state == STATE_UNKNOWN -async def test_press(hass: HomeAssistant) -> None: +async def test_press(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test pressing the button.""" state = hass.states.get(ENTITY_PUSH) assert state assert state.state == STATE_UNKNOWN now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.services.async_call( - DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: ENTITY_PUSH}, - blocking=True, - ) + freezer.move_to(now) + await hass.services.async_call( + DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: ENTITY_PUSH}, + blocking=True, + ) state = hass.states.get(ENTITY_PUSH) assert state diff --git a/tests/components/demo/test_climate.py b/tests/components/demo/test_climate.py index 69e385ce242..97b436ea2b0 100644 --- a/tests/components/demo/test_climate.py +++ b/tests/components/demo/test_climate.py @@ -278,12 +278,12 @@ async def test_set_fan_mode(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_FAN_MODE: "On Low"}, + {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_FAN_MODE: "on_low"}, blocking=True, ) state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_FAN_MODE) == "On Low" + assert state.attributes.get(ATTR_FAN_MODE) == "on_low" async def test_set_swing_mode_bad_attr(hass: HomeAssistant) -> None: @@ -311,12 +311,12 @@ async def test_set_swing(hass: HomeAssistant) -> None: await hass.services.async_call( DOMAIN, SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_SWING_MODE: "Auto"}, + {ATTR_ENTITY_ID: ENTITY_CLIMATE, ATTR_SWING_MODE: "auto"}, blocking=True, ) state = hass.states.get(ENTITY_CLIMATE) - assert state.attributes.get(ATTR_SWING_MODE) == "Auto" + assert state.attributes.get(ATTR_SWING_MODE) == "auto" async def test_set_hvac_bad_attr_and_state(hass: HomeAssistant) -> None: diff --git a/tests/components/demo/test_select.py b/tests/components/demo/test_select.py index a4fff2a231e..013a9900a83 100644 --- a/tests/components/demo/test_select.py +++ b/tests/components/demo/test_select.py @@ -11,6 +11,7 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component ENTITY_SPEED = "select.speed" @@ -51,7 +52,7 @@ async def test_select_option_bad_attr(hass: HomeAssistant) -> None: assert state assert state.state == "ridiculous_speed" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index 724ae612f0d..ada1c03a923 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -2,6 +2,7 @@ from datetime import datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import ( @@ -72,13 +73,15 @@ async def scanner(hass, enable_custom_integrations): return scanner -async def test_lights_on_when_sun_sets(hass: HomeAssistant, scanner) -> None: +async def test_lights_on_when_sun_sets( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, scanner +) -> None: """Test lights go on when there is someone home and the sun sets.""" test_time = datetime(2017, 4, 5, 1, 2, 3, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - assert await async_setup_component( - hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} - ) + freezer.move_to(test_time) + assert await async_setup_component( + hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} + ) await hass.services.async_call( light.DOMAIN, @@ -88,9 +91,9 @@ async def test_lights_on_when_sun_sets(hass: HomeAssistant, scanner) -> None: ) test_time = test_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - async_fire_time_changed(hass, test_time) - await hass.async_block_till_done() + freezer.move_to(test_time) + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() assert all( hass.states.get(ent_id).state == STATE_ON @@ -128,22 +131,22 @@ async def test_lights_turn_off_when_everyone_leaves( async def test_lights_turn_on_when_coming_home_after_sun_set( - hass: HomeAssistant, scanner + hass: HomeAssistant, freezer: FrozenDateTimeFactory, scanner ) -> None: """Test lights turn on when coming home after sun set.""" test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - await hass.services.async_call( - light.DOMAIN, light.SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True - ) + freezer.move_to(test_time) + await hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True + ) - assert await async_setup_component( - hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} - ) + assert await async_setup_component( + hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} + ) - hass.states.async_set(f"{DOMAIN}.device_2", STATE_HOME) + hass.states.async_set(f"{DOMAIN}.device_2", STATE_HOME) - await hass.async_block_till_done() + await hass.async_block_till_done() assert all( hass.states.get(ent_id).state == light.STATE_ON @@ -152,85 +155,85 @@ async def test_lights_turn_on_when_coming_home_after_sun_set( async def test_lights_turn_on_when_coming_home_after_sun_set_person( - hass: HomeAssistant, scanner + hass: HomeAssistant, freezer: FrozenDateTimeFactory, scanner ) -> None: """Test lights turn on when coming home after sun set.""" device_1 = f"{DOMAIN}.device_1" device_2 = f"{DOMAIN}.device_2" test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - await hass.services.async_call( - light.DOMAIN, light.SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True - ) - hass.states.async_set(device_1, STATE_NOT_HOME) - hass.states.async_set(device_2, STATE_NOT_HOME) - await hass.async_block_till_done() + freezer.move_to(test_time) + await hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True + ) + hass.states.async_set(device_1, STATE_NOT_HOME) + hass.states.async_set(device_2, STATE_NOT_HOME) + await hass.async_block_till_done() - assert all( - not light.is_on(hass, ent_id) - for ent_id in hass.states.async_entity_ids("light") - ) - assert hass.states.get(device_1).state == "not_home" - assert hass.states.get(device_2).state == "not_home" + assert all( + not light.is_on(hass, ent_id) + for ent_id in hass.states.async_entity_ids("light") + ) + assert hass.states.get(device_1).state == "not_home" + assert hass.states.get(device_2).state == "not_home" - assert await async_setup_component( - hass, - "person", - {"person": [{"id": "me", "name": "Me", "device_trackers": [device_1]}]}, - ) + assert await async_setup_component( + hass, + "person", + {"person": [{"id": "me", "name": "Me", "device_trackers": [device_1]}]}, + ) - assert await async_setup_component(hass, "group", {}) - await hass.async_block_till_done() - await group.Group.async_create_group( - hass, - "person_me", - created_by_service=False, - entity_ids=["person.me"], - icon=None, - mode=None, - object_id=None, - order=None, - ) + assert await async_setup_component(hass, "group", {}) + await hass.async_block_till_done() + await group.Group.async_create_group( + hass, + "person_me", + created_by_service=False, + entity_ids=["person.me"], + icon=None, + mode=None, + object_id=None, + order=None, + ) - assert await async_setup_component( - hass, - device_sun_light_trigger.DOMAIN, - {device_sun_light_trigger.DOMAIN: {"device_group": "group.person_me"}}, - ) + assert await async_setup_component( + hass, + device_sun_light_trigger.DOMAIN, + {device_sun_light_trigger.DOMAIN: {"device_group": "group.person_me"}}, + ) - assert all( - hass.states.get(ent_id).state == STATE_OFF - for ent_id in hass.states.async_entity_ids("light") - ) - assert hass.states.get(device_1).state == "not_home" - assert hass.states.get(device_2).state == "not_home" - assert hass.states.get("person.me").state == "not_home" + assert all( + hass.states.get(ent_id).state == STATE_OFF + for ent_id in hass.states.async_entity_ids("light") + ) + assert hass.states.get(device_1).state == "not_home" + assert hass.states.get(device_2).state == "not_home" + assert hass.states.get("person.me").state == "not_home" - # Unrelated device has no impact - hass.states.async_set(device_2, STATE_HOME) - await hass.async_block_till_done() + # Unrelated device has no impact + hass.states.async_set(device_2, STATE_HOME) + await hass.async_block_till_done() - assert all( - hass.states.get(ent_id).state == STATE_OFF - for ent_id in hass.states.async_entity_ids("light") - ) - assert hass.states.get(device_1).state == "not_home" - assert hass.states.get(device_2).state == "home" - assert hass.states.get("person.me").state == "not_home" + assert all( + hass.states.get(ent_id).state == STATE_OFF + for ent_id in hass.states.async_entity_ids("light") + ) + assert hass.states.get(device_1).state == "not_home" + assert hass.states.get(device_2).state == "home" + assert hass.states.get("person.me").state == "not_home" - # person home switches on - hass.states.async_set(device_1, STATE_HOME) - await hass.async_block_till_done() - await hass.async_block_till_done() + # person home switches on + hass.states.async_set(device_1, STATE_HOME) + await hass.async_block_till_done() + await hass.async_block_till_done() - assert all( - hass.states.get(ent_id).state == light.STATE_ON - for ent_id in hass.states.async_entity_ids("light") - ) - assert hass.states.get(device_1).state == "home" - assert hass.states.get(device_2).state == "home" - assert hass.states.get("person.me").state == "home" + assert all( + hass.states.get(ent_id).state == light.STATE_ON + for ent_id in hass.states.async_entity_ids("light") + ) + assert hass.states.get(device_1).state == "home" + assert hass.states.get(device_2).state == "home" + assert hass.states.get("person.me").state == "home" async def test_initialize_start(hass: HomeAssistant) -> None: diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py index e55a9b5b6b2..49912fd282f 100644 --- a/tests/components/device_tracker/test_config_entry.py +++ b/tests/components/device_tracker/test_config_entry.py @@ -259,7 +259,7 @@ async def test_connected_device_registered( assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{entity_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{entity_platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 0 # should be disabled assert len(entity_registry.entities) == 3 diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 67bc24909c5..024187a33f6 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta import json import logging import os +from types import ModuleType from unittest.mock import Mock, call, patch import pytest @@ -33,6 +34,7 @@ from . import common from tests.common import ( assert_setup_component, async_fire_time_changed, + import_and_test_deprecated_constant_enum, mock_registry, mock_restore_cache, patch_yaml_files, @@ -123,7 +125,7 @@ async def test_reading_yaml_config( assert device.config_picture == config.config_picture assert device.consider_home == config.consider_home assert device.icon == config.icon - assert f"{device_tracker.DOMAIN}.test" in hass.config.components + assert f"test.{device_tracker.DOMAIN}" in hass.config.components @patch("homeassistant.components.device_tracker.const.LOGGER.warning") @@ -603,7 +605,7 @@ async def test_bad_platform(hass: HomeAssistant) -> None: with assert_setup_component(0, device_tracker.DOMAIN): assert await async_setup_component(hass, device_tracker.DOMAIN, config) - assert f"{device_tracker.DOMAIN}.bad_platform" not in hass.config.components + assert f"bad_platform.{device_tracker.DOMAIN}" not in hass.config.components async def test_adding_unknown_device_to_config( @@ -681,3 +683,19 @@ def test_see_schema_allowing_ios_calls() -> None: "hostname": "beer", } ) + + +@pytest.mark.parametrize(("enum"), list(SourceType)) +@pytest.mark.parametrize( + "module", + [device_tracker, device_tracker.const], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: SourceType, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, "SOURCE_TYPE_", "2025.1" + ) diff --git a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr index d2ff64ad596..8c069de8f62 100644 --- a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr @@ -40,6 +40,7 @@ 'disabled_by': None, 'domain': 'devolo_home_control', 'entry_id': '123456', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index 8cf63cf07ae..9d8faab9b13 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -12,7 +12,7 @@ from devolo_plc_api.device_api import ( UpdateFirmwareCheck, WifiGuestAccessGet, ) -from devolo_plc_api.plcnet_api import LogicalNetwork +from devolo_plc_api.plcnet_api import LOCAL, REMOTE, LogicalNetwork from homeassistant.components.zeroconf import ZeroconfServiceInfo @@ -117,14 +117,34 @@ PLCNET = LogicalNetwork( { "mac_address": "AA:BB:CC:DD:EE:FF", "attached_to_router": False, - } + "topology": LOCAL, + "user_device_name": "test1", + }, + { + "mac_address": "11:22:33:44:55:66", + "attached_to_router": True, + "topology": REMOTE, + "user_device_name": "test2", + }, + { + "mac_address": "12:34:56:78:9A:BC", + "attached_to_router": False, + "topology": REMOTE, + "user_device_name": "test3", + }, ], data_rates=[ { "mac_address_from": "AA:BB:CC:DD:EE:FF", "mac_address_to": "11:22:33:44:55:66", - "rx_rate": 0.0, - "tx_rate": 0.0, + "rx_rate": 100.0, + "tx_rate": 100.0, + }, + { + "mac_address_from": "AA:BB:CC:DD:EE:FF", + "mac_address_to": "12:34:56:78:9A:BC", + "rx_rate": 150.0, + "tx_rate": 150.0, }, ], ) @@ -136,5 +156,18 @@ PLCNET_ATTACHED = LogicalNetwork( "attached_to_router": True, } ], - data_rates=[], + data_rates=[ + { + "mac_address_from": "AA:BB:CC:DD:EE:FF", + "mac_address_to": "11:22:33:44:55:66", + "rx_rate": 100.0, + "tx_rate": 100.0, + }, + { + "mac_address_from": "AA:BB:CC:DD:EE:FF", + "mac_address_to": "12:34:56:78:9A:BC", + "rx_rate": 150.0, + "tx_rate": 150.0, + }, + ], ) diff --git a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr index 236588b87ad..317aaac0116 100644 --- a/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr +++ b/tests/components/devolo_home_network/snapshots/test_diagnostics.ambr @@ -24,6 +24,7 @@ 'disabled_by': None, 'domain': 'devolo_home_network', 'entry_id': '123456', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/devolo_home_network/snapshots/test_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_sensor.ambr index 88eb46d57e8..4ab4635683c 100644 --- a/tests/components/devolo_home_network/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_sensor.ambr @@ -134,3 +134,99 @@ 'unit_of_measurement': None, }) # --- +# name: test_update_plc_phyrates + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title PLC downlink PHY rate (test2)', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + 'last_changed': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_update_plc_phyrates.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_plc_downlink_phy_rate_test2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PLC downlink PHY rate (test2)', + 'platform': 'devolo_home_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plc_rx_rate', + 'unique_id': '1234567890_plc_rx_rate_11:22:33:44:55:66', + 'unit_of_measurement': , + }) +# --- +# name: test_update_plc_phyrates.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_rate', + 'friendly_name': 'Mock Title PLC downlink PHY rate (test2)', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_plc_downlink_phy_rate_test2', + 'last_changed': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_update_plc_phyrates.3 + 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_plc_downlink_phy_rate_test2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PLC downlink PHY rate (test2)', + 'platform': 'devolo_home_network', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'plc_rx_rate', + 'unique_id': '1234567890_plc_rx_rate_11:22:33:44:55:66', + 'unit_of_measurement': , + }) +# --- diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index 230457f5617..e6f02033425 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import configure_integration +from .const import PLCNET from .mock import MockDevice from tests.common import async_fire_time_changed @@ -33,6 +34,30 @@ async def test_sensor_setup(hass: HomeAssistant) -> None: assert hass.states.get(f"{DOMAIN}.{device_name}_connected_wifi_clients") is not None assert hass.states.get(f"{DOMAIN}.{device_name}_connected_plc_devices") is None assert hass.states.get(f"{DOMAIN}.{device_name}_neighboring_wifi_networks") is None + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + ) + is not None + ) + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + ) + is not None + ) + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_downlink_phyrate_{PLCNET.devices[2].user_device_name}" + ) + is None + ) + assert ( + hass.states.get( + f"{DOMAIN}.{device_name}_plc_uplink_phyrate_{PLCNET.devices[2].user_device_name}" + ) + is None + ) await hass.config_entries.async_unload(entry.entry_id) @@ -100,3 +125,56 @@ async def test_sensor( assert state.state == "1" await hass.config_entries.async_unload(entry.entry_id) + + +async def test_update_plc_phyrates( + hass: HomeAssistant, + mock_device: MockDevice, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Test state change of plc_downlink_phyrate and plc_uplink_phyrate sensor devices.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key_downlink = f"{DOMAIN}.{device_name}_plc_downlink_phy_rate_{PLCNET.devices[1].user_device_name}" + state_key_uplink = f"{DOMAIN}.{device_name}_plc_uplink_phy_rate_{PLCNET.devices[1].user_device_name}" + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(state_key_downlink) == snapshot + assert entity_registry.async_get(state_key_downlink) == snapshot + assert hass.states.get(state_key_downlink) == snapshot + assert entity_registry.async_get(state_key_downlink) == snapshot + + # Emulate device failure + mock_device.plcnet.async_get_network_overview = AsyncMock( + side_effect=DeviceUnavailable + ) + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(state_key_downlink) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get(state_key_uplink) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + # Emulate state change + mock_device.reset() + freezer.tick(LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(state_key_downlink) + assert state is not None + assert state.state == str(PLCNET.data_rates[0].rx_rate) + + state = hass.states.get(state_key_uplink) + assert state is not None + assert state.state == str(PLCNET.data_rates[0].tx_rate) + + await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 5013568ad39..a63300b1ea2 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -828,6 +828,36 @@ async def test_device_tracker_hostname_and_macaddress_after_start_hostname_missi assert len(mock_init.mock_calls) == 0 +async def test_device_tracker_invalid_ip_address( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test an invalid ip address.""" + + with patch.object(hass.config_entries.flow, "async_init") as mock_init: + device_tracker_watcher = dhcp.DeviceTrackerWatcher( + hass, + {}, + [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], + ) + await device_tracker_watcher.async_start() + await hass.async_block_till_done() + hass.states.async_set( + "device_tracker.august_connect", + STATE_HOME, + { + ATTR_IP: "invalid", + ATTR_SOURCE_TYPE: SourceType.ROUTER, + ATTR_MAC: "B8:B7:F1:6D:B5:33", + }, + ) + await hass.async_block_till_done() + await device_tracker_watcher.async_stop() + await hass.async_block_till_done() + + assert "Ignoring invalid IP Address: invalid" in caplog.text + assert len(mock_init.mock_calls) == 0 + + async def test_device_tracker_ignore_self_assigned_ips_before_start( hass: HomeAssistant, ) -> None: diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py index 5dc76a2170e..48b12132cbe 100644 --- a/tests/components/directv/test_media_player.py +++ b/tests/components/directv/test_media_player.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime, timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.directv.media_player import ( @@ -305,6 +306,7 @@ async def test_check_attributes( async def test_attributes_paused( hass: HomeAssistant, mock_now: dt_util.dt.datetime, + freezer: FrozenDateTimeFactory, aioclient_mock: AiohttpClientMocker, ) -> None: """Test attributes while paused.""" @@ -315,11 +317,9 @@ async def test_attributes_paused( # Test to make sure that ATTR_MEDIA_POSITION_UPDATED_AT is not # updated if TV is paused. - with patch( - "homeassistant.util.dt.utcnow", return_value=mock_now + timedelta(minutes=5) - ): - await async_media_pause(hass, CLIENT_ENTITY_ID) - await hass.async_block_till_done() + freezer.move_to(mock_now + timedelta(minutes=5)) + await async_media_pause(hass, CLIENT_ENTITY_ID) + await hass.async_block_till_done() state = hass.states.get(CLIENT_ENTITY_ID) assert state.state == STATE_PAUSED diff --git a/tests/components/drop_connect/__init__.py b/tests/components/drop_connect/__init__.py new file mode 100644 index 00000000000..f67b77b906e --- /dev/null +++ b/tests/components/drop_connect/__init__.py @@ -0,0 +1 @@ +"""Tests for the DROP integration.""" diff --git a/tests/components/drop_connect/common.py b/tests/components/drop_connect/common.py new file mode 100644 index 00000000000..ea96af03617 --- /dev/null +++ b/tests/components/drop_connect/common.py @@ -0,0 +1,51 @@ +"""Define common test values.""" + +TEST_DATA_HUB_TOPIC = "drop_connect/DROP-1_C0FFEE/255" +TEST_DATA_HUB = ( + '{"curFlow":5.77,"peakFlow":13.8,"usedToday":232.77,"avgUsed":76,"psi":62.2,"psiLow":61,"psiHigh":62,' + '"water":1,"bypass":0,"pMode":"home","battery":50,"notif":1,"leak":0}' +) +TEST_DATA_HUB_RESET = ( + '{"curFlow":0,"peakFlow":0,"usedToday":0,"avgUsed":0,"psi":0,"psiLow":0,"psiHigh":0,' + '"water":0,"bypass":1,"pMode":"away","battery":0,"notif":0,"leak":0}' +) + +TEST_DATA_SALT_TOPIC = "drop_connect/DROP-1_C0FFEE/8" +TEST_DATA_SALT = '{"salt":1}' +TEST_DATA_SALT_RESET = '{"salt":0}' + +TEST_DATA_LEAK_TOPIC = "drop_connect/DROP-1_C0FFEE/20" +TEST_DATA_LEAK = '{"battery":100,"leak":1,"temp":68.2}' +TEST_DATA_LEAK_RESET = '{"battery":0,"leak":0,"temp":0}' + +TEST_DATA_SOFTENER_TOPIC = "drop_connect/DROP-1_C0FFEE/0" +TEST_DATA_SOFTENER = ( + '{"curFlow":5.0,"bypass":0,"battery":20,"capacity":1000,"resInUse":1,"psi":50.5}' +) +TEST_DATA_SOFTENER_RESET = ( + '{"curFlow":0,"bypass":1,"battery":0,"capacity":0,"resInUse":0,"psi":null}' +) + +TEST_DATA_FILTER_TOPIC = "drop_connect/DROP-1_C0FFEE/4" +TEST_DATA_FILTER = '{"curFlow":19.84,"bypass":0,"battery":12,"psi":38.2}' +TEST_DATA_FILTER_RESET = '{"curFlow":0,"bypass":1,"battery":0,"psi":null}' + +TEST_DATA_PROTECTION_VALVE_TOPIC = "drop_connect/DROP-1_C0FFEE/78" +TEST_DATA_PROTECTION_VALVE = ( + '{"curFlow":7.1,"psi":61.3,"water":1,"battery":0,"leak":1,"temp":70.5}' +) +TEST_DATA_PROTECTION_VALVE_RESET = ( + '{"curFlow":0,"psi":0,"water":0,"battery":0,"leak":0,"temp":0}' +) + +TEST_DATA_PUMP_CONTROLLER_TOPIC = "drop_connect/DROP-1_C0FFEE/83" +TEST_DATA_PUMP_CONTROLLER = '{"curFlow":2.2,"psi":62.2,"pump":1,"leak":1,"temp":68.8}' +TEST_DATA_PUMP_CONTROLLER_RESET = '{"curFlow":0,"psi":0,"pump":0,"leak":0,"temp":0}' + +TEST_DATA_RO_FILTER_TOPIC = "drop_connect/DROP-1_C0FFEE/95" +TEST_DATA_RO_FILTER = ( + '{"leak":1,"tdsIn":164,"tdsOut":9,"cart1":59,"cart2":80,"cart3":59}' +) +TEST_DATA_RO_FILTER_RESET = ( + '{"leak":0,"tdsIn":0,"tdsOut":0,"cart1":0,"cart2":0,"cart3":0}' +) diff --git a/tests/components/drop_connect/conftest.py b/tests/components/drop_connect/conftest.py new file mode 100644 index 00000000000..ce68a6f0c13 --- /dev/null +++ b/tests/components/drop_connect/conftest.py @@ -0,0 +1,177 @@ +"""Define fixtures available for all tests.""" +import pytest + +from homeassistant.components.drop_connect.const import ( + CONF_COMMAND_TOPIC, + CONF_DATA_TOPIC, + CONF_DEVICE_DESC, + CONF_DEVICE_ID, + CONF_DEVICE_NAME, + CONF_DEVICE_OWNER_ID, + CONF_DEVICE_TYPE, + CONF_HUB_ID, + DOMAIN, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_entry_hub(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_255", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/255/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/255/#", + CONF_DEVICE_DESC: "Hub", + CONF_DEVICE_ID: 255, + CONF_DEVICE_NAME: "Hub DROP-1_C0FFEE", + CONF_DEVICE_TYPE: "hub", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_salt(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_8", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/8/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/8/#", + CONF_DEVICE_DESC: "Salt Sensor", + CONF_DEVICE_ID: 8, + CONF_DEVICE_NAME: "Salt Sensor", + CONF_DEVICE_TYPE: "salt", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_leak(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_20", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/20/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/20/#", + CONF_DEVICE_DESC: "Leak Detector", + CONF_DEVICE_ID: 20, + CONF_DEVICE_NAME: "Leak Detector", + CONF_DEVICE_TYPE: "leak", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_softener(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_0", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/0/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/0/#", + CONF_DEVICE_DESC: "Softener", + CONF_DEVICE_ID: 0, + CONF_DEVICE_NAME: "Softener", + CONF_DEVICE_TYPE: "soft", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_filter(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_4", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/4/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/4/#", + CONF_DEVICE_DESC: "Filter", + CONF_DEVICE_ID: 4, + CONF_DEVICE_NAME: "Filter", + CONF_DEVICE_TYPE: "filt", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_protection_valve(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_78", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/78/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/78/#", + CONF_DEVICE_DESC: "Protection Valve", + CONF_DEVICE_ID: 78, + CONF_DEVICE_NAME: "Protection Valve", + CONF_DEVICE_TYPE: "pv", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_pump_controller(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_83", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/83/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/83/#", + CONF_DEVICE_DESC: "Pump Controller", + CONF_DEVICE_ID: 83, + CONF_DEVICE_NAME: "Pump Controller", + CONF_DEVICE_TYPE: "pc", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) + + +@pytest.fixture +def config_entry_ro_filter(hass: HomeAssistant): + """Config entry version 1 fixture.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="DROP-1_C0FFEE_255", + data={ + CONF_COMMAND_TOPIC: "drop_connect/DROP-1_C0FFEE/95/cmd", + CONF_DATA_TOPIC: "drop_connect/DROP-1_C0FFEE/95/#", + CONF_DEVICE_DESC: "RO Filter", + CONF_DEVICE_ID: 95, + CONF_DEVICE_NAME: "RO Filter", + CONF_DEVICE_TYPE: "ro", + CONF_HUB_ID: "DROP-1_C0FFEE", + CONF_DEVICE_OWNER_ID: "DROP-1_C0FFEE_255", + }, + version=1, + ) diff --git a/tests/components/drop_connect/test_binary_sensor.py b/tests/components/drop_connect/test_binary_sensor.py new file mode 100644 index 00000000000..ca94faeec5e --- /dev/null +++ b/tests/components/drop_connect/test_binary_sensor.py @@ -0,0 +1,192 @@ +"""Test DROP binary sensor entities.""" + +from homeassistant.components.drop_connect.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import ( + TEST_DATA_HUB, + TEST_DATA_HUB_RESET, + TEST_DATA_HUB_TOPIC, + TEST_DATA_LEAK, + TEST_DATA_LEAK_RESET, + TEST_DATA_LEAK_TOPIC, + TEST_DATA_PROTECTION_VALVE, + TEST_DATA_PROTECTION_VALVE_RESET, + TEST_DATA_PROTECTION_VALVE_TOPIC, + TEST_DATA_PUMP_CONTROLLER, + TEST_DATA_PUMP_CONTROLLER_RESET, + TEST_DATA_PUMP_CONTROLLER_TOPIC, + TEST_DATA_RO_FILTER, + TEST_DATA_RO_FILTER_RESET, + TEST_DATA_RO_FILTER_TOPIC, + TEST_DATA_SALT, + TEST_DATA_SALT_RESET, + TEST_DATA_SALT_TOPIC, + TEST_DATA_SOFTENER, + TEST_DATA_SOFTENER_RESET, + TEST_DATA_SOFTENER_TOPIC, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_binary_sensors_hub( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for hubs.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + pending_notifications_sensor_name = ( + "binary_sensor.hub_drop_1_c0ffee_notification_unread" + ) + hass.states.async_set(pending_notifications_sensor_name, STATE_UNKNOWN) + leak_sensor_name = "binary_sensor.hub_drop_1_c0ffee_leak_detected" + hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + pending_notifications = hass.states.get(pending_notifications_sensor_name) + assert pending_notifications.state == STATE_ON + + leak = hass.states.get(leak_sensor_name) + assert leak.state == STATE_OFF + + +async def test_binary_sensors_salt( + hass: HomeAssistant, config_entry_salt, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for salt sensors.""" + config_entry_salt.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + salt_sensor_name = "binary_sensor.salt_sensor_salt_low" + hass.states.async_set(salt_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_SALT_TOPIC, TEST_DATA_SALT_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_SALT_TOPIC, TEST_DATA_SALT) + await hass.async_block_till_done() + + salt = hass.states.get(salt_sensor_name) + assert salt.state == STATE_ON + + +async def test_binary_sensors_leak( + hass: HomeAssistant, config_entry_leak, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for leak detectors.""" + config_entry_leak.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + leak_sensor_name = "binary_sensor.leak_detector_leak_detected" + hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK) + await hass.async_block_till_done() + + leak = hass.states.get(leak_sensor_name) + assert leak.state == STATE_ON + + +async def test_binary_sensors_softener( + hass: HomeAssistant, config_entry_softener, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for softeners.""" + config_entry_softener.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + reserve_in_use_sensor_name = "binary_sensor.softener_reserve_capacity_in_use" + hass.states.async_set(reserve_in_use_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) + await hass.async_block_till_done() + + reserve_in_use = hass.states.get(reserve_in_use_sensor_name) + assert reserve_in_use.state == STATE_ON + + +async def test_binary_sensors_protection_valve( + hass: HomeAssistant, config_entry_protection_valve, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for protection valves.""" + config_entry_protection_valve.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + leak_sensor_name = "binary_sensor.protection_valve_leak_detected" + hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + ) + await hass.async_block_till_done() + + leak = hass.states.get(leak_sensor_name) + assert leak.state == STATE_ON + + +async def test_binary_sensors_pump_controller( + hass: HomeAssistant, config_entry_pump_controller, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for pump controllers.""" + config_entry_pump_controller.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + leak_sensor_name = "binary_sensor.pump_controller_leak_detected" + hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + pump_sensor_name = "binary_sensor.pump_controller_pump_status" + hass.states.async_set(pump_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message( + hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER_RESET + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER + ) + await hass.async_block_till_done() + + leak = hass.states.get(leak_sensor_name) + assert leak.state == STATE_ON + pump = hass.states.get(pump_sensor_name) + assert pump.state == STATE_ON + + +async def test_binary_sensors_ro_filter( + hass: HomeAssistant, config_entry_ro_filter, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for RO filters.""" + config_entry_ro_filter.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + leak_sensor_name = "binary_sensor.ro_filter_leak_detected" + hass.states.async_set(leak_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER) + await hass.async_block_till_done() + + leak = hass.states.get(leak_sensor_name) + assert leak.state == STATE_ON diff --git a/tests/components/drop_connect/test_config_flow.py b/tests/components/drop_connect/test_config_flow.py new file mode 100644 index 00000000000..fb727d2c7fd --- /dev/null +++ b/tests/components/drop_connect/test_config_flow.py @@ -0,0 +1,178 @@ +"""Test config flow.""" +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.mqtt import MqttServiceInfo + +from tests.typing import MqttMockHAClient + + +async def test_mqtt_setup(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload='{"devDesc":"Hub","devType":"hub","name":"Hub DROP-1_C0FFEE"}', + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + assert result is not None + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "drop_command_topic": "drop_connect/DROP-1_C0FFEE/cmd/255", + "drop_data_topic": "drop_connect/DROP-1_C0FFEE/data/255/#", + "device_desc": "Hub", + "device_id": "255", + "name": "Hub DROP-1_C0FFEE", + "device_type": "hub", + "drop_hub_id": "DROP-1_C0FFEE", + "drop_device_owner_id": "DROP-1_C0FFEE_255", + } + + +async def test_duplicate(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload='{"devDesc":"Hub","devType":"hub","name":"Hub DROP-1_C0FFEE"}', + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + assert result is not None + assert result["type"] == FlowResultType.CREATE_ENTRY + + # Attempting configuration of the same object should abort + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_incomplete_payload( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload='{"devDesc":"Hub"}', + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_bad_json( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload="{BAD JSON}", + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_bad_topic( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/FOO", + payload=('{"devDesc":"Hub","devType":"hub","name":"Hub DROP-1_C0FFEE"}'), + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_mqtt_setup_no_payload( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient +) -> None: + """Test we can finish a config flow through MQTT with custom prefix.""" + discovery_info = MqttServiceInfo( + topic="drop_connect/discovery/DROP-1_C0FFEE/255", + payload="", + qos=0, + retain=False, + subscribed_topic="drop_connect/discovery/#", + timestamp=None, + ) + result = await hass.config_entries.flow.async_init( + "drop_connect", + context={"source": config_entries.SOURCE_MQTT}, + data=discovery_info, + ) + assert result is not None + assert result["type"] == "abort" + assert result["reason"] == "invalid_discovery_info" + + +async def test_user_setup(hass: HomeAssistant) -> None: + """Test user setup.""" + result = await hass.config_entries.flow.async_init( + "drop_connect", context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "abort" + assert result["reason"] == "not_supported" diff --git a/tests/components/drop_connect/test_coordinator.py b/tests/components/drop_connect/test_coordinator.py new file mode 100644 index 00000000000..50f2633e241 --- /dev/null +++ b/tests/components/drop_connect/test_coordinator.py @@ -0,0 +1,74 @@ +"""Test DROP coordinator.""" +from homeassistant.components.drop_connect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import TEST_DATA_HUB, TEST_DATA_HUB_RESET, TEST_DATA_HUB_TOPIC + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_bad_json( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test bad JSON.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, "{BAD JSON}") + await hass.async_block_till_done() + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert current_flow_sensor.state == STATE_UNKNOWN + + +async def test_unload( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test entity unload.""" + # Load the hub device + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 5.8 + + # Unload the device + await hass.config_entries.async_unload(config_entry_hub.entry_id) + await hass.async_block_till_done() + + assert config_entry_hub.state is ConfigEntryState.NOT_LOADED + + # Verify sensor is unavailable + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert current_flow_sensor.state == STATE_UNAVAILABLE + + +async def test_no_mqtt(hass: HomeAssistant, config_entry_hub) -> None: + """Test no MQTT.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + protect_mode_select_name = "select.hub_drop_1_c0ffee_protect_mode" + protect_mode_select = hass.states.get(protect_mode_select_name) + assert protect_mode_select is None diff --git a/tests/components/drop_connect/test_select.py b/tests/components/drop_connect/test_select.py new file mode 100644 index 00000000000..24877069367 --- /dev/null +++ b/tests/components/drop_connect/test_select.py @@ -0,0 +1,59 @@ +"""Test DROP select entities.""" + +from homeassistant.components.drop_connect.const import DOMAIN +from homeassistant.components.select import ( + ATTR_OPTION, + ATTR_OPTIONS, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import TEST_DATA_HUB, TEST_DATA_HUB_RESET, TEST_DATA_HUB_TOPIC + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_selects_hub( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP binary sensors for hubs.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + protect_mode_select_name = "select.hub_drop_1_c0ffee_protect_mode" + protect_mode_select = hass.states.get(protect_mode_select_name) + assert protect_mode_select + assert protect_mode_select.attributes.get(ATTR_OPTIONS) == [ + "away", + "home", + "schedule", + ] + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + protect_mode_select = hass.states.get(protect_mode_select_name) + assert protect_mode_select + assert protect_mode_select.state == "home" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_OPTION: "away", ATTR_ENTITY_ID: protect_mode_select_name}, + blocking=True, + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + + protect_mode_select = hass.states.get(protect_mode_select_name) + assert protect_mode_select + assert protect_mode_select.state == "away" diff --git a/tests/components/drop_connect/test_sensor.py b/tests/components/drop_connect/test_sensor.py new file mode 100644 index 00000000000..589fd08488c --- /dev/null +++ b/tests/components/drop_connect/test_sensor.py @@ -0,0 +1,319 @@ +"""Test DROP sensor entities.""" +from homeassistant.components.drop_connect.const import DOMAIN +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import ( + TEST_DATA_FILTER, + TEST_DATA_FILTER_RESET, + TEST_DATA_FILTER_TOPIC, + TEST_DATA_HUB, + TEST_DATA_HUB_RESET, + TEST_DATA_HUB_TOPIC, + TEST_DATA_LEAK, + TEST_DATA_LEAK_RESET, + TEST_DATA_LEAK_TOPIC, + TEST_DATA_PROTECTION_VALVE, + TEST_DATA_PROTECTION_VALVE_RESET, + TEST_DATA_PROTECTION_VALVE_TOPIC, + TEST_DATA_PUMP_CONTROLLER, + TEST_DATA_PUMP_CONTROLLER_RESET, + TEST_DATA_PUMP_CONTROLLER_TOPIC, + TEST_DATA_RO_FILTER, + TEST_DATA_RO_FILTER_RESET, + TEST_DATA_RO_FILTER_TOPIC, + TEST_DATA_SOFTENER, + TEST_DATA_SOFTENER_RESET, + TEST_DATA_SOFTENER_TOPIC, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_sensors_hub( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for hubs.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + current_flow_sensor_name = "sensor.hub_drop_1_c0ffee_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + peak_flow_sensor_name = "sensor.hub_drop_1_c0ffee_peak_water_flow_rate_today" + hass.states.async_set(peak_flow_sensor_name, STATE_UNKNOWN) + used_today_sensor_name = "sensor.hub_drop_1_c0ffee_total_water_used_today" + hass.states.async_set(used_today_sensor_name, STATE_UNKNOWN) + average_usage_sensor_name = "sensor.hub_drop_1_c0ffee_average_daily_water_usage" + hass.states.async_set(average_usage_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.hub_drop_1_c0ffee_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + psi_high_sensor_name = "sensor.hub_drop_1_c0ffee_high_water_pressure_today" + hass.states.async_set(psi_high_sensor_name, STATE_UNKNOWN) + psi_low_sensor_name = "sensor.hub_drop_1_c0ffee_low_water_pressure_today" + hass.states.async_set(psi_low_sensor_name, STATE_UNKNOWN) + battery_sensor_name = "sensor.hub_drop_1_c0ffee_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 5.8 + + peak_flow_sensor = hass.states.get(peak_flow_sensor_name) + assert peak_flow_sensor + assert round(float(peak_flow_sensor.state), 1) == 13.8 + + used_today_sensor = hass.states.get(used_today_sensor_name) + assert used_today_sensor + assert round(float(used_today_sensor.state), 1) == 881.1 # liters + + average_usage_sensor = hass.states.get(average_usage_sensor_name) + assert average_usage_sensor + assert round(float(average_usage_sensor.state), 1) == 287.7 # liters + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 428.9 # centibars + + psi_high_sensor = hass.states.get(psi_high_sensor_name) + assert psi_high_sensor + assert round(float(psi_high_sensor.state), 1) == 427.5 # centibars + + psi_low_sensor = hass.states.get(psi_low_sensor_name) + assert psi_low_sensor + assert round(float(psi_low_sensor.state), 1) == 420.6 # centibars + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert int(battery_sensor.state) == 50 + + +async def test_sensors_leak( + hass: HomeAssistant, config_entry_leak, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for leak detectors.""" + config_entry_leak.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + battery_sensor_name = "sensor.leak_detector_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + temp_sensor_name = "sensor.leak_detector_temperature" + hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_LEAK_TOPIC, TEST_DATA_LEAK) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert int(battery_sensor.state) == 100 + + temp_sensor = hass.states.get(temp_sensor_name) + assert temp_sensor + assert round(float(temp_sensor.state), 1) == 20.1 # C + + +async def test_sensors_softener( + hass: HomeAssistant, config_entry_softener, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for softeners.""" + config_entry_softener.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + battery_sensor_name = "sensor.softener_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + current_flow_sensor_name = "sensor.softener_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.softener_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + capacity_sensor_name = "sensor.softener_capacity_remaining" + hass.states.async_set(capacity_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert int(battery_sensor.state) == 20 + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 5.0 + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 348.2 # centibars + + capacity_sensor = hass.states.get(capacity_sensor_name) + assert capacity_sensor + assert round(float(capacity_sensor.state), 1) == 3785.4 # liters + + +async def test_sensors_filter( + hass: HomeAssistant, config_entry_filter, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for filters.""" + config_entry_filter.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + battery_sensor_name = "sensor.filter_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + current_flow_sensor_name = "sensor.filter_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.filter_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert round(float(battery_sensor.state), 1) == 12.0 + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 19.8 + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 263.4 # centibars + + +async def test_sensors_protection_valve( + hass: HomeAssistant, config_entry_protection_valve, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for protection valves.""" + config_entry_protection_valve.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + battery_sensor_name = "sensor.protection_valve_battery" + hass.states.async_set(battery_sensor_name, STATE_UNKNOWN) + current_flow_sensor_name = "sensor.protection_valve_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.protection_valve_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + temp_sensor_name = "sensor.protection_valve_temperature" + hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + ) + await hass.async_block_till_done() + + battery_sensor = hass.states.get(battery_sensor_name) + assert battery_sensor + assert int(battery_sensor.state) == 0 + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 7.1 + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 422.6 # centibars + + temp_sensor = hass.states.get(temp_sensor_name) + assert temp_sensor + assert round(float(temp_sensor.state), 1) == 21.4 # C + + +async def test_sensors_pump_controller( + hass: HomeAssistant, config_entry_pump_controller, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for pump controllers.""" + config_entry_pump_controller.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + current_flow_sensor_name = "sensor.pump_controller_water_flow_rate" + hass.states.async_set(current_flow_sensor_name, STATE_UNKNOWN) + psi_sensor_name = "sensor.pump_controller_current_water_pressure" + hass.states.async_set(psi_sensor_name, STATE_UNKNOWN) + temp_sensor_name = "sensor.pump_controller_temperature" + hass.states.async_set(temp_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message( + hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER_RESET + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, TEST_DATA_PUMP_CONTROLLER_TOPIC, TEST_DATA_PUMP_CONTROLLER + ) + await hass.async_block_till_done() + + current_flow_sensor = hass.states.get(current_flow_sensor_name) + assert current_flow_sensor + assert round(float(current_flow_sensor.state), 1) == 2.2 + + psi_sensor = hass.states.get(psi_sensor_name) + assert psi_sensor + assert round(float(psi_sensor.state), 1) == 428.9 # centibars + + temp_sensor = hass.states.get(temp_sensor_name) + assert temp_sensor + assert round(float(temp_sensor.state), 1) == 20.4 # C + + +async def test_sensors_ro_filter( + hass: HomeAssistant, config_entry_ro_filter, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP sensors for RO filters.""" + config_entry_ro_filter.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + tds_in_sensor_name = "sensor.ro_filter_inlet_tds" + hass.states.async_set(tds_in_sensor_name, STATE_UNKNOWN) + tds_out_sensor_name = "sensor.ro_filter_outlet_tds" + hass.states.async_set(tds_out_sensor_name, STATE_UNKNOWN) + cart1_sensor_name = "sensor.ro_filter_cartridge_1_life_remaining" + hass.states.async_set(cart1_sensor_name, STATE_UNKNOWN) + cart2_sensor_name = "sensor.ro_filter_cartridge_2_life_remaining" + hass.states.async_set(cart2_sensor_name, STATE_UNKNOWN) + cart3_sensor_name = "sensor.ro_filter_cartridge_3_life_remaining" + hass.states.async_set(cart3_sensor_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_RO_FILTER_TOPIC, TEST_DATA_RO_FILTER) + await hass.async_block_till_done() + + tds_in_sensor = hass.states.get(tds_in_sensor_name) + assert tds_in_sensor + assert int(tds_in_sensor.state) == 164 + + tds_out_sensor = hass.states.get(tds_out_sensor_name) + assert tds_out_sensor + assert int(tds_out_sensor.state) == 9 + + cart1_sensor = hass.states.get(cart1_sensor_name) + assert cart1_sensor + assert int(cart1_sensor.state) == 59 + + cart2_sensor = hass.states.get(cart2_sensor_name) + assert cart2_sensor + assert int(cart2_sensor.state) == 80 + + cart3_sensor = hass.states.get(cart3_sensor_name) + assert cart3_sensor + assert int(cart3_sensor.state) == 59 diff --git a/tests/components/drop_connect/test_switch.py b/tests/components/drop_connect/test_switch.py new file mode 100644 index 00000000000..d7d954915c6 --- /dev/null +++ b/tests/components/drop_connect/test_switch.py @@ -0,0 +1,275 @@ +"""Test DROP switch entities.""" + +from homeassistant.components.drop_connect.const import DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import ( + TEST_DATA_FILTER, + TEST_DATA_FILTER_RESET, + TEST_DATA_FILTER_TOPIC, + TEST_DATA_HUB, + TEST_DATA_HUB_RESET, + TEST_DATA_HUB_TOPIC, + TEST_DATA_PROTECTION_VALVE, + TEST_DATA_PROTECTION_VALVE_RESET, + TEST_DATA_PROTECTION_VALVE_TOPIC, + TEST_DATA_SOFTENER, + TEST_DATA_SOFTENER_RESET, + TEST_DATA_SOFTENER_TOPIC, +) + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def test_switches_hub( + hass: HomeAssistant, config_entry_hub, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP switches for hubs.""" + config_entry_hub.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + water_supply_switch_name = "switch.hub_drop_1_c0ffee_water_supply" + hass.states.async_set(water_supply_switch_name, STATE_UNKNOWN) + bypass_switch_name = "switch.hub_drop_1_c0ffee_treatment_bypass" + hass.states.async_set(bypass_switch_name, STATE_UNKNOWN) + + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + water_supply_switch = hass.states.get(water_supply_switch_name) + assert water_supply_switch + assert water_supply_switch.state == STATE_ON + + # Test switch turn off method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: water_supply_switch_name}, + blocking=True, + ) + + # Simulate response from the hub + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + + water_supply_switch = hass.states.get(water_supply_switch_name) + assert water_supply_switch + assert water_supply_switch.state == STATE_OFF + + # Test switch turn on method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: water_supply_switch_name}, + blocking=True, + ) + + # Simulate response from the hub + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + water_supply_switch = hass.states.get(water_supply_switch_name) + assert water_supply_switch + assert water_supply_switch.state == STATE_ON + + bypass_switch = hass.states.get(bypass_switch_name) + assert bypass_switch + assert bypass_switch.state == STATE_OFF + + # Test switch turn on method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB_RESET) + await hass.async_block_till_done() + + bypass_switch = hass.states.get(bypass_switch_name) + assert bypass_switch + assert bypass_switch.state == STATE_ON + + # Test switch turn off method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_HUB_TOPIC, TEST_DATA_HUB) + await hass.async_block_till_done() + + bypass_switch = hass.states.get(bypass_switch_name) + assert bypass_switch + assert bypass_switch.state == STATE_OFF + + +async def test_switches_protection_valve( + hass: HomeAssistant, config_entry_protection_valve, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP switches for protection valves.""" + config_entry_protection_valve.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + ) + await hass.async_block_till_done() + + water_supply_switch_name = "switch.protection_valve_water_supply" + hass.states.async_set(water_supply_switch_name, STATE_UNKNOWN) + + # Test switch turn off method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: water_supply_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE_RESET + ) + await hass.async_block_till_done() + + water_supply_switch = hass.states.get(water_supply_switch_name) + assert water_supply_switch + assert water_supply_switch.state == STATE_OFF + + # Test switch turn on method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: water_supply_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message( + hass, TEST_DATA_PROTECTION_VALVE_TOPIC, TEST_DATA_PROTECTION_VALVE + ) + await hass.async_block_till_done() + + water_supply_switch = hass.states.get(water_supply_switch_name) + assert water_supply_switch + assert water_supply_switch.state == STATE_ON + + +async def test_switches_softener( + hass: HomeAssistant, config_entry_softener, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP switches for softeners.""" + config_entry_softener.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) + await hass.async_block_till_done() + + bypass_switch_name = "switch.softener_treatment_bypass" + hass.states.async_set(bypass_switch_name, STATE_UNKNOWN) + + # Test switch turn on method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER_RESET) + await hass.async_block_till_done() + + bypass_switch = hass.states.get(bypass_switch_name) + assert bypass_switch + assert bypass_switch.state == STATE_ON + + # Test switch turn off method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_SOFTENER_TOPIC, TEST_DATA_SOFTENER) + await hass.async_block_till_done() + + bypass_switch = hass.states.get(bypass_switch_name) + assert bypass_switch + assert bypass_switch.state == STATE_OFF + + +async def test_switches_filter( + hass: HomeAssistant, config_entry_filter, mqtt_mock: MqttMockHAClient +) -> None: + """Test DROP switches for filters.""" + config_entry_filter.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER_RESET) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER) + await hass.async_block_till_done() + + bypass_switch_name = "switch.filter_treatment_bypass" + hass.states.async_set(bypass_switch_name, STATE_UNKNOWN) + + # Test switch turn on method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER_RESET) + await hass.async_block_till_done() + + bypass_switch = hass.states.get(bypass_switch_name) + assert bypass_switch + assert bypass_switch.state == STATE_ON + + # Test switch turn off method. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: bypass_switch_name}, + blocking=True, + ) + + # Simulate response from the device + async_fire_mqtt_message(hass, TEST_DATA_FILTER_TOPIC, TEST_DATA_FILTER) + await hass.async_block_till_done() + + bypass_switch = hass.states.get(bypass_switch_name) + assert bypass_switch + assert bypass_switch.state == STATE_OFF diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 5c34fbd9e35..422bfa0c35c 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -335,7 +335,7 @@ async def test_setup_serial_fail( # override the mock to have it fail the first time and succeed after first_fail_connection_factory = AsyncMock( return_value=(transport, protocol), - side_effect=chain([serial.serialutil.SerialException], repeat(DEFAULT)), + side_effect=chain([serial.SerialException], repeat(DEFAULT)), ) assert result["type"] == "form" @@ -474,7 +474,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, } entry = MockConfigEntry( diff --git a/tests/components/dsmr/test_init.py b/tests/components/dsmr/test_init.py index 231cd65d768..b42f26f4ccc 100644 --- a/tests/components/dsmr/test_init.py +++ b/tests/components/dsmr/test_init.py @@ -98,7 +98,6 @@ async def test_migrate_unique_id( data={ "port": "/dev/ttyUSB0", "dsmr_version": dsmr_version, - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", }, diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index 99513b9a2a8..5e31fa7a82e 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -29,7 +29,6 @@ async def test_migrate_gas_to_mbus( data={ "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, @@ -126,7 +125,6 @@ async def test_migrate_gas_to_mbus_exists( data={ "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": "37464C4F32313139303333373331", }, diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index d3bfabdc0c6..419b562f431 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -51,7 +51,6 @@ async def test_default_setup( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -188,7 +187,6 @@ async def test_setup_only_energy( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", } entry_options = { @@ -243,7 +241,6 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "4", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -331,7 +328,6 @@ async def test_v5_meter( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -406,7 +402,6 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5L", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -509,7 +504,6 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": None, } @@ -710,7 +704,6 @@ async def test_belgian_meter_alt(hass: HomeAssistant, dsmr_connection_fixture) - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": None, } @@ -872,7 +865,6 @@ async def test_belgian_meter_mbus(hass: HomeAssistant, dsmr_connection_fixture) entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": None, } @@ -983,7 +975,6 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5B", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1037,7 +1028,6 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "5S", - "precision": 4, "serial_id": None, "serial_id_gas": None, } @@ -1111,7 +1101,6 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "Q3D", - "precision": 4, "serial_id": None, "serial_id_gas": None, } @@ -1151,7 +1140,7 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: await hass.async_block_till_done() active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total") - assert active_tariff.state == "54184.6316" + assert active_tariff.state == "54184.632" assert active_tariff.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert active_tariff.attributes.get(ATTR_ICON) is None assert ( @@ -1164,7 +1153,7 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: ) active_tariff = hass.states.get("sensor.electricity_meter_energy_production_total") - assert active_tariff.state == "19981.1069" + assert active_tariff.state == "19981.107" assert ( active_tariff.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING @@ -1184,7 +1173,6 @@ async def test_tcp(hass: HomeAssistant, dsmr_connection_fixture) -> None: "port": "1234", "dsmr_version": "2.2", "protocol": "dsmr_protocol", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1211,7 +1199,6 @@ async def test_rfxtrx_tcp(hass: HomeAssistant, rfxtrx_dsmr_connection_fixture) - "port": "1234", "dsmr_version": "2.2", "protocol": "rfxtrx_dsmr_protocol", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1239,7 +1226,6 @@ async def test_connection_errors_retry( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1282,7 +1268,6 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } @@ -1364,7 +1349,6 @@ async def test_gas_meter_providing_energy_reading( entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", - "precision": 4, "serial_id": "1234", "serial_id_gas": "5678", } diff --git a/tests/components/easyenergy/snapshots/test_services.ambr b/tests/components/easyenergy/snapshots/test_services.ambr new file mode 100644 index 00000000000..96b1eca5498 --- /dev/null +++ b/tests/components/easyenergy/snapshots/test_services.ambr @@ -0,0 +1,5590 @@ +# serializer version: 1 +# name: test_service[end0-start0-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat2-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat2-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat2-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start1-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start1-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end0-start2-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat2-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat2-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start2-incl_vat2-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start0-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start0-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start1-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat0-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat0-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat0-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat1-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat1-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat1-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end1-start2-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start0-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat2-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat2-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start0-incl_vat2-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start1-incl_vat0-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat0-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat0-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat1-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat1-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat1-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat2-get_energy_return_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat2-get_energy_usage_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start1-incl_vat2-get_gas_prices] + ServiceValidationError() +# --- +# name: test_service[end2-start2-incl_vat0-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat0-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat1-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat1-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat2-get_energy_return_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.11153, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.10698, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.10497, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.10172, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.10723, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.11462, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.11894, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.1599, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.164, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.17169, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.13635, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.1296, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.15487, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.16049, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.17596, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.18629, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.20394, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.19757, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.17143, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.15, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.14841, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.14934, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.139, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat2-get_energy_usage_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.13495, + 'timestamp': '2023-01-18 23:00:00+00:00', + }), + dict({ + 'price': 0.12945, + 'timestamp': '2023-01-19 00:00:00+00:00', + }), + dict({ + 'price': 0.12701, + 'timestamp': '2023-01-19 01:00:00+00:00', + }), + dict({ + 'price': 0.12308, + 'timestamp': '2023-01-19 02:00:00+00:00', + }), + dict({ + 'price': 0.12975, + 'timestamp': '2023-01-19 03:00:00+00:00', + }), + dict({ + 'price': 0.13869, + 'timestamp': '2023-01-19 04:00:00+00:00', + }), + dict({ + 'price': 0.14392, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.19348, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.19844, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.20774, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.16498, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.15682, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.18739, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.19419, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.21291, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.22541, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.24677, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.23906, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.20743, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.1815, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.17958, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.1807, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.16819, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end2-start2-incl_vat2-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 05:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 06:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 07:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 08:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 09:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 10:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 11:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 12:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 13:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 14:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 15:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 16:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 17:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 18:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 19:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 20:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 21:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 22:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-19 23:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 00:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 01:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 02:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 03:00:00+00:00', + }), + dict({ + 'price': 0.7253, + 'timestamp': '2023-01-20 04:00:00+00:00', + }), + ]), + }) +# --- diff --git a/tests/components/easyenergy/test_services.py b/tests/components/easyenergy/test_services.py new file mode 100644 index 00000000000..603768237f1 --- /dev/null +++ b/tests/components/easyenergy/test_services.py @@ -0,0 +1,151 @@ +"""Tests for the services provided by the easyEnergy integration.""" + +import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol + +from homeassistant.components.easyenergy.const import DOMAIN +from homeassistant.components.easyenergy.services import ( + ATTR_CONFIG_ENTRY, + ENERGY_RETURN_SERVICE_NAME, + ENERGY_USAGE_SERVICE_NAME, + GAS_SERVICE_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("init_integration") +async def test_has_services( + hass: HomeAssistant, +) -> None: + """Test the existence of the easyEnergy Service.""" + assert hass.services.has_service(DOMAIN, GAS_SERVICE_NAME) + assert hass.services.has_service(DOMAIN, ENERGY_USAGE_SERVICE_NAME) + assert hass.services.has_service(DOMAIN, ENERGY_RETURN_SERVICE_NAME) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "service", + [ + GAS_SERVICE_NAME, + ENERGY_USAGE_SERVICE_NAME, + ENERGY_RETURN_SERVICE_NAME, + ], +) +@pytest.mark.parametrize("incl_vat", [{"incl_vat": False}, {"incl_vat": True}]) +@pytest.mark.parametrize("start", [{"start": "2023-01-01 00:00:00"}, {}]) +@pytest.mark.parametrize("end", [{"end": "2023-01-01 00:00:00"}, {}]) +async def test_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + service: str, + incl_vat: dict[str, bool], + start: dict[str, str], + end: dict[str, str], +) -> None: + """Test the EnergyZero Service.""" + entry = {ATTR_CONFIG_ENTRY: mock_config_entry.entry_id} + + data = entry | incl_vat | start | end + + assert snapshot == await hass.services.async_call( + DOMAIN, + service, + data, + blocking=True, + return_response=True, + ) + + +@pytest.fixture +def config_entry_data( + mock_config_entry: MockConfigEntry, request: pytest.FixtureRequest +) -> dict[str, str]: + """Fixture for the config entry.""" + if "config_entry" in request.param and request.param["config_entry"] is True: + return {"config_entry": mock_config_entry.entry_id} + + return request.param + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize( + "service", + [ + GAS_SERVICE_NAME, + ENERGY_USAGE_SERVICE_NAME, + ENERGY_RETURN_SERVICE_NAME, + ], +) +@pytest.mark.parametrize( + ("config_entry_data", "service_data", "error", "error_message"), + [ + ({}, {}, vol.er.Error, "required key not provided .+"), + ( + {"config_entry": True}, + {}, + vol.er.Error, + "required key not provided .+", + ), + ( + {}, + {"incl_vat": True}, + vol.er.Error, + "required key not provided .+", + ), + ( + {"config_entry": True}, + {"incl_vat": "incorrect vat"}, + vol.er.Error, + "expected bool for dictionary value .+", + ), + ( + {"config_entry": "incorrect entry"}, + {"incl_vat": True}, + ServiceValidationError, + "Invalid config entry.+", + ), + ( + {"config_entry": True}, + { + "incl_vat": True, + "start": "incorrect date", + }, + ServiceValidationError, + "Invalid datetime provided.", + ), + ( + {"config_entry": True}, + { + "incl_vat": True, + "end": "incorrect date", + }, + ServiceValidationError, + "Invalid datetime provided.", + ), + ], + indirect=["config_entry_data"], +) +async def test_service_validation( + hass: HomeAssistant, + service: str, + config_entry_data: dict[str, str], + service_data: dict[str, str | bool], + error: type[Exception], + error_message: str, +) -> None: + """Test the easyEnergy Service.""" + + with pytest.raises(error, match=error_message): + await hass.services.async_call( + DOMAIN, + service, + config_entry_data | service_data, + blocking=True, + return_response=True, + ) diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py index 45261d45933..afeb5f6e382 100644 --- a/tests/components/efergy/test_sensor.py +++ b/tests/components/efergy/test_sensor.py @@ -1,7 +1,8 @@ """The tests for Efergy sensor platform.""" from datetime import timedelta -from homeassistant.components.efergy.sensor import SENSOR_TYPES +import pytest + from homeassistant.components.sensor import ( ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, @@ -25,15 +26,18 @@ from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default): + """Make sure all entities are enabled.""" + + async def test_sensor_readings( hass: HomeAssistant, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, ) -> None: """Test for successfully setting up the Efergy platform.""" - for description in SENSOR_TYPES: - description.entity_registry_enabled_default = True - entry = await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN) + await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN) state = hass.states.get("sensor.efergy_power_usage") assert state.state == "1580" @@ -85,11 +89,6 @@ async def test_sensor_readings( assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.MONETARY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "EUR" assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - entity = entity_registry.async_get("sensor.efergy_power_usage_728386") - assert entity.disabled_by is er.RegistryEntryDisabler.INTEGRATION - entity_registry.async_update_entity(entity.entity_id, **{"disabled_by": None}) - await hass.config_entries.async_reload(entry.entry_id) - await hass.async_block_till_done() state = hass.states.get("sensor.efergy_power_usage_728386") assert state.state == "1628" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER @@ -101,8 +100,6 @@ async def test_multi_sensor_readings( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test for multiple sensors in one household.""" - for description in SENSOR_TYPES: - description.entity_registry_enabled_default = True await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN, MULTI_SENSOR_TOKEN) state = hass.states.get("sensor.efergy_power_usage_728386") assert state.state == "218" diff --git a/tests/components/elgato/snapshots/test_config_flow.ambr b/tests/components/elgato/snapshots/test_config_flow.ambr index 46180994e61..39202d383fa 100644 --- a/tests/components/elgato/snapshots/test_config_flow.ambr +++ b/tests/components/elgato/snapshots/test_config_flow.ambr @@ -14,6 +14,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'elgato', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -25,6 +26,7 @@ 'disabled_by': None, 'domain': 'elgato', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -55,6 +57,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'elgato', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -66,6 +69,7 @@ 'disabled_by': None, 'domain': 'elgato', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -95,6 +99,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'elgato', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -106,6 +111,7 @@ 'disabled_by': None, 'domain': 'elgato', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 98f99349cac..167562578f2 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -95,6 +95,8 @@ ENTITY_IDS_BY_NUMBER = { "24": "media_player.kitchen", "25": "light.office_rgbw_lights", "26": "light.living_room_rgbww_lights", + "27": "media_player.group", + "28": "media_player.browse", } ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()} @@ -1017,6 +1019,12 @@ async def test_set_position_cover(hass_hue, hue_client) -> None: cover_test = hass_hue.states.get(cover_id) assert cover_test.state == "closed" + cover_json = await perform_get_light_state( + hue_client, "cover.living_room_window", HTTPStatus.OK + ) + assert cover_json["state"][HUE_API_STATE_ON] is False + assert cover_json["state"][HUE_API_STATE_BRI] == 1 + level = 20 brightness = round(level / 100 * 254) @@ -1093,6 +1101,7 @@ async def test_put_light_state_fan(hass_hue, hue_client) -> None: fan_json = await perform_get_light_state( hue_client, "fan.living_room_fan", HTTPStatus.OK ) + assert fan_json["state"][HUE_API_STATE_ON] is True assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 33 await perform_put_light_state( @@ -1110,6 +1119,7 @@ async def test_put_light_state_fan(hass_hue, hue_client) -> None: fan_json = await perform_get_light_state( hue_client, "fan.living_room_fan", HTTPStatus.OK ) + assert fan_json["state"][HUE_API_STATE_ON] is True assert ( round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 66 ) # small rounding error in inverse operation @@ -1130,8 +1140,27 @@ async def test_put_light_state_fan(hass_hue, hue_client) -> None: fan_json = await perform_get_light_state( hue_client, "fan.living_room_fan", HTTPStatus.OK ) + assert fan_json["state"][HUE_API_STATE_ON] is True assert round(fan_json["state"][HUE_API_STATE_BRI] * 100 / 254) == 100 + await perform_put_light_state( + hass_hue, + hue_client, + "fan.living_room_fan", + False, + brightness=0, + ) + assert ( + hass_hue.states.get("fan.living_room_fan").attributes[fan.ATTR_PERCENTAGE] == 0 + ) + with patch.object(hue_api, "STATE_CACHED_TIMEOUT", 0.000001): + await asyncio.sleep(0.000001) + fan_json = await perform_get_light_state( + hue_client, "fan.living_room_fan", HTTPStatus.OK + ) + assert fan_json["state"][HUE_API_STATE_ON] is False + assert fan_json["state"][HUE_API_STATE_BRI] == 1 + async def test_put_with_form_urlencoded_content_type(hass_hue, hue_client) -> None: """Test the form with urlencoded content.""" diff --git a/tests/components/energyzero/snapshots/test_config_flow.ambr b/tests/components/energyzero/snapshots/test_config_flow.ambr index 68c46a705d7..9b4b3bfc635 100644 --- a/tests/components/energyzero/snapshots/test_config_flow.ambr +++ b/tests/components/energyzero/snapshots/test_config_flow.ambr @@ -11,6 +11,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'energyzero', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -19,6 +20,7 @@ 'disabled_by': None, 'domain': 'energyzero', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/energyzero/snapshots/test_services.ambr b/tests/components/energyzero/snapshots/test_services.ambr new file mode 100644 index 00000000000..73d161477d0 --- /dev/null +++ b/tests/components/energyzero/snapshots/test_services.ambr @@ -0,0 +1,2401 @@ +# serializer version: 1 +# name: test_service[end0-start0-incl_vat0-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start0-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat0-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat1-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end0-start1-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat0-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat1-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start0-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat0-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat0-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat1-get_energy_prices] + dict({ + 'prices': list([ + dict({ + 'price': 0.35, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 0.26, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 0.27, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 0.28, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 0.38, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 0.41, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 0.46, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 0.39, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 0.44, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 0.48, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 0.49, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 0.55, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 0.37, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 0.4, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 0.32, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 0.33, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 0.31, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- +# name: test_service[end1-start1-incl_vat1-get_gas_prices] + dict({ + 'prices': list([ + dict({ + 'price': 1.43, + 'timestamp': '2022-12-05 23:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 00:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 01:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 02:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 03:00:00+00:00', + }), + dict({ + 'price': 1.43, + 'timestamp': '2022-12-06 04:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 05:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 06:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 07:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 08:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 09:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 10:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 11:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 12:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 13:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 14:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 15:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 16:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 17:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 18:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 19:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 20:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 21:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 22:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-06 23:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 00:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 01:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 02:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 03:00:00+00:00', + }), + dict({ + 'price': 1.45, + 'timestamp': '2022-12-07 04:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 05:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 06:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 07:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 08:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 09:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 10:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 11:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 12:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 13:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 14:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 15:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 16:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 17:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 18:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 19:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 20:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 21:00:00+00:00', + }), + dict({ + 'price': 1.47, + 'timestamp': '2022-12-07 22:00:00+00:00', + }), + ]), + }) +# --- diff --git a/tests/components/energyzero/test_services.py b/tests/components/energyzero/test_services.py new file mode 100644 index 00000000000..c0b54729e03 --- /dev/null +++ b/tests/components/energyzero/test_services.py @@ -0,0 +1,160 @@ +"""Tests for the services provided by the EnergyZero integration.""" + +import pytest +from syrupy.assertion import SnapshotAssertion +import voluptuous as vol + +from homeassistant.components.energyzero.const import DOMAIN +from homeassistant.components.energyzero.services import ( + ATTR_CONFIG_ENTRY, + ENERGY_SERVICE_NAME, + GAS_SERVICE_NAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("init_integration") +async def test_has_services( + hass: HomeAssistant, +) -> None: + """Test the existence of the EnergyZero Service.""" + assert hass.services.has_service(DOMAIN, GAS_SERVICE_NAME) + assert hass.services.has_service(DOMAIN, ENERGY_SERVICE_NAME) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME]) +@pytest.mark.parametrize("incl_vat", [{"incl_vat": False}, {"incl_vat": True}]) +@pytest.mark.parametrize("start", [{"start": "2023-01-01 00:00:00"}, {}]) +@pytest.mark.parametrize("end", [{"end": "2023-01-01 00:00:00"}, {}]) +async def test_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + service: str, + incl_vat: dict[str, bool], + start: dict[str, str], + end: dict[str, str], +) -> None: + """Test the EnergyZero Service.""" + entry = {ATTR_CONFIG_ENTRY: mock_config_entry.entry_id} + + data = entry | incl_vat | start | end + + assert snapshot == await hass.services.async_call( + DOMAIN, + service, + data, + blocking=True, + return_response=True, + ) + + +@pytest.fixture +def config_entry_data( + mock_config_entry: MockConfigEntry, request: pytest.FixtureRequest +) -> dict[str, str]: + """Fixture for the config entry.""" + if "config_entry" in request.param and request.param["config_entry"] is True: + return {"config_entry": mock_config_entry.entry_id} + + return request.param + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME]) +@pytest.mark.parametrize( + ("config_entry_data", "service_data", "error", "error_message"), + [ + ({}, {}, vol.er.Error, "required key not provided .+"), + ( + {"config_entry": True}, + {}, + vol.er.Error, + "required key not provided .+", + ), + ( + {}, + {"incl_vat": True}, + vol.er.Error, + "required key not provided .+", + ), + ( + {"config_entry": True}, + {"incl_vat": "incorrect vat"}, + vol.er.Error, + "expected bool for dictionary value .+", + ), + ( + {"config_entry": "incorrect entry"}, + {"incl_vat": True}, + ServiceValidationError, + "Invalid config entry.+", + ), + ( + {"config_entry": True}, + { + "incl_vat": True, + "start": "incorrect date", + }, + ServiceValidationError, + "Invalid datetime provided.", + ), + ( + {"config_entry": True}, + { + "incl_vat": True, + "end": "incorrect date", + }, + ServiceValidationError, + "Invalid datetime provided.", + ), + ], + indirect=["config_entry_data"], +) +async def test_service_validation( + hass: HomeAssistant, + service: str, + config_entry_data: dict[str, str], + service_data: dict[str, str], + error: type[Exception], + error_message: str, +) -> None: + """Test the EnergyZero Service validation.""" + + with pytest.raises(error, match=error_message): + await hass.services.async_call( + DOMAIN, + service, + config_entry_data | service_data, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("service", [GAS_SERVICE_NAME, ENERGY_SERVICE_NAME]) +async def test_service_called_with_unloaded_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + service: str, +) -> None: + """Test service calls with unloaded config entry.""" + + await mock_config_entry.async_unload(hass) + + data = {"config_entry": mock_config_entry.entry_id, "incl_vat": True} + + with pytest.raises( + ServiceValidationError, match=f"{mock_config_entry.title} is not loaded" + ): + await hass.services.async_call( + DOMAIN, + service, + data, + blocking=True, + return_response=True, + ) diff --git a/tests/components/enphase_envoy/conftest.py b/tests/components/enphase_envoy/conftest.py index c1fb03545cb..185f65aa892 100644 --- a/tests/components/enphase_envoy/conftest.py +++ b/tests/components/enphase_envoy/conftest.py @@ -49,6 +49,7 @@ def mock_envoy_fixture(serial_number, mock_authenticate, mock_setup, mock_auth): """Define a mocked Envoy fixture.""" mock_envoy = Mock(spec=Envoy) mock_envoy.serial_number = serial_number + mock_envoy.firmware = "7.1.2" mock_envoy.authenticate = mock_authenticate mock_envoy.setup = mock_setup mock_envoy.auth = mock_auth diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 098fc4ee37e..9266ffcf94e 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -15,6 +15,7 @@ 'disabled_by': None, 'domain': 'enphase_envoy', 'entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -24,5 +25,6 @@ 'unique_id': '**REDACTED**', 'version': 1, }), + 'envoy_firmware': '7.1.2', }) # --- diff --git a/tests/components/environment_canada/test_config_flow.py b/tests/components/environment_canada/test_config_flow.py index e3058697f3e..b745ac02693 100644 --- a/tests/components/environment_canada/test_config_flow.py +++ b/tests/components/environment_canada/test_config_flow.py @@ -6,12 +6,8 @@ import aiohttp import pytest from homeassistant import config_entries, data_entry_flow -from homeassistant.components.environment_canada.const import ( - CONF_LANGUAGE, - CONF_STATION, - DOMAIN, -) -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.components.environment_canada.const import CONF_STATION, DOMAIN +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry diff --git a/tests/components/environment_canada/test_diagnostics.py b/tests/components/environment_canada/test_diagnostics.py index 3eedb7a0ddb..fb1597e3622 100644 --- a/tests/components/environment_canada/test_diagnostics.py +++ b/tests/components/environment_canada/test_diagnostics.py @@ -5,12 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, patch from syrupy import SnapshotAssertion -from homeassistant.components.environment_canada.const import ( - CONF_LANGUAGE, - CONF_STATION, - DOMAIN, -) -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.components.environment_canada.const import CONF_STATION, DOMAIN +from homeassistant.const import CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture diff --git a/tests/components/esphome/bluetooth/__init__.py b/tests/components/esphome/bluetooth/__init__.py new file mode 100644 index 00000000000..10ff361d85c --- /dev/null +++ b/tests/components/esphome/bluetooth/__init__.py @@ -0,0 +1 @@ +"""Bluetooth tests for ESPHome.""" diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py index 7ed1403041d..cd250bc1080 100644 --- a/tests/components/esphome/bluetooth/test_client.py +++ b/tests/components/esphome/bluetooth/test_client.py @@ -3,16 +3,13 @@ from __future__ import annotations from aioesphomeapi import APIClient, APIVersion, BluetoothProxyFeature, DeviceInfo from bleak.exc import BleakError +from bleak_esphome.backend.cache import ESPHomeBluetoothCache +from bleak_esphome.backend.client import ESPHomeClient, ESPHomeClientData +from bleak_esphome.backend.device import ESPHomeBluetoothDevice +from bleak_esphome.backend.scanner import ESPHomeScanner import pytest from homeassistant.components.bluetooth import HaBluetoothConnector -from homeassistant.components.esphome.bluetooth.cache import ESPHomeBluetoothCache -from homeassistant.components.esphome.bluetooth.client import ( - ESPHomeClient, - ESPHomeClientData, -) -from homeassistant.components.esphome.bluetooth.device import ESPHomeBluetoothDevice -from homeassistant.components.esphome.bluetooth.scanner import ESPHomeScanner from homeassistant.core import HomeAssistant from tests.components.bluetooth import generate_ble_device @@ -35,17 +32,15 @@ async def client_data_fixture( mac_address=ESP_MAC_ADDRESS, name=ESP_NAME, bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN - & BluetoothProxyFeature.ACTIVE_CONNECTIONS - & BluetoothProxyFeature.REMOTE_CACHING - & BluetoothProxyFeature.PAIRING - & BluetoothProxyFeature.CACHE_CLEARING - & BluetoothProxyFeature.RAW_ADVERTISEMENTS, + | BluetoothProxyFeature.ACTIVE_CONNECTIONS + | BluetoothProxyFeature.REMOTE_CACHING + | BluetoothProxyFeature.PAIRING + | BluetoothProxyFeature.CACHE_CLEARING + | BluetoothProxyFeature.RAW_ADVERTISEMENTS, ), api_version=APIVersion(1, 9), title=ESP_NAME, - scanner=ESPHomeScanner( - hass, ESP_MAC_ADDRESS, ESP_NAME, lambda info: None, connector, True - ), + scanner=ESPHomeScanner(ESP_MAC_ADDRESS, ESP_NAME, connector, True), ) diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 48b0868e406..9182e021a65 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, Mock, patch from aioesphomeapi import ( APIClient, APIVersion, + BluetoothProxyFeature, DeviceInfo, EntityInfo, EntityState, @@ -71,6 +72,7 @@ def mock_config_entry(hass) -> MockConfigEntry: CONF_NOISE_PSK: "12345678123456781234567812345678", CONF_DEVICE_NAME: "test", }, + # ESPHome unique ids are lower case unique_id="11:22:33:44:55:aa", ) config_entry.add_to_hass(hass) @@ -95,7 +97,8 @@ def mock_device_info() -> DeviceInfo: uses_password=False, name="test", legacy_bluetooth_proxy_version=0, - mac_address="11:22:33:44:55:aa", + # ESPHome mac addresses are UPPER case + mac_address="11:22:33:44:55:AA", esphome_version="1.0.0", ) @@ -229,7 +232,7 @@ async def _mock_generic_device_entry( "name": "test", "friendly_name": "Test", "esphome_version": "1.0.0", - "mac_address": "11:22:33:44:55:aa", + "mac_address": "11:22:33:44:55:AA", } device_info = DeviceInfo(**(default_device_info | mock_device_info)) @@ -311,6 +314,54 @@ async def mock_voice_assistant_v2_entry(mock_voice_assistant_entry) -> MockConfi return await mock_voice_assistant_entry(version=2) +@pytest.fixture +async def mock_bluetooth_entry( + hass: HomeAssistant, + mock_client: APIClient, +): + """Set up an ESPHome entry with bluetooth.""" + + async def _mock_bluetooth_entry( + bluetooth_proxy_feature_flags: BluetoothProxyFeature, + ) -> MockESPHomeDevice: + return await _mock_generic_device_entry( + hass, + mock_client, + {"bluetooth_proxy_feature_flags": bluetooth_proxy_feature_flags}, + ([], []), + [], + ) + + return _mock_bluetooth_entry + + +@pytest.fixture +async def mock_bluetooth_entry_with_raw_adv(mock_bluetooth_entry) -> MockESPHomeDevice: + """Set up an ESPHome entry with bluetooth and raw advertisements.""" + return await mock_bluetooth_entry( + bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN + | BluetoothProxyFeature.ACTIVE_CONNECTIONS + | BluetoothProxyFeature.REMOTE_CACHING + | BluetoothProxyFeature.PAIRING + | BluetoothProxyFeature.CACHE_CLEARING + | BluetoothProxyFeature.RAW_ADVERTISEMENTS + ) + + +@pytest.fixture +async def mock_bluetooth_entry_with_legacy_adv( + mock_bluetooth_entry, +) -> MockESPHomeDevice: + """Set up an ESPHome entry with bluetooth with legacy advertisements.""" + return await mock_bluetooth_entry( + bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN + | BluetoothProxyFeature.ACTIVE_CONNECTIONS + | BluetoothProxyFeature.REMOTE_CACHING + | BluetoothProxyFeature.PAIRING + | BluetoothProxyFeature.CACHE_CLEARING + ) + + @pytest.fixture async def mock_generic_device_entry( hass: HomeAssistant, diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index d8de8f06bc6..0d2f0e60b82 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -12,6 +12,7 @@ 'disabled_by': None, 'domain': 'esphome', 'entry_id': '08d821dc059cf4f645cb024d32c8e708', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/esphome/test_bluetooth.py b/tests/components/esphome/test_bluetooth.py new file mode 100644 index 00000000000..46858c5826b --- /dev/null +++ b/tests/components/esphome/test_bluetooth.py @@ -0,0 +1,46 @@ +"""Test the ESPHome bluetooth integration.""" + +from homeassistant.components import bluetooth +from homeassistant.core import HomeAssistant + +from .conftest import MockESPHomeDevice + + +async def test_bluetooth_connect_with_raw_adv( + hass: HomeAssistant, mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice +) -> None: + """Test bluetooth connect with raw advertisements.""" + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner is not None + assert scanner.connectable is True + assert scanner.scanning is True + assert scanner.connector.can_connect() is False # no connection slots + await mock_bluetooth_entry_with_raw_adv.mock_disconnect(True) + await hass.async_block_till_done() + + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner is None + await mock_bluetooth_entry_with_raw_adv.mock_connect() + await hass.async_block_till_done() + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner.scanning is True + + +async def test_bluetooth_connect_with_legacy_adv( + hass: HomeAssistant, mock_bluetooth_entry_with_legacy_adv: MockESPHomeDevice +) -> None: + """Test bluetooth connect with legacy advertisements.""" + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner is not None + assert scanner.connectable is True + assert scanner.scanning is True + assert scanner.connector.can_connect() is False # no connection slots + await mock_bluetooth_entry_with_legacy_adv.mock_disconnect(True) + await hass.async_block_till_done() + + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner is None + await mock_bluetooth_entry_with_legacy_adv.mock_connect() + await hass.async_block_till_done() + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner.scanning is True diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 01ba07852d6..4161e69efd0 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -432,7 +432,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails( DeviceInfo( uses_password=False, name="test", - mac_address="11:22:33:44:55:aa", + mac_address="11:22:33:44:55:AA", ), ] diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 6000b270d87..d528010af1b 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -1,8 +1,13 @@ """Tests for the diagnostics data provided by the ESPHome integration.""" +from unittest.mock import ANY + from syrupy import SnapshotAssertion +from homeassistant.components import bluetooth from homeassistant.core import HomeAssistant +from .conftest import MockESPHomeDevice + from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -20,3 +25,77 @@ async def test_diagnostics( result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration) assert result == snapshot + + +async def test_diagnostics_with_bluetooth( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_bluetooth_entry_with_raw_adv: MockESPHomeDevice, +) -> None: + """Test diagnostics for config entry with Bluetooth.""" + scanner = bluetooth.async_scanner_by_source(hass, "11:22:33:44:55:AA") + assert scanner is not None + assert scanner.connectable is True + entry = mock_bluetooth_entry_with_raw_adv.entry + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + assert result == { + "bluetooth": { + "available": True, + "connections_free": 0, + "connections_limit": 0, + "scanner": { + "connectable": True, + "discovered_device_timestamps": {}, + "discovered_devices_and_advertisement_data": [], + "last_detection": ANY, + "monotonic_time": ANY, + "name": "test (11:22:33:44:55:AA)", + "scanning": True, + "source": "11:22:33:44:55:AA", + "start_time": ANY, + "time_since_last_device_detection": {}, + "type": "ESPHomeScanner", + }, + }, + "config": { + "data": { + "device_name": "test", + "host": "test.local", + "password": "", + "port": 6053, + }, + "disabled_by": None, + "domain": "esphome", + "entry_id": ANY, + "minor_version": 1, + "options": {"allow_service_calls": False}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "user", + "title": "Mock Title", + "unique_id": "11:22:33:44:55:aa", + "version": 1, + }, + "storage_data": { + "api_version": {"major": 99, "minor": 99}, + "device_info": { + "bluetooth_proxy_feature_flags": 63, + "compilation_time": "", + "esphome_version": "1.0.0", + "friendly_name": "Test", + "has_deep_sleep": False, + "legacy_bluetooth_proxy_version": 0, + "mac_address": "**REDACTED**", + "manufacturer": "", + "model": "", + "name": "test", + "project_name": "", + "project_version": "", + "suggested_area": "", + "uses_password": False, + "voice_assistant_version": 0, + "webserver_port": 0, + }, + "services": [], + }, + } diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index 0ba43092d01..a8535c38224 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -51,7 +51,7 @@ async def test_migrate_entity_unique_id( assert entity_registry.async_get_entity_id("sensor", "esphome", "my_sensor") is None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" + assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" async def test_migrate_entity_unique_id_downgrade_upgrade( @@ -71,7 +71,7 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( entity_registry.async_get_or_create( "sensor", "esphome", - "11:22:33:44:55:aa-sensor-mysensor", + "11:22:33:44:55:AA-sensor-mysensor", suggested_object_id="new_sensor", disabled_by=None, ) @@ -108,4 +108,4 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( ) # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" + assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index 99f4bbc86a9..6f383dcb6ba 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -16,12 +16,14 @@ from homeassistant.components.fan import ( ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_PERCENTAGE, + ATTR_PRESET_MODE, DOMAIN as FAN_DOMAIN, SERVICE_DECREASE_SPEED, SERVICE_INCREASE_SPEED, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, @@ -145,6 +147,7 @@ async def test_fan_entity_with_all_features_new_api( supports_direction=True, supports_speed=True, supports_oscillation=True, + supported_preset_modes=["Preset1", "Preset2"], ) ] states = [ @@ -154,6 +157,7 @@ async def test_fan_entity_with_all_features_new_api( oscillating=True, speed_level=3, direction=FanDirection.REVERSE, + preset_mode=None, ) ] user_service = [] @@ -270,6 +274,15 @@ async def test_fan_entity_with_all_features_new_api( ) mock_client.fan_command.reset_mock() + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PRESET_MODE: "Preset1"}, + blocking=True, + ) + mock_client.fan_command.assert_has_calls([call(key=1, preset_mode="Preset1")]) + mock_client.fan_command.reset_mock() + async def test_fan_entity_with_no_features_new_api( hass: HomeAssistant, mock_client: APIClient, mock_generic_device_entry @@ -285,6 +298,7 @@ async def test_fan_entity_with_no_features_new_api( supports_direction=False, supports_speed=False, supports_oscillation=False, + supported_preset_modes=[], ) ] states = [FanState(key=1, state=True)] diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 244e7487ed3..69ed653d75b 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -45,7 +45,7 @@ async def test_esphome_device_with_old_bluetooth( 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" + "esphome", "ble_firmware_outdated-11:22:33:44:55:AA" ) assert ( issue.learn_more_url @@ -87,7 +87,10 @@ async def test_esphome_device_with_password( issue_registry = ir.async_get(hass) assert ( issue_registry.async_get_issue( - "esphome", "api_password_deprecated-11:22:33:44:55:aa" + # This issue uses the ESPHome mac address which + # is always UPPER case + "esphome", + "api_password_deprecated-11:22:33:44:55:AA", ) is not None ) @@ -118,8 +121,10 @@ async def test_esphome_device_with_current_bluetooth( await hass.async_block_till_done() issue_registry = ir.async_get(hass) assert ( + # This issue uses the ESPHome device info mac address which + # is always UPPER case issue_registry.async_get_issue( - "esphome", "ble_firmware_outdated-11:22:33:44:55:aa" + "esphome", "ble_firmware_outdated-11:22:33:44:55:AA" ) is None ) diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 820ec9ad9c0..080976425f9 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -118,7 +118,7 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( assert entry is not None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" + assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" assert entry.entity_category is EntityCategory.DIAGNOSTIC @@ -156,7 +156,7 @@ async def test_generic_numeric_sensor_state_class_measurement( assert entry is not None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" + assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" assert entry.entity_category is None diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index 66cda6a088a..b8ba5fb6a18 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -56,6 +56,9 @@ async def test_event() -> None: event_types=["short_press", "long_press"], device_class=EventDeviceClass.DOORBELL, ) + # Delete the cache since we changed the entity description + # at run time + del event.device_class assert event.event_types == ["short_press", "long_press"] assert event.device_class == EventDeviceClass.DOORBELL diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py index ec421141768..828c13b6f16 100644 --- a/tests/components/fan/test_init.py +++ b/tests/components/fan/test_init.py @@ -1,18 +1,21 @@ """Tests for fan platforms.""" import pytest +from homeassistant.components import fan from homeassistant.components.fan import ( ATTR_PRESET_MODE, ATTR_PRESET_MODES, DOMAIN, SERVICE_SET_PRESET_MODE, FanEntity, + FanEntityFeature, NotValidPresetModeError, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component +from tests.common import import_and_test_deprecated_constant_enum from tests.testing_config.custom_components.test.fan import MockFan @@ -145,3 +148,32 @@ async def test_preset_mode_validation( with pytest.raises(NotValidPresetModeError) as exc: await test_fan._valid_preset_mode_or_raise("invalid") assert exc.value.translation_key == "not_valid_preset_mode" + + +@pytest.mark.parametrize(("enum"), list(fan.FanEntityFeature)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: fan.FanEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, fan, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockFan(FanEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockFan() + assert entity.supported_features_compat is FanEntityFeature(1) + assert "MockFan" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "FanEntityFeature.SET_SPEED" in caplog.text + caplog.clear() + assert entity.supported_features_compat is FanEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/fan/test_significant_change.py b/tests/components/fan/test_significant_change.py new file mode 100644 index 00000000000..764abb6e8ee --- /dev/null +++ b/tests/components/fan/test_significant_change.py @@ -0,0 +1,51 @@ +"""Test the Fan significant change platform.""" +import pytest + +from homeassistant.components.fan import ( + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODE, +) +from homeassistant.components.fan.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Fan significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_PERCENTAGE_STEP: "1"}, {ATTR_PERCENTAGE_STEP: "2"}, False), + ({ATTR_PERCENTAGE: 1}, {ATTR_PERCENTAGE: 2}, True), + ({ATTR_PERCENTAGE: 1}, {ATTR_PERCENTAGE: 1.9}, False), + ({ATTR_PERCENTAGE: "invalid"}, {ATTR_PERCENTAGE: 1}, True), + ({ATTR_PERCENTAGE: 1}, {ATTR_PERCENTAGE: "invalid"}, False), + ({ATTR_DIRECTION: "front"}, {ATTR_DIRECTION: "front"}, False), + ({ATTR_DIRECTION: "front"}, {ATTR_DIRECTION: "back"}, True), + ({ATTR_OSCILLATING: True}, {ATTR_OSCILLATING: True}, False), + ({ATTR_OSCILLATING: True}, {ATTR_OSCILLATING: False}, True), + ({ATTR_PRESET_MODE: "auto"}, {ATTR_PRESET_MODE: "auto"}, False), + ({ATTR_PRESET_MODE: "auto"}, {ATTR_PRESET_MODE: "whoosh"}, True), + ( + {ATTR_PRESET_MODE: "auto", ATTR_OSCILLATING: True}, + {ATTR_PRESET_MODE: "auto", ATTR_OSCILLATING: False}, + True, + ), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Fan significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/fastdotcom/test_config_flow.py b/tests/components/fastdotcom/test_config_flow.py index 4314a7688d8..17e75935dae 100644 --- a/tests/components/fastdotcom/test_config_flow.py +++ b/tests/components/fastdotcom/test_config_flow.py @@ -57,10 +57,7 @@ async def test_single_instance_allowed( async def test_import_flow_success(hass: HomeAssistant) -> None: """Test import flow.""" - with patch( - "homeassistant.components.fastdotcom.__init__.SpeedtestData", - return_value={"download": "50"}, - ), patch("homeassistant.components.fastdotcom.sensor.SpeedtestSensor"): + with patch("homeassistant.components.fastdotcom.coordinator.fast_com"): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, diff --git a/tests/components/fastdotcom/test_coordinator.py b/tests/components/fastdotcom/test_coordinator.py new file mode 100644 index 00000000000..f51f0254714 --- /dev/null +++ b/tests/components/fastdotcom/test_coordinator.py @@ -0,0 +1,55 @@ +"""Test the FastdotcomDataUpdateCoordindator.""" +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.fastdotcom.coordinator import DEFAULT_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_fastdotcom_data_update_coordinator( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test the update coordinator.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "5.0" + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=10.0 + ): + freezer.tick(timedelta(hours=DEFAULT_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state.state == "10.0" + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", + side_effect=Exception("Test error"), + ): + freezer.tick(timedelta(hours=DEFAULT_INTERVAL)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state.state is STATE_UNAVAILABLE diff --git a/tests/components/fastdotcom/test_init.py b/tests/components/fastdotcom/test_init.py new file mode 100644 index 00000000000..0acaddf36fc --- /dev/null +++ b/tests/components/fastdotcom/test_init.py @@ -0,0 +1,115 @@ +"""Test for Sensibo component Init.""" +from __future__ import annotations + +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant import config_entries +from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN +from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, STATE_UNKNOWN +from homeassistant.core import CoreState, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test unload an entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == config_entries.ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def test_from_import(hass: HomeAssistant) -> None: + """Test imported entry.""" + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await async_setup_component( + hass, + DOMAIN, + {"fastdotcom": {}}, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "5.0" + + +async def test_delayed_speedtest_during_startup( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test delayed speedtest during startup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ), patch.object(hass, "state", CoreState.starting): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == config_entries.ConfigEntryState.LOADED + state = hass.states.get("sensor.fast_com_download") + assert state is not None + # Assert state is unknown as coordinator is not allowed to start and fetch data yet + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "0" + + assert config_entry.state == config_entries.ConfigEntryState.LOADED + + +async def test_service_deprecated( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test deprecated service.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + "speedtest", + {}, + blocking=True, + ) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(DOMAIN, "service_deprecation") + assert issue + assert issue.is_fixable is True + assert issue.translation_key == "service_deprecation" diff --git a/tests/components/fastdotcom/test_sensor.py b/tests/components/fastdotcom/test_sensor.py new file mode 100644 index 00000000000..47826bf35cf --- /dev/null +++ b/tests/components/fastdotcom/test_sensor.py @@ -0,0 +1,31 @@ +"""Test the FastdotcomDataUpdateCoordindator.""" +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.fastdotcom.const import DEFAULT_NAME, DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_fastdotcom_data_update_coordinator( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Test the update coordinator.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="UNIQUE_TEST_ID", + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.fastdotcom.coordinator.fast_com", return_value=5.0 + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.fast_com_download") + assert state is not None + assert state.state == "5.0" diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 345c37dc8f1..cd906940931 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -68,6 +68,12 @@ def fixture_feed_atom_event(hass: HomeAssistant) -> bytes: return load_fixture_bytes("feedreader5.xml") +@pytest.fixture(name="feed_identically_timed_events") +def fixture_feed_identically_timed_events(hass: HomeAssistant) -> bytes: + """Load test feed data for two events published at the exact same time.""" + return load_fixture_bytes("feedreader6.xml") + + @pytest.fixture(name="events") async def fixture_events(hass: HomeAssistant) -> list[Event]: """Fixture that catches alexa events.""" @@ -285,6 +291,63 @@ async def test_atom_feed(hass: HomeAssistant, events, feed_atom_event) -> None: assert events[0].data.updated_parsed.tm_min == 30 +async def test_feed_identical_timestamps( + hass: HomeAssistant, events, feed_identically_timed_events +) -> None: + """Test feed with 2 entries with identical timestamps.""" + with patch( + "feedparser.http.get", + return_value=feed_identically_timed_events, + ), patch( + "homeassistant.components.feedreader.StoredData.get_timestamp", + return_value=gmtime( + datetime.fromisoformat("1970-01-01T00:00:00.0+0000").timestamp() + ), + ): + assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_2) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + assert len(events) == 2 + assert events[0].data.title == "Title 1" + assert events[1].data.title == "Title 2" + assert events[0].data.link == "http://www.example.com/link/1" + assert events[1].data.link == "http://www.example.com/link/2" + assert events[0].data.id == "GUID 1" + assert events[1].data.id == "GUID 2" + assert ( + events[0].data.updated_parsed.tm_year + == events[1].data.updated_parsed.tm_year + == 2018 + ) + assert ( + events[0].data.updated_parsed.tm_mon + == events[1].data.updated_parsed.tm_mon + == 4 + ) + assert ( + events[0].data.updated_parsed.tm_mday + == events[1].data.updated_parsed.tm_mday + == 30 + ) + assert ( + events[0].data.updated_parsed.tm_hour + == events[1].data.updated_parsed.tm_hour + == 15 + ) + assert ( + events[0].data.updated_parsed.tm_min + == events[1].data.updated_parsed.tm_min + == 10 + ) + assert ( + events[0].data.updated_parsed.tm_sec + == events[1].data.updated_parsed.tm_sec + == 0 + ) + + async def test_feed_updates( hass: HomeAssistant, events, feed_one_event, feed_two_event ) -> None: diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py index 0bcfcc38094..9cde648d27c 100644 --- a/tests/components/file/test_notify.py +++ b/tests/components/file/test_notify.py @@ -2,6 +2,7 @@ import os from unittest.mock import call, mock_open, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import notify @@ -28,7 +29,9 @@ async def test_bad_config(hass: HomeAssistant) -> None: True, ], ) -async def test_notify_file(hass: HomeAssistant, timestamp: bool) -> None: +async def test_notify_file( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, timestamp: bool +) -> None: """Test the notify file output.""" filename = "mock_file" message = "one, two, testing, testing" @@ -47,10 +50,12 @@ async def test_notify_file(hass: HomeAssistant, timestamp: bool) -> None: ) assert handle_config[notify.DOMAIN] + freezer.move_to(dt_util.utcnow()) + m_open = mock_open() with patch("homeassistant.components.file.notify.open", m_open, create=True), patch( "homeassistant.components.file.notify.os.stat" - ) as mock_st, patch("homeassistant.util.dt.utcnow", return_value=dt_util.utcnow()): + ) as mock_st: mock_st.return_value.st_size = 0 title = ( f"{ATTR_TITLE_DEFAULT} notifications " diff --git a/tests/components/fints/__init__.py b/tests/components/fints/__init__.py new file mode 100644 index 00000000000..6a2b1d96d20 --- /dev/null +++ b/tests/components/fints/__init__.py @@ -0,0 +1 @@ +"""Tests for FinTS component.""" diff --git a/tests/components/fints/test_client.py b/tests/components/fints/test_client.py new file mode 100644 index 00000000000..429d391b07e --- /dev/null +++ b/tests/components/fints/test_client.py @@ -0,0 +1,95 @@ +"""Tests for the FinTS client.""" + +from typing import Optional + +from fints.client import BankIdentifier, FinTSOperations +import pytest + +from homeassistant.components.fints.sensor import ( + BankCredentials, + FinTsClient, + SEPAAccount, +) + +BANK_INFORMATION = { + "bank_identifier": BankIdentifier(country_identifier="280", bank_code="50010517"), + "currency": "EUR", + "customer_id": "0815", + "owner_name": ["SURNAME, FIRSTNAME"], + "subaccount_number": None, + "supported_operations": { + FinTSOperations.GET_BALANCE: True, + FinTSOperations.GET_CREDIT_CARD_TRANSACTIONS: False, + FinTSOperations.GET_HOLDINGS: False, + FinTSOperations.GET_SCHEDULED_DEBITS_MULTIPLE: False, + FinTSOperations.GET_SCHEDULED_DEBITS_SINGLE: False, + FinTSOperations.GET_SEPA_ACCOUNTS: True, + FinTSOperations.GET_STATEMENT: False, + FinTSOperations.GET_STATEMENT_PDF: False, + FinTSOperations.GET_TRANSACTIONS: True, + FinTSOperations.GET_TRANSACTIONS_XML: False, + }, +} + + +@pytest.mark.parametrize( + ( + "account_number", + "iban", + "product_name", + "account_type", + "expected_balance_result", + "expected_holdings_result", + ), + [ + ("GIRO1", "GIRO1", "Valid balance account", 5, True, False), + (None, None, "Invalid account", None, False, False), + ("GIRO2", "GIRO2", "Account without type", None, False, False), + ("GIRO3", "GIRO3", "Balance account from fallback", None, True, False), + ("DEPOT1", "DEPOT1", "Valid holdings account", 33, False, True), + ("DEPOT2", "DEPOT2", "Holdings account from fallback", None, False, True), + ], +) +async def test_account_type( + account_number: Optional[str], + iban: Optional[str], + product_name: str, + account_type: Optional[int], + expected_balance_result: bool, + expected_holdings_result: bool, +) -> None: + """Check client methods is_balance_account and is_holdings_account.""" + credentials = BankCredentials( + blz=1234, login="test", pin="0000", url="https://example.com" + ) + account_config = {"GIRO3": True} + holdings_config = {"DEPOT2": True} + + client = FinTsClient( + credentials=credentials, + name="test", + account_config=account_config, + holdings_config=holdings_config, + ) + + client._account_information_fetched = True + client._account_information = { + iban: BANK_INFORMATION + | { + "account_number": account_number, + "iban": iban, + "product_name": product_name, + "type": account_type, + } + } + + sepa_account = SEPAAccount( + iban=iban, + bic="BANCODELTEST", + accountnumber=account_number, + subaccount=None, + blz="12345", + ) + + assert client.is_balance_account(sepa_account) == expected_balance_result + assert client.is_holdings_account(sepa_account) == expected_holdings_result diff --git a/tests/components/firmata/test_config_flow.py b/tests/components/firmata/test_config_flow.py index d4bf159c5c9..474455fc164 100644 --- a/tests/components/firmata/test_config_flow.py +++ b/tests/components/firmata/test_config_flow.py @@ -31,7 +31,7 @@ async def test_import_cannot_connect_serial(hass: HomeAssistant) -> None: with patch( "homeassistant.components.firmata.board.PymataExpress.start_aio", - side_effect=serial.serialutil.SerialException, + side_effect=serial.SerialException, ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -48,7 +48,7 @@ async def test_import_cannot_connect_serial_timeout(hass: HomeAssistant) -> None with patch( "homeassistant.components.firmata.board.PymataExpress.start_aio", - side_effect=serial.serialutil.SerialTimeoutException, + side_effect=serial.SerialTimeoutException, ): result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py index 3ed3695ff3d..74312348af1 100644 --- a/tests/components/fitbit/test_init.py +++ b/tests/components/fitbit/test_init.py @@ -106,7 +106,13 @@ async def test_token_refresh_success( ) -@pytest.mark.parametrize("token_expiration_time", [12345]) +@pytest.mark.parametrize( + ("token_expiration_time", "server_status"), + [ + (12345, HTTPStatus.UNAUTHORIZED), + (12345, HTTPStatus.BAD_REQUEST), + ], +) @pytest.mark.parametrize("closing", [True, False]) async def test_token_requires_reauth( hass: HomeAssistant, @@ -114,13 +120,14 @@ async def test_token_requires_reauth( config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, setup_credentials: None, + server_status: HTTPStatus, closing: bool, ) -> None: """Test where token is expired and the refresh attempt requires reauth.""" aioclient_mock.post( OAUTH2_TOKEN, - status=HTTPStatus.UNAUTHORIZED, + status=server_status, closing=closing, ) diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 871088eae63..91aafd944b0 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -599,21 +599,25 @@ async def test_settings_scope_config_entry( @pytest.mark.parametrize( - ("scopes"), - [(["heartrate"])], + ("scopes", "server_status"), + [ + (["heartrate"], HTTPStatus.INTERNAL_SERVER_ERROR), + (["heartrate"], HTTPStatus.BAD_REQUEST), + ], ) async def test_sensor_update_failed( hass: HomeAssistant, setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], requests_mock: Mocker, + server_status: HTTPStatus, ) -> None: """Test a failed sensor update when talking to the API.""" requests_mock.register_uri( "GET", TIMESERIES_API_URL_FORMAT.format(resource="activities/heart"), - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + status_code=server_status, ) assert await integration_setup() diff --git a/tests/components/flexit_bacnet/__init__.py b/tests/components/flexit_bacnet/__init__.py new file mode 100644 index 00000000000..4cae6e4f4bf --- /dev/null +++ b/tests/components/flexit_bacnet/__init__.py @@ -0,0 +1 @@ +"""Tests for the Flexit Nordic (BACnet) integration.""" diff --git a/tests/components/flexit_bacnet/conftest.py b/tests/components/flexit_bacnet/conftest.py new file mode 100644 index 00000000000..b136b134e01 --- /dev/null +++ b/tests/components/flexit_bacnet/conftest.py @@ -0,0 +1,44 @@ +"""Configuration for Flexit Nordic (BACnet) tests.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.flexit_bacnet.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture +async def flow_id(hass: HomeAssistant) -> str: + """Return initial ID for user-initiated configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + return result["flow_id"] + + +@pytest.fixture(autouse=True) +def mock_serial_number_and_device_name(): + """Mock serial number of the device.""" + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.serial_number", + "0000-0001", + ), patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.device_name", + "Device Name", + ): + yield + + +@pytest.fixture +def mock_setup_entry(): + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.flexit_bacnet.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock diff --git a/tests/components/flexit_bacnet/test_config_flow.py b/tests/components/flexit_bacnet/test_config_flow.py new file mode 100644 index 00000000000..ed513587af6 --- /dev/null +++ b/tests/components/flexit_bacnet/test_config_flow.py @@ -0,0 +1,120 @@ +"""Test the Flexit Nordic (BACnet) config flow.""" +import asyncio.exceptions +from unittest.mock import patch + +from flexit_bacnet import DecodingError +import pytest + +from homeassistant.components.flexit_bacnet.const import DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, flow_id: str, mock_setup_entry) -> None: + """Test we get the form and the happy path works.""" + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Device Name" + assert result["context"]["unique_id"] == "0000-0001" + assert result["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + asyncio.exceptions.TimeoutError, + "cannot_connect", + ), + (ConnectionError, "cannot_connect"), + (DecodingError, "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_flow_fails( + hass: HomeAssistant, flow_id: str, error: Exception, message: str, mock_setup_entry +) -> None: + """Test that we return 'cannot_connect' error when attempting to connect to an incorrect IP address. + + The flexit_bacnet library raises asyncio.exceptions.TimeoutError in that scenario. + """ + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update", + side_effect=error, + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": message} + assert len(mock_setup_entry.mock_calls) == 0 + + # ensure that user can recover from this error + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Device Name" + assert result2["context"]["unique_id"] == "0000-0001" + assert result2["data"] == { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_device_already_exist(hass: HomeAssistant, flow_id: str) -> None: + """Test that we cannot add already added device.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + unique_id="0000-0001", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.flexit_bacnet.config_flow.FlexitBACnet.update" + ): + result = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_IP_ADDRESS: "1.1.1.1", + CONF_DEVICE_ID: 2, + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/flux_led/test_light.py b/tests/components/flux_led/test_light.py index 974a029d143..6ddb9e1687f 100644 --- a/tests/components/flux_led/test_light.py +++ b/tests/components/flux_led/test_light.py @@ -23,7 +23,6 @@ from homeassistant.components.flux_led.const import ( CONF_CUSTOM_EFFECT_COLORS, CONF_CUSTOM_EFFECT_SPEED_PCT, CONF_CUSTOM_EFFECT_TRANSITION, - CONF_EFFECT, CONF_SPEED_PCT, CONF_TRANSITION, CONF_WHITE_CHANNEL_TYPE, @@ -55,6 +54,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_EFFECT, CONF_HOST, CONF_MODE, CONF_NAME, diff --git a/tests/components/flux_led/test_select.py b/tests/components/flux_led/test_select.py index c8fd64c6811..1cdbb9369ab 100644 --- a/tests/components/flux_led/test_select.py +++ b/tests/components/flux_led/test_select.py @@ -14,6 +14,7 @@ from homeassistant.components.flux_led.const import CONF_WHITE_CHANNEL_TYPE, DOM from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -133,7 +134,7 @@ async def test_select_addressable_strip_config(hass: HomeAssistant) -> None: state = hass.states.get(ic_type_entity_id) assert state.state == "WS2812B" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", @@ -149,7 +150,7 @@ async def test_select_addressable_strip_config(hass: HomeAssistant) -> None: bulb.async_set_device_config.assert_called_once_with(wiring="GRBW") bulb.async_set_device_config.reset_mock() - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", @@ -191,7 +192,7 @@ async def test_select_mutable_0x25_strip_config(hass: HomeAssistant) -> None: state = hass.states.get(operating_mode_entity_id) assert state.state == "RGBWW" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", @@ -226,7 +227,7 @@ async def test_select_24ghz_remote_config(hass: HomeAssistant) -> None: state = hass.states.get(remote_config_entity_id) assert state.state == "Open" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", @@ -275,7 +276,7 @@ async def test_select_white_channel_type(hass: HomeAssistant) -> None: state = hass.states.get(operating_mode_entity_id) assert state.state == WhiteChannelType.WARM.name.title() - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, "select_option", diff --git a/tests/components/forecast_solar/snapshots/test_init.ambr b/tests/components/forecast_solar/snapshots/test_init.ambr index a009105e2e6..43145bcef9e 100644 --- a/tests/components/forecast_solar/snapshots/test_init.ambr +++ b/tests/components/forecast_solar/snapshots/test_init.ambr @@ -8,6 +8,7 @@ 'disabled_by': None, 'domain': 'forecast_solar', 'entry_id': , + 'minor_version': 1, 'options': dict({ 'api_key': 'abcdef12345', 'azimuth': 190, diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index c64972b7904..2e053f7ccc5 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -37,7 +37,7 @@ async def setup_fronius_integration( def _load_and_patch_fixture( - override_data: dict[str, list[tuple[list[str], Any]]] + override_data: dict[str, list[tuple[list[str], Any]]], ) -> Callable[[str, str | None], str]: """Return a fixture loader that patches values at nested keys for a given filename.""" @@ -125,3 +125,17 @@ async def enable_all_entities(hass, freezer, config_entry_id, time_till_next_upd freezer.tick(time_till_next_update) async_fire_time_changed(hass) await hass.async_block_till_done() + + +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "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"] diff --git a/tests/components/fronius/test_init.py b/tests/components/fronius/test_init.py index cc56fea24b2..f8d86bac26a 100644 --- a/tests/components/fronius/test_init.py +++ b/tests/components/fronius/test_init.py @@ -7,13 +7,15 @@ from pyfronius import FroniusError 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.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import mock_responses, setup_fronius_integration +from . import mock_responses, remove_device, setup_fronius_integration from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator async def test_unload_config_entry( @@ -138,3 +140,29 @@ async def test_inverter_rescan_interruption( len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) == 2 ) + + +async def test_device_remove_devices( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test we can remove a device.""" + assert await async_setup_component(hass, "config", {}) + + mock_responses(aioclient_mock, fixture_set="gen24_storage") + config_entry = await setup_fronius_integration( + hass, is_logger=False, unique_id="12345678" + ) + + inverter_1 = device_registry.async_get_device(identifiers={(DOMAIN, "12345678")}) + assert ( + await remove_device( + await hass_ws_client(hass), inverter_1.id, config_entry.entry_id + ) + is True + ) + + assert not device_registry.async_get_device(identifiers={(DOMAIN, "12345678")}) diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 684e9a3ae5f..a8f48ce2e88 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -370,6 +370,7 @@ async def test_gen24( async def test_gen24_storage( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Gen24 inverter with BYD battery and Ohmpilot entities.""" @@ -465,8 +466,6 @@ async def test_gen24_storage( assert_state("sensor.byd_battery_box_premium_hv_dc_voltage", 0.0) # Devices - device_registry = dr.async_get(hass) - solar_net = device_registry.async_get_device( identifiers={(DOMAIN, "solar_net_12345678")} ) @@ -501,6 +500,7 @@ async def test_gen24_storage( async def test_primo_s0( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test Fronius Primo dual inverter with S0 meter entities.""" @@ -573,8 +573,6 @@ async def test_primo_s0( assert_state("sensor.solarnet_energy_year", 11128933.25) # Devices - device_registry = dr.async_get(hass) - solar_net = device_registry.async_get_device( identifiers={(DOMAIN, "solar_net_123.4567890")} ) diff --git a/tests/components/frontier_silicon/conftest.py b/tests/components/frontier_silicon/conftest.py index 40a6df85310..1def9b160b2 100644 --- a/tests/components/frontier_silicon/conftest.py +++ b/tests/components/frontier_silicon/conftest.py @@ -4,11 +4,8 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.frontier_silicon.const import ( - CONF_PIN, - CONF_WEBFSAPI_URL, - DOMAIN, -) +from homeassistant.components.frontier_silicon.const import CONF_WEBFSAPI_URL, DOMAIN +from homeassistant.const import CONF_PIN from tests.common import MockConfigEntry diff --git a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr index 31925e2d626..a2fe4b63cf8 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_config_flow.ambr @@ -31,6 +31,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'gardena_bluetooth', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -40,6 +41,7 @@ 'disabled_by': None, 'domain': 'gardena_bluetooth', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -238,6 +240,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'gardena_bluetooth', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -247,6 +250,7 @@ 'disabled_by': None, 'domain': 'gardena_bluetooth', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py index da318b1a94d..670d3efce51 100644 --- a/tests/components/gdacs/test_sensor.py +++ b/tests/components/gdacs/test_sensor.py @@ -72,10 +72,10 @@ async def test_setup(hass: HomeAssistant) -> None: == 4 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") assert state is not None assert int(state.state) == 3 - assert state.name == "GDACS (32.87336, -117.22743)" + assert state.name == "32.87336, -117.22743" attributes = state.attributes assert attributes[ATTR_STATUS] == "OK" assert attributes[ATTR_CREATED] == 3 @@ -96,7 +96,7 @@ async def test_setup(hass: HomeAssistant) -> None: == 4 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") attributes = state.attributes assert attributes[ATTR_CREATED] == 1 assert attributes[ATTR_UPDATED] == 2 @@ -125,6 +125,6 @@ async def test_setup(hass: HomeAssistant) -> None: == 1 ) - state = hass.states.get("sensor.gdacs_32_87336_117_22743") + state = hass.states.get("sensor.32_87336_117_22743") attributes = state.attributes assert attributes[ATTR_REMOVED] == 3 diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 8bfd0a66dd5..70746f70c9a 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -1,9 +1,11 @@ """The tests for generic camera component.""" import asyncio +from datetime import timedelta from http import HTTPStatus from unittest.mock import patch import aiohttp +from freezegun.api import FrozenDateTimeFactory import httpx import pytest import respx @@ -49,6 +51,7 @@ async def test_fetching_url( "username": "user", "password": "pass", "authentication": "basic", + "framerate": 20, } }, ) @@ -63,10 +66,87 @@ async def test_fetching_url( body = await resp.read() assert body == fakeimgbytes_png + # sleep .1 seconds to make cached image expire + await asyncio.sleep(0.1) + resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 2 +@respx.mock +async def test_image_caching( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + fakeimgbytes_png, +) -> None: + """Test that the image is cached and not fetched more often than the framerate indicates.""" + respx.get("http://example.com").respond(stream=fakeimgbytes_png) + + framerate = 5 + await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + "username": "user", + "password": "pass", + "authentication": "basic", + "framerate": framerate, + } + }, + ) + await hass.async_block_till_done() + + client = await hass_client() + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # time is frozen, image should have come from cache + assert respx.calls.call_count == 1 + + # advance time by 150ms + freezer.tick(timedelta(seconds=0.150)) + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # Only 150ms have passed, image should still have come from cache + assert respx.calls.call_count == 1 + + # advance time by another 150ms + freezer.tick(timedelta(seconds=0.150)) + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # 300ms have passed, now we should have fetched a new image + assert respx.calls.call_count == 2 + + resp = await client.get("/api/camera_proxy/camera.config_test") + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == fakeimgbytes_png + + # Still only 300ms have passed, should have returned the cached image + assert respx.calls.call_count == 2 + + @respx.mock async def test_fetching_without_verify_ssl( hass: HomeAssistant, hass_client: ClientSessionGenerator, fakeimgbytes_png @@ -468,6 +548,7 @@ async def test_timeout_cancelled( "still_image_url": "http://example.com", "username": "user", "password": "pass", + "framerate": 20, } }, ) @@ -497,6 +578,8 @@ async def test_timeout_cancelled( ] for total_calls in range(2, 4): + # sleep .1 seconds to make cached image expire + await asyncio.sleep(0.1) resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == total_calls assert resp.status == HTTPStatus.OK diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 47a3cdc30af..9196de8b096 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -41,6 +41,7 @@ from homeassistant.core import ( State, callback, ) +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -388,7 +389,7 @@ async def test_set_preset_mode_invalid(hass: HomeAssistant, setup_comp_2) -> Non await common.async_set_preset_mode(hass, "none") state = hass.states.get(ENTITY) assert state.attributes.get("preset_mode") == "none" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await common.async_set_preset_mode(hass, "Sleep") state = hass.states.get(ENTITY) assert state.attributes.get("preset_mode") == "none" diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py index a44357a5763..3875a525e73 100644 --- a/tests/components/geo_json_events/test_geo_location.py +++ b/tests/components/geo_json_events/test_geo_location.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_RADIUS, CONF_SCAN_INTERVAL, CONF_URL, - LENGTH_KILOMETERS, + UnitOfLength, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -77,7 +77,7 @@ async def test_entity_lifecycle( ATTR_LATITUDE: -31.0, ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", - ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, ATTR_SOURCE: "geo_json_events", } assert round(abs(float(state.state) - 15.5), 7) == 0 @@ -90,7 +90,7 @@ async def test_entity_lifecycle( ATTR_LATITUDE: -31.1, ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", - ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, ATTR_SOURCE: "geo_json_events", } assert round(abs(float(state.state) - 20.5), 7) == 0 @@ -103,7 +103,7 @@ async def test_entity_lifecycle( ATTR_LATITUDE: -31.2, ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", - ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, ATTR_SOURCE: "geo_json_events", } assert round(abs(float(state.state) - 25.5), 7) == 0 diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index 561d9aaedeb..afc6ada75cd 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -2,6 +2,8 @@ import datetime from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components import geonetnz_quakes from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.components.geonetnz_quakes import DEFAULT_SCAN_INTERVAL, DOMAIN, FEED @@ -38,7 +40,11 @@ from tests.common import async_fire_time_changed CONFIG = {geonetnz_quakes.DOMAIN: {CONF_RADIUS: 200}} -async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: """Test the general setup of the integration.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -64,9 +70,8 @@ async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update: + freezer.move_to(utcnow) + with patch("aio_geojson_client.feed.GeoJsonFeed.update") as mock_feed_update: mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3] assert await async_setup_component(hass, geonetnz_quakes.DOMAIN, CONFIG) await hass.async_block_till_done() @@ -167,17 +172,17 @@ async def test_setup(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> assert len(entity_registry.entities) == 1 -async def test_setup_imperial(hass: HomeAssistant) -> None: +async def test_setup_imperial( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the setup of the integration using imperial unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 15.5, (38.0, -3.0)) # Patching 'utcnow' to gain more control over the timed update. - utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update, patch( + freezer.move_to(dt_util.utcnow()) + with patch("aio_geojson_client.feed.GeoJsonFeed.update") as mock_feed_update, patch( "aio_geojson_client.feed.GeoJsonFeed.last_timestamp", create=True ): mock_feed_update.return_value = "OK", [mock_entry_1] diff --git a/tests/components/geonetnz_volcano/test_sensor.py b/tests/components/geonetnz_volcano/test_sensor.py index a237fb2c314..4d11ff0673c 100644 --- a/tests/components/geonetnz_volcano/test_sensor.py +++ b/tests/components/geonetnz_volcano/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory from homeassistant.components import geonetnz_volcano from homeassistant.components.geo_location import ATTR_DISTANCE @@ -149,15 +150,17 @@ async def test_setup(hass: HomeAssistant) -> None: ) -async def test_setup_imperial(hass: HomeAssistant) -> None: +async def test_setup_imperial( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the setup of the integration using imperial unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 1, 15.5, (38.0, -3.0)) # Patching 'utcnow' to gain more control over the timed update. - utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( + freezer.move_to(dt_util.utcnow()) + with patch( "aio_geojson_client.feed.GeoJsonFeed.update", new_callable=AsyncMock ) as mock_feed_update, patch( "aio_geojson_client.feed.GeoJsonFeed.__init__" diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr index 67691602fcf..1401b1e22a0 100644 --- a/tests/components/gios/snapshots/test_diagnostics.ambr +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'gios', 'entry_id': '86129426118ae32020417a53712d6eef', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/github/conftest.py b/tests/components/github/conftest.py index 04b53da6b91..b0b6f243fa0 100644 --- a/tests/components/github/conftest.py +++ b/tests/components/github/conftest.py @@ -4,11 +4,8 @@ from unittest.mock import patch import pytest -from homeassistant.components.github.const import ( - CONF_ACCESS_TOKEN, - CONF_REPOSITORIES, - DOMAIN, -) +from homeassistant.components.github.const import CONF_REPOSITORIES, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from .common import MOCK_ACCESS_TOKEN, TEST_REPOSITORY, setup_github_integration diff --git a/tests/components/github/test_config_flow.py b/tests/components/github/test_config_flow.py index ad3be582a5d..8d61eca1ab1 100644 --- a/tests/components/github/test_config_flow.py +++ b/tests/components/github/test_config_flow.py @@ -2,17 +2,18 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiogithubapi import GitHubException +import pytest from homeassistant import config_entries from homeassistant.components.github.config_flow import get_repositories from homeassistant.components.github.const import ( - CONF_ACCESS_TOKEN, CONF_REPOSITORIES, DEFAULT_REPOSITORIES, DOMAIN, ) +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.data_entry_flow import FlowResultType, UnknownFlow from .common import MOCK_ACCESS_TOKEN @@ -126,6 +127,44 @@ async def test_flow_with_activation_failure( assert result["step_id"] == "could_not_register" +async def test_flow_with_remove_while_activating( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test flow with user canceling while activating.""" + aioclient_mock.post( + "https://github.com/login/device/code", + json={ + "device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5", + "user_code": "WDJB-MJHT", + "verification_uri": "https://github.com/login/device", + "expires_in": 900, + "interval": 5, + }, + headers={"Content-Type": "application/json"}, + ) + aioclient_mock.post( + "https://github.com/login/oauth/access_token", + json={"error": "authorization_pending"}, + headers={"Content-Type": "application/json"}, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["step_id"] == "device" + assert result["type"] == FlowResultType.SHOW_PROGRESS + + assert hass.config_entries.flow.async_get(result["flow_id"]) + + # Simulate user canceling the flow + hass.config_entries.flow._async_remove_flow_progress(result["flow_id"]) + await hass.async_block_till_done() + + with pytest.raises(UnknownFlow): + hass.config_entries.flow.async_get(result["flow_id"]) + + async def test_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/glances/__init__.py b/tests/components/glances/__init__.py index 41f2675c41c..91f8da92799 100644 --- a/tests/components/glances/__init__.py +++ b/tests/components/glances/__init__.py @@ -181,8 +181,8 @@ HA_SENSOR_DATA: dict[str, Any] = { }, "sensors": { "cpu_thermal 1": {"temperature_core": 59}, - "err_temp": {"temperature_hdd": "Unavailable"}, - "na_temp": {"temperature_hdd": "Unavailable"}, + "err_temp": {"temperature_hdd": "unavailable"}, + "na_temp": {"temperature_hdd": "unavailable"}, }, "mem": { "memory_use_percent": 27.6, diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index 095c034abe0..af00126b219 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -19,30 +19,42 @@ async def test_sensor_states(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(entry.entry_id) - if state := hass.states.get("sensor.0_0_0_0_ssl_disk_use"): - assert state.state == HA_SENSOR_DATA["fs"]["/ssl"]["disk_use"] - if state := hass.states.get("sensor.0_0_0_0_cpu_thermal_1"): - assert state.state == HA_SENSOR_DATA["sensors"]["cpu_thermal 1"] - if state := hass.states.get("sensor.0_0_0_0_err_temp"): - assert state.state == HA_SENSOR_DATA["sensors"]["err_temp"] - if state := hass.states.get("sensor.0_0_0_0_na_temp"): - assert state.state == HA_SENSOR_DATA["sensors"]["na_temp"] - if state := hass.states.get("sensor.0_0_0_0_memory_use_percent"): - assert state.state == HA_SENSOR_DATA["mem"]["memory_use_percent"] - if state := hass.states.get("sensor.0_0_0_0_docker_active"): - assert state.state == HA_SENSOR_DATA["docker"]["docker_active"] - if state := hass.states.get("sensor.0_0_0_0_docker_cpu_use"): - assert state.state == HA_SENSOR_DATA["docker"]["docker_cpu_use"] - if state := hass.states.get("sensor.0_0_0_0_docker_memory_use"): - assert state.state == HA_SENSOR_DATA["docker"]["docker_memory_use"] - if state := hass.states.get("sensor.0_0_0_0_md3_available"): - assert state.state == HA_SENSOR_DATA["raid"]["md3"]["available"] - if state := hass.states.get("sensor.0_0_0_0_md3_used"): - assert state.state == HA_SENSOR_DATA["raid"]["md3"]["used"] - if state := hass.states.get("sensor.0_0_0_0_md1_available"): - assert state.state == HA_SENSOR_DATA["raid"]["md1"]["available"] - if state := hass.states.get("sensor.0_0_0_0_md1_used"): - assert state.state == HA_SENSOR_DATA["raid"]["md1"]["used"] + assert hass.states.get("sensor.0_0_0_0_ssl_used").state == str( + HA_SENSOR_DATA["fs"]["/ssl"]["disk_use"] + ) + assert hass.states.get("sensor.0_0_0_0_cpu_thermal_1_temperature").state == str( + HA_SENSOR_DATA["sensors"]["cpu_thermal 1"]["temperature_core"] + ) + assert hass.states.get("sensor.0_0_0_0_err_temp_temperature").state == str( + HA_SENSOR_DATA["sensors"]["err_temp"]["temperature_hdd"] + ) + assert hass.states.get("sensor.0_0_0_0_na_temp_temperature").state == str( + HA_SENSOR_DATA["sensors"]["na_temp"]["temperature_hdd"] + ) + assert hass.states.get("sensor.0_0_0_0_ram_used_percent").state == str( + HA_SENSOR_DATA["mem"]["memory_use_percent"] + ) + assert hass.states.get("sensor.0_0_0_0_containers_active").state == str( + HA_SENSOR_DATA["docker"]["docker_active"] + ) + assert hass.states.get("sensor.0_0_0_0_containers_cpu_used").state == str( + HA_SENSOR_DATA["docker"]["docker_cpu_use"] + ) + assert hass.states.get("sensor.0_0_0_0_containers_ram_used").state == str( + HA_SENSOR_DATA["docker"]["docker_memory_use"] + ) + assert hass.states.get("sensor.0_0_0_0_md3_raid_available").state == str( + HA_SENSOR_DATA["raid"]["md3"]["available"] + ) + assert hass.states.get("sensor.0_0_0_0_md3_raid_used").state == str( + HA_SENSOR_DATA["raid"]["md3"]["used"] + ) + assert hass.states.get("sensor.0_0_0_0_md1_raid_available").state == str( + HA_SENSOR_DATA["raid"]["md1"]["available"] + ) + assert hass.states.get("sensor.0_0_0_0_md1_raid_used").state == str( + HA_SENSOR_DATA["raid"]["md1"]["used"] + ) @pytest.mark.parametrize( diff --git a/tests/components/goodwe/conftest.py b/tests/components/goodwe/conftest.py new file mode 100644 index 00000000000..cabb0f6ea10 --- /dev/null +++ b/tests/components/goodwe/conftest.py @@ -0,0 +1,25 @@ +"""Fixtures for the Aladdin Connect integration tests.""" +from unittest.mock import AsyncMock, MagicMock + +from goodwe import Inverter +import pytest + + +@pytest.fixture(name="mock_inverter") +def fixture_mock_inverter(): + """Set up inverter fixture.""" + mock_inverter = MagicMock(spec=Inverter) + mock_inverter.serial_number = "dummy_serial_nr" + mock_inverter.arm_version = 1 + mock_inverter.arm_svn_version = 2 + mock_inverter.arm_firmware = "dummy.arm.version" + mock_inverter.firmware = "dummy.fw.version" + mock_inverter.model_name = "MOCK" + mock_inverter.rated_power = 10000 + mock_inverter.dsp1_version = 3 + mock_inverter.dsp2_version = 4 + mock_inverter.dsp_svn_version = 5 + + mock_inverter.read_runtime_data = AsyncMock(return_value={}) + + return mock_inverter diff --git a/tests/components/goodwe/snapshots/test_diagnostics.ambr b/tests/components/goodwe/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..4097848a34a --- /dev/null +++ b/tests/components/goodwe/snapshots/test_diagnostics.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'host': 'localhost', + 'model_family': 'ET', + }), + 'disabled_by': None, + 'domain': 'goodwe', + 'entry_id': '3bd2acb0e4f0476d40865546d0d91921', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }), + 'inverter': dict({ + 'arm_firmware': 'dummy.arm.version', + 'arm_svn_version': 2, + 'arm_version': 1, + 'dsp1_version': 3, + 'dsp2_version': 4, + 'dsp_svn_version': 5, + 'firmware': 'dummy.fw.version', + 'model_name': 'MOCK', + 'rated_power': 10000, + }), + }) +# --- diff --git a/tests/components/goodwe/test_diagnostics.py b/tests/components/goodwe/test_diagnostics.py new file mode 100644 index 00000000000..edda2ed2cb7 --- /dev/null +++ b/tests/components/goodwe/test_diagnostics.py @@ -0,0 +1,34 @@ +"""Test the CO2Signal diagnostics.""" +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.goodwe import CONF_MODEL_FAMILY, DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_inverter: MagicMock, +) -> None: + """Test config entry diagnostics.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "localhost", CONF_MODEL_FAMILY: "ET"}, + entry_id="3bd2acb0e4f0476d40865546d0d91921", + ) + config_entry.add_to_hass(hass) + with patch("homeassistant.components.goodwe.connect", return_value=mock_inverter): + assert await async_setup_component(hass, DOMAIN, {}) + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert result == snapshot diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index 3b2ed6d24e1..97d918c2e01 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Generator import datetime import http +import time from typing import Any, TypeVar from unittest.mock import Mock, mock_open, patch @@ -189,9 +190,9 @@ def creds( @pytest.fixture -def config_entry_token_expiry(token_expiry: datetime.datetime) -> float: +def config_entry_token_expiry() -> float: """Fixture for token expiration value stored in the config entry.""" - return token_expiry.timestamp() + return time.time() + 86400 @pytest.fixture @@ -260,7 +261,7 @@ def mock_events_list( @pytest.fixture def mock_events_list_items( - mock_events_list: Callable[[dict[str, Any]], None] + mock_events_list: Callable[[dict[str, Any]], None], ) -> Callable[[list[dict[str, Any]]], None]: """Fixture to construct an API response containing event items.""" diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 8466f5ad4eb..d1cc41e166a 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -9,6 +9,7 @@ from unittest.mock import patch import urllib from aiohttp.client_exceptions import ClientError +from freezegun.api import FrozenDateTimeFactory from gcal_sync.auth import API_BASE_URL import pytest @@ -578,11 +579,13 @@ async def test_scan_calendar_error( async def test_future_event_update_behavior( - hass: HomeAssistant, mock_events_list_items, component_setup + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_events_list_items, + component_setup, ) -> None: """Test an future event that becomes active.""" now = dt_util.now() - now_utc = dt_util.utcnow() one_hour_from_now = now + datetime.timedelta(minutes=60) end_event = one_hour_from_now + datetime.timedelta(minutes=90) event = { @@ -600,12 +603,9 @@ async def test_future_event_update_behavior( # Advance time until event has started now += datetime.timedelta(minutes=60) - now_utc += datetime.timedelta(minutes=60) - with patch("homeassistant.util.dt.utcnow", return_value=now_utc), patch( - "homeassistant.util.dt.now", return_value=now - ): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() # Event has started state = hass.states.get(TEST_ENTITY) @@ -613,11 +613,13 @@ async def test_future_event_update_behavior( async def test_future_event_offset_update_behavior( - hass: HomeAssistant, mock_events_list_items, component_setup + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_events_list_items, + component_setup, ) -> None: """Test an future event that becomes active.""" now = dt_util.now() - now_utc = dt_util.utcnow() one_hour_from_now = now + datetime.timedelta(minutes=60) end_event = one_hour_from_now + datetime.timedelta(minutes=90) event_summary = "Test Event in Progress" @@ -638,12 +640,9 @@ async def test_future_event_offset_update_behavior( # Advance time until event has started now += datetime.timedelta(minutes=45) - now_utc += datetime.timedelta(minutes=45) - with patch("homeassistant.util.dt.utcnow", return_value=now_utc), patch( - "homeassistant.util.dt.now", return_value=now - ): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() # Event has not started, but the offset was reached state = hass.states.get(TEST_ENTITY) diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index f534f624bf6..b2c472757b6 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -9,6 +9,7 @@ from typing import Any from unittest.mock import Mock, patch from aiohttp.client_exceptions import ClientError +from freezegun import freeze_time from oauth2client.client import ( DeviceFlowInfo, FlowExchangeError, @@ -130,7 +131,7 @@ async def primary_calendar( async def fire_alarm(hass, point_in_time): """Fire an alarm and wait for callbacks to run.""" - with patch("homeassistant.util.dt.utcnow", return_value=point_in_time): + with freeze_time(point_in_time): async_fire_time_changed(hass, point_in_time) await hass.async_block_till_done() diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 9ede0573922..26a5cb2e192 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -699,7 +699,11 @@ async def test_add_event_location( @pytest.mark.parametrize( "config_entry_token_expiry", - [datetime.datetime.max.replace(tzinfo=UTC).timestamp() + 1], + [ + (datetime.datetime.max.replace(tzinfo=UTC).timestamp() + 1), + (utcnow().replace(tzinfo=None).timestamp()), + ], + ids=["max_timestamp", "timestamp_naive"], ) async def test_invalid_token_expiry_in_config_entry( hass: HomeAssistant, diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 2122818bbb4..6fc1c9f580d 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -237,6 +237,26 @@ DEMO_DEVICES = [ "type": "action.devices.types.SETTOP", "willReportState": False, }, + { + "id": "media_player.browse", + "name": {"name": "Browse"}, + "traits": ["action.devices.traits.MediaState", "action.devices.traits.OnOff"], + "type": "action.devices.types.SETTOP", + "willReportState": False, + }, + { + "id": "media_player.group", + "name": {"name": "Group"}, + "traits": [ + "action.devices.traits.OnOff", + "action.devices.traits.Volume", + "action.devices.traits.Modes", + "action.devices.traits.TransportControl", + "action.devices.traits.MediaState", + ], + "type": "action.devices.types.SETTOP", + "willReportState": False, + }, { "id": "fan.living_room_fan", "name": {"name": "Living Room Fan"}, diff --git a/tests/components/google_assistant/snapshots/test_diagnostics.ambr b/tests/components/google_assistant/snapshots/test_diagnostics.ambr index dffcddf5de5..9a4ad8b3da3 100644 --- a/tests/components/google_assistant/snapshots/test_diagnostics.ambr +++ b/tests/components/google_assistant/snapshots/test_diagnostics.ambr @@ -7,6 +7,7 @@ }), 'disabled_by': None, 'domain': 'google_assistant', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -102,6 +103,8 @@ 'sensor', 'switch', 'vacuum', + 'valve', + 'water_heater', ]), 'project_id': '1234', 'report_state': False, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 903ba5ca036..3f1e28cb667 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from unittest.mock import ANY, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import ( @@ -27,6 +28,8 @@ from homeassistant.components import ( sensor, switch, vacuum, + valve, + water_heater, ) from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature from homeassistant.components.camera import CameraEntityFeature @@ -44,6 +47,8 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.components.vacuum import VacuumEntityFeature +from homeassistant.components.valve import ValveEntityFeature +from homeassistant.components.water_heater import WaterHeaterEntityFeature from homeassistant.config import async_process_ha_core_config from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -74,7 +79,8 @@ from homeassistant.core import ( HomeAssistant, State, ) -from homeassistant.util import color +from homeassistant.util import color, dt as dt_util +from homeassistant.util.unit_conversion import TemperatureConverter from . import BASIC_CONFIG, MockConfig @@ -393,6 +399,35 @@ async def test_onoff_humidifier(hass: HomeAssistant) -> None: assert off_calls[0].data == {ATTR_ENTITY_ID: "humidifier.bla"} +async def test_onoff_water_heater(hass: HomeAssistant) -> None: + """Test OnOff trait support for water_heater domain.""" + assert helpers.get_google_type(water_heater.DOMAIN, None) is not None + assert trait.OnOffTrait.supported( + water_heater.DOMAIN, WaterHeaterEntityFeature.ON_OFF, None, None + ) + + trt_on = trait.OnOffTrait(hass, State("water_heater.bla", STATE_ON), BASIC_CONFIG) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == {"on": True} + + trt_off = trait.OnOffTrait(hass, State("water_heater.bla", STATE_OFF), BASIC_CONFIG) + + assert trt_off.query_attributes() == {"on": False} + + on_calls = async_mock_service(hass, water_heater.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(trait.COMMAND_ONOFF, BASIC_DATA, {"on": True}, {}) + assert len(on_calls) == 1 + assert on_calls[0].data == {ATTR_ENTITY_ID: "water_heater.bla"} + + off_calls = async_mock_service(hass, water_heater.DOMAIN, SERVICE_TURN_OFF) + + await trt_on.execute(trait.COMMAND_ONOFF, BASIC_DATA, {"on": False}, {}) + assert len(off_calls) == 1 + assert off_calls[0].data == {ATTR_ENTITY_ID: "water_heater.bla"} + + async def test_dock_vacuum(hass: HomeAssistant) -> None: """Test dock trait support for vacuum domain.""" assert helpers.get_google_type(vacuum.DOMAIN, None) is not None @@ -549,17 +584,71 @@ async def test_startstop_vacuum(hass: HomeAssistant) -> None: assert unpause_calls[0].data == {ATTR_ENTITY_ID: "vacuum.bla"} -async def test_startstop_cover(hass: HomeAssistant) -> None: - """Test startStop trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None - assert trait.StartStopTrait.supported( - cover.DOMAIN, CoverEntityFeature.STOP, None, None - ) +@pytest.mark.parametrize( + ( + "domain", + "state_open", + "state_closed", + "state_opening", + "state_closing", + "supported_features", + "service_close", + "service_open", + "service_stop", + "service_toggle", + ), + [ + ( + cover.DOMAIN, + cover.STATE_OPEN, + cover.STATE_CLOSED, + cover.STATE_OPENING, + cover.STATE_CLOSING, + CoverEntityFeature.STOP + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + cover.SERVICE_OPEN_COVER, + cover.SERVICE_CLOSE_COVER, + cover.SERVICE_STOP_COVER, + cover.SERVICE_TOGGLE, + ), + ( + valve.DOMAIN, + valve.STATE_OPEN, + valve.STATE_CLOSED, + valve.STATE_OPENING, + valve.STATE_CLOSING, + ValveEntityFeature.STOP + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + valve.SERVICE_OPEN_VALVE, + valve.SERVICE_CLOSE_VALVE, + valve.SERVICE_STOP_VALVE, + cover.SERVICE_TOGGLE, + ), + ], +) +async def test_startstop_cover_valve( + hass: HomeAssistant, + domain: str, + state_open: str, + state_closed: str, + state_opening: str, + state_closing: str, + supported_features: str, + service_open: str, + service_close: str, + service_stop: str, + service_toggle: str, +) -> None: + """Test startStop trait support.""" + assert helpers.get_google_type(domain, None) is not None + assert trait.StartStopTrait.supported(domain, supported_features, None, None) state = State( - "cover.bla", - cover.STATE_CLOSED, - {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.STOP}, + f"{domain}.bla", + state_closed, + {ATTR_SUPPORTED_FEATURES: supported_features}, ) trt = trait.StartStopTrait( @@ -570,25 +659,48 @@ async def test_startstop_cover(hass: HomeAssistant) -> None: assert trt.sync_attributes() == {} - for state_value in (cover.STATE_CLOSING, cover.STATE_OPENING): + for state_value in (state_closing, state_opening): state.state = state_value assert trt.query_attributes() == {"isRunning": True} - stop_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_STOP_COVER) + stop_calls = async_mock_service(hass, domain, service_stop) + open_calls = async_mock_service(hass, domain, service_open) + close_calls = async_mock_service(hass, domain, service_close) + toggle_calls = async_mock_service(hass, domain, service_toggle) await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": False}, {}) assert len(stop_calls) == 1 - assert stop_calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert stop_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} - for state_value in (cover.STATE_CLOSED, cover.STATE_OPEN): + for state_value in (state_closed, state_open): state.state = state_value assert trt.query_attributes() == {"isRunning": False} - with pytest.raises(SmartHomeError, match="Cover is already stopped"): + for state_value in (state_closing, state_opening): + state.state = state_value + assert trt.query_attributes() == {"isRunning": True} + + state.state = state_open + with pytest.raises( + SmartHomeError, match=f"{domain.capitalize()} is already stopped" + ): await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": False}, {}) - with pytest.raises(SmartHomeError, match="Starting a cover is not supported"): - await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": True}, {}) + # Start triggers toggle open + state.state = state_closed + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": True}, {}) + assert len(open_calls) == 0 + assert len(close_calls) == 0 + assert len(toggle_calls) == 1 + assert toggle_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + # Second start triggers toggle close + state.state = state_open + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": True}, {}) + assert len(open_calls) == 0 + assert len(close_calls) == 0 + assert len(toggle_calls) == 2 + assert toggle_calls[1].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + state.state = state_closed with pytest.raises( SmartHomeError, match="Command action.devices.commands.PauseUnpause is not supported", @@ -596,25 +708,89 @@ async def test_startstop_cover(hass: HomeAssistant) -> None: await trt.execute(trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {"start": True}, {}) -async def test_startstop_cover_assumed(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ( + "domain", + "state_open", + "state_closed", + "state_opening", + "state_closing", + "supported_features", + "service_close", + "service_open", + "service_stop", + "service_toggle", + ), + [ + ( + cover.DOMAIN, + cover.STATE_OPEN, + cover.STATE_CLOSED, + cover.STATE_OPENING, + cover.STATE_CLOSING, + CoverEntityFeature.STOP + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE, + cover.SERVICE_OPEN_COVER, + cover.SERVICE_CLOSE_COVER, + cover.SERVICE_STOP_COVER, + cover.SERVICE_TOGGLE, + ), + ( + valve.DOMAIN, + valve.STATE_OPEN, + valve.STATE_CLOSED, + valve.STATE_OPENING, + valve.STATE_CLOSING, + ValveEntityFeature.STOP + | ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE, + valve.SERVICE_OPEN_VALVE, + valve.SERVICE_CLOSE_VALVE, + valve.SERVICE_STOP_VALVE, + cover.SERVICE_TOGGLE, + ), + ], +) +async def test_startstop_cover_valve_assumed( + hass: HomeAssistant, + domain: str, + state_open: str, + state_closed: str, + state_opening: str, + state_closing: str, + supported_features: str, + service_open: str, + service_close: str, + service_stop: str, + service_toggle: str, +) -> None: """Test startStop trait support for cover domain of assumed state.""" trt = trait.StartStopTrait( hass, State( - "cover.bla", - cover.STATE_CLOSED, + f"{domain}.bla", + state_closed, { - ATTR_SUPPORTED_FEATURES: CoverEntityFeature.STOP, + ATTR_SUPPORTED_FEATURES: supported_features, ATTR_ASSUMED_STATE: True, }, ), BASIC_CONFIG, ) - stop_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_STOP_COVER) + stop_calls = async_mock_service(hass, domain, service_stop) + toggle_calls = async_mock_service(hass, domain, service_toggle) await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": False}, {}) assert len(stop_calls) == 1 - assert stop_calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert len(toggle_calls) == 0 + assert stop_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + + stop_calls.clear() + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {"start": True}, {}) + assert len(stop_calls) == 0 + assert len(toggle_calls) == 1 + assert toggle_calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} @pytest.mark.parametrize("supported_color_modes", [["hs"], ["rgb"], ["xy"]]) @@ -1246,6 +1422,135 @@ async def test_temperature_control(hass: HomeAssistant) -> None: assert err.value.code == const.ERR_NOT_SUPPORTED +@pytest.mark.parametrize( + ("unit_in", "unit_out", "temp_in", "temp_out", "current_in", "current_out"), + [ + (UnitOfTemperature.CELSIUS, "C", "120", 120, "130", 130), + (UnitOfTemperature.FAHRENHEIT, "F", "248", 120, "266", 130), + ], +) +async def test_temperature_control_water_heater( + hass: HomeAssistant, + unit_in: UnitOfTemperature, + unit_out: str, + temp_in: str, + temp_out: float, + current_in: str, + current_out: float, +) -> None: + """Test TemperatureControl trait support for water heater domain.""" + hass.config.units.temperature_unit = unit_in + + min_temp = TemperatureConverter.convert( + water_heater.DEFAULT_MIN_TEMP, + UnitOfTemperature.CELSIUS, + unit_in, + ) + max_temp = TemperatureConverter.convert( + water_heater.DEFAULT_MAX_TEMP, + UnitOfTemperature.CELSIUS, + unit_in, + ) + + trt = trait.TemperatureControlTrait( + hass, + State( + "water_heater.bla", + "attributes", + { + "min_temp": min_temp, + "max_temp": max_temp, + "temperature": temp_in, + "current_temperature": current_in, + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "temperatureUnitForUX": unit_out, + "temperatureRange": { + "maxThresholdCelsius": water_heater.DEFAULT_MAX_TEMP, + "minThresholdCelsius": water_heater.DEFAULT_MIN_TEMP, + }, + } + assert trt.query_attributes() == { + "temperatureSetpointCelsius": temp_out, + "temperatureAmbientCelsius": current_out, + } + + +@pytest.mark.parametrize( + ("unit", "temp_init", "temp_in", "temp_out", "current_init"), + [ + (UnitOfTemperature.CELSIUS, "180", 220, 220, "180"), + (UnitOfTemperature.FAHRENHEIT, "356", 220, 428, "356"), + ], +) +async def test_temperature_control_water_heater_set_temperature( + hass: HomeAssistant, + unit: UnitOfTemperature, + temp_init: str, + temp_in: float, + temp_out: float, + current_init: str, +) -> None: + """Test TemperatureControl trait support for water heater domain - SetTemperature.""" + hass.config.units.temperature_unit = unit + + min_temp = TemperatureConverter.convert( + 40, + UnitOfTemperature.CELSIUS, + unit, + ) + max_temp = TemperatureConverter.convert( + 230, + UnitOfTemperature.CELSIUS, + unit, + ) + + trt = trait.TemperatureControlTrait( + hass, + State( + "water_heater.bla", + "attributes", + { + "min_temp": min_temp, + "max_temp": max_temp, + "temperature": temp_init, + "current_temperature": current_init, + }, + ), + BASIC_CONFIG, + ) + + assert trt.can_execute(trait.COMMAND_SET_TEMPERATURE, {}) + + calls = async_mock_service( + hass, water_heater.DOMAIN, water_heater.SERVICE_SET_TEMPERATURE + ) + + with pytest.raises(helpers.SmartHomeError): + await trt.execute( + trait.COMMAND_SET_TEMPERATURE, + BASIC_DATA, + {"temperature": -100}, + {}, + ) + + await trt.execute( + trait.COMMAND_SET_TEMPERATURE, + BASIC_DATA, + {"temperature": temp_in}, + {}, + ) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: "water_heater.bla", + ATTR_TEMPERATURE: temp_out, + } + + async def test_humidity_setting_humidifier_setpoint(hass: HomeAssistant) -> None: """Test HumiditySetting trait support for humidifier domain - setpoint.""" assert helpers.get_google_type(humidifier.DOMAIN, None) is not None @@ -2411,6 +2716,84 @@ async def test_modes_humidifier(hass: HomeAssistant) -> None: } +async def test_modes_water_heater(hass: HomeAssistant) -> None: + """Test Humidifier Mode trait.""" + assert helpers.get_google_type(water_heater.DOMAIN, None) is not None + assert trait.ModesTrait.supported( + water_heater.DOMAIN, WaterHeaterEntityFeature.OPERATION_MODE, None, None + ) + + trt = trait.ModesTrait( + hass, + State( + "water_heater.water_heater", + STATE_OFF, + attributes={ + water_heater.ATTR_OPERATION_LIST: [ + water_heater.STATE_ECO, + water_heater.STATE_HEAT_PUMP, + water_heater.STATE_GAS, + ], + ATTR_SUPPORTED_FEATURES: WaterHeaterEntityFeature.OPERATION_MODE, + water_heater.ATTR_OPERATION_MODE: water_heater.STATE_HEAT_PUMP, + }, + ), + BASIC_CONFIG, + ) + + attribs = trt.sync_attributes() + assert attribs == { + "availableModes": [ + { + "name": "operation mode", + "name_values": [{"name_synonym": ["operation mode"], "lang": "en"}], + "settings": [ + { + "setting_name": "eco", + "setting_values": [{"setting_synonym": ["eco"], "lang": "en"}], + }, + { + "setting_name": "heat_pump", + "setting_values": [ + {"setting_synonym": ["heat_pump"], "lang": "en"} + ], + }, + { + "setting_name": "gas", + "setting_values": [{"setting_synonym": ["gas"], "lang": "en"}], + }, + ], + "ordered": False, + }, + ] + } + + assert trt.query_attributes() == { + "currentModeSettings": {"operation mode": "heat_pump"}, + "on": False, + } + + assert trt.can_execute( + trait.COMMAND_MODES, params={"updateModeSettings": {"operation mode": "gas"}} + ) + + calls = async_mock_service( + hass, water_heater.DOMAIN, water_heater.SERVICE_SET_OPERATION_MODE + ) + await trt.execute( + trait.COMMAND_MODES, + BASIC_DATA, + {"updateModeSettings": {"operation mode": "gas"}}, + {}, + ) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": "water_heater.water_heater", + "operation_mode": "gas", + } + + async def test_sound_modes(hass: HomeAssistant) -> None: """Test Mode trait.""" assert helpers.get_google_type(media_player.DOMAIN, None) is not None @@ -2583,21 +2966,59 @@ async def test_traits_unknown_domains( caplog.clear() -async def test_openclose_cover(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None - assert trait.OpenCloseTrait.supported( - cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None - ) +@pytest.mark.parametrize( + ( + "domain", + "set_position_service", + "close_service", + "open_service", + "set_position_feature", + "attr_position", + "attr_current_position", + ), + [ + ( + cover.DOMAIN, + cover.SERVICE_SET_COVER_POSITION, + cover.SERVICE_CLOSE_COVER, + cover.SERVICE_OPEN_COVER, + CoverEntityFeature.SET_POSITION, + cover.ATTR_POSITION, + cover.ATTR_CURRENT_POSITION, + ), + ( + valve.DOMAIN, + valve.SERVICE_SET_VALVE_POSITION, + valve.SERVICE_CLOSE_VALVE, + valve.SERVICE_OPEN_VALVE, + ValveEntityFeature.SET_POSITION, + valve.ATTR_POSITION, + valve.ATTR_CURRENT_POSITION, + ), + ], +) +async def test_openclose_cover_valve( + hass: HomeAssistant, + domain: str, + set_position_service: str, + close_service: str, + open_service: str, + set_position_feature: int, + attr_position: str, + attr_current_position: str, +) -> None: + """Test OpenClose trait support.""" + assert helpers.get_google_type(domain, None) is not None + assert trait.OpenCloseTrait.supported(domain, set_position_service, None, None) trt = trait.OpenCloseTrait( hass, State( - "cover.bla", - cover.STATE_OPEN, + f"{domain}.bla", + "open", { - cover.ATTR_CURRENT_POSITION: 75, - ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + attr_current_position: 75, + ATTR_SUPPORTED_FEATURES: set_position_feature, }, ), BASIC_CONFIG, @@ -2606,34 +3027,74 @@ async def test_openclose_cover(hass: HomeAssistant) -> None: assert trt.sync_attributes() == {} assert trt.query_attributes() == {"openPercent": 75} - calls_set = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) - calls_open = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + calls_set = async_mock_service(hass, domain, set_position_service) + calls_open = async_mock_service(hass, domain, open_service) + calls_close = async_mock_service(hass, domain, close_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 50}, {}) await trt.execute( trait.COMMAND_OPENCLOSE_RELATIVE, BASIC_DATA, {"openRelativePercent": 50}, {} ) assert len(calls_set) == 1 - assert calls_set[0].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 50} + assert calls_set[0].data == { + ATTR_ENTITY_ID: f"{domain}.bla", + attr_position: 50, + } + calls_set.pop(0) assert len(calls_open) == 1 - assert calls_open[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert calls_open[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + calls_open.pop(0) + + assert len(calls_close) == 0 + + await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 0}, {}) + await trt.execute( + trait.COMMAND_OPENCLOSE_RELATIVE, BASIC_DATA, {"openRelativePercent": 0}, {} + ) + assert len(calls_set) == 1 + assert len(calls_close) == 1 + assert calls_close[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} + assert len(calls_open) == 0 -async def test_openclose_cover_unknown_state(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain with unknown state.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None +@pytest.mark.parametrize( + ("domain", "open_service", "set_position_feature", "open_feature"), + [ + ( + cover.DOMAIN, + cover.SERVICE_OPEN_COVER, + CoverEntityFeature.SET_POSITION, + CoverEntityFeature.OPEN, + ), + ( + valve.DOMAIN, + valve.SERVICE_OPEN_VALVE, + ValveEntityFeature.SET_POSITION, + ValveEntityFeature.OPEN, + ), + ], +) +async def test_openclose_cover_valve_unknown_state( + hass: HomeAssistant, + open_service: str, + domain: str, + set_position_feature: int, + open_feature: int, +) -> None: + """Test OpenClose trait support with unknown state.""" + assert helpers.get_google_type(domain, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None + cover.DOMAIN, set_position_feature, None, None ) # No state trt = trait.OpenCloseTrait( hass, State( - "cover.bla", + f"{domain}.bla", STATE_UNKNOWN, - {ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN}, + {ATTR_SUPPORTED_FEATURES: open_feature}, ), BASIC_CONFIG, ) @@ -2643,30 +3104,51 @@ async def test_openclose_cover_unknown_state(hass: HomeAssistant) -> None: with pytest.raises(helpers.SmartHomeError): trt.query_attributes() - calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + calls = async_mock_service(hass, domain, open_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 100}, {}) assert len(calls) == 1 - assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} with pytest.raises(helpers.SmartHomeError): trt.query_attributes() -async def test_openclose_cover_assumed_state(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None - assert trait.OpenCloseTrait.supported( - cover.DOMAIN, CoverEntityFeature.SET_POSITION, None, None - ) +@pytest.mark.parametrize( + ("domain", "set_position_service", "set_position_feature", "state_open"), + [ + ( + cover.DOMAIN, + cover.SERVICE_SET_COVER_POSITION, + CoverEntityFeature.SET_POSITION, + cover.STATE_OPEN, + ), + ( + valve.DOMAIN, + valve.SERVICE_SET_VALVE_POSITION, + ValveEntityFeature.SET_POSITION, + valve.STATE_OPEN, + ), + ], +) +async def test_openclose_cover_valve_assumed_state( + hass: HomeAssistant, + domain: str, + set_position_service: str, + set_position_feature: int, + state_open: str, +) -> None: + """Test OpenClose trait support.""" + assert helpers.get_google_type(domain, None) is not None + assert trait.OpenCloseTrait.supported(domain, set_position_feature, None, None) trt = trait.OpenCloseTrait( hass, State( - "cover.bla", - cover.STATE_OPEN, + f"{domain}.bla", + state_open, { ATTR_ASSUMED_STATE: True, - ATTR_SUPPORTED_FEATURES: CoverEntityFeature.SET_POSITION, + ATTR_SUPPORTED_FEATURES: set_position_feature, }, ), BASIC_CONFIG, @@ -2676,20 +3158,37 @@ async def test_openclose_cover_assumed_state(hass: HomeAssistant) -> None: assert trt.query_attributes() == {} - calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) + calls = async_mock_service(hass, domain, set_position_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 40}, {}) assert len(calls) == 1 - assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla", cover.ATTR_POSITION: 40} + assert calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla", cover.ATTR_POSITION: 40} -async def test_openclose_cover_query_only(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None - assert trait.OpenCloseTrait.supported(cover.DOMAIN, 0, None, None) +@pytest.mark.parametrize( + ("domain", "state_open"), + [ + ( + cover.DOMAIN, + cover.STATE_OPEN, + ), + ( + valve.DOMAIN, + valve.STATE_OPEN, + ), + ], +) +async def test_openclose_cover_valve_query_only( + hass: HomeAssistant, + domain: str, + state_open: str, +) -> None: + """Test OpenClose trait support.""" + assert helpers.get_google_type(domain, None) is not None + assert trait.OpenCloseTrait.supported(domain, 0, None, None) state = State( - "cover.bla", - cover.STATE_OPEN, + f"{domain}.bla", + state_open, ) trt = trait.OpenCloseTrait( @@ -2705,21 +3204,57 @@ async def test_openclose_cover_query_only(hass: HomeAssistant) -> None: assert trt.query_attributes() == {"openPercent": 100} -async def test_openclose_cover_no_position(hass: HomeAssistant) -> None: - """Test OpenClose trait support for cover domain.""" - assert helpers.get_google_type(cover.DOMAIN, None) is not None +@pytest.mark.parametrize( + ( + "domain", + "state_open", + "state_closed", + "supported_features", + "open_service", + "close_service", + ), + [ + ( + cover.DOMAIN, + cover.STATE_OPEN, + cover.STATE_CLOSED, + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + cover.SERVICE_OPEN_COVER, + cover.SERVICE_CLOSE_COVER, + ), + ( + valve.DOMAIN, + valve.STATE_OPEN, + valve.STATE_CLOSED, + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, + valve.SERVICE_OPEN_VALVE, + valve.SERVICE_CLOSE_VALVE, + ), + ], +) +async def test_openclose_cover_valve_no_position( + hass: HomeAssistant, + domain: str, + state_open: str, + state_closed: str, + supported_features: int, + open_service: str, + close_service: str, +) -> None: + """Test OpenClose trait support.""" + assert helpers.get_google_type(domain, None) is not None assert trait.OpenCloseTrait.supported( - cover.DOMAIN, - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + domain, + supported_features, None, None, ) state = State( - "cover.bla", - cover.STATE_OPEN, + f"{domain}.bla", + state_open, { - ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + ATTR_SUPPORTED_FEATURES: supported_features, }, ) @@ -2732,20 +3267,20 @@ async def test_openclose_cover_no_position(hass: HomeAssistant) -> None: assert trt.sync_attributes() == {"discreteOnlyOpenClose": True} assert trt.query_attributes() == {"openPercent": 100} - state.state = cover.STATE_CLOSED + state.state = state_closed assert trt.sync_attributes() == {"discreteOnlyOpenClose": True} assert trt.query_attributes() == {"openPercent": 0} - calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_CLOSE_COVER) + calls = async_mock_service(hass, domain, close_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 0}, {}) assert len(calls) == 1 - assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} - calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + calls = async_mock_service(hass, domain, open_service) await trt.execute(trait.COMMAND_OPENCLOSE, BASIC_DATA, {"openPercent": 100}, {}) assert len(calls) == 1 - assert calls[0].data == {ATTR_ENTITY_ID: "cover.bla"} + assert calls[0].data == {ATTR_ENTITY_ID: f"{domain}.bla"} with pytest.raises( SmartHomeError, match=r"Current position not know for relative command" @@ -3150,7 +3685,9 @@ async def test_humidity_setting_sensor_data( assert err.value.code == const.ERR_NOT_SUPPORTED -async def test_transport_control(hass: HomeAssistant) -> None: +async def test_transport_control( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the TransportControlTrait.""" assert helpers.get_google_type(media_player.DOMAIN, None) is not None @@ -3159,7 +3696,7 @@ async def test_transport_control(hass: HomeAssistant) -> None: media_player.DOMAIN, feature, None, None ) - now = datetime(2020, 1, 1) + now = datetime(2020, 1, 1, tzinfo=dt_util.UTC) trt = trait.TransportControlTrait( hass, @@ -3190,13 +3727,13 @@ async def test_transport_control(hass: HomeAssistant) -> None: ) # Patch to avoid time ticking over during the command failing the test - with patch("homeassistant.util.dt.utcnow", return_value=now): - await trt.execute( - trait.COMMAND_MEDIA_SEEK_RELATIVE, - BASIC_DATA, - {"relativePositionMs": 10000}, - {}, - ) + freezer.move_to(now) + await trt.execute( + trait.COMMAND_MEDIA_SEEK_RELATIVE, + BASIC_DATA, + {"relativePositionMs": 10000}, + {}, + ) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: "media_player.bla", diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr index 7d6eb920593..af8dec6a182 100644 --- a/tests/components/google_tasks/snapshots/test_todo.ambr +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -6,7 +6,7 @@ ) # --- # name: test_create_todo_list_item[description].1 - '{"title": "Soda", "status": "needsAction", "notes": "6-pack"}' + '{"title": "Soda", "status": "needsAction", "due": null, "notes": "6-pack"}' # --- # name: test_create_todo_list_item[due] tuple( @@ -15,7 +15,7 @@ ) # --- # name: test_create_todo_list_item[due].1 - '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00"}' + '{"title": "Soda", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00", "notes": null}' # --- # name: test_create_todo_list_item[summary] tuple( @@ -24,7 +24,7 @@ ) # --- # name: test_create_todo_list_item[summary].1 - '{"title": "Soda", "status": "needsAction"}' + '{"title": "Soda", "status": "needsAction", "due": null, "notes": null}' # --- # name: test_delete_todo_list_item[_handler] tuple( @@ -32,6 +32,56 @@ 'POST', ) # --- +# name: test_move_todo_item[api_responses0] + list([ + dict({ + 'status': 'needs_action', + 'summary': 'Water', + 'uid': 'some-task-id-1', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Milk', + 'uid': 'some-task-id-2', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Cheese', + 'uid': 'some-task-id-3', + }), + ]) +# --- +# name: test_move_todo_item[api_responses0].1 + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id-3/move?previous=some-task-id-1&alt=json', + 'POST', + ) +# --- +# name: test_move_todo_item[api_responses0].2 + None +# --- +# name: test_move_todo_item[api_responses0].3 + list([ + dict({ + 'status': 'needs_action', + 'summary': 'Water', + 'uid': 'some-task-id-1', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Cheese', + 'uid': 'some-task-id-3', + }), + dict({ + 'status': 'needs_action', + 'summary': 'Milk', + 'uid': 'some-task-id-2', + }), + ]) +# --- +# name: test_move_todo_item[api_responses0].4 + None +# --- # name: test_parent_child_ordering[api_responses0] list([ dict({ @@ -56,6 +106,24 @@ }), ]) # --- +# name: test_partial_update[clear_description] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[clear_description].1 + '{"title": "Water", "status": "needsAction", "due": null, "notes": null}' +# --- +# name: test_partial_update[clear_due_date] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[clear_due_date].1 + '{"title": "Water", "status": "needsAction", "due": null, "notes": null}' +# --- # name: test_partial_update[description] tuple( 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', @@ -63,7 +131,7 @@ ) # --- # name: test_partial_update[description].1 - '{"notes": "6-pack"}' + '{"title": "Water", "status": "needsAction", "due": null, "notes": "At least one gallon"}' # --- # name: test_partial_update[due_date] tuple( @@ -72,7 +140,16 @@ ) # --- # name: test_partial_update[due_date].1 - '{"due": "2023-11-18T00:00:00-08:00"}' + '{"title": "Water", "status": "needsAction", "due": "2023-11-18T00:00:00-08:00", "notes": null}' +# --- +# name: test_partial_update[empty_description] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update[empty_description].1 + '{"title": "Water", "status": "needsAction", "due": null, "notes": ""}' # --- # name: test_partial_update[rename] tuple( @@ -81,7 +158,7 @@ ) # --- # name: test_partial_update[rename].1 - '{"title": "Soda"}' + '{"title": "Soda", "status": "needsAction", "due": null, "notes": null}' # --- # name: test_partial_update_status[api_responses0] tuple( @@ -90,7 +167,7 @@ ) # --- # name: test_partial_update_status[api_responses0].1 - '{"status": "needsAction"}' + '{"title": "Water", "status": "needsAction", "due": null, "notes": null}' # --- # name: test_update_todo_list_item[api_responses0] tuple( @@ -99,5 +176,5 @@ ) # --- # name: test_update_todo_list_item[api_responses0].1 - '{"title": "Soda", "status": "completed"}' + '{"title": "Soda", "status": "completed", "due": null, "notes": null}' # --- diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 3329f89c1ca..ee1b1e4cfd4 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -48,6 +48,7 @@ LIST_TASKS_RESPONSE_WATER = { "id": "some-task-id", "title": "Water", "status": "needsAction", + "description": "Any size is ok", "position": "00000000000000000001", }, ], @@ -74,6 +75,29 @@ LIST_TASKS_RESPONSE_MULTIPLE = { }, ], } +LIST_TASKS_RESPONSE_REORDER = { + "items": [ + { + "id": "some-task-id-2", + "title": "Milk", + "status": "needsAction", + "position": "00000000000000000002", + }, + { + "id": "some-task-id-1", + "title": "Water", + "status": "needsAction", + "position": "00000000000000000001", + }, + # Task 3 moved after task 1 + { + "id": "some-task-id-3", + "title": "Cheese", + "status": "needsAction", + "position": "000000000000000000011", + }, + ], +} # API responses when testing update methods UPDATE_API_RESPONSES = [ @@ -493,9 +517,19 @@ async def test_update_todo_list_item_error( [ (UPDATE_API_RESPONSES, {"rename": "Soda"}), (UPDATE_API_RESPONSES, {"due_date": "2023-11-18"}), - (UPDATE_API_RESPONSES, {"description": "6-pack"}), + (UPDATE_API_RESPONSES, {"due_date": None}), + (UPDATE_API_RESPONSES, {"description": "At least one gallon"}), + (UPDATE_API_RESPONSES, {"description": ""}), + (UPDATE_API_RESPONSES, {"description": None}), ], - ids=("rename", "due_date", "description"), + ids=( + "rename", + "due_date", + "clear_due_date", + "description", + "empty_description", + "clear_description", + ), ) async def test_partial_update( hass: HomeAssistant, @@ -793,6 +827,64 @@ async def test_parent_child_ordering( assert items == snapshot +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE_MULTIPLE, + EMPTY_RESPONSE, # move + LIST_TASKS_RESPONSE_REORDER, # refresh after move + ] + ], +) +async def test_move_todo_item( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + hass_ws_client: WebSocketGenerator, + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for re-ordering a To-do Item.""" + + assert await integration_setup() + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == "3" + + items = await ws_get_items() + assert items == snapshot + + # Move to second in the list + client = await hass_ws_client() + data = { + "id": id, + "type": "todo/item/move", + "entity_id": ENTITY_ID, + "uid": "some-task-id-3", + "previous_uid": "some-task-id-1", + } + await client.send_json_auto_id(data) + resp = await client.receive_json() + assert resp.get("success") + + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == "3" + + items = await ws_get_items() + assert items == snapshot + + @pytest.mark.parametrize( "api_responses", [ diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 15132baf25a..9e575389e72 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -8,7 +8,6 @@ from homeassistant.components.google_travel_time.const import ( CONF_AVOID, CONF_DEPARTURE_TIME, CONF_DESTINATION, - CONF_LANGUAGE, CONF_ORIGIN, CONF_TIME, CONF_TIME_TYPE, @@ -21,7 +20,7 @@ from homeassistant.components.google_travel_time.const import ( DOMAIN, UNITS_IMPERIAL, ) -from homeassistant.const import CONF_API_KEY, CONF_MODE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant from .const import MOCK_CONFIG diff --git a/tests/components/govee_ble/test_sensor.py b/tests/components/govee_ble/test_sensor.py index 185ae2404da..5e7ca299fb6 100644 --- a/tests/components/govee_ble/test_sensor.py +++ b/tests/components/govee_ble/test_sensor.py @@ -1,7 +1,6 @@ """Test the Govee BLE sensors.""" from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -27,6 +26,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -112,9 +112,8 @@ async def test_gvh5178_multi_sensor(hass: HomeAssistant) -> None: # 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, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -139,9 +138,8 @@ async def test_gvh5178_multi_sensor(hass: HomeAssistant) -> None: assert primary_temp_sensor.state == "1.0" # Fastforward time without BLE advertisements - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=monotonic_now, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index 568b98daec1..e28582ca2e9 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -98,7 +98,7 @@ 'domain': 'climate', 'entity_category': None, 'entity_id': 'climate.fake_device_1', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -107,7 +107,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'fake-device-1', + 'original_name': None, 'platform': 'gree', 'previous_unique_id': None, 'supported_features': , diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr index d2b0a5fbf4e..eff96ba1bd3 100644 --- a/tests/components/gree/snapshots/test_switch.ambr +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -4,7 +4,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 Panel Light', + 'friendly_name': 'fake-device-1 Panel light', 'icon': 'mdi:lightbulb', }), 'context': , @@ -27,7 +27,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'switch', - 'friendly_name': 'fake-device-1 Fresh Air', + 'friendly_name': 'fake-device-1 Fresh air', }), 'context': , 'entity_id': 'switch.fake_device_1_fresh_air', @@ -74,7 +74,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_panel_light', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -83,11 +83,11 @@ }), 'original_device_class': , 'original_icon': 'mdi:lightbulb', - 'original_name': 'fake-device-1 Panel Light', + 'original_name': 'Panel light', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'light', 'unique_id': 'aabbcc112233_Panel Light', 'unit_of_measurement': None, }), @@ -103,7 +103,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_quiet', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -112,11 +112,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 Quiet', + 'original_name': 'Quiet', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'quiet', 'unique_id': 'aabbcc112233_Quiet', 'unit_of_measurement': None, }), @@ -132,7 +132,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_fresh_air', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -141,11 +141,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 Fresh Air', + 'original_name': 'Fresh air', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'fresh_air', 'unique_id': 'aabbcc112233_Fresh Air', 'unit_of_measurement': None, }), @@ -161,7 +161,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_xfan', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -170,11 +170,11 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'fake-device-1 XFan', + 'original_name': 'XFan', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'xfan', 'unique_id': 'aabbcc112233_XFan', 'unit_of_measurement': None, }), @@ -190,7 +190,7 @@ 'domain': 'switch', 'entity_category': None, 'entity_id': 'switch.fake_device_1_health_mode', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -199,11 +199,11 @@ }), 'original_device_class': , 'original_icon': 'mdi:pine-tree', - 'original_name': 'fake-device-1 Health mode', + 'original_name': 'Health mode', 'platform': 'gree', 'previous_unique_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'health_mode', 'unique_id': 'aabbcc112233_Health mode', 'unit_of_measurement': None, }), diff --git a/tests/components/gree/test_bridge.py b/tests/components/gree/test_bridge.py index b13544fd3f7..f40ab6525d4 100644 --- a/tests/components/gree/test_bridge.py +++ b/tests/components/gree/test_bridge.py @@ -1,7 +1,7 @@ """Tests for gree component.""" from datetime import timedelta -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN @@ -24,7 +24,7 @@ def mock_now(): async def test_discovery_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices don't change after multiple discoveries.""" mock_device_1 = build_device_mock( @@ -58,8 +58,8 @@ async def test_discovery_after_setup( device.side_effect = [mock_device_1, mock_device_2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index 82ad75b5d28..5b261fa266b 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import DEFAULT as DEFAULT_MOCK, AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory from greeclimate.device import HorizontalSwing, VerticalSwing from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError import pytest @@ -49,6 +50,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util @@ -115,7 +117,7 @@ async def test_discovery_setup_connection_error( async def test_discovery_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices don't change after multiple discoveries.""" MockDevice1 = build_device_mock( @@ -142,8 +144,8 @@ async def test_discovery_after_setup( device.side_effect = [MockDevice1, MockDevice2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 @@ -151,7 +153,7 @@ async def test_discovery_after_setup( async def test_discovery_add_device_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices can be added after initial setup.""" MockDevice1 = build_device_mock( @@ -178,8 +180,8 @@ async def test_discovery_add_device_after_setup( device.side_effect = [MockDevice2] next_update = mock_now + timedelta(minutes=6) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert discovery.return_value.scan_count == 2 @@ -187,7 +189,7 @@ async def test_discovery_add_device_after_setup( async def test_discovery_device_bind_after_setup( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test gree devices can be added after a late device bind.""" MockDevice1 = build_device_mock( @@ -212,15 +214,17 @@ async def test_discovery_device_bind_after_setup( MockDevice1.update_state.side_effect = None next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state != STATE_UNAVAILABLE -async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) -> None: +async def test_update_connection_failure( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, device, mock_now +) -> None: """Testing update hvac connection failure exception.""" device().update_state.side_effect = [ DEFAULT_MOCK, @@ -231,8 +235,8 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) await async_setup_gree(hass) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() # First update to make the device available @@ -241,13 +245,13 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) assert state.state != STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() next_update = mock_now + timedelta(minutes=15) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() # Then two more update failures to make the device unavailable @@ -257,7 +261,7 @@ async def test_update_connection_failure(hass: HomeAssistant, device, mock_now) async def test_update_connection_failure_recovery( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Testing update hvac connection failure recovery.""" device().update_state.side_effect = [ @@ -270,8 +274,8 @@ async def test_update_connection_failure_recovery( # First update becomes unavailable next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -280,8 +284,8 @@ async def test_update_connection_failure_recovery( # Second update restores the connection next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -290,7 +294,7 @@ async def test_update_connection_failure_recovery( async def test_update_unhandled_exception( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Testing update hvac connection unhandled response exception.""" device().update_state.side_effect = [DEFAULT_MOCK, Exception] @@ -302,8 +306,8 @@ async def test_update_unhandled_exception( assert state.state != STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -312,15 +316,15 @@ async def test_update_unhandled_exception( async def test_send_command_device_timeout( - hass: HomeAssistant, discovery, device, mock_now + hass: HomeAssistant, freezer: FrozenDateTimeFactory, discovery, device, mock_now ) -> None: """Test for sending power on command to the device with a device timeout.""" await async_setup_gree(hass) # First update to make the device available next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -535,7 +539,7 @@ async def test_send_invalid_preset_mode( """Test for sending preset mode command to the device.""" await async_setup_gree(hass) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SET_PRESET_MODE, @@ -696,7 +700,7 @@ async def test_send_invalid_fan_mode( """Test for sending fan mode command to the device.""" await async_setup_gree(hass) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SET_FAN_MODE, @@ -777,7 +781,7 @@ async def test_send_invalid_swing_mode( """Test for sending swing mode command to the device.""" await async_setup_gree(hass) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SET_SWING_MODE, diff --git a/tests/components/guardian/test_diagnostics.py b/tests/components/guardian/test_diagnostics.py index b58b2ccdba3..ec288461661 100644 --- a/tests/components/guardian/test_diagnostics.py +++ b/tests/components/guardian/test_diagnostics.py @@ -23,6 +23,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 1, + "minor_version": 1, "domain": "guardian", "title": REDACTED, "data": { diff --git a/tests/components/harmony/test_switch.py b/tests/components/harmony/test_switch.py index 59e5a7c7fc8..f843ab4deca 100644 --- a/tests/components/harmony/test_switch.py +++ b/tests/components/harmony/test_switch.py @@ -146,6 +146,7 @@ async def test_create_issue( hass: HomeAssistant, mock_write_config, entity_registry_enabled_by_default: None, + issue_registry: ir.IssueRegistry, ) -> None: """Test we create an issue when an automation or script is using a deprecated entity.""" assert await async_setup_component( @@ -186,7 +187,6 @@ async def test_create_issue( assert automations_with_entity(hass, ENTITY_WATCH_TV)[0] == "automation.test" assert scripts_with_entity(hass, ENTITY_WATCH_TV)[0] == "script.test" - issue_registry: ir.IssueRegistry = ir.async_get(hass) assert issue_registry.async_get_issue(DOMAIN, "deprecated_switches") assert issue_registry.async_get_issue( diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 21bf7e5b47a..5dd73a21615 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -100,6 +100,7 @@ async def test_supervisor_issue_repair_flow( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -195,6 +196,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -309,6 +311,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -389,6 +392,7 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -488,6 +492,7 @@ async def test_mount_failed_repair_flow( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") @@ -599,6 +604,7 @@ async def test_supervisor_issue_docker_config_repair_flow( "handler": "hassio", "description": None, "description_placeholders": None, + "minor_version": 1, } assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") diff --git a/tests/components/holiday/__init__.py b/tests/components/holiday/__init__.py new file mode 100644 index 00000000000..e906586aabc --- /dev/null +++ b/tests/components/holiday/__init__.py @@ -0,0 +1 @@ +"""Tests for the Holiday integration.""" diff --git a/tests/components/holiday/conftest.py b/tests/components/holiday/conftest.py new file mode 100644 index 00000000000..d9b0d1a5788 --- /dev/null +++ b/tests/components/holiday/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Holiday 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.holiday.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/holiday/test_calendar.py b/tests/components/holiday/test_calendar.py new file mode 100644 index 00000000000..06011fb8e6b --- /dev/null +++ b/tests/components/holiday/test_calendar.py @@ -0,0 +1,229 @@ +"""Tests for calendar platform of Holiday integration.""" +from datetime import datetime, timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.calendar import ( + DOMAIN as CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, +) +from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_holiday_calendar_entity( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test HolidayCalendarEntity functionality.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "US", CONF_PROVINCE: "AK"}, + title="United States, AK", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await async_setup_component(hass, "calendar", {}) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.united_states_ak", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.united_states_ak": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "United States, AK", + } + ] + } + } + + state = hass.states.get("calendar.united_states_ak") + assert state is not None + assert state.state == "on" + + # Test holidays for the next year + freezer.move_to(datetime(2023, 12, 31, 12, tzinfo=dt_util.UTC)) + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.united_states_ak", + "end_date_time": dt_util.now() + timedelta(days=1), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.united_states_ak": { + "events": [ + { + "start": "2024-01-01", + "end": "2024-01-02", + "summary": "New Year's Day", + "location": "United States, AK", + } + ] + } + } + + +async def test_default_language( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test default language.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "FR", CONF_PROVINCE: "BL"}, + title="France, BL", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test French calendar with English language + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.france_bl", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.france_bl": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "France, BL", + } + ] + } + } + + # Test French calendar with French language + hass.config.language = "fr" + + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.france_bl", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.france_bl": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "Jour de l'an", + "location": "France, BL", + } + ] + } + } + + +async def test_no_language( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test language defaults to English if language not exist.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "AL"}, + title="Albania", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.albania", + "end_date_time": dt_util.now(), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.albania": { + "events": [ + { + "start": "2023-01-01", + "end": "2023-01-02", + "summary": "New Year's Day", + "location": "Albania", + } + ] + } + } + + +async def test_no_next_event( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if there is no next event.""" + freezer.move_to(datetime(2023, 1, 1, 12, tzinfo=dt_util.UTC)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_COUNTRY: "DE"}, + title="Germany", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Move time to out of reach + freezer.move_to(datetime(dt_util.now().year + 5, 1, 1, 12, tzinfo=dt_util.UTC)) + async_fire_time_changed(hass) + + state = hass.states.get("calendar.germany") + assert state is not None + assert state.state == "off" + assert state.attributes == {"friendly_name": "Germany"} diff --git a/tests/components/holiday/test_config_flow.py b/tests/components/holiday/test_config_flow.py new file mode 100644 index 00000000000..7dce6131616 --- /dev/null +++ b/tests/components/holiday/test_config_flow.py @@ -0,0 +1,220 @@ +"""Test the Holiday config flow.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN +from homeassistant.const import CONF_COUNTRY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +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 + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_PROVINCE: "BW", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "Germany, BW" + assert result3["data"] == { + "country": "DE", + "province": "BW", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_no_subdivision(hass: HomeAssistant) -> None: + """Test we get the forms correctly without subdivision.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Sweden" + assert result2["data"] == { + "country": "SE", + } + + +async def test_form_translated_title(hass: HomeAssistant) -> None: + """Test the title gets translated.""" + hass.config.language = "de" + + 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"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "Schweden" + + +async def test_single_combination_country_province(hass: HomeAssistant) -> None: + """Test that configuring more than one instance is rejected.""" + data_de = { + CONF_COUNTRY: "DE", + CONF_PROVINCE: "BW", + } + data_se = { + CONF_COUNTRY: "SE", + } + MockConfigEntry(domain=DOMAIN, data=data_de).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, data=data_se).add_to_hass(hass) + + # Test for country without subdivisions + result_se = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=data_se, + ) + assert result_se["type"] == FlowResultType.ABORT + assert result_se["reason"] == "already_configured" + + # Test for country with subdivisions + result_de_step1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=data_de, + ) + assert result_de_step1["type"] == FlowResultType.FORM + + result_de_step2 = await hass.config_entries.flow.async_configure( + result_de_step1["flow_id"], + { + CONF_PROVINCE: data_de[CONF_PROVINCE], + }, + ) + assert result_de_step2["type"] == FlowResultType.ABORT + assert result_de_step2["reason"] == "already_configured" + + +async def test_form_babel_unresolved_language(hass: HomeAssistant) -> None: + """Test the config flow if using not babel supported language.""" + hass.config.language = "en-XX" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result["title"] == "Sweden" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROVINCE: "BW", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Germany, BW" + assert result["data"] == { + "country": "DE", + "province": "BW", + } + + +async def test_form_babel_replace_dash_with_underscore(hass: HomeAssistant) -> None: + """Test the config flow if using language with dash.""" + hass.config.language = "en-GB" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "SE", + }, + ) + await hass.async_block_till_done() + + assert result["title"] == "Sweden" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_COUNTRY: "DE", + }, + ) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PROVINCE: "BW", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Germany, BW" + assert result["data"] == { + "country": "DE", + "province": "BW", + } diff --git a/tests/components/holiday/test_init.py b/tests/components/holiday/test_init.py new file mode 100644 index 00000000000..a044e390a68 --- /dev/null +++ b/tests/components/holiday/test_init.py @@ -0,0 +1,29 @@ +"""Tests for the Holiday integration.""" + +from homeassistant.components.holiday.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_CONFIG_DATA = { + "country": "Germany", + "province": "BW", +} + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test removing integration.""" + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + state: ConfigEntryState = entry.state + assert state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index b4554f1a4e6..513827b5432 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import Mock, patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -38,34 +39,33 @@ def setup_comp(hass): mock_component(hass, "group") -async def test_if_fires_using_at(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at.""" now = dt_util.now() trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "5:00:00"}, - "action": { - "service": "test.automation", - "data_template": { - "some": "{{ trigger.platform }} - {{ trigger.now.hour }}", - "id": "{{ trigger.id}}", - }, + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "5:00:00"}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.hour }}", + "id": "{{ trigger.id}}", }, - } - }, - ) - await hass.async_block_till_done() + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -79,7 +79,7 @@ async def test_if_fires_using_at(hass: HomeAssistant, calls) -> None: ("has_date", "has_time"), [(True, True), (True, False), (False, True)] ) async def test_if_fires_using_at_input_datetime( - hass: HomeAssistant, calls, has_date, has_time + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls, has_date, has_time ) -> None: """Test for firing at input_datetime.""" await async_setup_component( @@ -107,24 +107,22 @@ async def test_if_fires_using_at_input_datetime( time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{trigger.entity_id}}" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "input_datetime.trigger"}, - "action": { - "service": "test.automation", - "data_template": {"some": some_data}, - }, - } - }, - ) - await hass.async_block_till_done() + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "input_datetime.trigger"}, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -161,7 +159,9 @@ async def test_if_fires_using_at_input_datetime( ) -async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_multiple_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at.""" now = dt_util.now() @@ -169,26 +169,23 @@ async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2) time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": ["5:00:00", "6:00:00"]}, - "action": { - "service": "test.automation", - "data_template": { - "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" - }, + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": ["5:00:00", "6:00:00"]}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.hour }}" }, - } - }, - ) - await hass.async_block_till_done() + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() @@ -203,7 +200,9 @@ async def test_if_fires_using_multiple_at(hass: HomeAssistant, calls) -> None: assert calls[1].data["some"] == "time - 6" -async def test_if_not_fires_using_wrong_at(hass: HomeAssistant, calls) -> None: +async def test_if_not_fires_using_wrong_at( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """YAML translates time values to total seconds. This should break the before rule. @@ -214,25 +213,23 @@ async def test_if_not_fires_using_wrong_at(hass: HomeAssistant, calls) -> None: year=now.year + 1, hour=1, minute=0, second=0 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - with assert_setup_component(1, automation.DOMAIN): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time", - "at": 3605, - # Total seconds. Hour = 3600 second - }, - "action": {"service": "test.automation"}, - } - }, - ) - await hass.async_block_till_done() + freezer.move_to(time_that_will_not_match_right_away) + with assert_setup_component(1, automation.DOMAIN): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": 3605, + # Total seconds. Hour = 3600 second + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() assert hass.states.get("automation.automation_0").state == STATE_UNAVAILABLE async_fire_time_changed( @@ -409,7 +406,9 @@ async def test_untrack_time_change(hass: HomeAssistant) -> None: assert len(mock_track_time_change.mock_calls) == 3 -async def test_if_fires_using_at_sensor(hass: HomeAssistant, calls) -> None: +async def test_if_fires_using_at_sensor( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at sensor time.""" now = dt_util.now() @@ -424,24 +423,22 @@ async def test_if_fires_using_at_sensor(hass: HomeAssistant, calls) -> None: time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1) some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}-{{trigger.entity_id}}" - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.as_utc(time_that_will_not_match_right_away), - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time", "at": "sensor.next_alarm"}, - "action": { - "service": "test.automation", - "data_template": {"some": some_data}, - }, - } - }, - ) - await hass.async_block_till_done() + + freezer.move_to(dt_util.as_utc(time_that_will_not_match_right_away)) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "sensor.next_alarm"}, + "action": { + "service": "test.automation", + "data_template": {"some": some_data}, + }, + } + }, + ) + await hass.async_block_till_done() async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1)) await hass.async_block_till_done() diff --git a/tests/components/homeassistant/triggers/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py index e7a6a98bb96..0f6a075eb6e 100644 --- a/tests/components/homeassistant/triggers/test_time_pattern.py +++ b/tests/components/homeassistant/triggers/test_time_pattern.py @@ -1,7 +1,7 @@ """The tests for the time_pattern automation.""" from datetime import timedelta -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -27,33 +27,33 @@ def setup_comp(hass): mock_component(hass, "group") -async def test_if_fires_when_hour_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_hour_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if hour is matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=3 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": 0, - "minutes": "*", - "seconds": "*", - }, - "action": { - "service": "test.automation", - "data_template": {"id": "{{ trigger.id}}"}, - }, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": 0, + "minutes": "*", + "seconds": "*", + }, + "action": { + "service": "test.automation", + "data_template": {"id": "{{ trigger.id}}"}, + }, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, hour=0)) await hass.async_block_till_done() @@ -72,30 +72,30 @@ async def test_if_fires_when_hour_matches(hass: HomeAssistant, calls) -> None: assert calls[0].data["id"] == 0 -async def test_if_fires_when_minute_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_minute_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if minutes are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=30 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": 0, - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": 0, + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, minute=0)) @@ -103,30 +103,30 @@ async def test_if_fires_when_minute_matches(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_when_second_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_second_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if seconds are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=30 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": 0, - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": 0, + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed(hass, now.replace(year=now.year + 2, second=0)) @@ -135,31 +135,29 @@ async def test_if_fires_when_second_matches(hass: HomeAssistant, calls) -> None: async def test_if_fires_when_second_as_string_matches( - hass: HomeAssistant, calls + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls ) -> None: """Test for firing if seconds are matching.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=15 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": "30", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": "30", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, time_that_will_not_match_right_away + timedelta(seconds=15) @@ -169,30 +167,30 @@ async def test_if_fires_when_second_as_string_matches( assert len(calls) == 1 -async def test_if_fires_when_all_matches(hass: HomeAssistant, calls) -> None: +async def test_if_fires_when_all_matches( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing if everything matches.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=4 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": 1, - "minutes": 2, - "seconds": 3, - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": 1, + "minutes": 2, + "seconds": 3, + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=1, minute=2, second=3) @@ -202,30 +200,30 @@ async def test_if_fires_when_all_matches(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_periodic_seconds(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_seconds( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every second.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, second=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "*", - "seconds": "/10", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "*", + "seconds": "/10", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=0, minute=0, second=10) @@ -235,31 +233,31 @@ async def test_if_fires_periodic_seconds(hass: HomeAssistant, calls) -> None: assert len(calls) >= 1 -async def test_if_fires_periodic_minutes(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_minutes( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every minute.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "*", - "minutes": "/2", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "*", + "minutes": "/2", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=0, minute=2, second=0) @@ -269,30 +267,30 @@ async def test_if_fires_periodic_minutes(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_if_fires_periodic_hours(hass: HomeAssistant, calls) -> None: +async def test_if_fires_periodic_hours( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing periodically every hour.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, hour=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "time_pattern", - "hours": "/2", - "minutes": "*", - "seconds": "*", - }, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time_pattern", + "hours": "/2", + "minutes": "*", + "seconds": "*", + }, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=2, minute=0, second=0) @@ -302,25 +300,25 @@ async def test_if_fires_periodic_hours(hass: HomeAssistant, calls) -> None: assert len(calls) == 1 -async def test_default_values(hass: HomeAssistant, calls) -> None: +async def test_default_values( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing at 2 minutes every hour.""" now = dt_util.utcnow() time_that_will_not_match_right_away = dt_util.utcnow().replace( year=now.year + 1, minute=1 ) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": {"platform": "time_pattern", "minutes": "2"}, - "action": {"service": "test.automation"}, - } - }, - ) + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time_pattern", "minutes": "2"}, + "action": {"service": "test.automation"}, + } + }, + ) async_fire_time_changed( hass, now.replace(year=now.year + 2, hour=1, minute=2, second=0) diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index 043213ec159..904b752205e 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -1,9 +1,9 @@ """HomeKit controller session fixtures.""" import datetime -from unittest import mock import unittest.mock from aiohomekit.testing import FakeController +from freezegun import freeze_time import pytest import homeassistant.util.dt as dt_util @@ -13,14 +13,13 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 pytest.register_assert_rewrite("tests.components.homekit_controller.common") -@pytest.fixture -def utcnow(request): +@pytest.fixture(autouse=True) +def freeze_time_in_future(request): """Freeze time at a known point.""" now = dt_util.utcnow() start_dt = datetime.datetime(now.year + 1, 1, 1, 0, 0, 0, tzinfo=now.tzinfo) - with mock.patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - yield dt_utcnow + with freeze_time(start_dt) as frozen_time: + yield frozen_time @pytest.fixture diff --git a/tests/components/homekit_controller/snapshots/test_diagnostics.ambr b/tests/components/homekit_controller/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..d3205b09de3 --- /dev/null +++ b/tests/components/homekit_controller/snapshots/test_diagnostics.ambr @@ -0,0 +1,635 @@ +# serializer version: 1 +# name: test_config_entry + dict({ + 'config-entry': dict({ + 'data': dict({ + 'AccessoryPairingID': '00:00:00:00:00:00', + }), + 'title': 'test', + 'version': 1, + }), + 'config-num': 0, + 'devices': list([ + dict({ + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': 'diagnostic', + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Identify', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-LS1-20833F Identify', + }), + 'entity_id': 'button.koogeek_ls1_20833f_identify', + 'state': 'unknown', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Light Strip', + 'state': dict({ + 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Koogeek-LS1-20833F Light Strip', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + 'hs', + ]), + 'supported_features': 0, + 'xy_color': None, + }), + 'entity_id': 'light.koogeek_ls1_20833f_light_strip', + 'state': 'off', + }), + 'unit_of_measurement': None, + }), + ]), + 'hw_version': '', + 'manfacturer': 'Koogeek', + 'model': 'LS1', + 'name': 'Koogeek-LS1-20833F', + 'sw_version': '2.2.15', + }), + ]), + 'entity-map': list([ + dict({ + 'aid': 1, + 'services': list([ + dict({ + 'characteristics': list([ + dict({ + 'description': 'Name', + 'format': 'string', + 'iid': 2, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000023-0000-1000-8000-0026BB765291', + 'value': 'Koogeek-LS1-20833F', + }), + dict({ + 'description': 'Manufacturer', + 'format': 'string', + 'iid': 3, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000020-0000-1000-8000-0026BB765291', + 'value': 'Koogeek', + }), + dict({ + 'description': 'Model', + 'format': 'string', + 'iid': 4, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000021-0000-1000-8000-0026BB765291', + 'value': 'LS1', + }), + dict({ + 'description': 'Serial Number', + 'format': 'string', + 'iid': 5, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000030-0000-1000-8000-0026BB765291', + 'value': '**REDACTED**', + }), + dict({ + 'description': 'Identify', + 'format': 'bool', + 'iid': 6, + 'perms': list([ + 'pw', + ]), + 'type': '00000014-0000-1000-8000-0026BB765291', + }), + dict({ + 'description': 'Firmware Revision', + 'format': 'string', + 'iid': 23, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000052-0000-1000-8000-0026BB765291', + 'value': '2.2.15', + }), + ]), + 'iid': 1, + 'type': '0000003E-0000-1000-8000-0026BB765291', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'On', + 'format': 'bool', + 'iid': 8, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000025-0000-1000-8000-0026BB765291', + 'value': False, + }), + dict({ + 'description': 'Hue', + 'format': 'float', + 'iid': 9, + 'maxValue': 359, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000013-0000-1000-8000-0026BB765291', + 'unit': 'arcdegrees', + 'value': 44, + }), + dict({ + 'description': 'Saturation', + 'format': 'float', + 'iid': 10, + 'maxValue': 100, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '0000002F-0000-1000-8000-0026BB765291', + 'unit': 'percentage', + 'value': 0, + }), + dict({ + 'description': 'Brightness', + 'format': 'int', + 'iid': 11, + 'maxValue': 100, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000008-0000-1000-8000-0026BB765291', + 'unit': 'percentage', + 'value': 100, + }), + dict({ + 'description': 'Name', + 'format': 'string', + 'iid': 12, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000023-0000-1000-8000-0026BB765291', + 'value': 'Light Strip', + }), + ]), + 'iid': 7, + 'type': '00000043-0000-1000-8000-0026BB765291', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'TIMER_SETTINGS', + 'format': 'tlv8', + 'iid': 14, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '4AAAF942-0DEC-11E5-B939-0800200C9A66', + 'value': 'AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }), + ]), + 'iid': 13, + 'type': '4AAAF940-0DEC-11E5-B939-0800200C9A66', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'FW Upgrade supported types', + 'format': 'string', + 'iid': 16, + 'maxLen': 64, + 'perms': list([ + 'pr', + 'hd', + ]), + 'type': '151909D2-3802-11E4-916C-0800200C9A66', + 'value': 'url,data', + }), + dict({ + 'description': 'FW Upgrade URL', + 'format': 'string', + 'iid': 17, + 'maxLen': 64, + 'perms': list([ + 'pw', + 'hd', + ]), + 'type': '151909D1-3802-11E4-916C-0800200C9A66', + }), + dict({ + 'description': 'FW Upgrade Status', + 'format': 'int', + 'iid': 18, + 'perms': list([ + 'pr', + 'ev', + 'hd', + ]), + 'type': '151909D6-3802-11E4-916C-0800200C9A66', + 'value': 0, + }), + dict({ + 'description': 'FW Upgrade Data', + 'format': 'data', + 'iid': 19, + 'perms': list([ + 'pw', + 'hd', + ]), + 'type': '151909D7-3802-11E4-916C-0800200C9A66', + }), + ]), + 'iid': 15, + 'type': '151909D0-3802-11E4-916C-0800200C9A66', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'Timezone', + 'format': 'int', + 'iid': 21, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '151909D5-3802-11E4-916C-0800200C9A66', + 'value': 0, + }), + dict({ + 'description': 'Time value since Epoch', + 'format': 'int', + 'iid': 22, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '151909D4-3802-11E4-916C-0800200C9A66', + 'value': 1550348623, + }), + ]), + 'iid': 20, + 'type': '151909D3-3802-11E4-916C-0800200C9A66', + }), + ]), + }), + ]), + }) +# --- +# name: test_device + dict({ + 'config-entry': dict({ + 'data': dict({ + 'AccessoryPairingID': '00:00:00:00:00:00', + }), + 'title': 'test', + 'version': 1, + }), + 'config-num': 0, + 'device': dict({ + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': 'diagnostic', + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Identify', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Koogeek-LS1-20833F Identify', + }), + 'entity_id': 'button.koogeek_ls1_20833f_identify', + 'state': 'unknown', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Koogeek-LS1-20833F Light Strip', + 'state': dict({ + 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Koogeek-LS1-20833F Light Strip', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + 'hs', + ]), + 'supported_features': 0, + 'xy_color': None, + }), + 'entity_id': 'light.koogeek_ls1_20833f_light_strip', + 'state': 'off', + }), + 'unit_of_measurement': None, + }), + ]), + 'hw_version': '', + 'manfacturer': 'Koogeek', + 'model': 'LS1', + 'name': 'Koogeek-LS1-20833F', + 'sw_version': '2.2.15', + }), + 'entity-map': list([ + dict({ + 'aid': 1, + 'services': list([ + dict({ + 'characteristics': list([ + dict({ + 'description': 'Name', + 'format': 'string', + 'iid': 2, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000023-0000-1000-8000-0026BB765291', + 'value': 'Koogeek-LS1-20833F', + }), + dict({ + 'description': 'Manufacturer', + 'format': 'string', + 'iid': 3, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000020-0000-1000-8000-0026BB765291', + 'value': 'Koogeek', + }), + dict({ + 'description': 'Model', + 'format': 'string', + 'iid': 4, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000021-0000-1000-8000-0026BB765291', + 'value': 'LS1', + }), + dict({ + 'description': 'Serial Number', + 'format': 'string', + 'iid': 5, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000030-0000-1000-8000-0026BB765291', + 'value': '**REDACTED**', + }), + dict({ + 'description': 'Identify', + 'format': 'bool', + 'iid': 6, + 'perms': list([ + 'pw', + ]), + 'type': '00000014-0000-1000-8000-0026BB765291', + }), + dict({ + 'description': 'Firmware Revision', + 'format': 'string', + 'iid': 23, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000052-0000-1000-8000-0026BB765291', + 'value': '2.2.15', + }), + ]), + 'iid': 1, + 'type': '0000003E-0000-1000-8000-0026BB765291', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'On', + 'format': 'bool', + 'iid': 8, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000025-0000-1000-8000-0026BB765291', + 'value': False, + }), + dict({ + 'description': 'Hue', + 'format': 'float', + 'iid': 9, + 'maxValue': 359, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000013-0000-1000-8000-0026BB765291', + 'unit': 'arcdegrees', + 'value': 44, + }), + dict({ + 'description': 'Saturation', + 'format': 'float', + 'iid': 10, + 'maxValue': 100, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '0000002F-0000-1000-8000-0026BB765291', + 'unit': 'percentage', + 'value': 0, + }), + dict({ + 'description': 'Brightness', + 'format': 'int', + 'iid': 11, + 'maxValue': 100, + 'minStep': 1, + 'minValue': 0, + 'perms': list([ + 'pr', + 'pw', + 'ev', + ]), + 'type': '00000008-0000-1000-8000-0026BB765291', + 'unit': 'percentage', + 'value': 100, + }), + dict({ + 'description': 'Name', + 'format': 'string', + 'iid': 12, + 'maxLen': 64, + 'perms': list([ + 'pr', + ]), + 'type': '00000023-0000-1000-8000-0026BB765291', + 'value': 'Light Strip', + }), + ]), + 'iid': 7, + 'type': '00000043-0000-1000-8000-0026BB765291', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'TIMER_SETTINGS', + 'format': 'tlv8', + 'iid': 14, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '4AAAF942-0DEC-11E5-B939-0800200C9A66', + 'value': 'AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }), + ]), + 'iid': 13, + 'type': '4AAAF940-0DEC-11E5-B939-0800200C9A66', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'FW Upgrade supported types', + 'format': 'string', + 'iid': 16, + 'maxLen': 64, + 'perms': list([ + 'pr', + 'hd', + ]), + 'type': '151909D2-3802-11E4-916C-0800200C9A66', + 'value': 'url,data', + }), + dict({ + 'description': 'FW Upgrade URL', + 'format': 'string', + 'iid': 17, + 'maxLen': 64, + 'perms': list([ + 'pw', + 'hd', + ]), + 'type': '151909D1-3802-11E4-916C-0800200C9A66', + }), + dict({ + 'description': 'FW Upgrade Status', + 'format': 'int', + 'iid': 18, + 'perms': list([ + 'pr', + 'ev', + 'hd', + ]), + 'type': '151909D6-3802-11E4-916C-0800200C9A66', + 'value': 0, + }), + dict({ + 'description': 'FW Upgrade Data', + 'format': 'data', + 'iid': 19, + 'perms': list([ + 'pw', + 'hd', + ]), + 'type': '151909D7-3802-11E4-916C-0800200C9A66', + }), + ]), + 'iid': 15, + 'type': '151909D0-3802-11E4-916C-0800200C9A66', + }), + dict({ + 'characteristics': list([ + dict({ + 'description': 'Timezone', + 'format': 'int', + 'iid': 21, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '151909D5-3802-11E4-916C-0800200C9A66', + 'value': 0, + }), + dict({ + 'description': 'Time value since Epoch', + 'format': 'int', + 'iid': 22, + 'perms': list([ + 'pr', + 'pw', + ]), + 'type': '151909D4-3802-11E4-916C-0800200C9A66', + 'value': 1550348623, + }), + ]), + 'iid': 20, + 'type': '151909D3-3802-11E4-916C-0800200C9A66', + }), + ]), + }), + ]), + }) +# --- diff --git a/tests/components/homekit_controller/specific_devices/test_connectsense.py b/tests/components/homekit_controller/specific_devices/test_connectsense.py index 44157c32203..9e08c6fed0a 100644 --- a/tests/components/homekit_controller/specific_devices/test_connectsense.py +++ b/tests/components/homekit_controller/specific_devices/test_connectsense.py @@ -1,10 +1,6 @@ """Make sure that ConnectSense Smart Outlet2 / In-Wall Outlet is enumerated properly.""" from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import ( - ELECTRIC_CURRENT_AMPERE, - ENERGY_KILO_WATT_HOUR, - POWER_WATT, -) +from homeassistant.const import UnitOfElectricCurrent, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from ..common import ( @@ -39,7 +35,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Current", unique_id="00:00:00:00:00:00_1_13_18", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, state="0.03", ), EntityTestInfo( @@ -47,7 +43,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Power", unique_id="00:00:00:00:00:00_1_13_19", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, state="0.8", ), EntityTestInfo( @@ -55,7 +51,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_13_20", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="379.69299", ), EntityTestInfo( @@ -69,7 +65,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Current", unique_id="00:00:00:00:00:00_1_25_30", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, state="0.05", ), EntityTestInfo( @@ -77,7 +73,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Power", unique_id="00:00:00:00:00:00_1_25_31", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, state="0.8", ), EntityTestInfo( @@ -85,7 +81,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_25_32", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="175.85001", ), EntityTestInfo( @@ -118,7 +114,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Current", unique_id="00:00:00:00:00:00_1_13_18", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, state="0.03", ), EntityTestInfo( @@ -126,7 +122,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Power", unique_id="00:00:00:00:00:00_1_13_19", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, state="0.8", ), EntityTestInfo( @@ -134,7 +130,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_13_20", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="379.69299", ), EntityTestInfo( @@ -148,7 +144,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Current", unique_id="00:00:00:00:00:00_1_25_30", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + unit_of_measurement=UnitOfElectricCurrent.AMPERE, state="0.05", ), EntityTestInfo( @@ -156,7 +152,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Power", unique_id="00:00:00:00:00:00_1_25_31", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, state="0.8", ), EntityTestInfo( @@ -164,7 +160,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: friendly_name="InWall Outlet-0394DE Energy kWh", unique_id="00:00:00:00:00:00_1_25_32", capabilities={"state_class": SensorStateClass.MEASUREMENT}, - unit_of_measurement=ENERGY_KILO_WATT_HOUR, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state="175.85001", ), EntityTestInfo( diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 62051bbf244..723881ac182 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -8,11 +8,7 @@ from unittest import mock from aiohomekit import AccessoryNotFoundError from aiohomekit.testing import FakePairing -from homeassistant.components.climate import ( - SUPPORT_TARGET_HUMIDITY, - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, -) +from homeassistant.components.climate import ClimateEntityFeature from homeassistant.components.sensor import SensorStateClass from homeassistant.config_entries import ConfigEntryState from homeassistant.const import UnitOfTemperature @@ -108,9 +104,9 @@ async def test_ecobee3_setup(hass: HomeAssistant) -> None: friendly_name="HomeW", unique_id="00:00:00:00:00:00_1_16", supported_features=( - SUPPORT_TARGET_TEMPERATURE - | SUPPORT_TARGET_TEMPERATURE_RANGE - | SUPPORT_TARGET_HUMIDITY + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.TARGET_HUMIDITY ), capabilities={ "hvac_modes": ["off", "heat", "cool", "heat_cool"], diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index 2c2c0b5e1c5..baee3082106 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -21,7 +21,7 @@ LIGHT_ON = ("lightbulb", "on") @pytest.mark.parametrize("failure_cls", [AccessoryDisconnectedError, EncryptionError]) -async def test_recover_from_failure(hass: HomeAssistant, utcnow, failure_cls) -> None: +async def test_recover_from_failure(hass: HomeAssistant, failure_cls) -> None: """Test that entity actually recovers from a network connection drop. See https://github.com/home-assistant/core/issues/18949 diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py index 44293ac439c..7114d138039 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py @@ -5,7 +5,7 @@ This Koogeek device has a custom power sensor that extra handling. It should have 2 entities - the actual switch and a sensor for power usage. """ from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import POWER_WATT +from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from ..common import ( @@ -51,7 +51,7 @@ async def test_koogeek_sw2_setup(hass: HomeAssistant) -> None: entity_id="sensor.koogeek_sw2_187a91_power", friendly_name="Koogeek-SW2-187A91 Power", unique_id="00:00:00:00:00:00_1_14_18", - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="0", ), diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py index 6d3c242c382..b42a7652c1c 100644 --- a/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_vp3.py @@ -1,6 +1,6 @@ """Make sure that existing VOCOlinc VP3 support isn't broken.""" from homeassistant.components.sensor import SensorStateClass -from homeassistant.const import POWER_WATT +from homeassistant.const import UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -58,7 +58,7 @@ async def test_vocolinc_vp3_setup( entity_id="sensor.original_vocolinc_vp3_power", friendly_name="VOCOlinc-VP3-123456 Power", unique_id="00:00:00:00:00:00_1_48_97", - unit_of_measurement=POWER_WATT, + unit_of_measurement=UnitOfPower.WATT, capabilities={"state_class": SensorStateClass.MEASUREMENT}, state="0", ), diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py index c38c3d47bfe..19991d7cc13 100644 --- a/tests/components/homekit_controller/test_alarm_control_panel.py +++ b/tests/components/homekit_controller/test_alarm_control_panel.py @@ -26,7 +26,7 @@ def create_security_system_service(accessory): targ_state.value = 50 -async def test_switch_change_alarm_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_change_alarm_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit alarm on and off again.""" helper = await setup_test_component(hass, create_security_system_service) @@ -83,7 +83,7 @@ async def test_switch_change_alarm_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_switch_read_alarm_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_alarm_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit alarm accessory.""" helper = await setup_test_component(hass, create_security_system_service) @@ -125,7 +125,7 @@ async def test_switch_read_alarm_state(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a alarm_control_panel unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py index 382d6182733..92c303cab45 100644 --- a/tests/components/homekit_controller/test_binary_sensor.py +++ b/tests/components/homekit_controller/test_binary_sensor.py @@ -17,7 +17,7 @@ def create_motion_sensor_service(accessory): cur_state.value = 0 -async def test_motion_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_motion_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit motion sensor accessory.""" helper = await setup_test_component(hass, create_motion_sensor_service) @@ -44,7 +44,7 @@ def create_contact_sensor_service(accessory): cur_state.value = 0 -async def test_contact_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_contact_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit contact accessory.""" helper = await setup_test_component(hass, create_contact_sensor_service) @@ -71,7 +71,7 @@ def create_smoke_sensor_service(accessory): cur_state.value = 0 -async def test_smoke_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_smoke_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit contact accessory.""" helper = await setup_test_component(hass, create_smoke_sensor_service) @@ -98,7 +98,7 @@ def create_carbon_monoxide_sensor_service(accessory): cur_state.value = 0 -async def test_carbon_monoxide_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_carbon_monoxide_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit contact accessory.""" helper = await setup_test_component(hass, create_carbon_monoxide_sensor_service) @@ -127,7 +127,7 @@ def create_occupancy_sensor_service(accessory): cur_state.value = 0 -async def test_occupancy_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_occupancy_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit occupancy sensor accessory.""" helper = await setup_test_component(hass, create_occupancy_sensor_service) @@ -154,7 +154,7 @@ def create_leak_sensor_service(accessory): cur_state.value = 0 -async def test_leak_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_leak_sensor_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit leak sensor accessory.""" helper = await setup_test_component(hass, create_leak_sensor_service) @@ -174,7 +174,7 @@ async def test_leak_sensor_read_state(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a binary_sensor unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_button.py b/tests/components/homekit_controller/test_button.py index 1f08b578a93..57592fb7a27 100644 --- a/tests/components/homekit_controller/test_button.py +++ b/tests/components/homekit_controller/test_button.py @@ -95,7 +95,7 @@ async def test_ecobee_clear_hold_press_button(hass: HomeAssistant) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a button unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_camera.py b/tests/components/homekit_controller/test_camera.py index bbb8e5a8eaa..f74f2e62772 100644 --- a/tests/components/homekit_controller/test_camera.py +++ b/tests/components/homekit_controller/test_camera.py @@ -17,7 +17,7 @@ def create_camera(accessory): async def test_migrate_unique_ids( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test migrating entity unique ids.""" aid = get_next_aid() @@ -33,7 +33,7 @@ async def test_migrate_unique_ids( ) -async def test_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit camera.""" helper = await setup_test_component(hass, create_camera) @@ -41,7 +41,7 @@ async def test_read_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "idle" -async def test_get_image(hass: HomeAssistant, utcnow) -> None: +async def test_get_image(hass: HomeAssistant) -> None: """Test getting a JPEG from a camera.""" helper = await setup_test_component(hass, create_camera) image = await camera.async_get_image(hass, helper.entity_id) diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index c80016770fd..e4fe754013a 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -72,9 +72,7 @@ def create_thermostat_service_min_max(accessory): char.maxValue = 1 -async def test_climate_respect_supported_op_modes_1( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_respect_supported_op_modes_1(hass: HomeAssistant) -> None: """Test that climate respects minValue/maxValue hints.""" helper = await setup_test_component(hass, create_thermostat_service_min_max) state = await helper.poll_and_get_state() @@ -89,16 +87,14 @@ def create_thermostat_service_valid_vals(accessory): char.valid_values = [0, 1, 2] -async def test_climate_respect_supported_op_modes_2( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_respect_supported_op_modes_2(hass: HomeAssistant) -> None: """Test that climate respects validValue hints.""" helper = await setup_test_component(hass, create_thermostat_service_valid_vals) state = await helper.poll_and_get_state() assert state.attributes["hvac_modes"] == ["off", "heat", "cool"] -async def test_climate_change_thermostat_state(hass: HomeAssistant, utcnow) -> None: +async def test_climate_change_thermostat_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit thermostat on and off again.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -181,9 +177,7 @@ async def test_climate_change_thermostat_state(hass: HomeAssistant, utcnow) -> N ) -async def test_climate_check_min_max_values_per_mode( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_check_min_max_values_per_mode(hass: HomeAssistant) -> None: """Test that we we get the appropriate min/max values for each mode.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -218,9 +212,7 @@ async def test_climate_check_min_max_values_per_mode( assert climate_state.attributes["max_temp"] == 40 -async def test_climate_change_thermostat_temperature( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_change_thermostat_temperature(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit thermostat on and off again.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -251,9 +243,7 @@ async def test_climate_change_thermostat_temperature( ) -async def test_climate_change_thermostat_temperature_range( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_change_thermostat_temperature_range(hass: HomeAssistant) -> None: """Test that we can set separate heat and cool setpoints in heat_cool mode.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -287,7 +277,7 @@ async def test_climate_change_thermostat_temperature_range( async def test_climate_change_thermostat_temperature_range_iphone( - hass: HomeAssistant, utcnow + hass: HomeAssistant, ) -> None: """Test that we can set all three set points at once (iPhone heat_cool mode support).""" helper = await setup_test_component(hass, create_thermostat_service) @@ -322,7 +312,7 @@ async def test_climate_change_thermostat_temperature_range_iphone( async def test_climate_cannot_set_thermostat_temp_range_in_wrong_mode( - hass: HomeAssistant, utcnow + hass: HomeAssistant, ) -> None: """Test that we cannot set range values when not in heat_cool mode.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -381,7 +371,7 @@ def create_thermostat_single_set_point_auto(accessory): async def test_climate_check_min_max_values_per_mode_sspa_device( - hass: HomeAssistant, utcnow + hass: HomeAssistant, ) -> None: """Test appropriate min/max values for each mode on sspa devices.""" helper = await setup_test_component(hass, create_thermostat_single_set_point_auto) @@ -417,9 +407,7 @@ async def test_climate_check_min_max_values_per_mode_sspa_device( assert climate_state.attributes["max_temp"] == 35 -async def test_climate_set_thermostat_temp_on_sspa_device( - hass: HomeAssistant, utcnow -) -> None: +async def test_climate_set_thermostat_temp_on_sspa_device(hass: HomeAssistant) -> None: """Test setting temperature in different modes on device with single set point in auto.""" helper = await setup_test_component(hass, create_thermostat_single_set_point_auto) @@ -473,7 +461,7 @@ async def test_climate_set_thermostat_temp_on_sspa_device( ) -async def test_climate_set_mode_via_temp(hass: HomeAssistant, utcnow) -> None: +async def test_climate_set_mode_via_temp(hass: HomeAssistant) -> None: """Test setting temperature and mode at same tims.""" helper = await setup_test_component(hass, create_thermostat_single_set_point_auto) @@ -514,7 +502,7 @@ async def test_climate_set_mode_via_temp(hass: HomeAssistant, utcnow) -> None: ) -async def test_climate_change_thermostat_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_climate_change_thermostat_humidity(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit thermostat on and off again.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -545,7 +533,7 @@ async def test_climate_change_thermostat_humidity(hass: HomeAssistant, utcnow) - ) -async def test_climate_read_thermostat_state(hass: HomeAssistant, utcnow) -> None: +async def test_climate_read_thermostat_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit thermostat accessory.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -602,7 +590,7 @@ async def test_climate_read_thermostat_state(hass: HomeAssistant, utcnow) -> Non assert state.state == HVACMode.HEAT_COOL -async def test_hvac_mode_vs_hvac_action(hass: HomeAssistant, utcnow) -> None: +async def test_hvac_mode_vs_hvac_action(hass: HomeAssistant) -> None: """Check that we haven't conflated hvac_mode and hvac_action.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -639,9 +627,7 @@ async def test_hvac_mode_vs_hvac_action(hass: HomeAssistant, utcnow) -> None: assert state.attributes["hvac_action"] == "heating" -async def test_hvac_mode_vs_hvac_action_current_mode_wrong( - hass: HomeAssistant, utcnow -) -> None: +async def test_hvac_mode_vs_hvac_action_current_mode_wrong(hass: HomeAssistant) -> None: """Check that we cope with buggy HEATING_COOLING_CURRENT.""" helper = await setup_test_component(hass, create_thermostat_service) @@ -705,9 +691,7 @@ def create_heater_cooler_service_min_max(accessory): char.maxValue = 2 -async def test_heater_cooler_respect_supported_op_modes_1( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_respect_supported_op_modes_1(hass: HomeAssistant) -> None: """Test that climate respects minValue/maxValue hints.""" helper = await setup_test_component(hass, create_heater_cooler_service_min_max) state = await helper.poll_and_get_state() @@ -722,18 +706,14 @@ def create_theater_cooler_service_valid_vals(accessory): char.valid_values = [1, 2] -async def test_heater_cooler_respect_supported_op_modes_2( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_respect_supported_op_modes_2(hass: HomeAssistant) -> None: """Test that climate respects validValue hints.""" helper = await setup_test_component(hass, create_theater_cooler_service_valid_vals) state = await helper.poll_and_get_state() assert state.attributes["hvac_modes"] == ["heat", "cool", "off"] -async def test_heater_cooler_change_thermostat_state( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_change_thermostat_state(hass: HomeAssistant) -> None: """Test that we can change the operational mode.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -790,7 +770,7 @@ async def test_heater_cooler_change_thermostat_state( ) -async def test_can_turn_on_after_off(hass: HomeAssistant, utcnow) -> None: +async def test_can_turn_on_after_off(hass: HomeAssistant) -> None: """Test that we always force device from inactive to active when setting mode. This is a regression test for #81863. @@ -825,9 +805,7 @@ async def test_can_turn_on_after_off(hass: HomeAssistant, utcnow) -> None: ) -async def test_heater_cooler_change_thermostat_temperature( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_change_thermostat_temperature(hass: HomeAssistant) -> None: """Test that we can change the target temperature.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -870,7 +848,7 @@ async def test_heater_cooler_change_thermostat_temperature( ) -async def test_heater_cooler_change_fan_speed(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_change_fan_speed(hass: HomeAssistant) -> None: """Test that we can change the target fan speed.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -918,7 +896,7 @@ async def test_heater_cooler_change_fan_speed(hass: HomeAssistant, utcnow) -> No ) -async def test_heater_cooler_read_fan_speed(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_read_fan_speed(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit thermostat accessory.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -967,7 +945,7 @@ async def test_heater_cooler_read_fan_speed(hass: HomeAssistant, utcnow) -> None assert state.attributes["fan_mode"] == "high" -async def test_heater_cooler_read_thermostat_state(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_read_thermostat_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit thermostat accessory.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -1021,9 +999,7 @@ async def test_heater_cooler_read_thermostat_state(hass: HomeAssistant, utcnow) assert state.state == HVACMode.HEAT_COOL -async def test_heater_cooler_hvac_mode_vs_hvac_action( - hass: HomeAssistant, utcnow -) -> None: +async def test_heater_cooler_hvac_mode_vs_hvac_action(hass: HomeAssistant) -> None: """Check that we haven't conflated hvac_mode and hvac_action.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -1062,7 +1038,7 @@ async def test_heater_cooler_hvac_mode_vs_hvac_action( assert state.attributes["hvac_action"] == "heating" -async def test_heater_cooler_change_swing_mode(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_change_swing_mode(hass: HomeAssistant) -> None: """Test that we can change the swing mode.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -1093,7 +1069,7 @@ async def test_heater_cooler_change_swing_mode(hass: HomeAssistant, utcnow) -> N ) -async def test_heater_cooler_turn_off(hass: HomeAssistant, utcnow) -> None: +async def test_heater_cooler_turn_off(hass: HomeAssistant) -> None: """Test that both hvac_action and hvac_mode return "off" when turned off.""" helper = await setup_test_component(hass, create_heater_cooler_service) @@ -1113,7 +1089,7 @@ async def test_heater_cooler_turn_off(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a switch unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py index 49462a035e9..7d004a8a428 100644 --- a/tests/components/homekit_controller/test_cover.py +++ b/tests/components/homekit_controller/test_cover.py @@ -93,7 +93,7 @@ def create_window_covering_service_with_v_tilt_2(accessory): tilt_target.maxValue = 0 -async def test_change_window_cover_state(hass: HomeAssistant, utcnow) -> None: +async def test_change_window_cover_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit alarm on and off again.""" helper = await setup_test_component(hass, create_window_covering_service) @@ -118,7 +118,7 @@ async def test_change_window_cover_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_read_window_cover_state(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit alarm accessory.""" helper = await setup_test_component(hass, create_window_covering_service) @@ -151,7 +151,7 @@ async def test_read_window_cover_state(hass: HomeAssistant, utcnow) -> None: assert state.attributes["obstruction-detected"] is True -async def test_read_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_tilt_horizontal(hass: HomeAssistant) -> None: """Test that horizontal tilt is handled correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_h_tilt @@ -166,7 +166,7 @@ async def test_read_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) -> assert state.attributes["current_tilt_position"] == 83 -async def test_read_window_cover_tilt_horizontal_2(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_tilt_horizontal_2(hass: HomeAssistant) -> None: """Test that horizontal tilt is handled correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_h_tilt_2 @@ -181,7 +181,7 @@ async def test_read_window_cover_tilt_horizontal_2(hass: HomeAssistant, utcnow) assert state.attributes["current_tilt_position"] == 83 -async def test_read_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_tilt_vertical(hass: HomeAssistant) -> None: """Test that vertical tilt is handled correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt @@ -196,7 +196,7 @@ async def test_read_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> N assert state.attributes["current_tilt_position"] == 83 -async def test_read_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) -> None: +async def test_read_window_cover_tilt_vertical_2(hass: HomeAssistant) -> None: """Test that vertical tilt is handled correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt_2 @@ -211,7 +211,7 @@ async def test_read_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) -> assert state.attributes["current_tilt_position"] == 83 -async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) -> None: +async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant) -> None: """Test that horizontal tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_h_tilt @@ -232,9 +232,7 @@ async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) - ) -async def test_write_window_cover_tilt_horizontal_2( - hass: HomeAssistant, utcnow -) -> None: +async def test_write_window_cover_tilt_horizontal_2(hass: HomeAssistant) -> None: """Test that horizontal tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_h_tilt_2 @@ -255,7 +253,7 @@ async def test_write_window_cover_tilt_horizontal_2( ) -async def test_write_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> None: +async def test_write_window_cover_tilt_vertical(hass: HomeAssistant) -> None: """Test that vertical tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt @@ -276,7 +274,7 @@ async def test_write_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> ) -async def test_write_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) -> None: +async def test_write_window_cover_tilt_vertical_2(hass: HomeAssistant) -> None: """Test that vertical tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt_2 @@ -297,7 +295,7 @@ async def test_write_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) - ) -async def test_window_cover_stop(hass: HomeAssistant, utcnow) -> None: +async def test_window_cover_stop(hass: HomeAssistant) -> None: """Test that vertical tilt is written correctly.""" helper = await setup_test_component( hass, create_window_covering_service_with_v_tilt @@ -333,7 +331,7 @@ def create_garage_door_opener_service(accessory): return service -async def test_change_door_state(hass: HomeAssistant, utcnow) -> None: +async def test_change_door_state(hass: HomeAssistant) -> None: """Test that we can turn open and close a HomeKit garage door.""" helper = await setup_test_component(hass, create_garage_door_opener_service) @@ -358,7 +356,7 @@ async def test_change_door_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_read_door_state(hass: HomeAssistant, utcnow) -> None: +async def test_read_door_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit garage door.""" helper = await setup_test_component(hass, create_garage_door_opener_service) @@ -399,7 +397,7 @@ async def test_read_door_state(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a cover unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_device_trigger.py b/tests/components/homekit_controller/test_device_trigger.py index ed3894c331b..2f66a1eea26 100644 --- a/tests/components/homekit_controller/test_device_trigger.py +++ b/tests/components/homekit_controller/test_device_trigger.py @@ -87,7 +87,6 @@ async def test_enumerate_remote( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - utcnow, ) -> None: """Test that remote is correctly enumerated.""" await setup_test_component(hass, create_remote) @@ -139,7 +138,6 @@ async def test_enumerate_button( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - utcnow, ) -> None: """Test that a button is correctly enumerated.""" await setup_test_component(hass, create_button) @@ -190,7 +188,6 @@ async def test_enumerate_doorbell( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - utcnow, ) -> None: """Test that a button is correctly enumerated.""" await setup_test_component(hass, create_doorbell) @@ -241,7 +238,6 @@ async def test_handle_events( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - utcnow, calls, ) -> None: """Test that events are handled.""" @@ -362,7 +358,6 @@ async def test_handle_events_late_setup( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - utcnow, calls, ) -> None: """Test that events are handled when setup happens after startup.""" diff --git a/tests/components/homekit_controller/test_diagnostics.py b/tests/components/homekit_controller/test_diagnostics.py index 0f1073b877d..c0a9ebbb8d4 100644 --- a/tests/components/homekit_controller/test_diagnostics.py +++ b/tests/components/homekit_controller/test_diagnostics.py @@ -1,5 +1,7 @@ """Test homekit_controller diagnostics.""" -from unittest.mock import ANY + +from syrupy import SnapshotAssertion +from syrupy.filters import props from homeassistant.components.homekit_controller.const import KNOWN_DEVICES from homeassistant.core import HomeAssistant @@ -15,7 +17,9 @@ from tests.typing import ClientSessionGenerator async def test_config_entry( - hass: HomeAssistant, hass_client: ClientSessionGenerator, utcnow + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") @@ -23,277 +27,14 @@ async def test_config_entry( diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "config-entry": { - "title": "test", - "version": 1, - "data": {"AccessoryPairingID": "00:00:00:00:00:00"}, - }, - "config-num": 0, - "entity-map": [ - { - "aid": 1, - "services": [ - { - "iid": 1, - "type": "0000003E-0000-1000-8000-0026BB765291", - "characteristics": [ - { - "type": "00000023-0000-1000-8000-0026BB765291", - "iid": 2, - "perms": ["pr"], - "format": "string", - "value": "Koogeek-LS1-20833F", - "description": "Name", - "maxLen": 64, - }, - { - "type": "00000020-0000-1000-8000-0026BB765291", - "iid": 3, - "perms": ["pr"], - "format": "string", - "value": "Koogeek", - "description": "Manufacturer", - "maxLen": 64, - }, - { - "type": "00000021-0000-1000-8000-0026BB765291", - "iid": 4, - "perms": ["pr"], - "format": "string", - "value": "LS1", - "description": "Model", - "maxLen": 64, - }, - { - "type": "00000030-0000-1000-8000-0026BB765291", - "iid": 5, - "perms": ["pr"], - "format": "string", - "value": "**REDACTED**", - "description": "Serial Number", - "maxLen": 64, - }, - { - "type": "00000014-0000-1000-8000-0026BB765291", - "iid": 6, - "perms": ["pw"], - "format": "bool", - "description": "Identify", - }, - { - "type": "00000052-0000-1000-8000-0026BB765291", - "iid": 23, - "perms": ["pr"], - "format": "string", - "value": "2.2.15", - "description": "Firmware Revision", - "maxLen": 64, - }, - ], - }, - { - "iid": 7, - "type": "00000043-0000-1000-8000-0026BB765291", - "characteristics": [ - { - "type": "00000025-0000-1000-8000-0026BB765291", - "iid": 8, - "perms": ["pr", "pw", "ev"], - "format": "bool", - "value": False, - "description": "On", - }, - { - "type": "00000013-0000-1000-8000-0026BB765291", - "iid": 9, - "perms": ["pr", "pw", "ev"], - "format": "float", - "value": 44, - "description": "Hue", - "unit": "arcdegrees", - "minValue": 0, - "maxValue": 359, - "minStep": 1, - }, - { - "type": "0000002F-0000-1000-8000-0026BB765291", - "iid": 10, - "perms": ["pr", "pw", "ev"], - "format": "float", - "value": 0, - "description": "Saturation", - "unit": "percentage", - "minValue": 0, - "maxValue": 100, - "minStep": 1, - }, - { - "type": "00000008-0000-1000-8000-0026BB765291", - "iid": 11, - "perms": ["pr", "pw", "ev"], - "format": "int", - "value": 100, - "description": "Brightness", - "unit": "percentage", - "minValue": 0, - "maxValue": 100, - "minStep": 1, - }, - { - "type": "00000023-0000-1000-8000-0026BB765291", - "iid": 12, - "perms": ["pr"], - "format": "string", - "value": "Light Strip", - "description": "Name", - "maxLen": 64, - }, - ], - }, - { - "iid": 13, - "type": "4AAAF940-0DEC-11E5-B939-0800200C9A66", - "characteristics": [ - { - "type": "4AAAF942-0DEC-11E5-B939-0800200C9A66", - "iid": 14, - "perms": ["pr", "pw"], - "format": "tlv8", - "value": "AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "description": "TIMER_SETTINGS", - } - ], - }, - { - "iid": 15, - "type": "151909D0-3802-11E4-916C-0800200C9A66", - "characteristics": [ - { - "type": "151909D2-3802-11E4-916C-0800200C9A66", - "iid": 16, - "perms": ["pr", "hd"], - "format": "string", - "value": "url,data", - "description": "FW Upgrade supported types", - "maxLen": 64, - }, - { - "type": "151909D1-3802-11E4-916C-0800200C9A66", - "iid": 17, - "perms": ["pw", "hd"], - "format": "string", - "description": "FW Upgrade URL", - "maxLen": 64, - }, - { - "type": "151909D6-3802-11E4-916C-0800200C9A66", - "iid": 18, - "perms": ["pr", "ev", "hd"], - "format": "int", - "value": 0, - "description": "FW Upgrade Status", - }, - { - "type": "151909D7-3802-11E4-916C-0800200C9A66", - "iid": 19, - "perms": ["pw", "hd"], - "format": "data", - "description": "FW Upgrade Data", - }, - ], - }, - { - "iid": 20, - "type": "151909D3-3802-11E4-916C-0800200C9A66", - "characteristics": [ - { - "type": "151909D5-3802-11E4-916C-0800200C9A66", - "iid": 21, - "perms": ["pr", "pw"], - "format": "int", - "value": 0, - "description": "Timezone", - }, - { - "type": "151909D4-3802-11E4-916C-0800200C9A66", - "iid": 22, - "perms": ["pr", "pw"], - "format": "int", - "value": 1550348623, - "description": "Time value since Epoch", - }, - ], - }, - ], - } - ], - "devices": [ - { - "name": "Koogeek-LS1-20833F", - "model": "LS1", - "manfacturer": "Koogeek", - "sw_version": "2.2.15", - "hw_version": "", - "entities": [ - { - "device_class": None, - "disabled": False, - "disabled_by": None, - "entity_category": "diagnostic", - "icon": None, - "original_device_class": None, - "original_icon": None, - "original_name": "Koogeek-LS1-20833F Identify", - "state": { - "attributes": { - "friendly_name": "Koogeek-LS1-20833F Identify" - }, - "entity_id": "button.koogeek_ls1_20833f_identify", - "last_changed": ANY, - "last_updated": ANY, - "state": "unknown", - }, - "unit_of_measurement": None, - }, - { - "device_class": None, - "disabled": False, - "disabled_by": None, - "entity_category": None, - "icon": None, - "original_device_class": None, - "original_icon": None, - "original_name": "Koogeek-LS1-20833F Light Strip", - "state": { - "attributes": { - "friendly_name": "Koogeek-LS1-20833F Light Strip", - "supported_color_modes": ["hs"], - "supported_features": 0, - "brightness": None, - "color_mode": None, - "hs_color": None, - "rgb_color": None, - "xy_color": None, - }, - "entity_id": "light.koogeek_ls1_20833f_light_strip", - "last_changed": ANY, - "last_updated": ANY, - "state": "off", - }, - "unit_of_measurement": None, - }, - ], - } - ], - } + assert diag == snapshot(exclude=props("last_updated", "last_changed")) async def test_device( hass: HomeAssistant, hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, - utcnow, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a device entry.""" accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") @@ -304,263 +45,4 @@ async def test_device( diag = await get_diagnostics_for_device(hass, hass_client, config_entry, device) - assert diag == { - "config-entry": { - "title": "test", - "version": 1, - "data": {"AccessoryPairingID": "00:00:00:00:00:00"}, - }, - "config-num": 0, - "entity-map": [ - { - "aid": 1, - "services": [ - { - "iid": 1, - "type": "0000003E-0000-1000-8000-0026BB765291", - "characteristics": [ - { - "type": "00000023-0000-1000-8000-0026BB765291", - "iid": 2, - "perms": ["pr"], - "format": "string", - "value": "Koogeek-LS1-20833F", - "description": "Name", - "maxLen": 64, - }, - { - "type": "00000020-0000-1000-8000-0026BB765291", - "iid": 3, - "perms": ["pr"], - "format": "string", - "value": "Koogeek", - "description": "Manufacturer", - "maxLen": 64, - }, - { - "type": "00000021-0000-1000-8000-0026BB765291", - "iid": 4, - "perms": ["pr"], - "format": "string", - "value": "LS1", - "description": "Model", - "maxLen": 64, - }, - { - "type": "00000030-0000-1000-8000-0026BB765291", - "iid": 5, - "perms": ["pr"], - "format": "string", - "value": "**REDACTED**", - "description": "Serial Number", - "maxLen": 64, - }, - { - "type": "00000014-0000-1000-8000-0026BB765291", - "iid": 6, - "perms": ["pw"], - "format": "bool", - "description": "Identify", - }, - { - "type": "00000052-0000-1000-8000-0026BB765291", - "iid": 23, - "perms": ["pr"], - "format": "string", - "value": "2.2.15", - "description": "Firmware Revision", - "maxLen": 64, - }, - ], - }, - { - "iid": 7, - "type": "00000043-0000-1000-8000-0026BB765291", - "characteristics": [ - { - "type": "00000025-0000-1000-8000-0026BB765291", - "iid": 8, - "perms": ["pr", "pw", "ev"], - "format": "bool", - "value": False, - "description": "On", - }, - { - "type": "00000013-0000-1000-8000-0026BB765291", - "iid": 9, - "perms": ["pr", "pw", "ev"], - "format": "float", - "value": 44, - "description": "Hue", - "unit": "arcdegrees", - "minValue": 0, - "maxValue": 359, - "minStep": 1, - }, - { - "type": "0000002F-0000-1000-8000-0026BB765291", - "iid": 10, - "perms": ["pr", "pw", "ev"], - "format": "float", - "value": 0, - "description": "Saturation", - "unit": "percentage", - "minValue": 0, - "maxValue": 100, - "minStep": 1, - }, - { - "type": "00000008-0000-1000-8000-0026BB765291", - "iid": 11, - "perms": ["pr", "pw", "ev"], - "format": "int", - "value": 100, - "description": "Brightness", - "unit": "percentage", - "minValue": 0, - "maxValue": 100, - "minStep": 1, - }, - { - "type": "00000023-0000-1000-8000-0026BB765291", - "iid": 12, - "perms": ["pr"], - "format": "string", - "value": "Light Strip", - "description": "Name", - "maxLen": 64, - }, - ], - }, - { - "iid": 13, - "type": "4AAAF940-0DEC-11E5-B939-0800200C9A66", - "characteristics": [ - { - "type": "4AAAF942-0DEC-11E5-B939-0800200C9A66", - "iid": 14, - "perms": ["pr", "pw"], - "format": "tlv8", - "value": "AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "description": "TIMER_SETTINGS", - } - ], - }, - { - "iid": 15, - "type": "151909D0-3802-11E4-916C-0800200C9A66", - "characteristics": [ - { - "type": "151909D2-3802-11E4-916C-0800200C9A66", - "iid": 16, - "perms": ["pr", "hd"], - "format": "string", - "value": "url,data", - "description": "FW Upgrade supported types", - "maxLen": 64, - }, - { - "type": "151909D1-3802-11E4-916C-0800200C9A66", - "iid": 17, - "perms": ["pw", "hd"], - "format": "string", - "description": "FW Upgrade URL", - "maxLen": 64, - }, - { - "type": "151909D6-3802-11E4-916C-0800200C9A66", - "iid": 18, - "perms": ["pr", "ev", "hd"], - "format": "int", - "value": 0, - "description": "FW Upgrade Status", - }, - { - "type": "151909D7-3802-11E4-916C-0800200C9A66", - "iid": 19, - "perms": ["pw", "hd"], - "format": "data", - "description": "FW Upgrade Data", - }, - ], - }, - { - "iid": 20, - "type": "151909D3-3802-11E4-916C-0800200C9A66", - "characteristics": [ - { - "type": "151909D5-3802-11E4-916C-0800200C9A66", - "iid": 21, - "perms": ["pr", "pw"], - "format": "int", - "value": 0, - "description": "Timezone", - }, - { - "type": "151909D4-3802-11E4-916C-0800200C9A66", - "iid": 22, - "perms": ["pr", "pw"], - "format": "int", - "value": 1550348623, - "description": "Time value since Epoch", - }, - ], - }, - ], - } - ], - "device": { - "name": "Koogeek-LS1-20833F", - "model": "LS1", - "manfacturer": "Koogeek", - "sw_version": "2.2.15", - "hw_version": "", - "entities": [ - { - "device_class": None, - "disabled": False, - "disabled_by": None, - "entity_category": "diagnostic", - "icon": None, - "original_device_class": None, - "original_icon": None, - "original_name": "Koogeek-LS1-20833F Identify", - "state": { - "attributes": {"friendly_name": "Koogeek-LS1-20833F Identify"}, - "entity_id": "button.koogeek_ls1_20833f_identify", - "last_changed": ANY, - "last_updated": ANY, - "state": "unknown", - }, - "unit_of_measurement": None, - }, - { - "device_class": None, - "disabled": False, - "disabled_by": None, - "entity_category": None, - "icon": None, - "original_device_class": None, - "original_icon": None, - "original_name": "Koogeek-LS1-20833F Light Strip", - "state": { - "attributes": { - "friendly_name": "Koogeek-LS1-20833F Light Strip", - "supported_color_modes": ["hs"], - "supported_features": 0, - "brightness": None, - "color_mode": None, - "hs_color": None, - "rgb_color": None, - "xy_color": None, - }, - "entity_id": "light.koogeek_ls1_20833f_light_strip", - "last_changed": ANY, - "last_updated": ANY, - "state": "off", - }, - "unit_of_measurement": None, - }, - ], - }, - } + assert diag == snapshot(exclude=props("last_updated", "last_changed")) diff --git a/tests/components/homekit_controller/test_event.py b/tests/components/homekit_controller/test_event.py index 7fb0d1fd55f..a836fb1c669 100644 --- a/tests/components/homekit_controller/test_event.py +++ b/tests/components/homekit_controller/test_event.py @@ -64,9 +64,7 @@ def create_doorbell(accessory): battery.add_char(CharacteristicsTypes.BATTERY_LEVEL) -async def test_remote( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow -) -> None: +async def test_remote(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test that remote is supported.""" helper = await setup_test_component(hass, create_remote) @@ -109,9 +107,7 @@ async def test_remote( assert state.attributes["event_type"] == "long_press" -async def test_button( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow -) -> None: +async def test_button(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: """Test that a button is correctly enumerated.""" helper = await setup_test_component(hass, create_button) entity_id = "event.testdevice_button_1" @@ -148,7 +144,7 @@ async def test_button( async def test_doorbell( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test that doorbell service is handled.""" helper = await setup_test_component(hass, create_doorbell) diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py index 2fb64fc345d..938f09c453e 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -89,7 +89,7 @@ def create_fanv2_service_without_rotation_speed(accessory): swing_mode.value = 0 -async def test_fan_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_fan_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit fan accessory.""" helper = await setup_test_component(hass, create_fan_service) @@ -104,7 +104,7 @@ async def test_fan_read_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "on" -async def test_turn_on(hass: HomeAssistant, utcnow) -> None: +async def test_turn_on(hass: HomeAssistant) -> None: """Test that we can turn a fan on.""" helper = await setup_test_component(hass, create_fan_service) @@ -151,7 +151,7 @@ async def test_turn_on(hass: HomeAssistant, utcnow) -> None: ) -async def test_turn_on_off_without_rotation_speed(hass: HomeAssistant, utcnow) -> None: +async def test_turn_on_off_without_rotation_speed(hass: HomeAssistant) -> None: """Test that we can turn a fan on.""" helper = await setup_test_component( hass, create_fanv2_service_without_rotation_speed @@ -184,7 +184,7 @@ async def test_turn_on_off_without_rotation_speed(hass: HomeAssistant, utcnow) - ) -async def test_turn_off(hass: HomeAssistant, utcnow) -> None: +async def test_turn_off(hass: HomeAssistant) -> None: """Test that we can turn a fan off.""" helper = await setup_test_component(hass, create_fan_service) @@ -204,7 +204,7 @@ async def test_turn_off(hass: HomeAssistant, utcnow) -> None: ) -async def test_set_speed(hass: HomeAssistant, utcnow) -> None: +async def test_set_speed(hass: HomeAssistant) -> None: """Test that we set fan speed.""" helper = await setup_test_component(hass, create_fan_service) @@ -263,7 +263,7 @@ async def test_set_speed(hass: HomeAssistant, utcnow) -> None: ) -async def test_set_percentage(hass: HomeAssistant, utcnow) -> None: +async def test_set_percentage(hass: HomeAssistant) -> None: """Test that we set fan speed by percentage.""" helper = await setup_test_component(hass, create_fan_service) @@ -296,7 +296,7 @@ async def test_set_percentage(hass: HomeAssistant, utcnow) -> None: ) -async def test_speed_read(hass: HomeAssistant, utcnow) -> None: +async def test_speed_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fan_service) @@ -336,7 +336,7 @@ async def test_speed_read(hass: HomeAssistant, utcnow) -> None: assert state.attributes["percentage"] == 0 -async def test_set_direction(hass: HomeAssistant, utcnow) -> None: +async def test_set_direction(hass: HomeAssistant) -> None: """Test that we can set fan spin direction.""" helper = await setup_test_component(hass, create_fan_service) @@ -367,7 +367,7 @@ async def test_set_direction(hass: HomeAssistant, utcnow) -> None: ) -async def test_direction_read(hass: HomeAssistant, utcnow) -> None: +async def test_direction_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fan_service) @@ -382,7 +382,7 @@ async def test_direction_read(hass: HomeAssistant, utcnow) -> None: assert state.attributes["direction"] == "reverse" -async def test_fanv2_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_fanv2_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit fan accessory.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -397,7 +397,7 @@ async def test_fanv2_read_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "on" -async def test_v2_turn_on(hass: HomeAssistant, utcnow) -> None: +async def test_v2_turn_on(hass: HomeAssistant) -> None: """Test that we can turn a fan on.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -472,7 +472,7 @@ async def test_v2_turn_on(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_turn_off(hass: HomeAssistant, utcnow) -> None: +async def test_v2_turn_off(hass: HomeAssistant) -> None: """Test that we can turn a fan off.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -492,7 +492,7 @@ async def test_v2_turn_off(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_set_speed(hass: HomeAssistant, utcnow) -> None: +async def test_v2_set_speed(hass: HomeAssistant) -> None: """Test that we set fan speed.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -551,7 +551,7 @@ async def test_v2_set_speed(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_set_percentage(hass: HomeAssistant, utcnow) -> None: +async def test_v2_set_percentage(hass: HomeAssistant) -> None: """Test that we set fan speed by percentage.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -584,7 +584,7 @@ async def test_v2_set_percentage(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_set_percentage_with_min_step(hass: HomeAssistant, utcnow) -> None: +async def test_v2_set_percentage_with_min_step(hass: HomeAssistant) -> None: """Test that we set fan speed by percentage.""" helper = await setup_test_component(hass, create_fanv2_service_with_min_step) @@ -617,7 +617,7 @@ async def test_v2_set_percentage_with_min_step(hass: HomeAssistant, utcnow) -> N ) -async def test_v2_speed_read(hass: HomeAssistant, utcnow) -> None: +async def test_v2_speed_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -656,7 +656,7 @@ async def test_v2_speed_read(hass: HomeAssistant, utcnow) -> None: assert state.attributes["percentage"] == 0 -async def test_v2_set_direction(hass: HomeAssistant, utcnow) -> None: +async def test_v2_set_direction(hass: HomeAssistant) -> None: """Test that we can set fan spin direction.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -687,7 +687,7 @@ async def test_v2_set_direction(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_direction_read(hass: HomeAssistant, utcnow) -> None: +async def test_v2_direction_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -702,7 +702,7 @@ async def test_v2_direction_read(hass: HomeAssistant, utcnow) -> None: assert state.attributes["direction"] == "reverse" -async def test_v2_oscillate(hass: HomeAssistant, utcnow) -> None: +async def test_v2_oscillate(hass: HomeAssistant) -> None: """Test that we can control a fans oscillation.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -733,7 +733,7 @@ async def test_v2_oscillate(hass: HomeAssistant, utcnow) -> None: ) -async def test_v2_oscillate_read(hass: HomeAssistant, utcnow) -> None: +async def test_v2_oscillate_read(hass: HomeAssistant) -> None: """Test that we can read a fans oscillation.""" helper = await setup_test_component(hass, create_fanv2_service) @@ -749,7 +749,7 @@ async def test_v2_oscillate_read(hass: HomeAssistant, utcnow) -> None: async def test_v2_set_percentage_non_standard_rotation_range( - hass: HomeAssistant, utcnow + hass: HomeAssistant, ) -> None: """Test that we set fan speed with a non-standard rotation range.""" helper = await setup_test_component( @@ -812,7 +812,7 @@ async def test_v2_set_percentage_non_standard_rotation_range( async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a fan unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_humidifier.py b/tests/components/homekit_controller/test_humidifier.py index 718c6957356..1a1db53d8dd 100644 --- a/tests/components/homekit_controller/test_humidifier.py +++ b/tests/components/homekit_controller/test_humidifier.py @@ -63,7 +63,7 @@ def create_dehumidifier_service(accessory): return service -async def test_humidifier_active_state(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_active_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit humidifier on and off again.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -86,7 +86,7 @@ async def test_humidifier_active_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_dehumidifier_active_state(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_active_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit dehumidifier on and off again.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -109,7 +109,7 @@ async def test_dehumidifier_active_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_humidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_read_humidity(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -148,7 +148,7 @@ async def test_humidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: assert state.state == "off" -async def test_dehumidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_read_humidity(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -185,7 +185,7 @@ async def test_dehumidifier_read_humidity(hass: HomeAssistant, utcnow) -> None: assert state.attributes["humidity"] == 40 -async def test_humidifier_set_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_set_humidity(hass: HomeAssistant) -> None: """Test that we can set the state of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -201,7 +201,7 @@ async def test_humidifier_set_humidity(hass: HomeAssistant, utcnow) -> None: ) -async def test_dehumidifier_set_humidity(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_set_humidity(hass: HomeAssistant) -> None: """Test that we can set the state of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -217,7 +217,7 @@ async def test_dehumidifier_set_humidity(hass: HomeAssistant, utcnow) -> None: ) -async def test_humidifier_set_mode(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_set_mode(hass: HomeAssistant) -> None: """Test that we can set the mode of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -250,7 +250,7 @@ async def test_humidifier_set_mode(hass: HomeAssistant, utcnow) -> None: ) -async def test_dehumidifier_set_mode(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_set_mode(hass: HomeAssistant) -> None: """Test that we can set the mode of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -283,7 +283,7 @@ async def test_dehumidifier_set_mode(hass: HomeAssistant, utcnow) -> None: ) -async def test_humidifier_read_only_mode(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_read_only_mode(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -323,7 +323,7 @@ async def test_humidifier_read_only_mode(hass: HomeAssistant, utcnow) -> None: assert state.attributes["mode"] == "normal" -async def test_dehumidifier_read_only_mode(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_read_only_mode(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -363,7 +363,7 @@ async def test_dehumidifier_read_only_mode(hass: HomeAssistant, utcnow) -> None: assert state.attributes["mode"] == "normal" -async def test_humidifier_target_humidity_modes(hass: HomeAssistant, utcnow) -> None: +async def test_humidifier_target_humidity_modes(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit humidifier accessory.""" helper = await setup_test_component(hass, create_humidifier_service) @@ -408,7 +408,7 @@ async def test_humidifier_target_humidity_modes(hass: HomeAssistant, utcnow) -> assert state.attributes["humidity"] == 37 -async def test_dehumidifier_target_humidity_modes(hass: HomeAssistant, utcnow) -> None: +async def test_dehumidifier_target_humidity_modes(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit dehumidifier accessory.""" helper = await setup_test_component(hass, create_dehumidifier_service) @@ -456,7 +456,7 @@ async def test_dehumidifier_target_humidity_modes(hass: HomeAssistant, utcnow) - async def test_migrate_entity_ids( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test that we can migrate humidifier entity ids.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 7f7bec3bb2f..57d206a6025 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -46,7 +46,7 @@ def create_motion_sensor_service(accessory): cur_state.value = 0 -async def test_unload_on_stop(hass: HomeAssistant, utcnow) -> None: +async def test_unload_on_stop(hass: HomeAssistant) -> None: """Test async_unload is called on stop.""" await setup_test_component(hass, create_motion_sensor_service) with patch( diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index 5d33d744de7..72bf579b36e 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -54,7 +54,7 @@ def create_lightbulb_service_with_color_temp(accessory): return service -async def test_switch_change_light_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_change_light_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit light on and off again.""" helper = await setup_test_component(hass, create_lightbulb_service_with_hs) @@ -85,9 +85,7 @@ async def test_switch_change_light_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_switch_change_light_state_color_temp( - hass: HomeAssistant, utcnow -) -> None: +async def test_switch_change_light_state_color_temp(hass: HomeAssistant) -> None: """Test that we can turn change color_temp.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -107,7 +105,7 @@ async def test_switch_change_light_state_color_temp( ) -async def test_switch_read_light_state_dimmer(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_light_state_dimmer(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service) @@ -142,7 +140,7 @@ async def test_switch_read_light_state_dimmer(hass: HomeAssistant, utcnow) -> No assert state.state == "off" -async def test_switch_push_light_state_dimmer(hass: HomeAssistant, utcnow) -> None: +async def test_switch_push_light_state_dimmer(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service) @@ -170,7 +168,7 @@ async def test_switch_push_light_state_dimmer(hass: HomeAssistant, utcnow) -> No assert state.state == "off" -async def test_switch_read_light_state_hs(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_light_state_hs(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service_with_hs) @@ -208,7 +206,7 @@ async def test_switch_read_light_state_hs(hass: HomeAssistant, utcnow) -> None: assert state.state == "off" -async def test_switch_push_light_state_hs(hass: HomeAssistant, utcnow) -> None: +async def test_switch_push_light_state_hs(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service_with_hs) @@ -239,7 +237,7 @@ async def test_switch_push_light_state_hs(hass: HomeAssistant, utcnow) -> None: assert state.state == "off" -async def test_switch_read_light_state_color_temp(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_light_state_color_temp(hass: HomeAssistant) -> None: """Test that we can read the color_temp of a light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -267,7 +265,7 @@ async def test_switch_read_light_state_color_temp(hass: HomeAssistant, utcnow) - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 -async def test_switch_push_light_state_color_temp(hass: HomeAssistant, utcnow) -> None: +async def test_switch_push_light_state_color_temp(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -288,9 +286,7 @@ async def test_switch_push_light_state_color_temp(hass: HomeAssistant, utcnow) - assert state.attributes["color_temp"] == 400 -async def test_light_becomes_unavailable_but_recovers( - hass: HomeAssistant, utcnow -) -> None: +async def test_light_becomes_unavailable_but_recovers(hass: HomeAssistant) -> None: """Test transition to and from unavailable state.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -318,7 +314,7 @@ async def test_light_becomes_unavailable_but_recovers( assert state.attributes["color_temp"] == 400 -async def test_light_unloaded_removed(hass: HomeAssistant, utcnow) -> None: +async def test_light_unloaded_removed(hass: HomeAssistant) -> None: """Test entity and HKDevice are correctly unloaded and removed.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -344,7 +340,7 @@ async def test_light_unloaded_removed(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a light unique id.""" aid = get_next_aid() @@ -362,7 +358,7 @@ async def test_migrate_unique_id( async def test_only_migrate_once( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we handle migration happening after an upgrade and than a downgrade and then an upgrade.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_lock.py b/tests/components/homekit_controller/test_lock.py index e265bf586a2..9aacda81683 100644 --- a/tests/components/homekit_controller/test_lock.py +++ b/tests/components/homekit_controller/test_lock.py @@ -28,7 +28,7 @@ def create_lock_service(accessory): return service -async def test_switch_change_lock_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_change_lock_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit lock on and off again.""" helper = await setup_test_component(hass, create_lock_service) @@ -53,7 +53,7 @@ async def test_switch_change_lock_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_switch_read_lock_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_lock_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit lock accessory.""" helper = await setup_test_component(hass, create_lock_service) @@ -118,7 +118,7 @@ async def test_switch_read_lock_state(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a lock unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_media_player.py b/tests/components/homekit_controller/test_media_player.py index e9ea1d552ce..1573fccea02 100644 --- a/tests/components/homekit_controller/test_media_player.py +++ b/tests/components/homekit_controller/test_media_player.py @@ -61,7 +61,7 @@ def create_tv_service_with_target_media_state(accessory): return service -async def test_tv_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_tv_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit fan accessory.""" helper = await setup_test_component(hass, create_tv_service) @@ -90,7 +90,7 @@ async def test_tv_read_state(hass: HomeAssistant, utcnow) -> None: assert state.state == "idle" -async def test_tv_read_sources(hass: HomeAssistant, utcnow) -> None: +async def test_tv_read_sources(hass: HomeAssistant) -> None: """Test that we can read the input source of a HomeKit TV.""" helper = await setup_test_component(hass, create_tv_service) @@ -99,7 +99,7 @@ async def test_tv_read_sources(hass: HomeAssistant, utcnow) -> None: assert state.attributes["source_list"] == ["HDMI 1", "HDMI 2"] -async def test_play_remote_key(hass: HomeAssistant, utcnow) -> None: +async def test_play_remote_key(hass: HomeAssistant) -> None: """Test that we can play media on a media player.""" helper = await setup_test_component(hass, create_tv_service) @@ -146,7 +146,7 @@ async def test_play_remote_key(hass: HomeAssistant, utcnow) -> None: ) -async def test_pause_remote_key(hass: HomeAssistant, utcnow) -> None: +async def test_pause_remote_key(hass: HomeAssistant) -> None: """Test that we can pause a media player.""" helper = await setup_test_component(hass, create_tv_service) @@ -193,7 +193,7 @@ async def test_pause_remote_key(hass: HomeAssistant, utcnow) -> None: ) -async def test_play(hass: HomeAssistant, utcnow) -> None: +async def test_play(hass: HomeAssistant) -> None: """Test that we can play media on a media player.""" helper = await setup_test_component(hass, create_tv_service_with_target_media_state) @@ -242,7 +242,7 @@ async def test_play(hass: HomeAssistant, utcnow) -> None: ) -async def test_pause(hass: HomeAssistant, utcnow) -> None: +async def test_pause(hass: HomeAssistant) -> None: """Test that we can turn pause a media player.""" helper = await setup_test_component(hass, create_tv_service_with_target_media_state) @@ -290,7 +290,7 @@ async def test_pause(hass: HomeAssistant, utcnow) -> None: ) -async def test_stop(hass: HomeAssistant, utcnow) -> None: +async def test_stop(hass: HomeAssistant) -> None: """Test that we can stop a media player.""" helper = await setup_test_component(hass, create_tv_service_with_target_media_state) @@ -331,7 +331,7 @@ async def test_stop(hass: HomeAssistant, utcnow) -> None: ) -async def test_tv_set_source(hass: HomeAssistant, utcnow) -> None: +async def test_tv_set_source(hass: HomeAssistant) -> None: """Test that we can set the input source of a HomeKit TV.""" helper = await setup_test_component(hass, create_tv_service) @@ -352,7 +352,7 @@ async def test_tv_set_source(hass: HomeAssistant, utcnow) -> None: assert state.attributes["source"] == "HDMI 2" -async def test_tv_set_source_fail(hass: HomeAssistant, utcnow) -> None: +async def test_tv_set_source_fail(hass: HomeAssistant) -> None: """Test that we can set the input source of a HomeKit TV.""" helper = await setup_test_component(hass, create_tv_service) @@ -369,7 +369,7 @@ async def test_tv_set_source_fail(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a media_player unique id.""" aid = get_next_aid() diff --git a/tests/components/homekit_controller/test_number.py b/tests/components/homekit_controller/test_number.py index dedff37fa4b..d35df281eab 100644 --- a/tests/components/homekit_controller/test_number.py +++ b/tests/components/homekit_controller/test_number.py @@ -30,7 +30,7 @@ def create_switch_with_spray_level(accessory): async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a number unique id.""" aid = get_next_aid() @@ -48,7 +48,7 @@ async def test_migrate_unique_id( ) -async def test_read_number(hass: HomeAssistant, utcnow) -> None: +async def test_read_number(hass: HomeAssistant) -> None: """Test a switch service that has a sensor characteristic is correctly handled.""" helper = await setup_test_component(hass, create_switch_with_spray_level) @@ -74,7 +74,7 @@ async def test_read_number(hass: HomeAssistant, utcnow) -> None: assert state.state == "5" -async def test_write_number(hass: HomeAssistant, utcnow) -> None: +async def test_write_number(hass: HomeAssistant) -> None: """Test a switch service that has a sensor characteristic is correctly handled.""" helper = await setup_test_component(hass, create_switch_with_spray_level) diff --git a/tests/components/homekit_controller/test_select.py b/tests/components/homekit_controller/test_select.py index 70228ef3dbb..baae2cf8219 100644 --- a/tests/components/homekit_controller/test_select.py +++ b/tests/components/homekit_controller/test_select.py @@ -34,7 +34,7 @@ def create_service_with_temperature_units(accessory: Accessory): async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test we can migrate a select unique id.""" aid = get_next_aid() @@ -53,7 +53,7 @@ async def test_migrate_unique_id( ) -async def test_read_current_mode(hass: HomeAssistant, utcnow) -> None: +async def test_read_current_mode(hass: HomeAssistant) -> None: """Test that Ecobee mode can be correctly read and show as human readable text.""" helper = await setup_test_component(hass, create_service_with_ecobee_mode) @@ -91,7 +91,7 @@ async def test_read_current_mode(hass: HomeAssistant, utcnow) -> None: assert state.state == "away" -async def test_write_current_mode(hass: HomeAssistant, utcnow) -> None: +async def test_write_current_mode(hass: HomeAssistant) -> None: """Test can set a specific mode.""" helper = await setup_test_component(hass, create_service_with_ecobee_mode) helper.accessory.services.first(service_type=ServicesTypes.THERMOSTAT) @@ -139,7 +139,7 @@ async def test_write_current_mode(hass: HomeAssistant, utcnow) -> None: ) -async def test_read_select(hass: HomeAssistant, utcnow) -> None: +async def test_read_select(hass: HomeAssistant) -> None: """Test the generic select can read the current value.""" helper = await setup_test_component(hass, create_service_with_temperature_units) @@ -169,7 +169,7 @@ async def test_read_select(hass: HomeAssistant, utcnow) -> None: assert state.state == "fahrenheit" -async def test_write_select(hass: HomeAssistant, utcnow) -> None: +async def test_write_select(hass: HomeAssistant) -> None: """Test can set a value.""" helper = await setup_test_component(hass, create_service_with_temperature_units) helper.accessory.services.first(service_type=ServicesTypes.THERMOSTAT) diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index e15227d9d87..3134605125e 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -69,7 +69,7 @@ def create_battery_level_sensor(accessory): return service -async def test_temperature_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_temperature_sensor_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit temperature sensor accessory.""" helper = await setup_test_component( hass, create_temperature_sensor_service, suffix="temperature" @@ -95,7 +95,7 @@ async def test_temperature_sensor_read_state(hass: HomeAssistant, utcnow) -> Non assert state.attributes["state_class"] == SensorStateClass.MEASUREMENT -async def test_temperature_sensor_not_added_twice(hass: HomeAssistant, utcnow) -> None: +async def test_temperature_sensor_not_added_twice(hass: HomeAssistant) -> None: """A standalone temperature sensor should not get a characteristic AND a service entity.""" helper = await setup_test_component( hass, create_temperature_sensor_service, suffix="temperature" @@ -109,7 +109,7 @@ async def test_temperature_sensor_not_added_twice(hass: HomeAssistant, utcnow) - assert created_sensors == {helper.entity_id} -async def test_humidity_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_humidity_sensor_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit humidity sensor accessory.""" helper = await setup_test_component( hass, create_humidity_sensor_service, suffix="humidity" @@ -134,7 +134,7 @@ async def test_humidity_sensor_read_state(hass: HomeAssistant, utcnow) -> None: assert state.attributes["device_class"] == SensorDeviceClass.HUMIDITY -async def test_light_level_sensor_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_light_level_sensor_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit temperature sensor accessory.""" helper = await setup_test_component( hass, create_light_level_sensor_service, suffix="light_level" @@ -159,9 +159,7 @@ async def test_light_level_sensor_read_state(hass: HomeAssistant, utcnow) -> Non assert state.attributes["device_class"] == SensorDeviceClass.ILLUMINANCE -async def test_carbon_dioxide_level_sensor_read_state( - hass: HomeAssistant, utcnow -) -> None: +async def test_carbon_dioxide_level_sensor_read_state(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit carbon dioxide sensor accessory.""" helper = await setup_test_component( hass, create_carbon_dioxide_level_sensor_service, suffix="carbon_dioxide" @@ -184,7 +182,7 @@ async def test_carbon_dioxide_level_sensor_read_state( assert state.state == "20" -async def test_battery_level_sensor(hass: HomeAssistant, utcnow) -> None: +async def test_battery_level_sensor(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit battery level sensor.""" helper = await setup_test_component( hass, create_battery_level_sensor, suffix="battery" @@ -211,7 +209,7 @@ async def test_battery_level_sensor(hass: HomeAssistant, utcnow) -> None: assert state.attributes["device_class"] == SensorDeviceClass.BATTERY -async def test_battery_charging(hass: HomeAssistant, utcnow) -> None: +async def test_battery_charging(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit battery's charging state.""" helper = await setup_test_component( hass, create_battery_level_sensor, suffix="battery" @@ -235,7 +233,7 @@ async def test_battery_charging(hass: HomeAssistant, utcnow) -> None: assert state.attributes["icon"] == "mdi:battery-charging-20" -async def test_battery_low(hass: HomeAssistant, utcnow) -> None: +async def test_battery_low(hass: HomeAssistant) -> None: """Test reading the state of a HomeKit battery's low state.""" helper = await setup_test_component( hass, create_battery_level_sensor, suffix="battery" @@ -277,7 +275,7 @@ def create_switch_with_sensor(accessory): return service -async def test_switch_with_sensor(hass: HomeAssistant, utcnow) -> None: +async def test_switch_with_sensor(hass: HomeAssistant) -> None: """Test a switch service that has a sensor characteristic is correctly handled.""" helper = await setup_test_component(hass, create_switch_with_sensor) @@ -307,7 +305,7 @@ async def test_switch_with_sensor(hass: HomeAssistant, utcnow) -> None: assert state.state == "50" -async def test_sensor_unavailable(hass: HomeAssistant, utcnow) -> None: +async def test_sensor_unavailable(hass: HomeAssistant) -> None: """Test a sensor becoming unavailable.""" helper = await setup_test_component(hass, create_switch_with_sensor) @@ -384,7 +382,6 @@ def test_thread_status_to_str() -> None: async def test_rssi_sensor( hass: HomeAssistant, - utcnow, entity_registry_enabled_by_default: None, enable_bluetooth: None, ) -> None: @@ -410,7 +407,6 @@ async def test_rssi_sensor( async def test_migrate_rssi_sensor_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, - utcnow, entity_registry_enabled_by_default: None, enable_bluetooth: None, ) -> None: diff --git a/tests/components/homekit_controller/test_storage.py b/tests/components/homekit_controller/test_storage.py index 583640854a6..afab63983e2 100644 --- a/tests/components/homekit_controller/test_storage.py +++ b/tests/components/homekit_controller/test_storage.py @@ -71,7 +71,7 @@ def create_lightbulb_service(accessory): async def test_storage_is_updated_on_add( - hass: HomeAssistant, hass_storage: dict[str, Any], utcnow + hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test entity map storage is cleaned up on adding an accessory.""" await setup_test_component(hass, create_lightbulb_service) diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py index 8867ffc9bd1..5b6a77b75c9 100644 --- a/tests/components/homekit_controller/test_switch.py +++ b/tests/components/homekit_controller/test_switch.py @@ -49,7 +49,7 @@ def create_char_switch_service(accessory): on_char.value = False -async def test_switch_change_outlet_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_change_outlet_state(hass: HomeAssistant) -> None: """Test that we can turn a HomeKit outlet on and off again.""" helper = await setup_test_component(hass, create_switch_service) @@ -74,7 +74,7 @@ async def test_switch_change_outlet_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_switch_read_outlet_state(hass: HomeAssistant, utcnow) -> None: +async def test_switch_read_outlet_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit outlet accessory.""" helper = await setup_test_component(hass, create_switch_service) @@ -107,7 +107,7 @@ async def test_switch_read_outlet_state(hass: HomeAssistant, utcnow) -> None: assert switch_1.attributes["outlet_in_use"] is True -async def test_valve_change_active_state(hass: HomeAssistant, utcnow) -> None: +async def test_valve_change_active_state(hass: HomeAssistant) -> None: """Test that we can turn a valve on and off again.""" helper = await setup_test_component(hass, create_valve_service) @@ -132,7 +132,7 @@ async def test_valve_change_active_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_valve_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_valve_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a valve accessory.""" helper = await setup_test_component(hass, create_valve_service) @@ -165,7 +165,7 @@ async def test_valve_read_state(hass: HomeAssistant, utcnow) -> None: assert switch_1.attributes["in_use"] is False -async def test_char_switch_change_state(hass: HomeAssistant, utcnow) -> None: +async def test_char_switch_change_state(hass: HomeAssistant) -> None: """Test that we can turn a characteristic on and off again.""" helper = await setup_test_component( hass, create_char_switch_service, suffix="pairing_mode" @@ -198,7 +198,7 @@ async def test_char_switch_change_state(hass: HomeAssistant, utcnow) -> None: ) -async def test_char_switch_read_state(hass: HomeAssistant, utcnow) -> None: +async def test_char_switch_read_state(hass: HomeAssistant) -> None: """Test that we can read the state of a HomeKit characteristic switch.""" helper = await setup_test_component( hass, create_char_switch_service, suffix="pairing_mode" @@ -220,7 +220,7 @@ async def test_char_switch_read_state(hass: HomeAssistant, utcnow) -> None: async def test_migrate_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry, utcnow + hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test a we can migrate a switch unique id.""" aid = get_next_aid() diff --git a/tests/components/homematicip_cloud/test_button.py b/tests/components/homematicip_cloud/test_button.py index c4b83692267..5135c0ec48a 100644 --- a/tests/components/homematicip_cloud/test_button.py +++ b/tests/components/homematicip_cloud/test_button.py @@ -1,5 +1,6 @@ """Tests for HomematicIP Cloud button.""" -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.button.const import SERVICE_PRESS @@ -11,7 +12,7 @@ from .helper import get_and_check_entity_basics async def test_hmip_garage_door_controller_button( - hass: HomeAssistant, default_mock_hap_factory + hass: HomeAssistant, freezer: FrozenDateTimeFactory, default_mock_hap_factory ) -> None: """Test HomematicipGarageDoorControllerButton.""" entity_id = "button.garagentor" @@ -28,13 +29,13 @@ async def test_hmip_garage_door_controller_button( assert state.state == STATE_UNKNOWN now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) state = hass.states.get(entity_id) assert state diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py index b042e3daa6c..20193d91239 100644 --- a/tests/components/homematicip_cloud/test_climate.py +++ b/tests/components/homematicip_cloud/test_climate.py @@ -3,6 +3,7 @@ import datetime from homematicip.base.enums import AbsenceType from homematicip.functionalHomes import IndoorClimateHome +import pytest from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -23,6 +24,7 @@ from homeassistant.components.homematicip_cloud.climate import ( PERMANENT_END_TIME, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component from .helper import HAPID, async_manipulate_test_data, get_and_check_entity_basics @@ -340,12 +342,13 @@ async def test_hmip_heating_group_cool( assert ha_state.attributes[ATTR_PRESET_MODE] == "none" assert ha_state.attributes[ATTR_PRESET_MODES] == [] - await hass.services.async_call( - "climate", - "set_preset_mode", - {"entity_id": entity_id, "preset_mode": "Cool2"}, - blocking=True, - ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "climate", + "set_preset_mode", + {"entity_id": entity_id, "preset_mode": "Cool2"}, + blocking=True, + ) assert len(hmip_device.mock_calls) == service_call_counter + 12 # fire_update_event shows that set_active_profile has not been called. diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index e778c82928b..0c24d9daebe 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -1,6 +1,5 @@ """Fixtures for HomeWizard integration tests.""" from collections.abc import Generator -import json from unittest.mock import AsyncMock, MagicMock, patch from homewizard_energy.errors import NotFoundError @@ -11,7 +10,7 @@ from homeassistant.components.homewizard.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, get_fixture_path, load_fixture +from tests.common import MockConfigEntry, get_fixture_path, load_json_object_fixture @pytest.fixture @@ -35,22 +34,22 @@ def mock_homewizardenergy( client = homewizard.return_value client.device.return_value = Device.from_dict( - json.loads(load_fixture(f"{device_fixture}/device.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/device.json", DOMAIN) ) client.data.return_value = Data.from_dict( - json.loads(load_fixture(f"{device_fixture}/data.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/data.json", DOMAIN) ) if get_fixture_path(f"{device_fixture}/state.json", DOMAIN).exists(): client.state.return_value = State.from_dict( - json.loads(load_fixture(f"{device_fixture}/state.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/state.json", DOMAIN) ) else: client.state.side_effect = NotFoundError if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists(): client.system.return_value = System.from_dict( - json.loads(load_fixture(f"{device_fixture}/system.json", DOMAIN)) + load_json_object_fixture(f"{device_fixture}/system.json", DOMAIN) ) else: client.system.side_effect = NotFoundError diff --git a/tests/components/homewizard/snapshots/test_config_flow.ambr b/tests/components/homewizard/snapshots/test_config_flow.ambr index b5b7411532e..663d9153991 100644 --- a/tests/components/homewizard/snapshots/test_config_flow.ambr +++ b/tests/components/homewizard/snapshots/test_config_flow.ambr @@ -12,6 +12,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'homewizard', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -21,6 +22,7 @@ 'disabled_by': None, 'domain': 'homewizard', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -52,6 +54,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'homewizard', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -61,6 +64,7 @@ 'disabled_by': None, 'domain': 'homewizard', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -92,6 +96,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'homewizard', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -101,6 +106,7 @@ 'disabled_by': None, 'domain': 'homewizard', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -128,6 +134,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'homewizard', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -137,6 +144,7 @@ 'disabled_by': None, 'domain': 'homewizard', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/homewizard/snapshots/test_number.ambr b/tests/components/homewizard/snapshots/test_number.ambr index 436abc70ac1..5c7e71ea9ac 100644 --- a/tests/components/homewizard/snapshots/test_number.ambr +++ b/tests/components/homewizard/snapshots/test_number.ambr @@ -14,7 +14,7 @@ 'entity_id': 'number.device_status_light_brightness', 'last_changed': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_number_entities[HWE-SKT].1 diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index ebd8d80ece2..a54f98899c6 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -41,7 +41,7 @@ async def test_number_entities( assert snapshot == device_entry # Test unknown handling - assert state.state == "100" + assert state.state == "100.0" mock_homewizardenergy.state.return_value.brightness = None @@ -64,7 +64,7 @@ async def test_number_entities( ) assert len(mock_homewizardenergy.state_set.mock_calls) == 1 - mock_homewizardenergy.state_set.assert_called_with(brightness=127) + mock_homewizardenergy.state_set.assert_called_with(brightness=129) mock_homewizardenergy.state_set.side_effect = RequestError with pytest.raises( @@ -97,7 +97,7 @@ async def test_number_entities( ) -@pytest.mark.parametrize("device_fixture", ["HWE-WTR", "SDM230", "SDM630"]) +@pytest.mark.parametrize("device_fixture", ["HWE-P1", "HWE-WTR", "SDM230", "SDM630"]) async def test_entities_not_created_for_device(hass: HomeAssistant) -> None: - """Does not load button when device has no support for it.""" + """Does not load number when device has no support for it.""" assert not hass.states.get("number.device_status_light_brightness") diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index 2f6e777a3a8..61ca34fab7a 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -29,6 +29,13 @@ pytestmark = [ @pytest.mark.parametrize( ("device_fixture", "entity_ids"), [ + ( + "HWE-P1", + [ + "switch.device", + "switch.device_switch_lock", + ], + ), ( "HWE-WTR", [ diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index 695688e77f0..ccfc2c5d264 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -132,28 +132,51 @@ async def test_remove_stale_device( """Test that the stale device is removed.""" location.devices_by_id[another_device.deviceid] = another_device - config_entry.add_to_hass(hass) - - device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, + config_entry_other = MockConfigEntry( + domain="OtherDomain", + data={}, + unique_id="unique_id", + ) + config_entry_other.add_to_hass(hass) + device_entry_other = device_registry.async_get_or_create( + config_entry_id=config_entry_other.entry_id, identifiers={("OtherDomain", 7654321)}, ) + device_registry.async_update_device( + device_entry_other.id, + add_config_entry_id=config_entry.entry_id, + merge_identifiers={(DOMAIN, 7654321)}, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - assert ( - hass.states.async_entity_ids_count() == 6 - ) # 2 climate entities; 4 sensor entities + assert hass.states.async_entity_ids_count() == 6 - device_entry = dr.async_entries_for_config_entry( + device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - assert len(device_entry) == 3 - assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) - assert any((DOMAIN, 7654321) in device.identifiers for device in device_entry) + + device_entries_other = dr.async_entries_for_config_entry( + device_registry, config_entry_other.entry_id + ) + + assert len(device_entries) == 2 + assert any((DOMAIN, 1234567) in device.identifiers for device in device_entries) + assert any((DOMAIN, 7654321) in device.identifiers for device in device_entries) assert any( - ("OtherDomain", 7654321) in device.identifiers for device in device_entry + ("OtherDomain", 7654321) in device.identifiers for device in device_entries + ) + assert len(device_entries_other) == 1 + assert any( + ("OtherDomain", 7654321) in device.identifiers + for device in device_entries_other + ) + assert any( + (DOMAIN, 7654321) in device.identifiers for device in device_entries_other ) assert await config_entry.async_unload(hass) @@ -169,11 +192,21 @@ async def test_remove_stale_device( hass.states.async_entity_ids_count() == 3 ) # 1 climate entities; 2 sensor entities - device_entry = dr.async_entries_for_config_entry( + device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - assert len(device_entry) == 2 - assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) - assert any( - ("OtherDomain", 7654321) in device.identifiers for device in device_entry + assert len(device_entries) == 1 + assert any((DOMAIN, 1234567) in device.identifiers for device in device_entries) + assert not any((DOMAIN, 7654321) in device.identifiers for device in device_entries) + assert not any( + ("OtherDomain", 7654321) in device.identifiers for device in device_entries + ) + + device_entries_other = dr.async_entries_for_config_entry( + device_registry, config_entry_other.entry_id + ) + assert len(device_entries_other) == 1 + assert any( + ("OtherDomain", 7654321) in device.identifiers + for device in device_entries_other ) diff --git a/tests/components/huawei_lte/test_select.py b/tests/components/huawei_lte/test_select.py new file mode 100644 index 00000000000..c3f6ded65b6 --- /dev/null +++ b/tests/components/huawei_lte/test_select.py @@ -0,0 +1,43 @@ +"""Tests for the Huawei LTE selects.""" +from unittest.mock import MagicMock, patch + +from huawei_lte_api.enums.net import LTEBandEnum, NetworkBandEnum, NetworkModeEnum + +from homeassistant.components.huawei_lte.const import DOMAIN +from homeassistant.components.select import SERVICE_SELECT_OPTION +from homeassistant.components.select.const import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, CONF_URL +from homeassistant.core import HomeAssistant + +from . import magic_client + +from tests.common import MockConfigEntry + +SELECT_NETWORK_MODE = "select.lte_preferred_network_mode" + + +@patch("homeassistant.components.huawei_lte.Connection", MagicMock()) +@patch("homeassistant.components.huawei_lte.Client") +async def test_set_net_mode(client, hass: HomeAssistant) -> None: + """Test setting network mode.""" + client.return_value = magic_client({}) + huawei_lte = MockConfigEntry( + domain=DOMAIN, data={CONF_URL: "http://huawei-lte.example.com"} + ) + huawei_lte.add_to_hass(hass) + await hass.config_entries.async_setup(huawei_lte.entry_id) + await hass.async_block_till_done() + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: SELECT_NETWORK_MODE, + ATTR_OPTION: NetworkModeEnum.MODE_4G_3G_AUTO.value, + }, + blocking=True, + ) + await hass.async_block_till_done() + client.return_value.net.set_net_mode.assert_called_once() + client.return_value.net.set_net_mode.assert_called_with( + LTEBandEnum.ALL, NetworkBandEnum.ALL, NetworkModeEnum.MODE_4G_3G_AUTO.value + ) diff --git a/tests/components/hue/test_services.py b/tests/components/hue/test_services.py index 01b349c7361..ec1c1154d75 100644 --- a/tests/components/hue/test_services.py +++ b/tests/components/hue/test_services.py @@ -49,11 +49,12 @@ SCENE_RESPONSE = { async def test_hue_activate_scene(hass: HomeAssistant, mock_api_v1) -> None: """Test successful hue_activate_scene.""" config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, - "test", + version=1, + minor_version=1, + domain=hue.DOMAIN, + title="Mock Title", + data={"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + source="test", options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, ) @@ -85,11 +86,12 @@ async def test_hue_activate_scene(hass: HomeAssistant, mock_api_v1) -> None: async def test_hue_activate_scene_transition(hass: HomeAssistant, mock_api_v1) -> None: """Test successful hue_activate_scene with transition.""" config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, - "test", + version=1, + minor_version=1, + domain=hue.DOMAIN, + title="Mock Title", + data={"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + source="test", options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, ) @@ -123,11 +125,12 @@ async def test_hue_activate_scene_group_not_found( ) -> None: """Test failed hue_activate_scene due to missing group.""" config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, - "test", + version=1, + minor_version=1, + domain=hue.DOMAIN, + title="Mock Title", + data={"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + source="test", options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, ) @@ -156,11 +159,12 @@ async def test_hue_activate_scene_scene_not_found( ) -> None: """Test failed hue_activate_scene due to missing scene.""" config_entry = config_entries.ConfigEntry( - 1, - hue.DOMAIN, - "Mock Title", - {"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, - "test", + version=1, + minor_version=1, + domain=hue.DOMAIN, + title="Mock Title", + data={"host": "1.2.3.4", "api_key": "mock-api-key", "api_version": 1}, + source="test", options={CONF_ALLOW_HUE_GROUPS: True, CONF_ALLOW_UNREACHABLE: False}, ) diff --git a/tests/components/humidifier/test_init.py b/tests/components/humidifier/test_init.py index a80f3956f20..45da5ba750f 100644 --- a/tests/components/humidifier/test_init.py +++ b/tests/components/humidifier/test_init.py @@ -1,9 +1,19 @@ """The tests for the humidifier component.""" +from enum import Enum +from types import ModuleType from unittest.mock import MagicMock -from homeassistant.components.humidifier import HumidifierEntity +import pytest + +from homeassistant.components import humidifier +from homeassistant.components.humidifier import ( + HumidifierEntity, + HumidifierEntityFeature, +) from homeassistant.core import HomeAssistant +from tests.common import import_and_test_deprecated_constant_enum + class MockHumidifierEntity(HumidifierEntity): """Mock Humidifier device to use in tests.""" @@ -34,3 +44,48 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: await humidifier.async_turn_off() assert humidifier.turn_off.called + + +def _create_tuples(enum: Enum, constant_prefix: str) -> list[tuple[Enum, str]]: + result = [] + for enum in enum: + result.append((enum, constant_prefix)) + return result + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(humidifier.HumidifierEntityFeature, "SUPPORT_") + + _create_tuples(humidifier.HumidifierDeviceClass, "DEVICE_CLASS_"), +) +@pytest.mark.parametrize(("module"), [humidifier, humidifier.const]) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, constant_prefix, "2025.1" + ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockHumidifierEntity(HumidifierEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockHumidifierEntity() + assert entity.supported_features_compat is HumidifierEntityFeature(1) + assert "MockHumidifierEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "HumidifierEntityFeature.MODES" in caplog.text + caplog.clear() + assert entity.supported_features_compat is HumidifierEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/humidifier/test_significant_change.py b/tests/components/humidifier/test_significant_change.py new file mode 100644 index 00000000000..3d1b2a7e1ab --- /dev/null +++ b/tests/components/humidifier/test_significant_change.py @@ -0,0 +1,53 @@ +"""Test the Humidifier significant change platform.""" +import pytest + +from homeassistant.components.humidifier import ( + ATTR_ACTION, + ATTR_CURRENT_HUMIDITY, + ATTR_HUMIDITY, + ATTR_MODE, +) +from homeassistant.components.humidifier.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Humidifier significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_ACTION: "old_value"}, {ATTR_ACTION: "old_value"}, False), + ({ATTR_ACTION: "old_value"}, {ATTR_ACTION: "new_value"}, True), + ({ATTR_MODE: "old_value"}, {ATTR_MODE: "new_value"}, True), + # multiple attributes + ( + {ATTR_ACTION: "old_value", ATTR_MODE: "old_value"}, + {ATTR_ACTION: "new_value", ATTR_MODE: "old_value"}, + True, + ), + # float attributes + ({ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: 61}, True), + ({ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: 60.9}, False), + ({ATTR_CURRENT_HUMIDITY: "invalid"}, {ATTR_CURRENT_HUMIDITY: 60.0}, True), + ({ATTR_CURRENT_HUMIDITY: 60.0}, {ATTR_CURRENT_HUMIDITY: "invalid"}, False), + ({ATTR_HUMIDITY: 62.0}, {ATTR_HUMIDITY: 63.0}, True), + ({ATTR_HUMIDITY: 62.0}, {ATTR_HUMIDITY: 62.9}, False), + # insignificant attributes + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Humidifier significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index 1f892785812..8e22fbe84f7 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -116,7 +116,7 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture async def mock_added_config_entry( - mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]] + mock_add_config_entry: Callable[[], Awaitable[MockConfigEntry]], ) -> MockConfigEntry: """Mock ConfigEntry that's been added to HA.""" return await mock_add_config_entry() diff --git a/tests/components/hydrawise/test_switch.py b/tests/components/hydrawise/test_switch.py index 30a58735122..f044d3467cd 100644 --- a/tests/components/hydrawise/test_switch.py +++ b/tests/components/hydrawise/test_switch.py @@ -51,7 +51,7 @@ async def test_manual_watering_services( blocking=True, ) mock_pydrawise.start_zone.assert_called_once_with( - zones[0], custom_run_duration=DEFAULT_WATERING_TIME + zones[0], custom_run_duration=DEFAULT_WATERING_TIME.total_seconds() ) state = hass.states.get("switch.zone_one_manual_watering") assert state is not None diff --git a/tests/components/ign_sismologia/test_geo_location.py b/tests/components/ign_sismologia/test_geo_location.py index 02a11b3fe7a..d1e4eb3d115 100644 --- a/tests/components/ign_sismologia/test_geo_location.py +++ b/tests/components/ign_sismologia/test_geo_location.py @@ -2,6 +2,8 @@ import datetime from unittest.mock import MagicMock, call, patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.components.ign_sismologia.geo_location import ( @@ -71,7 +73,7 @@ def _generate_mock_feed_entry( return feed_entry -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test the general setup of the platform.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -93,11 +95,10 @@ async def test_setup(hass: HomeAssistant) -> None: ) mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (38.3, -3.3)) - # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "georss_ign_sismologia_client.IgnSismologiaFeed" - ) as mock_feed: + freezer.move_to(utcnow) + + with patch("georss_ign_sismologia_client.IgnSismologiaFeed") as mock_feed: mock_feed.return_value.update.return_value = ( "OK", [mock_entry_1, mock_entry_2, mock_entry_3], diff --git a/tests/components/image_upload/test_init.py b/tests/components/image_upload/test_init.py index 486f98e92c2..9f842d25b64 100644 --- a/tests/components/image_upload/test_init.py +++ b/tests/components/image_upload/test_init.py @@ -4,6 +4,7 @@ import tempfile from unittest.mock import patch from aiohttp import ClientSession, ClientWebSocketResponse +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.websocket_api import const as ws_const from homeassistant.core import HomeAssistant @@ -17,15 +18,17 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator async def test_upload_image( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, hass_client: ClientSessionGenerator, hass_ws_client: WebSocketGenerator, ) -> None: """Test we can upload an image.""" now = dt_util.utcnow() + freezer.move_to(now) with tempfile.TemporaryDirectory() as tempdir, patch.object( hass.config, "path", return_value=tempdir - ), patch("homeassistant.util.dt.utcnow", return_value=now): + ): assert await async_setup_component(hass, "image_upload", {}) ws_client: ClientWebSocketResponse = await hass_ws_client() client: ClientSession = await hass_client() diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index f0c77c9bce3..e333071b0bd 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -302,7 +302,7 @@ async def _test_common_success_w_authorize( """Test bluetooth and user flow success paths.""" async def subscribe_state_updates( - state_callback: Callable[[State], None] + state_callback: Callable[[State], None], ) -> Callable[[], None]: state_callback(State.AUTHORIZED) return lambda: None @@ -612,7 +612,7 @@ async def test_provision_not_authorized(hass: HomeAssistant, exc, error) -> None """Test bluetooth flow with error.""" async def subscribe_state_updates( - state_callback: Callable[[State], None] + state_callback: Callable[[State], None], ) -> Callable[[], None]: state_callback(State.AUTHORIZED) return lambda: None diff --git a/tests/components/iqvia/snapshots/test_diagnostics.ambr b/tests/components/iqvia/snapshots/test_diagnostics.ambr index 49006716fb3..c46a2cc15e3 100644 --- a/tests/components/iqvia/snapshots/test_diagnostics.ambr +++ b/tests/components/iqvia/snapshots/test_diagnostics.ambr @@ -350,6 +350,7 @@ 'disabled_by': None, 'domain': 'iqvia', 'entry_id': '690ac4b7e99855fc5ee7b987a758d5cb', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 8750461c47f..4df733a93fc 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -2,8 +2,16 @@ from datetime import datetime +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME import homeassistant.util.dt as dt_util +MOCK_USER_INPUT = { + CONF_NAME: "Home", + CONF_LOCATION: {CONF_LATITUDE: 12.34, CONF_LONGITUDE: 23.45}, +} + +MOCK_CONFIG = {CONF_LATITUDE: 12.34, CONF_LONGITUDE: 23.45} + PRAYER_TIMES = { "Fajr": "2020-01-01T06:10:00+00:00", "Sunrise": "2020-01-01T07:25:00+00:00", diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index f331c5bf49b..0375c788b11 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -1,5 +1,9 @@ """Tests for Islamic Prayer Times config flow.""" +from unittest.mock import patch + +from prayer_times_calculator import InvalidResponseError import pytest +from requests.exceptions import ConnectionError as ConnError from homeassistant import config_entries, data_entry_flow from homeassistant.components import islamic_prayer_times @@ -12,6 +16,8 @@ from homeassistant.components.islamic_prayer_times.const import ( ) from homeassistant.core import HomeAssistant +from . import MOCK_CONFIG, MOCK_USER_INPUT + from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -25,13 +31,47 @@ async def test_flow_works(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - await hass.async_block_till_done() + with patch( + "homeassistant.components.islamic_prayer_times.config_flow.async_validate_location", + return_value={}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "Islamic Prayer Times" + assert result["title"] == "Home" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidResponseError, "invalid_location"), + (ConnError, "conn_error"), + ], +) +async def test_flow_error( + hass: HomeAssistant, exception: Exception, error: str +) -> None: + """Test flow errors.""" + result = await hass.config_entries.flow.async_init( + islamic_prayer_times.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.islamic_prayer_times.config_flow.PrayerTimesCalculator.fetch_prayer_times", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"]["base"] == error async def test_options(hass: HomeAssistant) -> None: @@ -39,7 +79,7 @@ async def test_options(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, title="Islamic Prayer Times", - data={}, + data=MOCK_CONFIG, options={CONF_CALC_METHOD: "isna"}, ) entry.add_to_hass(hass) @@ -68,14 +108,19 @@ async def test_options(hass: HomeAssistant) -> None: async def test_integration_already_configured(hass: HomeAssistant) -> None: """Test integration is already configured.""" entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={}, + domain=DOMAIN, data=MOCK_CONFIG, options={}, unique_id="12.34-23.45" ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( islamic_prayer_times.DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_INPUT + ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index 0c3f19e43fe..746abf27d43 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -10,7 +10,7 @@ from homeassistant import config_entries from homeassistant.components import islamic_prayer_times from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util @@ -185,3 +185,25 @@ async def test_migrate_unique_id( entity_migrated = entity_registry.async_get(entity.entity_id) assert entity_migrated assert entity_migrated.unique_id == f"{entry.entry_id}-{old_unique_id}" + + +async def test_migration_from_1_1_to_1_2(hass: HomeAssistant) -> None: + """Test migrating from version 1.1 to 1.2.""" + entry = MockConfigEntry( + domain=islamic_prayer_times.DOMAIN, + data={}, + ) + entry.add_to_hass(hass) + + with patch( + "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ), freeze_time(NOW): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.data == { + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + } + assert entry.minor_version == 2 diff --git a/tests/components/kitchen_sink/test_init.py b/tests/components/kitchen_sink/test_init.py index ebd0f781d22..71f3a83c701 100644 --- a/tests/components/kitchen_sink/test_init.py +++ b/tests/components/kitchen_sink/test_init.py @@ -229,6 +229,7 @@ async def test_issues_created( "description_placeholders": None, "flow_id": flow_id, "handler": DOMAIN, + "minor_version": 1, "type": "create_entry", "version": 1, } diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py index a905e66fe5d..3dedea7d8d4 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -4,14 +4,9 @@ import logging import pytest -from homeassistant.components.knx.const import ( - CONF_PAYLOAD, - CONF_PAYLOAD_LENGTH, - DOMAIN, - KNX_ADDRESS, -) +from homeassistant.components.knx.const import CONF_PAYLOAD_LENGTH, DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import ButtonSchema -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_NAME, CONF_PAYLOAD, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util diff --git a/tests/components/knx/test_select.py b/tests/components/knx/test_select.py index 1c89338920e..1b408a298a2 100644 --- a/tests/components/knx/test_select.py +++ b/tests/components/knx/test_select.py @@ -2,7 +2,6 @@ import pytest from homeassistant.components.knx.const import ( - CONF_PAYLOAD, CONF_PAYLOAD_LENGTH, CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, @@ -10,8 +9,9 @@ from homeassistant.components.knx.const import ( KNX_ADDRESS, ) from homeassistant.components.knx.schema import SelectSchema -from homeassistant.const import CONF_NAME, STATE_UNKNOWN +from homeassistant.const import CONF_NAME, CONF_PAYLOAD, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import ServiceValidationError from .conftest import KNXTestKit @@ -77,7 +77,7 @@ async def test_select_dpt_2_simple(hass: HomeAssistant, knx: KNXTestKit) -> None assert state.state is STATE_UNKNOWN # select invalid option - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( "select", "select_option", diff --git a/tests/components/konnected/test_init.py b/tests/components/konnected/test_init.py index 2dff9672f17..658f1053f93 100644 --- a/tests/components/konnected/test_init.py +++ b/tests/components/konnected/test_init.py @@ -706,7 +706,7 @@ async def test_state_updates_zone( resp = await client.post( "/api/konnected/device/112233445566", headers={"Authorization": "Bearer abcdefgh"}, - json={"zone": "5", "temp": 32, "addr": 1}, + json={"zone": "5", "temp": 32.0, "addr": 1}, ) assert resp.status == HTTPStatus.OK result = await resp.json() @@ -863,7 +863,7 @@ async def test_state_updates_pin( resp = await client.post( "/api/konnected/device/112233445566", headers={"Authorization": "Bearer abcdefgh"}, - json={"pin": "7", "temp": 32, "addr": 1}, + json={"pin": "7", "temp": 32.0, "addr": 1}, ) assert resp.status == HTTPStatus.OK result = await resp.json() diff --git a/tests/components/kostal_plenticore/test_diagnostics.py b/tests/components/kostal_plenticore/test_diagnostics.py index 87c8c0e26a8..d509a323e6a 100644 --- a/tests/components/kostal_plenticore/test_diagnostics.py +++ b/tests/components/kostal_plenticore/test_diagnostics.py @@ -43,6 +43,7 @@ async def test_entry_diagnostics( "config_entry": { "entry_id": "2ab8dd92a62787ddfe213a67e09406bd", "version": 1, + "minor_version": 1, "domain": "kostal_plenticore", "title": "scb", "data": {"host": "192.168.1.2", "password": REDACTED}, diff --git a/tests/components/lacrosse_view/__init__.py b/tests/components/lacrosse_view/__init__.py index 66789508f05..913f6c72f24 100644 --- a/tests/components/lacrosse_view/__init__.py +++ b/tests/components/lacrosse_view/__init__.py @@ -70,7 +70,7 @@ TEST_ALREADY_FLOAT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["HeatIndex"], location=Location(id="1", name="Test"), - data={"HeatIndex": {"values": [{"s": 2.3}], "unit": "degrees_celsius"}}, + data={"HeatIndex": {"values": [{"s": 2.3}], "unit": "degrees_fahrenheit"}}, permissions={"read": True}, model="Test", ) @@ -81,7 +81,7 @@ TEST_ALREADY_INT_SENSOR = Sensor( sensor_id="2", sensor_field_names=["WindSpeed"], location=Location(id="1", name="Test"), - data={"WindSpeed": {"values": [{"s": 2}], "unit": "degrees_celsius"}}, + data={"WindSpeed": {"values": [{"s": 2}], "unit": "kilometers_per_hour"}}, permissions={"read": True}, model="Test", ) @@ -107,3 +107,14 @@ TEST_MISSING_FIELD_DATA_SENSOR = Sensor( permissions={"read": True}, model="Test", ) +TEST_UNITS_OVERRIDE_SENSOR = Sensor( + name="Test", + device_id="1", + type="Test", + sensor_id="2", + sensor_field_names=["Temperature"], + location=Location(id="1", name="Test"), + data={"Temperature": {"values": [{"s": "2.1"}], "unit": "degrees_fahrenheit"}}, + permissions={"read": True}, + model="Test", +) diff --git a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr index 30094f97cd3..9d880746ff9 100644 --- a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr +++ b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr @@ -17,6 +17,7 @@ 'disabled_by': None, 'domain': 'lacrosse_view', 'entry_id': 'lacrosse_view_test_entry_id', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index 837d13b8a4b..8fc028e2da1 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -19,6 +19,7 @@ from . import ( TEST_NO_PERMISSION_SENSOR, TEST_SENSOR, TEST_STRING_SENSOR, + TEST_UNITS_OVERRIDE_SENSOR, TEST_UNSUPPORTED_SENSOR, ) @@ -94,6 +95,7 @@ async def test_field_not_supported( (TEST_STRING_SENSOR, "dry", "wet_dry"), (TEST_ALREADY_FLOAT_SENSOR, "-16.5", "heat_index"), (TEST_ALREADY_INT_SENSOR, "2", "wind_speed"), + (TEST_UNITS_OVERRIDE_SENSOR, "-16.6", "temperature"), ], ) async def test_field_types( diff --git a/tests/components/landisgyr_heat_meter/test_config_flow.py b/tests/components/landisgyr_heat_meter/test_config_flow.py index b58c91f8f16..19338d8d576 100644 --- a/tests/components/landisgyr_heat_meter/test_config_flow.py +++ b/tests/components/landisgyr_heat_meter/test_config_flow.py @@ -104,7 +104,7 @@ async def test_list_entry(mock_port, mock_heat_meter, hass: HomeAssistant) -> No async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: """Test manual entry fails.""" - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -135,7 +135,7 @@ async def test_manual_entry_fail(mock_heat_meter, hass: HomeAssistant) -> None: async def test_list_entry_fail(mock_port, mock_heat_meter, hass: HomeAssistant) -> None: """Test select from list entry fails.""" - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException port = mock_serial_port() result = await hass.config_entries.flow.async_init( diff --git a/tests/components/landisgyr_heat_meter/test_sensor.py b/tests/components/landisgyr_heat_meter/test_sensor.py index 5ed2a397ccd..f05d12e49a2 100644 --- a/tests/components/landisgyr_heat_meter/test_sensor.py +++ b/tests/components/landisgyr_heat_meter/test_sensor.py @@ -150,7 +150,7 @@ async def test_exception_on_polling( assert state.state == "123.0" # Now 'disable' the connection and wait for polling and see if it fails - mock_heat_meter().read.side_effect = serial.serialutil.SerialException + mock_heat_meter().read.side_effect = serial.SerialException freezer.tick(POLLING_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 675057899b0..903002063e8 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1444,6 +1444,7 @@ async def test_light_service_call_color_conversion( platform.ENTITIES.append(platform.MockLight("Test_legacy", STATE_ON)) platform.ENTITIES.append(platform.MockLight("Test_rgbw", STATE_ON)) platform.ENTITIES.append(platform.MockLight("Test_rgbww", STATE_ON)) + platform.ENTITIES.append(platform.MockLight("Test_temperature", STATE_ON)) entity0 = platform.ENTITIES[0] entity0.supported_color_modes = {light.ColorMode.HS} @@ -1470,6 +1471,9 @@ async def test_light_service_call_color_conversion( entity6 = platform.ENTITIES[6] entity6.supported_color_modes = {light.ColorMode.RGBWW} + entity7 = platform.ENTITIES[7] + entity7.supported_color_modes = {light.ColorMode.COLOR_TEMP} + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() @@ -1498,6 +1502,9 @@ async def test_light_service_call_color_conversion( state = hass.states.get(entity6.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.RGBWW] + state = hass.states.get(entity7.entity_id) + assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] + await hass.services.async_call( "light", "turn_on", @@ -1510,6 +1517,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 100), @@ -1530,6 +1538,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 255, "rgbw_color": (0, 0, 255, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 255, "rgbww_color": (0, 0, 255, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 255, "color_temp_kelvin": 1739, "color_temp": 575} await hass.services.async_call( "light", @@ -1543,6 +1553,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 100, "hs_color": (240, 0), @@ -1564,6 +1575,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint of the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 255, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 255, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1577,6 +1590,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (128, 0, 0), @@ -1597,6 +1611,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (128, 0, 0, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 6279, "color_temp": 159} await hass.services.async_call( "light", @@ -1610,6 +1626,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgb_color": (255, 255, 255), @@ -1631,6 +1648,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1644,6 +1663,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.1, 0.8), @@ -1664,6 +1684,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (0, 255, 22, 0)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (0, 255, 22, 0, 0)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 8645, "color_temp": 115} await hass.services.async_call( "light", @@ -1677,6 +1699,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "xy_color": (0.323, 0.329), @@ -1698,6 +1721,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 75, 140, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1711,6 +1736,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (128, 0, 0, 64), @@ -1732,6 +1758,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (128, 0, 30, 117, 117)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3011, "color_temp": 332} await hass.services.async_call( "light", @@ -1745,6 +1773,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbw_color": (255, 255, 255, 255), @@ -1766,6 +1795,8 @@ async def test_light_service_call_color_conversion( _, data = entity6.last_call("turn_on") # The midpoint the the white channels is warm, compensated by adding green + blue assert data == {"brightness": 128, "rgbww_color": (0, 76, 141, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 5962, "color_temp": 167} await hass.services.async_call( "light", @@ -1779,6 +1810,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (128, 0, 0, 64, 32), @@ -1799,6 +1831,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (128, 9, 0, 33)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (128, 0, 0, 64, 32)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3845, "color_temp": 260} await hass.services.async_call( "light", @@ -1812,6 +1846,7 @@ async def test_light_service_call_color_conversion( entity4.entity_id, entity5.entity_id, entity6.entity_id, + entity7.entity_id, ], "brightness_pct": 50, "rgbww_color": (255, 255, 255, 255, 255), @@ -1833,6 +1868,8 @@ async def test_light_service_call_color_conversion( assert data == {"brightness": 128, "rgbw_color": (96, 44, 0, 255)} _, data = entity6.last_call("turn_on") assert data == {"brightness": 128, "rgbww_color": (255, 255, 255, 255, 255)} + _, data = entity7.last_call("turn_on") + assert data == {"brightness": 128, "color_temp_kelvin": 3451, "color_temp": 289} async def test_light_service_call_color_conversion_named_tuple( @@ -2552,3 +2589,24 @@ def test_filter_supported_color_modes() -> None: # ColorMode.BRIGHTNESS has priority over ColorMode.ONOFF supported = {light.ColorMode.ONOFF, light.ColorMode.BRIGHTNESS} assert light.filter_supported_color_modes(supported) == {light.ColorMode.BRIGHTNESS} + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockLightEntityEntity(light.LightEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockLightEntityEntity() + assert entity.supported_features_compat is light.LightEntityFeature(1) + assert "MockLightEntityEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "LightEntityFeature" in caplog.text + assert "and color modes" in caplog.text + caplog.clear() + assert entity.supported_features_compat is light.LightEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/litejet/conftest.py b/tests/components/litejet/conftest.py index 3bbd9ef4ef0..2c631265c30 100644 --- a/tests/components/litejet/conftest.py +++ b/tests/components/litejet/conftest.py @@ -1,6 +1,6 @@ """Fixtures for LiteJet testing.""" from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -21,6 +21,12 @@ def mock_litejet(): async def get_switch_name(number): return f"Mock Switch #{number}" + def get_switch_keypad_number(number): + return number + 100 + + def get_switch_keypad_name(number): + return f"Mock Keypad #{number + 100}" + mock_lj = mock_pylitejet.return_value mock_lj.switch_pressed_callbacks = {} @@ -65,6 +71,8 @@ def mock_litejet(): mock_lj.get_switch_name = AsyncMock(side_effect=get_switch_name) mock_lj.press_switch = AsyncMock() mock_lj.release_switch = AsyncMock() + mock_lj.get_switch_keypad_number = Mock(side_effect=get_switch_keypad_number) + mock_lj.get_switch_keypad_name = Mock(side_effect=get_switch_keypad_name) mock_lj.scenes.return_value = range(1, 3) mock_lj.get_scene_name = AsyncMock(side_effect=get_scene_name) @@ -74,6 +82,7 @@ def mock_litejet(): mock_lj.start_time = dt_util.utcnow() mock_lj.last_delta = timedelta(0) mock_lj.connected = True + mock_lj.model_name = "MockJet" def connected_changed(connected: bool, reason: str) -> None: mock_lj.connected = connected diff --git a/tests/components/litejet/test_diagnostics.py b/tests/components/litejet/test_diagnostics.py index 368cdf557c8..a2c8bc72476 100644 --- a/tests/components/litejet/test_diagnostics.py +++ b/tests/components/litejet/test_diagnostics.py @@ -17,6 +17,7 @@ async def test_diagnostics( diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert diag == { + "model": "MockJet", "loads": [1, 2], "button_switches": [1, 2], "scenes": [1, 2], diff --git a/tests/components/litterrobot/test_select.py b/tests/components/litterrobot/test_select.py index f6a32a6ef35..b35fdf5c917 100644 --- a/tests/components/litterrobot/test_select.py +++ b/tests/components/litterrobot/test_select.py @@ -12,6 +12,7 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from .conftest import setup_integration @@ -59,7 +60,7 @@ async def test_invalid_wait_time_select(hass: HomeAssistant, mock_account) -> No data = {ATTR_ENTITY_ID: SELECT_ENTITY_ID, ATTR_OPTION: "10"} - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( PLATFORM_DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/livisi/__init__.py b/tests/components/livisi/__init__.py index 3d28d1db708..48a7e21ad8d 100644 --- a/tests/components/livisi/__init__.py +++ b/tests/components/livisi/__init__.py @@ -1,7 +1,7 @@ """Tests for the LIVISI Smart Home integration.""" from unittest.mock import patch -from homeassistant.components.livisi.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_PASSWORD VALID_CONFIG = { CONF_HOST: "1.1.1.1", diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 67d0703ca7c..22d8abade50 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -65,16 +65,27 @@ def set_time_zone(hass: HomeAssistant) -> None: hass.config.set_time_zone("America/Regina") +EXPECTED_ADD_ITEM = { + "status": "needs_action", + "summary": "replace batteries", +} + + @pytest.mark.parametrize( ("item_data", "expected_item_data"), [ - ({}, {}), - ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}), + ({}, EXPECTED_ADD_ITEM), + ({"due_date": "2023-11-17"}, {**EXPECTED_ADD_ITEM, "due": "2023-11-17"}), ( {"due_datetime": "2023-11-17T11:30:00+00:00"}, - {"due": "2023-11-17T05:30:00-06:00"}, + {**EXPECTED_ADD_ITEM, "due": "2023-11-17T05:30:00-06:00"}, ), - ({"description": "Additional detail"}, {"description": "Additional detail"}), + ( + {"description": "Additional detail"}, + {**EXPECTED_ADD_ITEM, "description": "Additional detail"}, + ), + ({"description": ""}, {**EXPECTED_ADD_ITEM, "description": ""}), + ({"description": None}, EXPECTED_ADD_ITEM), ], ) async def test_add_item( @@ -101,11 +112,10 @@ async def test_add_item( items = await ws_get_items() assert len(items) == 1 - assert items[0]["summary"] == "replace batteries" - assert items[0]["status"] == "needs_action" - for k, v in expected_item_data.items(): - assert items[0][k] == v - assert "uid" in items[0] + item_data = items[0] + assert "uid" in item_data + del item_data["uid"] + assert item_data == expected_item_data state = hass.states.get(TEST_ENTITY) assert state @@ -207,19 +217,29 @@ async def test_bulk_remove( assert state.state == "0" +EXPECTED_UPDATE_ITEM = { + "status": "needs_action", + "summary": "soda", +} + + @pytest.mark.parametrize( ("item_data", "expected_item_data", "expected_state"), [ - ({"status": "completed"}, {"status": "completed"}, "0"), - ({"due_date": "2023-11-17"}, {"due": "2023-11-17"}, "1"), + ({"status": "completed"}, {**EXPECTED_UPDATE_ITEM, "status": "completed"}, "0"), + ( + {"due_date": "2023-11-17"}, + {**EXPECTED_UPDATE_ITEM, "due": "2023-11-17"}, + "1", + ), ( {"due_datetime": "2023-11-17T11:30:00+00:00"}, - {"due": "2023-11-17T05:30:00-06:00"}, + {**EXPECTED_UPDATE_ITEM, "due": "2023-11-17T05:30:00-06:00"}, "1", ), ( {"description": "Additional detail"}, - {"description": "Additional detail"}, + {**EXPECTED_UPDATE_ITEM, "description": "Additional detail"}, "1", ), ], @@ -246,6 +266,7 @@ async def test_update_item( # Fetch item items = await ws_get_items() assert len(items) == 1 + item = items[0] assert item["summary"] == "soda" assert item["status"] == "needs_action" @@ -254,7 +275,7 @@ async def test_update_item( assert state assert state.state == "1" - # Mark item completed + # Update item await hass.services.async_call( TODO_DOMAIN, "update_item", @@ -268,14 +289,130 @@ async def test_update_item( assert len(items) == 1 item = items[0] assert item["summary"] == "soda" - for k, v in expected_item_data.items(): - assert items[0][k] == v + assert "uid" in item + del item["uid"] + assert item == expected_item_data state = hass.states.get(TEST_ENTITY) assert state assert state.state == expected_state +@pytest.mark.parametrize( + ("item_data", "expected_item_data"), + [ + ( + {"status": "completed"}, + { + "summary": "soda", + "status": "completed", + "description": "Additional detail", + "due": "2024-01-01", + }, + ), + ( + {"due_date": "2024-01-02"}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + "due": "2024-01-02", + }, + ), + ( + {"due_date": None}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + }, + ), + ( + {"due_datetime": "2024-01-01 10:30:00"}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + "due": "2024-01-01T10:30:00-06:00", + }, + ), + ( + {"due_datetime": None}, + { + "summary": "soda", + "status": "needs_action", + "description": "Additional detail", + }, + ), + ( + {"description": "updated description"}, + { + "summary": "soda", + "status": "needs_action", + "due": "2024-01-01", + "description": "updated description", + }, + ), + ( + {"description": None}, + {"summary": "soda", "status": "needs_action", "due": "2024-01-01"}, + ), + ], + ids=[ + "status", + "due_date", + "clear_due_date", + "due_datetime", + "clear_due_datetime", + "description", + "clear_description", + ], +) +async def test_update_existing_field( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + item_data: dict[str, Any], + expected_item_data: dict[str, Any], +) -> None: + """Test updating a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "soda", "description": "Additional detail", "due_date": "2024-01-01"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch item + items = await ws_get_items() + assert len(items) == 1 + + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "needs_action" + + # Perform update + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": item["uid"], **item_data}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item is updated + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert "uid" in item + del item["uid"] + assert item == expected_item_data + + async def test_rename( hass: HomeAssistant, setup_integration: None, diff --git a/tests/components/lock/conftest.py b/tests/components/lock/conftest.py new file mode 100644 index 00000000000..07399a39e92 --- /dev/null +++ b/tests/components/lock/conftest.py @@ -0,0 +1,141 @@ +"""Fixtures for the lock entity platform tests.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + LockEntity, + LockEntityFeature, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +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 MockLock(LockEntity): + """Mocked lock entity.""" + + def __init__( + self, + supported_features: LockEntityFeature = LockEntityFeature(0), + code_format: str | None = None, + ) -> None: + """Initialize the lock.""" + self.calls_open = MagicMock() + self.calls_lock = MagicMock() + self.calls_unlock = MagicMock() + self._attr_code_format = code_format + self._attr_supported_features = supported_features + self._attr_has_entity_name = True + self._attr_name = "test_lock" + self._attr_unique_id = "very_unique_lock_id" + super().__init__() + + def lock(self, **kwargs: Any) -> None: + """Mock lock lock calls.""" + self.calls_lock(**kwargs) + + def unlock(self, **kwargs: Any) -> None: + """Mock lock unlock calls.""" + self.calls_unlock(**kwargs) + + def open(self, **kwargs: Any) -> None: + """Mock lock open calls.""" + self.calls_open(**kwargs) + + +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 + + +@pytest.fixture +async def code_format() -> str | None: + """Return the code format for the test lock entity.""" + return None + + +@pytest.fixture(name="supported_features") +async def lock_supported_features() -> LockEntityFeature: + """Return the supported features for the test lock entity.""" + return LockEntityFeature.OPEN + + +@pytest.fixture(name="mock_lock_entity") +async def setup_lock_platform_test_entity( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + code_format: str | None, + supported_features: LockEntityFeature, +) -> MagicMock: + """Set up lock entity using an entity platform.""" + + 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, LOCK_DOMAIN) + return True + + MockPlatform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + ), + ) + + # Unnamed sensor without device class -> no name + entity = MockLock( + supported_features=supported_features, + code_format=code_format, + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test lock platform via config entry.""" + async_add_entities([entity]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{LOCK_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(entity.entity_id) + assert state is not None + + return entity diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 16f40fda786..854b89fd1d8 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -1,11 +1,12 @@ """The tests for the lock component.""" from __future__ import annotations +import re from typing import Any -from unittest.mock import MagicMock import pytest +from homeassistant.components import lock from homeassistant.components.lock import ( ATTR_CODE, CONF_DEFAULT_CODE, @@ -18,327 +19,379 @@ from homeassistant.components.lock import ( STATE_LOCKING, STATE_UNLOCKED, STATE_UNLOCKING, - LockEntity, LockEntityFeature, - _async_lock, - _async_open, - _async_unlock, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError import homeassistant.helpers.entity_registry as er -from homeassistant.setup import async_setup_component +from homeassistant.helpers.typing import UNDEFINED, UndefinedType -from tests.testing_config.custom_components.test.lock import MockLock +from .conftest import MockLock + +from tests.common import import_and_test_deprecated_constant_enum -class MockLockEntity(LockEntity): - """Mock lock to use in tests.""" +async def help_test_async_lock_service( + hass: HomeAssistant, + entity_id: str, + service: str, + code: str | None | UndefinedType = UNDEFINED, +) -> None: + """Help to lock a test lock.""" + data: dict[str, Any] = {"entity_id": entity_id} + if code is not UNDEFINED: + data[ATTR_CODE] = code - def __init__( - self, - code_format: str | None = None, - lock_option_default_code: str = "", - supported_features: LockEntityFeature = LockEntityFeature(0), - ) -> None: - """Initialize mock lock entity.""" - self._attr_supported_features = supported_features - self.calls_lock = MagicMock() - self.calls_unlock = MagicMock() - self.calls_open = MagicMock() - if code_format is not None: - self._attr_code_format = code_format - self._lock_option_default_code = lock_option_default_code - - async def async_lock(self, **kwargs: Any) -> None: - """Lock the lock.""" - self.calls_lock(kwargs) - self._attr_is_locking = False - self._attr_is_locked = True - - async def async_unlock(self, **kwargs: Any) -> None: - """Unlock the lock.""" - self.calls_unlock(kwargs) - self._attr_is_unlocking = False - self._attr_is_locked = False - - async def async_open(self, **kwargs: Any) -> None: - """Open the door latch.""" - self.calls_open(kwargs) + await hass.services.async_call(DOMAIN, service, data, blocking=True) -async def test_lock_default(hass: HomeAssistant) -> None: +async def test_lock_default(hass: HomeAssistant, mock_lock_entity: MockLock) -> None: """Test lock entity with defaults.""" - lock = MockLockEntity() - lock.hass = hass - assert lock.code_format is None - assert lock.state is None + assert mock_lock_entity.code_format is None + assert mock_lock_entity.state is None + assert mock_lock_entity.is_jammed is None + assert mock_lock_entity.is_locked is None + assert mock_lock_entity.is_locking is None + assert mock_lock_entity.is_unlocking is None -async def test_lock_states(hass: HomeAssistant) -> None: +async def test_lock_states(hass: HomeAssistant, mock_lock_entity: MockLock) -> None: """Test lock entity states.""" - lock = MockLockEntity() - lock.hass = hass + assert mock_lock_entity.state is None - assert lock.state is None + mock_lock_entity._attr_is_locking = True + assert mock_lock_entity.is_locking + assert mock_lock_entity.state == STATE_LOCKING - lock._attr_is_locking = True - assert lock.is_locking - assert lock.state == STATE_LOCKING + mock_lock_entity._attr_is_locked = True + mock_lock_entity._attr_is_locking = False + assert mock_lock_entity.is_locked + assert mock_lock_entity.state == STATE_LOCKED - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) - assert lock.is_locked - assert lock.state == STATE_LOCKED + mock_lock_entity._attr_is_unlocking = True + assert mock_lock_entity.is_unlocking + assert mock_lock_entity.state == STATE_UNLOCKING - lock._attr_is_unlocking = True - assert lock.is_unlocking - assert lock.state == STATE_UNLOCKING + mock_lock_entity._attr_is_locked = False + mock_lock_entity._attr_is_unlocking = False + assert not mock_lock_entity.is_locked + assert mock_lock_entity.state == STATE_UNLOCKED - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) - assert not lock.is_locked - assert lock.state == STATE_UNLOCKED - - lock._attr_is_jammed = True - assert lock.is_jammed - assert lock.state == STATE_JAMMED - assert not lock.is_locked + mock_lock_entity._attr_is_jammed = True + assert mock_lock_entity.is_jammed + assert mock_lock_entity.state == STATE_JAMMED + assert not mock_lock_entity.is_locked -async def test_set_default_code_option( +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_set_mock_lock_options( hass: HomeAssistant, entity_registry: er.EntityRegistry, - enable_custom_integrations: None, + mock_lock_entity: MockLock, ) -> None: - """Test default code stored in the registry.""" - - entry = entity_registry.async_get_or_create("lock", "test", "very_unique") - await hass.async_block_till_done() - - platform = getattr(hass.components, "test.lock") - platform.init(empty=True) - platform.ENTITIES["lock1"] = platform.MockLock( - name="Test", - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, - unique_id="very_unique", - ) - - assert await async_setup_component(hass, "lock", {"lock": {"platform": "test"}}) - await hass.async_block_till_done() - - entity0: MockLock = platform.ENTITIES["lock1"] + """Test mock attributes and default code stored in the registry.""" entity_registry.async_update_entity_options( - entry.entity_id, "lock", {CONF_DEFAULT_CODE: "1234"} + "lock.test_lock", "lock", {CONF_DEFAULT_CODE: "1234"} ) await hass.async_block_till_done() - assert entity0._lock_option_default_code == "1234" + assert mock_lock_entity._lock_option_default_code == "1234" + state = hass.states.get(mock_lock_entity.entity_id) + assert state is not None + assert state.attributes["code_format"] == r"^\d{4}$" + assert state.attributes["supported_features"] == LockEntityFeature.OPEN +@pytest.mark.parametrize("code_format", [r"^\d{4}$"]) async def test_default_code_option_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, - enable_custom_integrations: None, + mock_lock_entity: MockLock, ) -> None: """Test default code stored in the registry is updated.""" - entry = entity_registry.async_get_or_create("lock", "test", "very_unique") - await hass.async_block_till_done() - - platform = getattr(hass.components, "test.lock") - platform.init(empty=True) - - # Pre-register entities - entry = entity_registry.async_get_or_create("lock", "test", "very_unique") - entity_registry.async_update_entity_options( - entry.entity_id, - "lock", - { - "default_code": "5432", - }, - ) - platform.ENTITIES["lock1"] = platform.MockLock( - name="Test", - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, - unique_id="very_unique", - ) - - assert await async_setup_component(hass, "lock", {"lock": {"platform": "test"}}) - await hass.async_block_till_done() - - entity0: MockLock = platform.ENTITIES["lock1"] - assert entity0._lock_option_default_code == "5432" + assert mock_lock_entity._lock_option_default_code == "" entity_registry.async_update_entity_options( - entry.entity_id, "lock", {CONF_DEFAULT_CODE: "1234"} + "lock.test_lock", "lock", {CONF_DEFAULT_CODE: "4321"} ) await hass.async_block_till_done() - assert entity0._lock_option_default_code == "1234" + assert mock_lock_entity._lock_option_default_code == "4321" -async def test_lock_open_with_code(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_open_with_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: """Test lock entity with open service.""" - lock = MockLockEntity( - code_format=r"^\d{4}$", supported_features=LockEntityFeature.OPEN + state = hass.states.get(mock_lock_entity.entity_id) + assert state.attributes["code_format"] == r"^\d{4}$" + + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="" + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="HELLO" + ) + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="1234" ) - lock.hass = hass - - assert lock.state_attributes == {"code_format": r"^\d{4}$"} - - with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) - with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) - with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "HELLO"})) - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "1234"})) - assert lock.calls_open.call_count == 1 + assert mock_lock_entity.calls_open.call_count == 1 + mock_lock_entity.calls_open.assert_called_with(code="1234") -async def test_lock_lock_with_code(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_lock_with_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: """Test lock entity with open service.""" - lock = MockLockEntity(code_format=r"^\d{4}$") - lock.hass = hass + state = hass.states.get(mock_lock_entity.entity_id) + assert state.attributes["code_format"] == r"^\d{4}$" - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) - assert not lock.is_locked + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="1234" + ) + mock_lock_entity.calls_unlock.assert_called_with(code="1234") + assert mock_lock_entity.calls_lock.call_count == 0 - with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) - with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) - with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "HELLO"})) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "1234"})) - assert lock.is_locked + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="" + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="HELLO" + ) + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="1234" + ) + assert mock_lock_entity.calls_lock.call_count == 1 + mock_lock_entity.calls_lock.assert_called_with(code="1234") -async def test_lock_unlock_with_code(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_unlock_with_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: """Test unlock entity with open service.""" - lock = MockLockEntity(code_format=r"^\d{4}$") - lock.hass = hass + state = hass.states.get(mock_lock_entity.entity_id) + assert state.attributes["code_format"] == r"^\d{4}$" - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) - assert lock.is_locked - - with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) - with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) - with pytest.raises(ValueError): - await _async_unlock( - lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "HELLO"}) - ) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) - assert not lock.is_locked - - -async def test_lock_with_illegal_code(hass: HomeAssistant) -> None: - """Test lock entity with default code that does not match the code format.""" - lock = MockLockEntity( - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="1234" ) - lock.hass = hass + mock_lock_entity.calls_lock.assert_called_with(code="1234") + assert mock_lock_entity.calls_unlock.call_count == 0 - with pytest.raises(ValueError): - await _async_open( - lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "123456"}) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK ) - with pytest.raises(ValueError): - await _async_lock( - lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "123456"}) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="" ) - with pytest.raises(ValueError): - await _async_unlock( - lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "123456"}) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="HELLO" ) - - -async def test_lock_with_no_code(hass: HomeAssistant) -> None: - """Test lock entity with default code that does not match the code format.""" - lock = MockLockEntity( - supported_features=LockEntityFeature.OPEN, + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="1234" ) - lock.hass = hass - - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) - lock.calls_open.assert_called_with({}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) - lock.calls_lock.assert_called_with({}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) - lock.calls_unlock.assert_called_with({}) - - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) - lock.calls_open.assert_called_with({}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) - lock.calls_lock.assert_called_with({}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) - lock.calls_unlock.assert_called_with({}) + assert mock_lock_entity.calls_unlock.call_count == 1 + mock_lock_entity.calls_unlock.assert_called_with(code="1234") -async def test_lock_with_default_code(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_with_illegal_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: + """Test lock entity with default code that does not match the code format.""" + + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="123456" + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="123456" + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="123456" + ) + + +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(None, LockEntityFeature.OPEN)], +) +async def test_lock_with_no_code( + hass: HomeAssistant, mock_lock_entity: MockLock +) -> None: + """Test lock entity without code.""" + await help_test_async_lock_service(hass, mock_lock_entity.entity_id, SERVICE_OPEN) + mock_lock_entity.calls_open.assert_called_with() + await help_test_async_lock_service(hass, mock_lock_entity.entity_id, SERVICE_LOCK) + mock_lock_entity.calls_lock.assert_called_with() + await help_test_async_lock_service(hass, mock_lock_entity.entity_id, SERVICE_UNLOCK) + mock_lock_entity.calls_unlock.assert_called_with() + + mock_lock_entity.calls_open.reset_mock() + mock_lock_entity.calls_lock.reset_mock() + mock_lock_entity.calls_unlock.reset_mock() + + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="" + ) + mock_lock_entity.calls_open.assert_called_with() + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="" + ) + mock_lock_entity.calls_lock.assert_called_with() + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="" + ) + mock_lock_entity.calls_unlock.assert_called_with() + + +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_with_default_code( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_lock_entity: MockLock +) -> None: """Test lock entity with default code.""" - lock = MockLockEntity( - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, - lock_option_default_code="1234", + entity_registry.async_update_entity_options( + "lock.test_lock", "lock", {CONF_DEFAULT_CODE: "1234"} ) - lock.hass = hass + await hass.async_block_till_done() - assert lock.state_attributes == {"code_format": r"^\d{4}$"} - assert lock._lock_option_default_code == "1234" + assert mock_lock_entity.state_attributes == {"code_format": r"^\d{4}$"} + assert mock_lock_entity._lock_option_default_code == "1234" - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) - lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) - lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) - lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) - - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: ""})) - lock.calls_open.assert_called_with({ATTR_CODE: "1234"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: ""})) - lock.calls_lock.assert_called_with({ATTR_CODE: "1234"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: ""})) - lock.calls_unlock.assert_called_with({ATTR_CODE: "1234"}) - - -async def test_lock_with_provided_and_default_code(hass: HomeAssistant) -> None: - """Test lock entity with provided code when default code is set.""" - lock = MockLockEntity( - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, - lock_option_default_code="1234", + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="1234" ) - lock.hass = hass - - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {ATTR_CODE: "4321"})) - lock.calls_open.assert_called_with({ATTR_CODE: "4321"}) - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {ATTR_CODE: "4321"})) - lock.calls_lock.assert_called_with({ATTR_CODE: "4321"}) - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "4321"})) - lock.calls_unlock.assert_called_with({ATTR_CODE: "4321"}) - - -async def test_lock_with_illegal_default_code(hass: HomeAssistant) -> None: - """Test lock entity with default code that does not match the code format.""" - lock = MockLockEntity( - code_format=r"^\d{4}$", - supported_features=LockEntityFeature.OPEN, - lock_option_default_code="123456", + mock_lock_entity.calls_open.assert_called_with(code="1234") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="1234" ) - lock.hass = hass + mock_lock_entity.calls_lock.assert_called_with(code="1234") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="1234" + ) + mock_lock_entity.calls_unlock.assert_called_with(code="1234") - assert lock.state_attributes == {"code_format": r"^\d{4}$"} - assert lock._lock_option_default_code == "123456" + mock_lock_entity.calls_open.reset_mock() + mock_lock_entity.calls_lock.reset_mock() + mock_lock_entity.calls_unlock.reset_mock() - with pytest.raises(ValueError): - await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) - with pytest.raises(ValueError): - await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) - with pytest.raises(ValueError): - await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN, code="" + ) + mock_lock_entity.calls_open.assert_called_with(code="1234") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK, code="" + ) + mock_lock_entity.calls_lock.assert_called_with(code="1234") + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK, code="" + ) + mock_lock_entity.calls_unlock.assert_called_with(code="1234") + + +@pytest.mark.parametrize( + ("code_format", "supported_features"), + [(r"^\d{4}$", LockEntityFeature.OPEN)], +) +async def test_lock_with_illegal_default_code( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_lock_entity: MockLock +) -> None: + """Test lock entity with illegal default code.""" + entity_registry.async_update_entity_options( + "lock.test_lock", "lock", {CONF_DEFAULT_CODE: "123456"} + ) + await hass.async_block_till_done() + + assert mock_lock_entity.state_attributes == {"code_format": r"^\d{4}$"} + assert mock_lock_entity._lock_option_default_code == "" + + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_OPEN + ) + with pytest.raises(ServiceValidationError): + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_LOCK + ) + with pytest.raises( + ServiceValidationError, + match=re.escape( + rf"The code for lock.test_lock doesn't match pattern ^\d{{{4}}}$" + ), + ) as exc: + await help_test_async_lock_service( + hass, mock_lock_entity.entity_id, SERVICE_UNLOCK + ) + + assert ( + str(exc.value) + == rf"The code for lock.test_lock doesn't match pattern ^\d{{{4}}}$" + ) + assert exc.value.translation_key == "add_default_code" + + +@pytest.mark.parametrize(("enum"), list(LockEntityFeature)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: LockEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, lock, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockLockEntity(lock.LockEntity): + _attr_supported_features = 1 + + entity = MockLockEntity() + assert entity.supported_features is lock.LockEntityFeature(1) + assert "MockLockEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "LockEntityFeature.OPEN" in caplog.text + caplog.clear() + assert entity.supported_features is lock.LockEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index d95b409a67b..671c70168d2 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -5,8 +5,9 @@ from collections.abc import Callable from datetime import datetime, timedelta from http import HTTPStatus import json -from unittest.mock import Mock, patch +from unittest.mock import Mock +from freezegun import freeze_time import pytest import voluptuous as vol @@ -504,10 +505,7 @@ async def test_logbook_describe_event( ) assert await async_setup_component(hass, "logbook", {}) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() - timedelta(seconds=5), - ): + with freeze_time(dt_util.utcnow() - timedelta(seconds=5)): hass.bus.async_fire("some_event") await async_wait_recording_done(hass) @@ -569,10 +567,7 @@ async def test_exclude_described_event( }, ) - with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() - timedelta(seconds=5), - ): + with freeze_time(dt_util.utcnow() - timedelta(seconds=5)): hass.bus.async_fire( "some_automation_event", {logbook.ATTR_NAME: name, logbook.ATTR_ENTITY_ID: entity_id}, diff --git a/tests/components/lovelace/test_cast.py b/tests/components/lovelace/test_cast.py index e67ab3f841a..4181d73c4d3 100644 --- a/tests/components/lovelace/test_cast.py +++ b/tests/components/lovelace/test_cast.py @@ -44,7 +44,7 @@ async def mock_yaml_dashboard(hass): ) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={ "title": "YAML Title", "views": [ diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 05bc7f372b8..a772b37f047 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -141,7 +141,7 @@ async def test_lovelace_from_yaml( events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo"}, ): await client.send_json({"id": 7, "type": "lovelace/config"}) @@ -154,7 +154,7 @@ async def test_lovelace_from_yaml( # Fake new data to see we fire event with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo2"}, ): await client.send_json({"id": 8, "type": "lovelace/config", "force": True}) @@ -245,7 +245,7 @@ async def test_dashboard_from_yaml( events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo"}, ): await client.send_json( @@ -260,7 +260,7 @@ async def test_dashboard_from_yaml( # Fake new data to see we fire event with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"hello": "yo2"}, ): await client.send_json( diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index f7830f03ed6..4a280eccfda 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -38,7 +38,7 @@ async def test_yaml_resources_backwards( ) -> None: """Test defining resources in YAML ll config (legacy).""" with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"resources": RESOURCE_EXAMPLES}, ): assert await async_setup_component( diff --git a/tests/components/lovelace/test_system_health.py b/tests/components/lovelace/test_system_health.py index 7a39bc4605d..72e7adb3a13 100644 --- a/tests/components/lovelace/test_system_health.py +++ b/tests/components/lovelace/test_system_health.py @@ -39,7 +39,7 @@ async def test_system_health_info_yaml(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}}) await hass.async_block_till_done() with patch( - "homeassistant.components.lovelace.dashboard.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml_dict", return_value={"views": [{"cards": []}]}, ): info = await get_system_health_info(hass, "lovelace") diff --git a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json index c85ee4d70e3..503fd3b9a7a 100644 --- a/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json +++ b/tests/components/matter/fixtures/config_entry_diagnostics_redacted.json @@ -14,7 +14,6 @@ "node_id": 5, "date_commissioned": "2023-01-16T21:07:57.508440", "last_interview": "2023-01-16T21:07:57.508448", - "last_subscription_attempt": 0, "interview_version": 2, "attributes": { "0/4/0": 128, diff --git a/tests/components/matter/fixtures/nodes/device_diagnostics.json b/tests/components/matter/fixtures/nodes/device_diagnostics.json index d95fbe5efa9..1d1d450e1f0 100644 --- a/tests/components/matter/fixtures/nodes/device_diagnostics.json +++ b/tests/components/matter/fixtures/nodes/device_diagnostics.json @@ -3,7 +3,6 @@ "date_commissioned": "2023-01-16T21:07:57.508440", "last_interview": "2023-01-16T21:07:57.508448", "interview_version": 2, - "last_subscription_attempt": 0, "attributes": { "0/4/0": 128, "0/4/65532": 1, diff --git a/tests/components/matter/test_api.py b/tests/components/matter/test_api.py index 041920f653f..24dac910d33 100644 --- a/tests/components/matter/test_api.py +++ b/tests/components/matter/test_api.py @@ -32,7 +32,7 @@ async def test_commission( msg = await ws_client.receive_json() assert msg["success"] - matter_client.commission_with_code.assert_called_once_with("12345678") + matter_client.commission_with_code.assert_called_once_with("12345678", True) matter_client.commission_with_code.reset_mock() matter_client.commission_with_code.side_effect = InvalidCommand( @@ -40,17 +40,13 @@ async def test_commission( ) await ws_client.send_json( - { - ID: 2, - TYPE: "matter/commission", - "code": "12345678", - } + {ID: 2, TYPE: "matter/commission", "code": "12345678", "network_only": False} ) msg = await ws_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "9" - matter_client.commission_with_code.assert_called_once_with("12345678") + matter_client.commission_with_code.assert_called_once_with("12345678", False) # This tests needs to be adjusted to remove lingering tasks @@ -74,7 +70,7 @@ async def test_commission_on_network( msg = await ws_client.receive_json() assert msg["success"] - matter_client.commission_on_network.assert_called_once_with(1234) + matter_client.commission_on_network.assert_called_once_with(1234, None) matter_client.commission_on_network.reset_mock() matter_client.commission_on_network.side_effect = NodeCommissionFailed( @@ -82,17 +78,13 @@ async def test_commission_on_network( ) await ws_client.send_json( - { - ID: 2, - TYPE: "matter/commission_on_network", - "pin": 1234, - } + {ID: 2, TYPE: "matter/commission_on_network", "pin": 1234, "ip_addr": "1.2.3.4"} ) msg = await ws_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "1" - matter_client.commission_on_network.assert_called_once_with(1234) + matter_client.commission_on_network.assert_called_once_with(1234, "1.2.3.4") # This tests needs to be adjusted to remove lingering tasks diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index ec8453b5c56..81d210ed579 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -6,18 +6,7 @@ from matter_server.client.models.node import MatterNode from matter_server.common.helpers.util import create_attribute_path_from_attribute import pytest -from homeassistant.components.climate import ( - HVAC_MODE_COOL, - HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, - HVACAction, - HVACMode, -) -from homeassistant.components.climate.const import ( - HVAC_MODE_DRY, - HVAC_MODE_FAN_ONLY, - HVAC_MODE_OFF, -) +from homeassistant.components.climate import HVACAction, HVACMode from homeassistant.core import HomeAssistant from .common import ( @@ -51,7 +40,7 @@ async def test_thermostat( # test set temperature when target temp is None assert state.attributes["temperature"] is None - assert state.state == HVAC_MODE_COOL + assert state.state == HVACMode.COOL with pytest.raises( ValueError, match="Current target_temperature should not be None" ): @@ -85,7 +74,7 @@ async def test_thermostat( ): state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_HEAT_COOL + assert state.state == HVACMode.HEAT_COOL await hass.services.async_call( "climate", "set_temperature", @@ -119,19 +108,19 @@ async def test_thermostat( await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_OFF + assert state.state == HVACMode.OFF set_node_attribute(thermostat, 1, 513, 28, 7) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_FAN_ONLY + assert state.state == HVACMode.FAN_ONLY set_node_attribute(thermostat, 1, 513, 28, 8) await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_DRY + assert state.state == HVACMode.DRY # test running state update from device set_node_attribute(thermostat, 1, 513, 41, 1) @@ -188,7 +177,7 @@ async def test_thermostat( state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_HEAT + assert state.state == HVACMode.HEAT # change occupied heating setpoint to 20 set_node_attribute(thermostat, 1, 513, 18, 2000) @@ -225,7 +214,7 @@ async def test_thermostat( state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_COOL + assert state.state == HVACMode.COOL # change occupied cooling setpoint to 18 set_node_attribute(thermostat, 1, 513, 17, 1800) @@ -273,7 +262,7 @@ async def test_thermostat( state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVAC_MODE_HEAT_COOL + assert state.state == HVACMode.HEAT_COOL # change occupied cooling setpoint to 18 set_node_attribute(thermostat, 1, 513, 17, 2500) @@ -340,7 +329,7 @@ async def test_thermostat( "set_hvac_mode", { "entity_id": "climate.longan_link_hvac", - "hvac_mode": HVAC_MODE_HEAT, + "hvac_mode": HVACMode.HEAT, }, blocking=True, ) diff --git a/tests/components/matter/test_door_lock.py b/tests/components/matter/test_door_lock.py index a9753824edc..51d48cddba7 100644 --- a/tests/components/matter/test_door_lock.py +++ b/tests/components/matter/test_door_lock.py @@ -14,6 +14,8 @@ from homeassistant.components.lock import ( ) from homeassistant.const import ATTR_CODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +import homeassistant.helpers.entity_registry as er from .common import set_node_attribute, trigger_subscription_callback @@ -101,6 +103,7 @@ async def test_lock_requires_pin( hass: HomeAssistant, matter_client: MagicMock, door_lock: MatterNode, + entity_registry: er.EntityRegistry, ) -> None: """Test door lock with PINCode.""" @@ -111,7 +114,7 @@ async def test_lock_requires_pin( # set door state to unlocked set_node_attribute(door_lock, 1, 257, 0, 2) - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): # Lock door using invalid code format await trigger_subscription_callback(hass, matter_client) await hass.services.async_call( @@ -137,6 +140,26 @@ async def test_lock_requires_pin( timed_request_timeout_ms=1000, ) + # Lock door using default code + default_code = "7654321" + entity_registry.async_update_entity_options( + "lock.mock_door_lock", "lock", {"default_code": default_code} + ) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( + "lock", + "lock", + {"entity_id": "lock.mock_door_lock"}, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 2 + assert matter_client.send_device_command.call_args == call( + node_id=door_lock.node_id, + endpoint_id=1, + command=clusters.DoorLock.Commands.LockDoor(default_code.encode()), + timed_request_timeout_ms=1000, + ) + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) diff --git a/tests/components/maxcube/test_maxcube_climate.py b/tests/components/maxcube/test_maxcube_climate.py index f279f049ac3..3f2b325330e 100644 --- a/tests/components/maxcube/test_maxcube_climate.py +++ b/tests/components/maxcube/test_maxcube_climate.py @@ -50,6 +50,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.util import utcnow @@ -370,7 +371,7 @@ async def test_thermostat_set_invalid_preset( hass: HomeAssistant, cube: MaxCube, thermostat: MaxThermostat ) -> None: """Set hvac mode to heat.""" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index cf71b52c046..f3b70187f33 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -10,86 +10,7 @@ from homeassistant.const import ( STATE_PLAYING, STATE_STANDBY, ) - - -class ExtendedMediaPlayer(mp.MediaPlayerEntity): - """Media player test class.""" - - def __init__(self, hass): - """Initialize the test media player.""" - self.hass = hass - self._volume = 0 - self._state = STATE_OFF - - @property - def state(self): - """State of the player.""" - return self._state - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self._volume - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return ( - mp.const.MediaPlayerEntityFeature.VOLUME_SET - | mp.const.MediaPlayerEntityFeature.VOLUME_STEP - | mp.const.MediaPlayerEntityFeature.PLAY - | mp.const.MediaPlayerEntityFeature.PAUSE - | mp.const.MediaPlayerEntityFeature.TURN_OFF - | mp.const.MediaPlayerEntityFeature.TURN_ON - ) - - def set_volume_level(self, volume): - """Set volume level, range 0..1.""" - self._volume = volume - - def volume_up(self): - """Turn volume up for media player.""" - if self.volume_level < 1: - self.set_volume_level(min(1, self.volume_level + 0.1)) - - def volume_down(self): - """Turn volume down for media player.""" - if self.volume_level > 0: - self.set_volume_level(max(0, self.volume_level - 0.1)) - - def media_play(self): - """Play the media player.""" - self._state = STATE_PLAYING - - def media_pause(self): - """Plause the media player.""" - self._state = STATE_PAUSED - - def media_play_pause(self): - """Play or pause the media player.""" - if self._state == STATE_PLAYING: - self._state = STATE_PAUSED - else: - self._state = STATE_PLAYING - - def turn_on(self): - """Turn on state.""" - self._state = STATE_ON - - def turn_off(self): - """Turn off state.""" - self._state = STATE_OFF - - def standby(self): - """Put device in standby.""" - self._state = STATE_STANDBY - - def toggle(self): - """Toggle the power on the media player.""" - if self._state in [STATE_OFF, STATE_IDLE, STATE_STANDBY]: - self._state = STATE_ON - else: - self._state = STATE_OFF +from homeassistant.core import HomeAssistant class SimpleMediaPlayer(mp.MediaPlayerEntity): @@ -148,28 +69,92 @@ class SimpleMediaPlayer(mp.MediaPlayerEntity): self._state = STATE_STANDBY +class ExtendedMediaPlayer(SimpleMediaPlayer): + """Media player test class.""" + + def volume_up(self): + """Turn volume up for media player.""" + if self.volume_level < 1: + self.set_volume_level(min(1, self.volume_level + 0.1)) + + def volume_down(self): + """Turn volume down for media player.""" + if self.volume_level > 0: + self.set_volume_level(max(0, self.volume_level - 0.1)) + + def media_play_pause(self): + """Play or pause the media player.""" + if self._state == STATE_PLAYING: + self._state = STATE_PAUSED + else: + self._state = STATE_PLAYING + + def toggle(self): + """Toggle the power on the media player.""" + if self._state in [STATE_OFF, STATE_IDLE, STATE_STANDBY]: + self._state = STATE_ON + else: + self._state = STATE_OFF + + +class AttrMediaPlayer(SimpleMediaPlayer): + """Media player setting properties via _attr_*.""" + + _attr_volume_step = 0.2 + + +class DescrMediaPlayer(SimpleMediaPlayer): + """Media player setting properties via entity description.""" + + entity_description = mp.MediaPlayerEntityDescription(key="test", volume_step=0.3) + + @pytest.fixture(params=[ExtendedMediaPlayer, SimpleMediaPlayer]) def player(hass, request): """Return a media player.""" return request.param(hass) -async def test_volume_up(player) -> None: +@pytest.mark.parametrize( + ("player_class", "volume_step"), + [ + (ExtendedMediaPlayer, 0.1), + (SimpleMediaPlayer, 0.1), + (AttrMediaPlayer, 0.2), + (DescrMediaPlayer, 0.3), + ], +) +async def test_volume_up( + hass: HomeAssistant, player_class: type[mp.MediaPlayerEntity], volume_step: float +) -> None: """Test the volume_up and set volume methods.""" + player = player_class(hass) assert player.volume_level == 0 await player.async_set_volume_level(0.5) assert player.volume_level == 0.5 await player.async_volume_up() - assert player.volume_level == 0.6 + assert player.volume_level == 0.5 + volume_step -async def test_volume_down(player) -> None: +@pytest.mark.parametrize( + ("player_class", "volume_step"), + [ + (ExtendedMediaPlayer, 0.1), + (SimpleMediaPlayer, 0.1), + (AttrMediaPlayer, 0.2), + (DescrMediaPlayer, 0.3), + ], +) +async def test_volume_down( + hass: HomeAssistant, player_class: type[mp.MediaPlayerEntity], volume_step: float +) -> None: """Test the volume_down and set volume methods.""" + player = player_class(hass) assert player.volume_level == 0 await player.async_set_volume_level(0.5) assert player.volume_level == 0.5 await player.async_volume_down() - assert player.volume_level == 0.4 + assert player.volume_level == 0.5 - volume_step async def test_media_play_pause(player) -> None: diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index b7bf35ab2f8..b4228d1ee69 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -10,6 +10,7 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaClass, MediaPlayerEnqueue, + MediaPlayerEntity, MediaPlayerEntityFeature, ) from homeassistant.components.websocket_api.const import TYPE_RESULT @@ -159,9 +160,6 @@ async def test_media_browse( client = await hass_ws_client(hass) with patch( - "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", - MediaPlayerEntityFeature.BROWSE_MEDIA, - ), patch( "homeassistant.components.media_player.MediaPlayerEntity.async_browse_media", return_value=BrowseMedia( media_class=MediaClass.DIRECTORY, @@ -176,7 +174,7 @@ async def test_media_browse( { "id": 5, "type": "media_player/browse_media", - "entity_id": "media_player.bedroom", + "entity_id": "media_player.browse", "media_content_type": "album", "media_content_id": "abcd", } @@ -202,9 +200,6 @@ async def test_media_browse( assert mock_browse_media.mock_calls[0][1] == ("album", "abcd") with patch( - "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", - MediaPlayerEntityFeature.BROWSE_MEDIA, - ), patch( "homeassistant.components.media_player.MediaPlayerEntity.async_browse_media", return_value={"bla": "yo"}, ): @@ -212,7 +207,7 @@ async def test_media_browse( { "id": 6, "type": "media_player/browse_media", - "entity_id": "media_player.bedroom", + "entity_id": "media_player.browse", } ) @@ -231,19 +226,14 @@ async def test_group_members_available_when_off(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - # Fake group support for DemoYoutubePlayer - with patch( - "homeassistant.components.demo.media_player.MediaPlayerEntity.supported_features", - MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.TURN_OFF, - ): - await hass.services.async_call( - "media_player", - "turn_off", - {ATTR_ENTITY_ID: "media_player.bedroom"}, - blocking=True, - ) + await hass.services.async_call( + "media_player", + "turn_off", + {ATTR_ENTITY_ID: "media_player.group"}, + blocking=True, + ) - state = hass.states.get("media_player.bedroom") + state = hass.states.get("media_player.group") assert state.state == STATE_OFF assert "group_members" in state.attributes @@ -339,3 +329,23 @@ async def test_get_async_get_browse_image_quoting( url = player.get_browse_image_url("album", media_content_id) await client.get(url) mock_browse_image.assert_called_with("album", media_content_id, None) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockMediaPlayerEntity(MediaPlayerEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockMediaPlayerEntity() + assert entity.supported_features_compat is MediaPlayerEntityFeature(1) + assert "MockMediaPlayerEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "MediaPlayerEntityFeature.PAUSE" in caplog.text + caplog.clear() + assert entity.supported_features_compat is MediaPlayerEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/media_player/test_significant_change.py b/tests/components/media_player/test_significant_change.py new file mode 100644 index 00000000000..233f133c342 --- /dev/null +++ b/tests/components/media_player/test_significant_change.py @@ -0,0 +1,130 @@ +"""Test the Media Player significant change platform.""" +import pytest + +from homeassistant.components.media_player import ( + ATTR_APP_ID, + ATTR_APP_NAME, + ATTR_ENTITY_PICTURE_LOCAL, + ATTR_GROUP_MEMBERS, + ATTR_INPUT_SOURCE, + ATTR_MEDIA_ALBUM_ARTIST, + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_CHANNEL, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_EPISODE, + ATTR_MEDIA_PLAYLIST, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_REPEAT, + ATTR_MEDIA_SEASON, + ATTR_MEDIA_SERIES_TITLE, + ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_TITLE, + ATTR_MEDIA_TRACK, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, +) +from homeassistant.components.media_player.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Media Player significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_APP_ID: "old_value"}, {ATTR_APP_ID: "old_value"}, False), + ({ATTR_APP_ID: "old_value"}, {ATTR_APP_ID: "new_value"}, True), + ({ATTR_APP_NAME: "old_value"}, {ATTR_APP_NAME: "new_value"}, True), + ( + {ATTR_ENTITY_PICTURE_LOCAL: "old_value"}, + {ATTR_ENTITY_PICTURE_LOCAL: "new_value"}, + True, + ), + ( + {ATTR_GROUP_MEMBERS: ["old1", "old2"]}, + {ATTR_GROUP_MEMBERS: ["old1", "new"]}, + False, + ), + ({ATTR_INPUT_SOURCE: "old_value"}, {ATTR_INPUT_SOURCE: "new_value"}, True), + ( + {ATTR_MEDIA_ALBUM_ARTIST: "old_value"}, + {ATTR_MEDIA_ALBUM_ARTIST: "new_value"}, + True, + ), + ( + {ATTR_MEDIA_ALBUM_NAME: "old_value"}, + {ATTR_MEDIA_ALBUM_NAME: "new_value"}, + True, + ), + ({ATTR_MEDIA_ARTIST: "old_value"}, {ATTR_MEDIA_ARTIST: "new_value"}, True), + ({ATTR_MEDIA_CHANNEL: "old_value"}, {ATTR_MEDIA_CHANNEL: "new_value"}, True), + ( + {ATTR_MEDIA_CONTENT_ID: "old_value"}, + {ATTR_MEDIA_CONTENT_ID: "new_value"}, + True, + ), + ( + {ATTR_MEDIA_CONTENT_TYPE: "old_value"}, + {ATTR_MEDIA_CONTENT_TYPE: "new_value"}, + True, + ), + ({ATTR_MEDIA_DURATION: "old_value"}, {ATTR_MEDIA_DURATION: "new_value"}, True), + ({ATTR_MEDIA_EPISODE: "old_value"}, {ATTR_MEDIA_EPISODE: "new_value"}, True), + ({ATTR_MEDIA_PLAYLIST: "old_value"}, {ATTR_MEDIA_PLAYLIST: "new_value"}, True), + ({ATTR_MEDIA_REPEAT: "old_value"}, {ATTR_MEDIA_REPEAT: "new_value"}, True), + ({ATTR_MEDIA_SEASON: "old_value"}, {ATTR_MEDIA_SEASON: "new_value"}, True), + ( + {ATTR_MEDIA_SERIES_TITLE: "old_value"}, + {ATTR_MEDIA_SERIES_TITLE: "new_value"}, + True, + ), + ({ATTR_MEDIA_SHUFFLE: "old_value"}, {ATTR_MEDIA_SHUFFLE: "new_value"}, True), + ({ATTR_MEDIA_TITLE: "old_value"}, {ATTR_MEDIA_TITLE: "new_value"}, True), + ({ATTR_MEDIA_TRACK: "old_value"}, {ATTR_MEDIA_TRACK: "new_value"}, True), + ( + {ATTR_MEDIA_VOLUME_MUTED: "old_value"}, + {ATTR_MEDIA_VOLUME_MUTED: "new_value"}, + True, + ), + ({ATTR_SOUND_MODE: "old_value"}, {ATTR_SOUND_MODE: "new_value"}, True), + # multiple attributes + ( + {ATTR_SOUND_MODE: "old_value", ATTR_MEDIA_VOLUME_MUTED: "old_value"}, + {ATTR_SOUND_MODE: "new_value", ATTR_MEDIA_VOLUME_MUTED: "old_value"}, + True, + ), + # float attributes + ({ATTR_MEDIA_VOLUME_LEVEL: 0.1}, {ATTR_MEDIA_VOLUME_LEVEL: 0.2}, True), + ({ATTR_MEDIA_VOLUME_LEVEL: 0.1}, {ATTR_MEDIA_VOLUME_LEVEL: 0.19}, False), + ({ATTR_MEDIA_VOLUME_LEVEL: "invalid"}, {ATTR_MEDIA_VOLUME_LEVEL: 1}, True), + ({ATTR_MEDIA_VOLUME_LEVEL: 1}, {ATTR_MEDIA_VOLUME_LEVEL: "invalid"}, False), + # insignificant attributes + ({ATTR_MEDIA_POSITION: "old_value"}, {ATTR_MEDIA_POSITION: "new_value"}, False), + ( + {ATTR_MEDIA_POSITION_UPDATED_AT: "old_value"}, + {ATTR_MEDIA_POSITION_UPDATED_AT: "new_value"}, + False, + ), + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Media Player significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index 8f877eb1eca..f3d49f3c0bc 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -9,7 +9,9 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.melcloud.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.issue_registry as ir from tests.common import MockConfigEntry @@ -287,3 +289,158 @@ async def test_token_refresh(hass: HomeAssistant, mock_login, mock_get_devices) entry = entries[0] assert entry.data["username"] == "test-email@test-domain.com" assert entry.data["token"] == "test-token" + + +async def test_token_reauthentication( + hass: HomeAssistant, + mock_login, + mock_get_devices, +) -> None: + """Re-configuration with existing username should refresh token, if made invalid.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("error", "reason"), + [ + (asyncio.TimeoutError(), "cannot_connect"), + (AttributeError(name="get"), "invalid_auth"), + ], +) +async def test_form_errors_reauthentication( + hass: HomeAssistant, mock_login, error, reason +) -> None: + """Test we handle cannot connect error.""" + mock_login.side_effect = error + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"]["base"] == reason + + mock_login.side_effect = None + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("error", "reason"), + [ + (HTTPStatus.UNAUTHORIZED, "invalid_auth"), + (HTTPStatus.FORBIDDEN, "invalid_auth"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "cannot_connect"), + ], +) +async def test_client_errors_reauthentication( + hass: HomeAssistant, mock_login, mock_request_info, error, reason +) -> None: + """Test we handle cannot connect error.""" + mock_login.side_effect = ClientResponseError(mock_request_info(), (), status=error) + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-email@test-domain.com", "token": "test-original-token"}, + unique_id="test-email@test-domain.com", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=mock_entry.data, + ) + + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["errors"]["base"] == reason + assert result["type"] == FlowResultType.FORM + + mock_login.side_effect = None + with patch( + "homeassistant.components.melcloud.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"username": "test-email@test-domain.com", "password": "test-password"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index 158f86fe452..8e3d5eda19d 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -175,7 +175,12 @@ async def setup_mikrotik_entry(hass: HomeAssistant, **kwargs: Any) -> None: wireless_data: list[dict[str, Any]] = kwargs.get("wireless_data", WIRELESS_DATA) wifiwave2_data: list[dict[str, Any]] = kwargs.get("wifiwave2_data", WIFIWAVE2_DATA) - def mock_command(self, cmd: str, params: dict[str, Any] | None = None) -> Any: + def mock_command( + self, + cmd: str, + params: dict[str, Any] | None = None, + suppress_errors: bool = False, + ) -> Any: if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: return support_wireless if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIFIWAVE2]: diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index 55cebaec525..bf1dc3abedf 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -52,7 +52,9 @@ def mock_device_registry_devices(hass: HomeAssistant) -> None: ) -def mock_command(self, cmd: str, params: dict[str, Any] | None = None) -> Any: +def mock_command( + self, cmd: str, params: dict[str, Any] | None = None, suppress_errors: bool = False +) -> Any: """Mock the Mikrotik command method.""" if cmd == mikrotik.const.MIKROTIK_SERVICES[mikrotik.const.IS_WIRELESS]: return True diff --git a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr index ef03e36343b..2a62fea7f35 100644 --- a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_binary_sensor[bedrock_mock_config_entry-BedrockServer-status_response1] +# name: test_binary_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -13,7 +13,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor[java_mock_config_entry-JavaServer-status_response0] +# name: test_binary_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -27,7 +27,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1] +# name: test_binary_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', @@ -41,7 +41,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_update[java_mock_config_entry-JavaServer-status_response0] +# name: test_binary_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'connectivity', diff --git a/tests/components/minecraft_server/snapshots/test_sensor.ambr b/tests/components/minecraft_server/snapshots/test_sensor.ambr index fed0ae93c66..b0f77f27b80 100644 --- a/tests/components/minecraft_server/snapshots/test_sensor.ambr +++ b/tests/components/minecraft_server/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1] +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -13,7 +13,7 @@ 'state': '5', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].1 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -27,7 +27,7 @@ 'state': '3', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].2 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -41,7 +41,7 @@ 'state': '10', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].3 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -54,7 +54,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].4 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -67,7 +67,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].5 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -80,7 +80,7 @@ 'state': '123', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].6 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Map name', @@ -93,7 +93,7 @@ 'state': 'Dummy Map Name', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].7 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Game mode', @@ -106,7 +106,7 @@ 'state': 'Dummy Game Mode', }) # --- -# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].8 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Edition', @@ -119,7 +119,7 @@ 'state': 'MCPE', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0] +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -133,7 +133,7 @@ 'state': '5', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].1 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -152,7 +152,7 @@ 'state': '3', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].2 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -166,7 +166,7 @@ 'state': '10', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].3 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -179,7 +179,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].4 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -192,7 +192,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].5 +# name: test_sensor[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -205,7 +205,7 @@ 'state': '123', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1] +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -219,7 +219,7 @@ 'state': '5', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].1 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -233,7 +233,7 @@ 'state': '3', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].2 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -247,7 +247,7 @@ 'state': '10', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].3 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -260,7 +260,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].4 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -273,7 +273,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].5 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', @@ -286,7 +286,7 @@ 'state': '123', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].6 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].6 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Map name', @@ -299,7 +299,7 @@ 'state': 'Dummy Map Name', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].7 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].7 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Game mode', @@ -312,7 +312,7 @@ 'state': 'Dummy Game Mode', }) # --- -# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].8 +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-lookup-status_response1-entity_ids1].8 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Edition', @@ -325,7 +325,7 @@ 'state': 'MCPE', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0] +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Latency', @@ -339,7 +339,7 @@ 'state': '5', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].1 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].1 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players online', @@ -358,7 +358,7 @@ 'state': '3', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].2 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].2 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Players max', @@ -372,7 +372,7 @@ 'state': '10', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].3 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].3 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server World message', @@ -385,7 +385,7 @@ 'state': 'Dummy MOTD', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].4 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].4 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Version', @@ -398,7 +398,7 @@ 'state': 'Dummy Version', }) # --- -# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].5 +# name: test_sensor_update[java_mock_config_entry-JavaServer-async_lookup-status_response0-entity_ids0].5 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Minecraft Server Protocol version', diff --git a/tests/components/minecraft_server/test_binary_sensor.py b/tests/components/minecraft_server/test_binary_sensor.py index 9fae35b113d..4db564bc143 100644 --- a/tests/components/minecraft_server/test_binary_sensor.py +++ b/tests/components/minecraft_server/test_binary_sensor.py @@ -22,16 +22,27 @@ from tests.common import async_fire_time_changed @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, snapshot: SnapshotAssertion, @@ -41,7 +52,7 @@ async def test_binary_sensor( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -53,16 +64,27 @@ async def test_binary_sensor( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor_update( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, snapshot: SnapshotAssertion, @@ -73,7 +95,7 @@ async def test_binary_sensor_update( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -88,16 +110,27 @@ async def test_binary_sensor_update( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response"), + ("mock_config_entry", "server", "lookup_function_name", "status_response"), [ - ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), - ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ( + "java_mock_config_entry", + JavaServer, + "async_lookup", + TEST_JAVA_STATUS_RESPONSE, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + "lookup", + TEST_BEDROCK_STATUS_RESPONSE, + ), ], ) async def test_binary_sensor_update_failure( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, request: pytest.FixtureRequest, freezer: FrozenDateTimeFactory, @@ -107,7 +140,7 @@ async def test_binary_sensor_update_failure( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 785905492c1..2a0208f2251 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -41,7 +41,7 @@ async def test_address_validation_failure(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( @@ -58,7 +58,7 @@ async def test_java_connection_failure(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -95,7 +95,7 @@ async def test_java_connection(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -138,7 +138,7 @@ async def test_recovery(hass: HomeAssistant) -> None: "homeassistant.components.minecraft_server.api.BedrockServer.lookup", side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/minecraft_server/test_diagnostics.py b/tests/components/minecraft_server/test_diagnostics.py index 6979325fa0c..80b5c91c1fb 100644 --- a/tests/components/minecraft_server/test_diagnostics.py +++ b/tests/components/minecraft_server/test_diagnostics.py @@ -42,9 +42,14 @@ async def test_config_entry_diagnostics( mock_config_entry = request.getfixturevalue(mock_config_entry) mock_config_entry.add_to_hass(hass) + if server.__name__ == "JavaServer": + lookup_function_name = "async_lookup" + else: + lookup_function_name = "lookup" + # Setup mock entry. with patch( - f"mcstatus.server.{server.__name__}.lookup", + f"mcstatus.server.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"mcstatus.server.{server.__name__}.async_status", diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 018fdac542e..5b0d9509d69 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -122,7 +122,7 @@ async def test_setup_and_unload_entry( java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -138,14 +138,14 @@ async def test_setup_and_unload_entry( assert java_mock_config_entry.state == ConfigEntryState.NOT_LOADED -async def test_setup_entry_failure( +async def test_setup_entry_lookup_failure( hass: HomeAssistant, java_mock_config_entry: MockConfigEntry ) -> None: - """Test failed entry setup.""" + """Test lookup failure in entry setup.""" java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=ValueError, ): assert not await hass.config_entries.async_setup( @@ -156,6 +156,24 @@ async def test_setup_entry_failure( assert java_mock_config_entry.state == ConfigEntryState.SETUP_ERROR +async def test_setup_entry_init_failure( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test init failure in entry setup.""" + java_mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.async_initialize", + side_effect=None, + ): + assert not await hass.config_entries.async_setup( + java_mock_config_entry.entry_id + ) + + await hass.async_block_till_done() + assert java_mock_config_entry.state == ConfigEntryState.SETUP_RETRY + + async def test_setup_entry_not_ready( hass: HomeAssistant, java_mock_config_entry: MockConfigEntry ) -> None: @@ -163,7 +181,7 @@ async def test_setup_entry_not_ready( java_mock_config_entry.add_to_hass(hass) with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -196,7 +214,7 @@ async def test_entry_migration( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=[ ValueError, # async_migrate_entry JavaServer(host=TEST_HOST, port=TEST_PORT), # async_migrate_entry @@ -258,7 +276,7 @@ async def test_entry_migration_host_only( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( "homeassistant.components.minecraft_server.api.JavaServer.async_status", @@ -293,7 +311,7 @@ async def test_entry_migration_v3_failure( # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.JavaServer.async_lookup", side_effect=[ ValueError, # async_migrate_entry ValueError, # async_migrate_entry diff --git a/tests/components/minecraft_server/test_sensor.py b/tests/components/minecraft_server/test_sensor.py index 006c735e034..7d599669d71 100644 --- a/tests/components/minecraft_server/test_sensor.py +++ b/tests/components/minecraft_server/test_sensor.py @@ -55,17 +55,25 @@ BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -75,6 +83,7 @@ async def test_sensor( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -85,7 +94,7 @@ async def test_sensor( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -98,17 +107,25 @@ async def test_sensor( @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES_DISABLED_BY_DEFAULT, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT, ), @@ -118,6 +135,7 @@ async def test_sensor_disabled_by_default( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -127,7 +145,7 @@ async def test_sensor_disabled_by_default( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -141,17 +159,25 @@ async def test_sensor_disabled_by_default( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -161,6 +187,7 @@ async def test_sensor_update( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -172,7 +199,7 @@ async def test_sensor_update( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", @@ -189,17 +216,25 @@ async def test_sensor_update( @pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( - ("mock_config_entry", "server", "status_response", "entity_ids"), + ( + "mock_config_entry", + "server", + "lookup_function_name", + "status_response", + "entity_ids", + ), [ ( "java_mock_config_entry", JavaServer, + "async_lookup", TEST_JAVA_STATUS_RESPONSE, JAVA_SENSOR_ENTITIES, ), ( "bedrock_mock_config_entry", BedrockServer, + "lookup", TEST_BEDROCK_STATUS_RESPONSE, BEDROCK_SENSOR_ENTITIES, ), @@ -209,6 +244,7 @@ async def test_sensor_update_failure( hass: HomeAssistant, mock_config_entry: str, server: JavaServer | BedrockServer, + lookup_function_name: str, status_response: JavaStatusResponse | BedrockStatusResponse, entity_ids: list[str], request: pytest.FixtureRequest, @@ -219,7 +255,7 @@ async def test_sensor_update_failure( mock_config_entry.add_to_hass(hass) with patch( - f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + f"homeassistant.components.minecraft_server.api.{server.__name__}.{lookup_function_name}", return_value=server(host=TEST_HOST, port=TEST_PORT), ), patch( f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py index 59f2a130737..d504703c222 100644 --- a/tests/components/mobile_app/test_init.py +++ b/tests/components/mobile_app/test_init.py @@ -1,16 +1,34 @@ """Tests for the mobile app integration.""" -from homeassistant.components.mobile_app.const import DATA_DELETED_IDS, DOMAIN +from collections.abc import Awaitable, Callable +from typing import Any +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.mobile_app.const import ( + ATTR_DEVICE_NAME, + CONF_CLOUDHOOK_URL, + CONF_USER_ID, + DATA_DELETED_IDS, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import ATTR_DEVICE_ID, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import CALL_SERVICE +from .const import CALL_SERVICE, REGISTER_CLEARTEXT -from tests.common import async_mock_service +from tests.common import ( + MockConfigEntry, + MockUser, + async_mock_cloud_connection_status, + async_mock_service, +) -async def test_unload_unloads( - hass: HomeAssistant, create_registrations, webhook_client -) -> None: +@pytest.mark.usefixtures("create_registrations") +async def test_unload_unloads(hass: HomeAssistant, webhook_client) -> None: """Test we clean up when we unload.""" # Second config entry is the one without encryption config_entry = hass.config_entries.async_entries("mobile_app")[1] @@ -28,11 +46,11 @@ async def test_unload_unloads( assert len(calls) == 1 +@pytest.mark.usefixtures("create_registrations") async def test_remove_entry( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - create_registrations, ) -> None: """Test we clean up when we remove entry.""" for config_entry in hass.config_entries.async_entries("mobile_app"): @@ -41,3 +59,98 @@ async def test_remove_entry( assert len(device_registry.devices) == 0 assert len(entity_registry.entities) == 0 + + +async def _test_create_cloud_hook( + hass: HomeAssistant, + hass_admin_user: MockUser, + additional_config: dict[str, Any], + async_active_subscription_return_value: bool, + additional_steps: Callable[[ConfigEntry, Mock, str], Awaitable[None]], +) -> None: + config_entry = MockConfigEntry( + data={ + **REGISTER_CLEARTEXT, + CONF_WEBHOOK_ID: "test-webhook-id", + ATTR_DEVICE_NAME: "Test", + ATTR_DEVICE_ID: "Test", + CONF_USER_ID: hass_admin_user.id, + **additional_config, + }, + domain=DOMAIN, + title="Test", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.cloud.async_active_subscription", + return_value=async_active_subscription_return_value, + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", autospec=True + ) as mock_create_cloudhook: + cloud_hook = "https://hook-url" + mock_create_cloudhook.return_value = cloud_hook + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + await additional_steps(config_entry, mock_create_cloudhook, cloud_hook) + + +async def test_create_cloud_hook_on_setup( + hass: HomeAssistant, + hass_admin_user: MockUser, +) -> None: + """Test creating a cloud hook during setup.""" + + async def additional_steps( + config_entry: ConfigEntry, mock_create_cloudhook: Mock, cloud_hook: str + ) -> None: + assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook + mock_create_cloudhook.assert_called_once_with( + hass, config_entry.data[CONF_WEBHOOK_ID] + ) + + await _test_create_cloud_hook(hass, hass_admin_user, {}, True, additional_steps) + + +async def test_create_cloud_hook_aleady_exists( + hass: HomeAssistant, + hass_admin_user: MockUser, +) -> None: + """Test creating a cloud hook is not called, when a cloud hook already exists.""" + cloud_hook = "https://hook-url-already-exists" + + async def additional_steps( + config_entry: ConfigEntry, mock_create_cloudhook: Mock, _: str + ) -> None: + assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook + mock_create_cloudhook.assert_not_called() + + await _test_create_cloud_hook( + hass, hass_admin_user, {CONF_CLOUDHOOK_URL: cloud_hook}, True, additional_steps + ) + + +async def test_create_cloud_hook_after_connection( + hass: HomeAssistant, + hass_admin_user: MockUser, +) -> None: + """Test creating a cloud hook when connected to the cloud.""" + + async def additional_steps( + config_entry: ConfigEntry, mock_create_cloudhook: Mock, cloud_hook: str + ) -> None: + assert CONF_CLOUDHOOK_URL not in config_entry.data + mock_create_cloudhook.assert_not_called() + + async_mock_cloud_connection_status(hass, True) + await hass.async_block_till_done() + assert config_entry.data[CONF_CLOUDHOOK_URL] == cloud_hook + mock_create_cloudhook.assert_called_once_with( + hass, config_entry.data[CONF_WEBHOOK_ID] + ) + + await _test_create_cloud_hook(hass, hass_admin_user, {}, False, additional_steps) diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index a892dd205fb..e47a6165b30 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -1,5 +1,4 @@ """Thetests for the Modbus sensor component.""" -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN @@ -10,7 +9,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT, MODBUS_DOMAIN, @@ -26,13 +24,12 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") SLAVE_UNIQUE_ID = "ground_floor_sensor" @@ -57,7 +54,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SLAVE: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, CONF_DEVICE_CLASS: "door", - CONF_LAZY_ERROR: 10, } ] }, @@ -69,7 +65,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_DEVICE_ADDRESS: 10, CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, CONF_DEVICE_CLASS: "door", - CONF_LAZY_ERROR: 10, } ] }, @@ -196,44 +191,6 @@ async def test_all_binary_sensor(hass: HomeAssistant, expected, mock_do_cycle) - assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_BINARY_SENSORS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [False * 16], - True, - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_binary_sensor( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index f2de0177c74..325b68869e0 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -1,17 +1,36 @@ """The tests for the Modbus climate component.""" -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.climate.const import ( + ATTR_FAN_MODE, + ATTR_FAN_MODES, ATTR_HVAC_MODE, ATTR_HVAC_MODES, + FAN_AUTO, + FAN_DIFFUSE, + FAN_FOCUS, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, + FAN_OFF, + FAN_ON, + FAN_TOP, HVACMode, ) from homeassistant.components.modbus.const import ( CONF_CLIMATES, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_AUTO, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_LOW, + CONF_FAN_MODE_MEDIUM, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -22,7 +41,6 @@ from homeassistant.components.modbus.const import ( CONF_HVAC_MODE_REGISTER, CONF_HVAC_MODE_VALUES, CONF_HVAC_ONOFF_REGISTER, - CONF_LAZY_ERROR, CONF_TARGET_TEMP, CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_WRITE_REGISTERS, @@ -40,7 +58,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") @@ -77,7 +95,6 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, CONF_DATA_TYPE: DataType.INT32, - CONF_LAZY_ERROR: 10, } ], }, @@ -186,7 +203,7 @@ async def test_config_climate(hass: HomeAssistant, mock_modbus) -> None: ], ) async def test_config_hvac_mode_register(hass: HomeAssistant, mock_modbus) -> None: - """Run configuration test for mode register.""" + """Run configuration test for HVAC mode register.""" state = hass.states.get(ENTITY_ID) assert HVACMode.OFF in state.attributes[ATTR_HVAC_MODES] assert HVACMode.HEAT in state.attributes[ATTR_HVAC_MODES] @@ -196,6 +213,47 @@ async def test_config_hvac_mode_register(hass: HomeAssistant, mock_modbus) -> No assert HVACMode.FAN_ONLY in state.attributes[ATTR_HVAC_MODES] +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 11, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_OFF: 1, + CONF_FAN_MODE_AUTO: 2, + CONF_FAN_MODE_LOW: 3, + CONF_FAN_MODE_MEDIUM: 4, + CONF_FAN_MODE_HIGH: 5, + }, + }, + } + ], + }, + ], +) +async def test_config_fan_mode_register(hass: HomeAssistant, mock_modbus) -> None: + """Run configuration test for Fan mode register.""" + state = hass.states.get(ENTITY_ID) + assert FAN_ON in state.attributes[ATTR_FAN_MODES] + assert FAN_OFF in state.attributes[ATTR_FAN_MODES] + assert FAN_AUTO in state.attributes[ATTR_FAN_MODES] + assert FAN_LOW in state.attributes[ATTR_FAN_MODES] + assert FAN_MEDIUM in state.attributes[ATTR_FAN_MODES] + assert FAN_HIGH in state.attributes[ATTR_FAN_MODES] + assert FAN_TOP not in state.attributes[ATTR_FAN_MODES] + assert FAN_MIDDLE not in state.attributes[ATTR_FAN_MODES] + assert FAN_DIFFUSE not in state.attributes[ATTR_FAN_MODES] + assert FAN_FOCUS not in state.attributes[ATTR_FAN_MODES] + + @pytest.mark.parametrize( "do_config", [ @@ -341,6 +399,96 @@ async def test_service_climate_update( assert hass.states.get(ENTITY_ID).state == result +@pytest.mark.parametrize( + ("do_config", "result", "register_words"), + [ + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + }, + ] + }, + FAN_LOW, + [0x00], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + }, + ] + }, + FAN_MEDIUM, + [0x01], + ), + ( + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, + CONF_DATA_TYPE: DataType.INT32, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_LOW: 0, + CONF_FAN_MODE_MEDIUM: 1, + CONF_FAN_MODE_HIGH: 2, + }, + }, + CONF_HVAC_ONOFF_REGISTER: 119, + }, + ] + }, + FAN_HIGH, + [0x02], + ), + ], +) +async def test_service_climate_fan_update( + hass: HomeAssistant, mock_modbus, mock_ha, result, register_words +) -> None: + """Run test for service homeassistant.update_entity.""" + mock_modbus.read_holding_registers.return_value = ReadResult(register_words) + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": ENTITY_ID}, blocking=True + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).attributes[ATTR_FAN_MODE] == result + + @pytest.mark.parametrize( ("temperature", "result", "do_config"), [ @@ -532,10 +680,10 @@ async def test_service_climate_set_temperature( ), ], ) -async def test_service_set_mode( +async def test_service_set_hvac_mode( hass: HomeAssistant, hvac_mode, result, mock_modbus, mock_ha ) -> None: - """Test set mode.""" + """Test set HVAC mode.""" mock_modbus.read_holding_registers.return_value = ReadResult(result) await hass.services.async_call( CLIMATE_DOMAIN, @@ -548,6 +696,69 @@ async def test_service_set_mode( ) +@pytest.mark.parametrize( + ("fan_mode", "result", "do_config"), + [ + ( + FAN_OFF, + [0x02], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 1, + CONF_FAN_MODE_OFF: 2, + }, + }, + } + ] + }, + ), + ( + FAN_ON, + [0x01], + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 1, + CONF_FAN_MODE_OFF: 2, + }, + }, + } + ] + }, + ), + ], +) +async def test_service_set_fan_mode( + hass: HomeAssistant, fan_mode, result, mock_modbus, mock_ha +) -> None: + """Test set Fan mode.""" + mock_modbus.read_holding_registers.return_value = ReadResult(result) + await hass.services.async_call( + CLIMATE_DOMAIN, + "set_fan_mode", + { + "entity_id": ENTITY_ID, + ATTR_FAN_MODE: fan_mode, + }, + blocking=True, + ) + + test_value = State(ENTITY_ID, 35) test_value.attributes = {ATTR_TEMPERATURE: 37} @@ -581,46 +792,6 @@ async def test_restore_state_climate( assert state.attributes[ATTR_TEMPERATURE] == 37 -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_CLIMATES: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_TARGET_TEMP: 117, - CONF_ADDRESS: 117, - CONF_SLAVE: 10, - CONF_LAZY_ERROR: 1, - } - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x8000], - True, - "17", - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_climate( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory, start_expect, end_expect -) -> None: - """Run test for sensor.""" - hass.states.async_set(ENTITY_ID, 17) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index b91b38b1f70..39897822bc8 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -1,5 +1,4 @@ """The tests for the Modbus cover component.""" -from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest @@ -9,7 +8,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_CLOSED, CONF_STATE_CLOSING, CONF_STATE_OPEN, @@ -33,7 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult ENTITY_ID = f"{COVER_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") ENTITY_ID2 = f"{ENTITY_ID}_2" @@ -59,7 +57,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_SLAVE: 10, CONF_SCAN_INTERVAL: 20, - CONF_LAZY_ERROR: 10, } ] }, @@ -71,7 +68,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_ADDRESS: 10, CONF_SCAN_INTERVAL: 20, - CONF_LAZY_ERROR: 10, } ] }, @@ -127,45 +123,6 @@ async def test_coil_cover(hass: HomeAssistant, expected, mock_do_cycle) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_COVERS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x00], - True, - STATE_OPEN, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_cover( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "do_config", [ diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 932e07b2d1a..0922329d4b7 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -11,7 +11,6 @@ from homeassistant.components.modbus.const import ( CONF_DEVICE_ADDRESS, CONF_FANS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -66,7 +65,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_SLAVE: 1, CONF_COMMAND_OFF: 0x00, CONF_COMMAND_ON: 0x01, - CONF_LAZY_ERROR: 10, CONF_VERIFY: { CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_ADDRESS: 1235, @@ -84,7 +82,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_DEVICE_ADDRESS: 1, CONF_COMMAND_OFF: 0x00, CONF_COMMAND_ON: 0x01, - CONF_LAZY_ERROR: 10, CONF_VERIFY: { CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_ADDRESS: 1235, diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index e66115f24d9..df415807119 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -40,9 +40,19 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_WRITE_REGISTERS, CONF_BAUDRATE, CONF_BYTESIZE, + CONF_CLIMATES, CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, + CONF_FAN_MODE_HIGH, + CONF_FAN_MODE_OFF, + CONF_FAN_MODE_ON, + CONF_FAN_MODE_REGISTER, + CONF_FAN_MODE_VALUES, + CONF_HVAC_MODE_COOL, + CONF_HVAC_MODE_HEAT, + CONF_HVAC_MODE_REGISTER, + CONF_HVAC_MODE_VALUES, CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, @@ -53,6 +63,7 @@ from homeassistant.components.modbus.const import ( CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_TARGET_TEMP, CONF_VIRTUAL_COUNT, DEFAULT_SCAN_INTERVAL, MODBUS_DOMAIN as DOMAIN, @@ -68,6 +79,7 @@ from homeassistant.components.modbus.const import ( ) from homeassistant.components.modbus.validators import ( duplicate_entity_validator, + duplicate_fan_mode_validator, duplicate_modbus_validator, nan_validator, number_validator, @@ -361,6 +373,25 @@ async def test_duplicate_modbus_validator(do_config) -> None: assert len(do_config) == 1 +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_ADDRESS: 11, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 7, + CONF_FAN_MODE_OFF: 9, + CONF_FAN_MODE_HIGH: 9, + }, + } + ], +) +async def test_duplicate_fan_mode_validator(do_config) -> None: + """Test duplicate modbus validator.""" + duplicate_fan_mode_validator(do_config) + assert len(do_config[CONF_FAN_MODE_VALUES]) == 2 + + @pytest.mark.parametrize( "do_config", [ @@ -404,12 +435,170 @@ async def test_duplicate_modbus_validator(do_config) -> None: ], } ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + ], + } + ], ], ) async def test_duplicate_entity_validator(do_config) -> None: """Test duplicate entity validator.""" duplicate_entity_validator(do_config) - assert len(do_config[0][CONF_SENSORS]) == 1 + if CONF_SENSORS in do_config[0]: + assert len(do_config[0][CONF_SENSORS]) == 1 + elif CONF_CLIMATES in do_config[0]: + assert len(do_config[0][CONF_CLIMATES]) == 1 + + +@pytest.mark.parametrize( + "do_config", + [ + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 119, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_HVAC_MODE_REGISTER: { + CONF_ADDRESS: 118, + CONF_HVAC_MODE_VALUES: { + CONF_HVAC_MODE_COOL: 0, + CONF_HVAC_MODE_HEAT: 1, + }, + }, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_TARGET_TEMP: 99, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + ], + } + ], + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_SLAVE: 0, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 120, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + { + CONF_NAME: TEST_ENTITY_NAME + " 2", + CONF_ADDRESS: 118, + CONF_SLAVE: 0, + CONF_TARGET_TEMP: 117, + CONF_FAN_MODE_REGISTER: { + CONF_ADDRESS: 121, + CONF_FAN_MODE_VALUES: { + CONF_FAN_MODE_ON: 0, + CONF_FAN_MODE_HIGH: 1, + }, + }, + }, + ], + } + ], + ], +) +async def test_duplicate_entity_validator_with_climate(do_config) -> None: + """Test duplicate entity validator.""" + duplicate_entity_validator(do_config) + assert len(do_config[0][CONF_CLIMATES]) == 1 @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 1d6963aaa12..ecd9abd71b8 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -10,7 +10,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -55,7 +54,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_LAZY_ERROR: 10, } ] }, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index d0a4e23f780..8fb7f9fd951 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1,7 +1,6 @@ """The tests for the Modbus sensor component.""" import struct -from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.modbus.const import ( @@ -10,7 +9,6 @@ from homeassistant.components.modbus.const import ( CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_NAN_VALUE, @@ -19,7 +17,6 @@ from homeassistant.components.modbus.const import ( CONF_SLAVE_COUNT, CONF_SWAP, CONF_SWAP_BYTE, - CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VIRTUAL_COUNT, @@ -50,7 +47,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult from tests.common import mock_restore_cache_with_extra_data @@ -81,7 +78,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SCALE: 1, CONF_OFFSET: 0, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, - CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", } @@ -98,7 +94,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_SCALE: 1, CONF_OFFSET: 0, CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, - CONF_LAZY_ERROR: 10, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, CONF_DEVICE_CLASS: "battery", } @@ -125,7 +120,6 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_DATA_TYPE: DataType.INT16, - CONF_SWAP: CONF_SWAP_NONE, } ] }, @@ -228,7 +222,6 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: CONF_ADDRESS: 1234, CONF_DATA_TYPE: DataType.CUSTOM, CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, CONF_STRUCTURE: "invalid", }, ] @@ -555,7 +548,6 @@ async def test_config_wrong_struct_sensor( ( { CONF_DATA_TYPE: DataType.INT16, - CONF_SWAP: CONF_SWAP_NONE, }, [0x0102], False, @@ -1146,41 +1138,6 @@ async def test_unpack_ok(hass: HomeAssistant, mock_do_cycle, expected) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_SENSORS: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 1, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception"), - [ - ( - [0x8000], - True, - ), - ], -) -async def test_lazy_error_sensor( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for sensor.""" - hass.states.async_set(ENTITY_ID, 17) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == "17" - await do_next_cycle(hass, mock_do_cycle, 5) - assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE - - @pytest.mark.parametrize( "do_config", [ @@ -1290,7 +1247,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No [ ( { - CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT16, }, [0x0102], @@ -1306,7 +1262,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT32, }, [0x0102, 0x0304], diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 0eb40d2c082..28c44440581 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -2,7 +2,6 @@ from datetime import timedelta from unittest import mock -from freezegun.api import FrozenDateTimeFactory from pymodbus.exceptions import ModbusException import pytest @@ -13,7 +12,6 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_REGISTER_INPUT, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, - CONF_LAZY_ERROR, CONF_STATE_OFF, CONF_STATE_ON, CONF_VERIFY, @@ -39,7 +37,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .conftest import TEST_ENTITY_NAME, ReadResult, do_next_cycle +from .conftest import TEST_ENTITY_NAME, ReadResult from tests.common import async_fire_time_changed @@ -64,7 +62,6 @@ ENTITY_ID2 = f"{ENTITY_ID}_2" CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 1234, CONF_WRITE_TYPE: CALL_TYPE_COIL, - CONF_LAZY_ERROR: 10, } ] }, @@ -227,46 +224,6 @@ async def test_all_switch(hass: HomeAssistant, mock_do_cycle, expected) -> None: assert hass.states.get(ENTITY_ID).state == expected -@pytest.mark.parametrize( - "do_config", - [ - { - CONF_SWITCHES: [ - { - CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 1234, - CONF_SLAVE: 1, - CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, - CONF_SCAN_INTERVAL: 10, - CONF_LAZY_ERROR: 2, - CONF_VERIFY: {}, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), - [ - ( - [0x00], - True, - STATE_OFF, - STATE_UNAVAILABLE, - ), - ], -) -async def test_lazy_error_switch( - hass: HomeAssistant, start_expect, end_expect, mock_do_cycle: FrozenDateTimeFactory -) -> None: - """Run test for given config.""" - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect - - @pytest.mark.parametrize( "mock_test_state", [(State(ENTITY_ID, STATE_ON),), (State(ENTITY_ID, STATE_OFF),)], diff --git a/tests/components/motionmount/__init__.py b/tests/components/motionmount/__init__.py new file mode 100644 index 00000000000..da6fbae32a3 --- /dev/null +++ b/tests/components/motionmount/__init__.py @@ -0,0 +1,42 @@ +"""Tests for the Vogel's MotionMount integration.""" + +from ipaddress import ip_address + +from homeassistant.components import zeroconf +from homeassistant.const import CONF_HOST, CONF_PORT + +HOST = "192.168.1.31" +PORT = 23 + +TVM_ZEROCONF_SERVICE_TYPE = "_tvm._tcp.local." + +ZEROCONF_NAME = "My MotionMount" +ZEROCONF_HOST = HOST +ZEROCONF_HOSTNAME = "MMF8A55F.local." +ZEROCONF_PORT = PORT +ZEROCONF_MAC = "c4:dd:57:f8:a5:5f" + +MOCK_USER_INPUT = { + CONF_HOST: HOST, + CONF_PORT: PORT, +} + +MOCK_ZEROCONF_TVM_SERVICE_INFO_V1 = zeroconf.ZeroconfServiceInfo( + type=TVM_ZEROCONF_SERVICE_TYPE, + name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], + hostname=ZEROCONF_HOSTNAME, + port=ZEROCONF_PORT, + properties={"txtvers": "1", "model": "TVM 7675"}, +) + +MOCK_ZEROCONF_TVM_SERVICE_INFO_V2 = zeroconf.ZeroconfServiceInfo( + type=TVM_ZEROCONF_SERVICE_TYPE, + name=f"{ZEROCONF_NAME}.{TVM_ZEROCONF_SERVICE_TYPE}", + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], + hostname=ZEROCONF_HOSTNAME, + port=ZEROCONF_PORT, + properties={"mac": ZEROCONF_MAC, "txtvers": "2", "model": "TVM 7675"}, +) diff --git a/tests/components/motionmount/conftest.py b/tests/components/motionmount/conftest.py new file mode 100644 index 00000000000..8a838dac83c --- /dev/null +++ b/tests/components/motionmount/conftest.py @@ -0,0 +1,44 @@ +"""Fixtures for Vogel's MotionMount integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.motionmount.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from . import HOST, PORT, ZEROCONF_MAC, ZEROCONF_NAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=ZEROCONF_NAME, + domain=DOMAIN, + data={CONF_HOST: HOST, CONF_PORT: PORT}, + unique_id=ZEROCONF_MAC, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.motionmount.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_motionmount_config_flow() -> Generator[None, MagicMock, None]: + """Return a mocked MotionMount config flow.""" + + with patch( + "homeassistant.components.motionmount.config_flow.motionmount.MotionMount", + autospec=True, + ) as motionmount_mock: + client = motionmount_mock.return_value + yield client diff --git a/tests/components/motionmount/test_config_flow.py b/tests/components/motionmount/test_config_flow.py new file mode 100644 index 00000000000..aa7ea73b577 --- /dev/null +++ b/tests/components/motionmount/test_config_flow.py @@ -0,0 +1,488 @@ +"""Tests for the Vogel's MotionMount config flow.""" +import dataclasses +import socket +from unittest.mock import MagicMock, PropertyMock + +import motionmount +import pytest + +from homeassistant.components.motionmount.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + HOST, + MOCK_USER_INPUT, + MOCK_ZEROCONF_TVM_SERVICE_INFO_V1, + MOCK_ZEROCONF_TVM_SERVICE_INFO_V2, + PORT, + ZEROCONF_HOSTNAME, + ZEROCONF_MAC, + ZEROCONF_NAME, +) + +from tests.common import MockConfigEntry + +MAC = bytes.fromhex("c4dd57f8a55f") +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the user set up form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + +async def test_user_connection_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is an connection error.""" + mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_connection_error_invalid_hostname( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when an invalid hostname is provided.""" + mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_user_timeout_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a timeout error.""" + mock_motionmount_config_flow.connect.side_effect = TimeoutError() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "time_out" + + +async def test_user_not_connected_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a not connected error.""" + mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_connected" + + +async def test_user_response_error_single_device_old_ce_old_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow creates an entry when there is a response error.""" + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == HOST + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + + +async def test_user_response_error_single_device_new_ce_old_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow creates an entry when there is a response error.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock( + return_value=b"\x00\x00\x00\x00\x00\x00" + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + + +async def test_user_response_error_single_device_new_ce_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow creates an entry when there is a response error.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_user_response_error_multi_device_old_ce_old_new_pro( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there are multiple devices.""" + mock_config_entry.add_to_hass(hass) + + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_response_error_multi_device_new_ce_new_pro( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there are multiple devices.""" + mock_config_entry.add_to_hass(hass) + + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + user_input = MOCK_USER_INPUT.copy() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_connection_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is an connection error.""" + mock_motionmount_config_flow.connect.side_effect = ConnectionRefusedError() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_zeroconf_connection_error_invalid_hostname( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is an connection error.""" + mock_motionmount_config_flow.connect.side_effect = socket.gaierror() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_zeroconf_timout_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a timeout error.""" + mock_motionmount_config_flow.connect.side_effect = TimeoutError() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "time_out" + + +async def test_zeroconf_not_connected_error( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the flow is aborted when there is a not connected error.""" + mock_motionmount_config_flow.connect.side_effect = motionmount.NotConnectedError() + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_connected" + + +async def test_show_zeroconf_form_old_ce_old_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_show_zeroconf_form_old_ce_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + mock_motionmount_config_flow.connect.side_effect = ( + motionmount.MotionMountResponseError(motionmount.MotionMountResponse.NotFound) + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_show_zeroconf_form_new_ce_old_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + type(mock_motionmount_config_flow).mac = PropertyMock( + return_value=b"\x00\x00\x00\x00\x00\x00" + ) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V1) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_show_zeroconf_form_new_ce_new_pro( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test that the zeroconf confirmation form is served.""" + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + assert result["description_placeholders"] == {CONF_NAME: "My MotionMount"} + + +async def test_zeroconf_device_exists_abort( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test we abort zeroconf flow if device already configured.""" + mock_config_entry.add_to_hass(hass) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test the full manual user flow from start to finish.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_INPUT.copy(), + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC + + +async def test_full_zeroconf_flow_implementation( + hass: HomeAssistant, + mock_motionmount_config_flow: MagicMock, +) -> None: + """Test the full manual user flow from start to finish.""" + type(mock_motionmount_config_flow).name = PropertyMock(return_value=ZEROCONF_NAME) + type(mock_motionmount_config_flow).mac = PropertyMock(return_value=MAC) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_TVM_SERVICE_INFO_V2) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ZEROCONF_NAME + + assert result["data"] + assert result["data"][CONF_HOST] == ZEROCONF_HOSTNAME + assert result["data"][CONF_PORT] == PORT + assert result["data"][CONF_NAME] == ZEROCONF_NAME + + assert result["result"] + assert result["result"].unique_id == ZEROCONF_MAC diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 6d6c7475366..9bb5c8b2585 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -33,6 +33,7 @@ from homeassistant.components.mqtt.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from .test_common import ( help_custom_config, @@ -1130,8 +1131,9 @@ async def test_set_preset_mode_optimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "comfort" - await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) - assert "'invalid' is not a valid preset mode" in caplog.text + with pytest.raises(ServiceValidationError): + await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) + assert "'invalid' is not a valid preset mode" in caplog.text @pytest.mark.parametrize( @@ -1187,8 +1189,9 @@ async def test_set_preset_mode_explicit_optimistic( state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "comfort" - await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) - assert "'invalid' is not a valid preset mode" in caplog.text + with pytest.raises(ServiceValidationError): + await common.async_set_preset_mode(hass, "invalid", ENTITY_CLIMATE) + assert "'invalid' is not a valid preset mode" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 0664f6e8d6f..cb5ff53d7e9 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -8,6 +8,7 @@ from pathlib import Path from typing import Any from unittest.mock import ANY, MagicMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol import yaml @@ -31,6 +32,7 @@ from homeassistant.generated.mqtt import MQTT from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_mqtt_message from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient @@ -1320,9 +1322,8 @@ async def help_test_entity_debug_info_max_messages( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + with freeze_time(start_dt): for i in range(0, debug_info.STORED_MESSAGES + 1): async_fire_mqtt_message(hass, "test-topic", f"{i}") @@ -1396,7 +1397,7 @@ async def help_test_entity_debug_info_message( debug_info_data = debug_info.info_for_device(hass, device.id) - start_dt = datetime(2019, 1, 1, 0, 0, 0) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) if state_topic is not None: assert len(debug_info_data["entities"][0]["subscriptions"]) >= 1 @@ -1404,8 +1405,7 @@ async def help_test_entity_debug_info_message( "subscriptions" ] - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + with freeze_time(start_dt): async_fire_mqtt_message(hass, str(state_topic), state_payload) debug_info_data = debug_info.info_for_device(hass, device.id) @@ -1426,8 +1426,7 @@ async def help_test_entity_debug_info_message( expected_transmissions = [] if service: # Trigger an outgoing MQTT message - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt + with freeze_time(start_dt): if service: service_data = {ATTR_ENTITY_ID: f"{domain}.beer_test"} if service_parameters: diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 8db1c89bc40..df7b7a64b3d 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -1,4 +1,5 @@ """The tests for the MQTT cover platform.""" +from copy import deepcopy from typing import Any from unittest.mock import patch @@ -22,7 +23,6 @@ from homeassistant.components.mqtt.cover import ( CONF_TILT_STATUS_TEMPLATE, CONF_TILT_STATUS_TOPIC, MQTT_COVER_ATTRIBUTES_BLOCKED, - MqttCover, ) from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -196,8 +196,21 @@ async def test_opening_and_closing_state_via_custom_state_payload( } ], ) +@pytest.mark.parametrize( + ("position", "assert_state"), + [ + (0, STATE_CLOSED), + (1, STATE_OPEN), + (30, STATE_OPEN), + (99, STATE_OPEN), + (100, STATE_OPEN), + ], +) async def test_open_closed_state_from_position_optimistic( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + assert_state: str, ) -> None: """Test the state after setting the position using optimistic mode.""" await mqtt_mock_entry() @@ -208,24 +221,201 @@ async def test_open_closed_state_from_position_optimistic( await hass.services.async_call( cover.DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: 0}, + {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: position}, blocking=True, ) state = hass.states.get("cover.test") - assert state.state == STATE_CLOSED + assert state.state == assert_state assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_POSITION) == position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "position_topic": "position-topic", + "set_position_topic": "set-position-topic", + "qos": 0, + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + "optimistic": True, + "position_closed": 10, + "position_open": 90, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "assert_state"), + [ + (0, STATE_CLOSED), + (1, STATE_CLOSED), + (10, STATE_CLOSED), + (11, STATE_OPEN), + (30, STATE_OPEN), + (99, STATE_OPEN), + (100, STATE_OPEN), + ], +) +async def test_open_closed_state_from_position_optimistic_alt_positions( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + assert_state: str, +) -> None: + """Test the state after setting the position. + + Test with alt opened and closed positions using optimistic mode. + """ + await mqtt_mock_entry() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN await hass.services.async_call( cover.DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: 100}, + {ATTR_ENTITY_ID: "cover.test", ATTR_POSITION: position}, blocking=True, ) state = hass.states.get("cover.test") - assert state.state == STATE_OPEN + assert state.state == assert_state assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_POSITION) == position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "tilt_command_topic": "set-position-topic", + "qos": 0, + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + "optimistic": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("tilt_position", "tilt_toggled_position"), + [(0, 100), (1, 0), (99, 0), (100, 0)], +) +async def test_tilt_open_closed_toggle_optimistic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + tilt_position: int, + tilt_toggled_position: int, +) -> None: + """Test the tilt state after setting and toggling the tilt position. + + Test opened and closed tilt positions using optimistic mode. + """ + await mqtt_mock_entry() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test", ATTR_TILT_POSITION: tilt_position}, + blocking=True, + ) + + state = hass.states.get("cover.test") + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == tilt_position + + # toggle cover tilt + await hass.services.async_call( + cover.DOMAIN, + SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, + ) + + state = hass.states.get("cover.test") + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == tilt_toggled_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + cover.DOMAIN: { + "name": "test", + "tilt_command_topic": "set-position-topic", + "qos": 0, + "payload_open": "OPEN", + "payload_close": "CLOSE", + "payload_stop": "STOP", + "optimistic": True, + "tilt_min": 5, + "tilt_max": 95, + "tilt_closed_value": 15, + "tilt_opened_value": 85, + } + } + } + ], +) +@pytest.mark.parametrize( + ("tilt_position", "tilt_toggled_position"), + [(0, 88), (11, 88), (12, 11), (30, 11), (90, 11), (100, 11)], +) +async def test_tilt_open_closed_toggle_optimistic_alt_positions( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + tilt_position: int, + tilt_toggled_position: int, +) -> None: + """Test the tilt state after setting and toggling the tilt position. + + Test with alt opened and closed tilt positions using optimistic mode. + """ + await mqtt_mock_entry() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + cover.DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: "cover.test", ATTR_TILT_POSITION: tilt_position}, + blocking=True, + ) + + state = hass.states.get("cover.test") + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == tilt_position + + # toggle cover tilt + await hass.services.async_call( + cover.DOMAIN, + SERVICE_TOGGLE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, + ) + + state = hass.states.get("cover.test") + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == tilt_toggled_position @pytest.mark.parametrize( @@ -2235,350 +2425,6 @@ async def test_tilt_position_altered_range( ) -async def test_find_percentage_in_range_defaults(hass: HomeAssistant) -> None: - """Test find percentage in range with default range.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 100, - "position_closed": 0, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 100, - "tilt_closed_position": 0, - "tilt_min": 0, - "tilt_max": 100, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_percentage_in_range(44) == 44 - assert mqtt_cover.find_percentage_in_range(44, "cover") == 44 - - -async def test_find_percentage_in_range_altered(hass: HomeAssistant) -> None: - """Test find percentage in range with altered range.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 180, - "position_closed": 80, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 180, - "tilt_closed_position": 80, - "tilt_min": 80, - "tilt_max": 180, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_percentage_in_range(120) == 40 - assert mqtt_cover.find_percentage_in_range(120, "cover") == 40 - - -async def test_find_percentage_in_range_defaults_inverted(hass: HomeAssistant) -> None: - """Test find percentage in range with default range but inverted.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 0, - "position_closed": 100, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 100, - "tilt_closed_position": 0, - "tilt_min": 100, - "tilt_max": 0, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_percentage_in_range(44) == 56 - assert mqtt_cover.find_percentage_in_range(44, "cover") == 56 - - -async def test_find_percentage_in_range_altered_inverted(hass: HomeAssistant) -> None: - """Test find percentage in range with altered range and inverted.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 80, - "position_closed": 180, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 180, - "tilt_closed_position": 80, - "tilt_min": 180, - "tilt_max": 80, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_percentage_in_range(120) == 60 - assert mqtt_cover.find_percentage_in_range(120, "cover") == 60 - - -async def test_find_in_range_defaults(hass: HomeAssistant) -> None: - """Test find in range with default range.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 100, - "position_closed": 0, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 100, - "tilt_closed_position": 0, - "tilt_min": 0, - "tilt_max": 100, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_in_range_from_percent(44) == 44 - assert mqtt_cover.find_in_range_from_percent(44, "cover") == 44 - - -async def test_find_in_range_altered(hass: HomeAssistant) -> None: - """Test find in range with altered range.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 180, - "position_closed": 80, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 180, - "tilt_closed_position": 80, - "tilt_min": 80, - "tilt_max": 180, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_in_range_from_percent(40) == 120 - assert mqtt_cover.find_in_range_from_percent(40, "cover") == 120 - - -async def test_find_in_range_defaults_inverted(hass: HomeAssistant) -> None: - """Test find in range with default range but inverted.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 0, - "position_closed": 100, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 100, - "tilt_closed_position": 0, - "tilt_min": 100, - "tilt_max": 0, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_in_range_from_percent(56) == 44 - assert mqtt_cover.find_in_range_from_percent(56, "cover") == 44 - - -async def test_find_in_range_altered_inverted(hass: HomeAssistant) -> None: - """Test find in range with altered range and inverted.""" - mqtt_cover = MqttCover( - hass, - { - "name": "cover.test", - "state_topic": "state-topic", - "get_position_topic": None, - "command_topic": "command-topic", - "availability_topic": None, - "tilt_command_topic": "tilt-command-topic", - "tilt_status_topic": "tilt-status-topic", - "qos": 0, - "retain": False, - "state_open": "OPEN", - "state_closed": "CLOSE", - "position_open": 80, - "position_closed": 180, - "payload_open": "OPEN", - "payload_close": "CLOSE", - "payload_stop": "STOP", - "payload_available": None, - "payload_not_available": None, - "optimistic": False, - "value_template": None, - "tilt_open_position": 180, - "tilt_closed_position": 80, - "tilt_min": 180, - "tilt_max": 80, - "tilt_optimistic": False, - "set_position_topic": None, - "set_position_template": None, - "unique_id": None, - "device_config": None, - }, - None, - None, - ) - - assert mqtt_cover.find_in_range_from_percent(60) == 120 - assert mqtt_cover.find_in_range_from_percent(60, "cover") == 120 - - @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) async def test_availability_when_connection_lost( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator @@ -3582,7 +3428,7 @@ async def test_publishing_with_custom_encoding( ) -> None: """Test publishing MQTT payload with different encoding.""" domain = cover.DOMAIN - config = DEFAULT_CONFIG + config = deepcopy(DEFAULT_CONFIG) config[mqtt.DOMAIN][domain]["position_topic"] = "some-position-topic" await help_test_publishing_with_custom_encoding( diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index e178eb40c0e..1a75d61c733 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -88,6 +88,68 @@ async def test_setting_event_value_via_mqtt_message( 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]) +async def test_multiple_events_are_all_updating_the_state( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test all events are respected and trigger a state write.""" + await mqtt_mock_entry() + with patch( + "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + ) as mock_async_ha_write_state: + async_fire_mqtt_message( + hass, "test-topic", '{"event_type": "press", "duration": "short" }' + ) + assert len(mock_async_ha_write_state.mock_calls) == 1 + async_fire_mqtt_message( + hass, "test-topic", '{"event_type": "press", "duration": "short" }' + ) + assert len(mock_async_ha_write_state.mock_calls) == 2 + + +@pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) +async def test_handling_retained_event_payloads( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test if event messages with a retained flag are ignored.""" + await mqtt_mock_entry() + with patch( + "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + ) as mock_async_ha_write_state: + async_fire_mqtt_message( + hass, + "test-topic", + '{"event_type": "press", "duration": "short" }', + retain=True, + ) + assert len(mock_async_ha_write_state.mock_calls) == 0 + + async_fire_mqtt_message( + hass, + "test-topic", + '{"event_type": "press", "duration": "short" }', + retain=False, + ) + assert len(mock_async_ha_write_state.mock_calls) == 1 + + async_fire_mqtt_message( + hass, + "test-topic", + '{"event_type": "press", "duration": "short" }', + retain=True, + ) + assert len(mock_async_ha_write_state.mock_calls) == 1 + + async_fire_mqtt_message( + hass, + "test-topic", + '{"event_type": "press", "duration": "short" }', + retain=False, + ) + assert len(mock_async_ha_write_state.mock_calls) == 2 + + @pytest.mark.freeze_time("2023-08-01 00:00:00+00:00") @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index d31570548f0..98e2c9b71fe 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -8,6 +8,7 @@ import ssl from typing import Any, TypedDict from unittest.mock import ANY, MagicMock, call, mock_open, patch +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -40,6 +41,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import async_get_platforms from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from homeassistant.util.dt import utcnow from .test_common import help_all_subscribe_calls @@ -3256,6 +3258,7 @@ async def test_debug_info_wildcard( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3279,10 +3282,9 @@ async def test_debug_info_wildcard( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/abc", "123") + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/abc", "123") debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) >= 1 @@ -3304,6 +3306,7 @@ async def test_debug_info_filter_same( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info removes messages with same timestamp.""" await mqtt_mock_entry() @@ -3327,14 +3330,13 @@ async def test_debug_info_filter_same( "subscriptions" ] - dt1 = datetime(2019, 1, 1, 0, 0, 0) - dt2 = datetime(2019, 1, 1, 0, 0, 1) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = dt1 - async_fire_mqtt_message(hass, "sensor/abc", "123") - async_fire_mqtt_message(hass, "sensor/abc", "123") - dt_utcnow.return_value = dt2 - async_fire_mqtt_message(hass, "sensor/abc", "123") + dt1 = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + dt2 = datetime(2019, 1, 1, 0, 0, 1, tzinfo=dt_util.UTC) + freezer.move_to(dt1) + async_fire_mqtt_message(hass, "sensor/abc", "123") + async_fire_mqtt_message(hass, "sensor/abc", "123") + freezer.move_to(dt2) + async_fire_mqtt_message(hass, "sensor/abc", "123") debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 @@ -3364,6 +3366,7 @@ async def test_debug_info_same_topic( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3388,10 +3391,9 @@ async def test_debug_info_same_topic( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 @@ -3408,16 +3410,16 @@ async def test_debug_info_same_topic( async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) await hass.async_block_till_done() - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) async def test_debug_info_qos_retain( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mqtt_mock_entry: MqttMockHAClientGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test debug info.""" await mqtt_mock_entry() @@ -3441,19 +3443,18 @@ async def test_debug_info_qos_retain( "subscriptions" ] - start_dt = datetime(2019, 1, 1, 0, 0, 0) - with patch("homeassistant.util.dt.utcnow") as dt_utcnow: - dt_utcnow.return_value = start_dt - # simulate the first message was replayed from the broker with retained flag - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=True) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=False) - # simpulate someone else subscribed and retained messages were replayed - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=True) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=False) - # simulate an update message - async_fire_mqtt_message(hass, "sensor/abc", "123", qos=2, retain=False) + start_dt = datetime(2019, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + freezer.move_to(start_dt) + # simulate the first message was replayed from the broker with retained flag + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=True) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=False) + # simpulate someone else subscribed and retained messages were replayed + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=True) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=False) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=2, retain=False) debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 0c18881d86e..030f5a2ac9a 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -707,7 +707,7 @@ async def test_publishing_with_custom_encoding( ) -> None: """Test publishing MQTT payload with different encoding.""" domain = select.DOMAIN - config = DEFAULT_CONFIG + config = copy.deepcopy(DEFAULT_CONFIG) config[mqtt.DOMAIN][domain]["options"] = ["milk", "beer"] await help_test_publishing_with_custom_encoding( diff --git a/tests/components/mqtt/test_valve.py b/tests/components/mqtt/test_valve.py new file mode 100644 index 00000000000..e37b52f56fb --- /dev/null +++ b/tests/components/mqtt/test_valve.py @@ -0,0 +1,1507 @@ +"""The tests for the MQTT valve platform.""" +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt, valve +from homeassistant.components.mqtt.valve import ( + MQTT_VALVE_ATTRIBUTES_BLOCKED, + ValveEntityFeature, +) +from homeassistant.components.valve import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + SERVICE_SET_VALVE_POSITION, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_STOP_VALVE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant + +from .test_common import ( + help_custom_config, + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_encoding_subscribable_topics, + 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_entity_id_update_subscriptions, + 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_skipped_async_ha_write_state, + 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: { + valve.DOMAIN: { + "command_topic": "command-topic", + "state_topic": "test-topic", + "name": "test", + } + } +} + +DEFAULT_CONFIG_REPORTS_POSITION = { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "state_topic": "test-topic", + "reports_position": True, + } + } +} + + +@pytest.fixture(autouse=True) +def valve_platform_only(): + """Only setup the valve platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.VALVE]): + yield + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state"), + [ + ("open", STATE_OPEN), + ("closed", STATE_CLOSED), + ("closing", STATE_CLOSING), + ("opening", STATE_OPENING), + ('{"state" : "open"}', STATE_OPEN), + ('{"state" : "closed"}', STATE_CLOSED), + ('{"state" : "closing"}', STATE_CLOSING), + ('{"state" : "opening"}', STATE_OPENING), + ], +) +async def test_state_via_state_topic_no_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, +) -> None: + """Test the controlling state via topic without position and without template.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "value_template": "{{ value_json.state }}", + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state"), + [ + ('{"state":"open"}', STATE_OPEN), + ('{"state":"closed"}', STATE_CLOSED), + ('{"state":"closing"}', STATE_CLOSING), + ('{"state":"opening"}', STATE_OPENING), + ], +) +async def test_state_via_state_topic_with_template( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, +) -> None: + """Test the controlling state via topic with template.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + "value_template": "{{ value_json.position }}", + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state"), + [ + ('{"position":100}', STATE_OPEN), + ('{"position":50.0}', STATE_OPEN), + ('{"position":0}', STATE_CLOSED), + ('{"position":"non_numeric"}', STATE_UNKNOWN), + ('{"ignored":12}', STATE_UNKNOWN), + ], +) +async def test_state_via_state_topic_with_position_template( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, +) -> None: + """Test the controlling state via topic with position template.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state", "valve_position"), + [ + ("invalid", STATE_UNKNOWN, None), + ("0", STATE_CLOSED, 0), + ("opening", STATE_OPENING, None), + ("50", STATE_OPEN, 50), + ("closing", STATE_CLOSING, None), + ("100", STATE_OPEN, 100), + ("open", STATE_UNKNOWN, None), + ("closed", STATE_UNKNOWN, None), + ("-10", STATE_CLOSED, 0), + ("110", STATE_OPEN, 100), + ('{"position": 0, "state": "opening"}', STATE_OPENING, 0), + ('{"position": 10, "state": "opening"}', STATE_OPENING, 10), + ('{"position": 50, "state": "open"}', STATE_OPEN, 50), + ('{"position": 100, "state": "closing"}', STATE_CLOSING, 100), + ('{"position": 90, "state": "closing"}', STATE_CLOSING, 90), + ('{"position": 0, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": -10, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": 110, "state": "open"}', STATE_OPEN, 100), + ], +) +async def test_state_via_state_topic_through_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, + valve_position: int | None, +) -> None: + """Test the controlling state via topic through position. + + Test is still possible to process a `opening` or `closing` state update. + Additional we test json messages can be processed containing both position and state. + Incoming rendered positions are clamped between 0..100. + """ + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == valve_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + } + } + } + ], +) +async def test_opening_closing_state_is_reset( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the controlling state via topic through position. + + Test a `opening` or `closing` state update is reset correctly after sequential updates. + """ + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + messages = [ + ('{"position": 0, "state": "opening"}', STATE_OPENING, 0), + ('{"position": 50, "state": "opening"}', STATE_OPENING, 50), + ('{"position": 60}', STATE_OPENING, 60), + ('{"position": 100, "state": "opening"}', STATE_OPENING, 100), + ('{"position": 100, "state": null}', STATE_OPEN, 100), + ('{"position": 90, "state": "closing"}', STATE_CLOSING, 90), + ('{"position": 40}', STATE_CLOSING, 40), + ('{"position": 0}', STATE_CLOSED, 0), + ('{"position": 10}', STATE_OPEN, 10), + ('{"position": 0, "state": "opening"}', STATE_OPENING, 0), + ('{"position": 0, "state": "closing"}', STATE_CLOSING, 0), + ('{"position": 0}', STATE_CLOSED, 0), + ] + + for message, asserted_state, valve_position in messages: + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == valve_position + + +@pytest.mark.parametrize( + ("hass_config", "message", "err_message"), + [ + ( + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": False, + } + } + }, + '{"position": 0}', + "Missing required `state` attribute in json payload", + ), + ( + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + } + } + }, + '{"state": "opening"}', + "Missing required `position` attribute in json payload", + ), + ], +) +async def test_invalid_state_updates( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + message: str, + err_message: str, +) -> None: + """Test the controlling state via topic through position. + + Test a `opening` or `closing` state update is reset correctly after sequential updates. + """ + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + state = hass.states.get("valve.test") + assert err_message in caplog.text + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("message", "asserted_state", "valve_position"), + [ + ("-128", STATE_CLOSED, 0), + ("0", STATE_OPEN, 50), + ("127", STATE_OPEN, 100), + ("-130", STATE_CLOSED, 0), + ("130", STATE_OPEN, 100), + ('{"position": -128, "state": "opening"}', STATE_OPENING, 0), + ('{"position": -30, "state": "opening"}', STATE_OPENING, 38), + ('{"position": 30, "state": "open"}', STATE_OPEN, 61), + ('{"position": 127, "state": "closing"}', STATE_CLOSING, 100), + ('{"position": 100, "state": "closing"}', STATE_CLOSING, 89), + ('{"position": -128, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": -130, "state": "closed"}', STATE_CLOSED, 0), + ('{"position": 130, "state": "open"}', STATE_OPEN, 100), + ], +) +async def test_state_via_state_trough_position_with_alt_range( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + asserted_state: str, + valve_position: int | None, +) -> None: + """Test the controlling state via topic through position and an alternative range. + + Test is still possible to process a `opening` or `closing` state update. + Additional we test json messages can be processed containing both position and state. + Incoming rendered positions are clamped between 0..100. + """ + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", message) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == valve_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "SToP", + "payload_open": "OPeN", + "payload_close": "CLOsE", + } + } + } + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message"), + [ + (SERVICE_CLOSE_VALVE, "CLOsE"), + (SERVICE_OPEN_VALVE, "OPeN"), + (SERVICE_STOP_VALVE, "SToP"), + ], +) +async def tests_controling_valve_by_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, +) -> None: + """Test controlling a valve by state.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("hass_config", "supported_features"), + [ + (DEFAULT_CONFIG, ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ({"payload_open": "OPEN", "payload_close": "CLOSE"},), + ), + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, + ), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ({"payload_open": "OPEN", "payload_close": None},), + ), + ValveEntityFeature.OPEN, + ), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ({"payload_open": None, "payload_close": "CLOSE"},), + ), + ValveEntityFeature.CLOSE, + ), + ( + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG, ({"payload_stop": "STOP"},) + ), + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP, + ), + ( + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG_REPORTS_POSITION, + ({"payload_stop": "STOP"},), + ), + ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP + | ValveEntityFeature.SET_POSITION, + ), + ], +) +async def tests_supported_features( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + supported_features: ValveEntityFeature, +) -> None: + """Test the valve's supported features.""" + assert await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state is not None + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == supported_features + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"payload_open": "OPEN"},) + ), + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"payload_close": "CLOSE"},) + ), + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"state_open": "open"},) + ), + help_custom_config( + valve.DOMAIN, DEFAULT_CONFIG_REPORTS_POSITION, ({"state_closed": "closed"},) + ), + ], +) +async def tests_open_close_payload_config_not_allowed( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test open or close payload configs fail if valve reports position.""" + assert await mqtt_mock_entry() + + assert hass.states.get("valve.test") is None + + assert ( + "Options `payload_open`, `payload_close`, `state_open` and " + "`state_closed` are not allowed if the valve reports a position." in caplog.text + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "STOP", + "optimistic": True, + } + } + }, + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "payload_stop": "STOP", + } + } + }, + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message", "asserted_state"), + [ + (SERVICE_CLOSE_VALVE, "CLOSE", STATE_CLOSED), + (SERVICE_OPEN_VALVE, "OPEN", STATE_OPEN), + ], +) +async def tests_controling_valve_by_state_optimistic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, + asserted_state: str, +) -> None: + """Test controlling a valve by state explicit and implicit optimistic.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message"), + [ + (SERVICE_CLOSE_VALVE, "0"), + (SERVICE_OPEN_VALVE, "100"), + (SERVICE_STOP_VALVE, "-1"), + ], +) +async def tests_controling_valve_by_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, +) -> None: + """Test controlling a valve by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message"), + [ + (0, "0"), + (30, "30"), + (100, "100"), + ], +) +async def tests_controling_valve_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, +) -> None: + """Test controlling a valve by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + "optimistic": True, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message", "asserted_position", "asserted_state"), + [ + (0, "0", 0, STATE_CLOSED), + (30, "30", 30, STATE_OPEN), + (100, "100", 100, STATE_OPEN), + ], +) +async def tests_controling_valve_optimistic_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, + asserted_position: int, + asserted_state: str, +) -> None: + """Test controlling a valve optimistic by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == asserted_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message"), + [ + (0, "-128"), + (30, "-52"), + (80, "76"), + (100, "127"), + ], +) +async def tests_controling_valve_with_alt_range_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, +) -> None: + """Test controlling a valve with an alt range by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "reports_position": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message"), + [ + (SERVICE_CLOSE_VALVE, "-128"), + (SERVICE_OPEN_VALVE, "127"), + ], +) +async def tests_controling_valve_with_alt_range_by_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, +) -> None: + """Test controlling a valve with an alt range by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "STOP", + "optimistic": True, + "reports_position": True, + } + } + }, + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "command_topic": "command-topic", + "payload_stop": "STOP", + "reports_position": True, + } + } + }, + ], +) +@pytest.mark.parametrize( + ("service", "asserted_message", "asserted_state", "asserted_position"), + [ + (SERVICE_CLOSE_VALVE, "0", STATE_CLOSED, 0), + (SERVICE_OPEN_VALVE, "100", STATE_OPEN, 100), + ], +) +async def tests_controling_valve_by_position_optimistic( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + service: str, + asserted_message: str, + asserted_state: str, + asserted_position: int, +) -> None: + """Test controlling a valve by state explicit and implicit optimistic.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_CURRENT_POSITION) is None + + await hass.services.async_call( + valve.DOMAIN, + service, + {ATTR_ENTITY_ID: "valve.test"}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes[ATTR_CURRENT_POSITION] == asserted_position + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_stop": "-1", + "reports_position": True, + "optimistic": True, + "position_closed": -128, + "position_open": 127, + } + } + } + ], +) +@pytest.mark.parametrize( + ("position", "asserted_message", "asserted_position", "asserted_state"), + [ + (0, "-128", 0, STATE_CLOSED), + (30, "-52", 30, STATE_OPEN), + (50, "0", 50, STATE_OPEN), + (100, "127", 100, STATE_OPEN), + ], +) +async def tests_controling_valve_optimistic_alt_trange_by_set_valve_position( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + position: int, + asserted_message: str, + asserted_position: int, + asserted_state: str, +) -> None: + """Test controlling a valve optimistic and alt range by position.""" + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + valve.DOMAIN, + SERVICE_SET_VALVE_POSITION, + {ATTR_ENTITY_ID: "valve.test", ATTR_POSITION: position}, + blocking=True, + ) + + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", asserted_message, 0, False + ) + + state = hass.states.get("valve.test") + assert state.state == asserted_state + assert state.attributes.get(ATTR_CURRENT_POSITION) == asserted_position + + +@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, valve.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, valve.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, valve.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, valve.DOMAIN, DEFAULT_CONFIG + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "device_class": "water", + "state_topic": "test-topic", + } + } + } + ], +) +async def test_valid_device_class( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test the setting of a valid device class.""" + await mqtt_mock_entry() + + state = hass.states.get("valve.test") + assert state.attributes.get("device_class") == "water" + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: { + "name": "test", + "device_class": "abc123", + "state_topic": "test-topic", + } + } + } + ], +) +async def test_invalid_device_class( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the setting of an invalid device class.""" + assert await mqtt_mock_entry() + assert "expected ValveDeviceClass" 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, valve.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, + valve.DOMAIN, + DEFAULT_CONFIG, + MQTT_VALVE_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, valve.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, + valve.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, + valve.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, + valve.DOMAIN, + DEFAULT_CONFIG, + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + valve.DOMAIN: [ + { + "name": "Test 1", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "name": "Test 2", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + } + ], +) +async def test_unique_id( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test unique_id option only creates one valve per id.""" + await help_test_unique_id(hass, mqtt_mock_entry, valve.DOMAIN) + + +async def test_discovery_removal_valve( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test removal of discovered valve.""" + data = '{ "name": "test", "command_topic": "test_topic" }' + await help_test_discovery_removal(hass, mqtt_mock_entry, caplog, valve.DOMAIN, data) + + +async def test_discovery_update_valve( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered valve.""" + config1 = {"name": "Beer", "command_topic": "test_topic"} + config2 = {"name": "Milk", "command_topic": "test_topic"} + await help_test_discovery_update( + hass, mqtt_mock_entry, caplog, valve.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_valve( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test update of discovered valve.""" + data1 = '{ "name": "Beer", "command_topic": "test_topic" }' + with patch( + "homeassistant.components.mqtt.valve.MqttValve.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock_entry, + caplog, + valve.DOMAIN, + data1, + discovery_update, + ) + + +@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", "command_topic": "test_topic#" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic" }' + await help_test_discovery_broken( + hass, mqtt_mock_entry, caplog, valve.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT valve device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry, valve.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test MQTT valve device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry, valve.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, valve.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, valve.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, valve.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, valve.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, + valve.DOMAIN, + DEFAULT_CONFIG, + SERVICE_OPEN_VALVE, + command_payload="OPEN", + ) + + +@pytest.mark.parametrize( + ("service", "topic", "parameters", "payload", "template"), + [ + ( + SERVICE_OPEN_VALVE, + "command_topic", + None, + "OPEN", + 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 = valve.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, +) -> None: + """Test reloading the MQTT platform.""" + domain = valve.DOMAIN + config = DEFAULT_CONFIG + await help_test_reloadable(hass, mqtt_client_mock, domain, config) + + +@pytest.mark.parametrize( + ("topic", "value", "attribute", "attribute_value"), + [ + ("state_topic", "open", None, None), + ("state_topic", "closing", None, None), + ], +) +async def test_encoding_subscribable_topics( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + value: str, + attribute: str | None, + attribute_value: Any, +) -> None: + """Test handling of incoming encoded payload.""" + await help_test_encoding_subscribable_topics( + hass, + mqtt_mock_entry, + valve.DOMAIN, + DEFAULT_CONFIG[mqtt.DOMAIN][valve.DOMAIN], + topic, + value, + attribute, + attribute_value, + skip_raw_test=True, + ) + + +@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 = valve.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 = valve.DOMAIN + config = DEFAULT_CONFIG + await help_test_unload_config_entry_with_platform( + hass, mqtt_mock_entry, domain, config + ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + valve.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "state_topic": "test-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "open", "closed"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """Test a write state command is only called when there is change.""" + await mqtt_mock_entry() + await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 64fbb61aac3..6df50f04ae2 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -16,12 +16,12 @@ from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.components.mysensors.config_flow import DEFAULT_BAUD_RATE from homeassistant.components.mysensors.const import ( CONF_BAUD_RATE, - CONF_DEVICE, CONF_GATEWAY_TYPE, CONF_GATEWAY_TYPE_SERIAL, CONF_VERSION, DOMAIN, ) +from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/mysensors/test_config_flow.py b/tests/components/mysensors/test_config_flow.py index dc24a48edd4..bff13d1604f 100644 --- a/tests/components/mysensors/test_config_flow.py +++ b/tests/components/mysensors/test_config_flow.py @@ -9,7 +9,6 @@ import pytest from homeassistant import config_entries from homeassistant.components.mysensors.const import ( CONF_BAUD_RATE, - CONF_DEVICE, CONF_GATEWAY_TYPE, CONF_GATEWAY_TYPE_MQTT, CONF_GATEWAY_TYPE_SERIAL, @@ -23,6 +22,7 @@ from homeassistant.components.mysensors.const import ( DOMAIN, ConfGatewayType, ) +from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult, FlowResultType diff --git a/tests/components/nest/test_camera.py b/tests/components/nest/test_camera.py index 56c5bedaf0d..647a3419501 100644 --- a/tests/components/nest/test_camera.py +++ b/tests/components/nest/test_camera.py @@ -8,6 +8,7 @@ from http import HTTPStatus from unittest.mock import AsyncMock, Mock, patch import aiohttp +from freezegun import freeze_time from google_nest_sdm.event import EventMessage import pytest @@ -173,7 +174,7 @@ async def async_get_image(hass, width=None, height=None): async def fire_alarm(hass, point_in_time): """Fire an alarm and wait for callbacks to run.""" - with patch("homeassistant.util.dt.utcnow", return_value=point_in_time): + with freeze_time(point_in_time): async_fire_time_changed(hass, point_in_time) await hass.async_block_till_done() diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index c920eb5717d..e1c3cc187db 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -39,7 +39,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .common import ( DEVICE_COMMAND, @@ -1192,7 +1192,7 @@ async def test_thermostat_invalid_fan_mode( assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await common.async_set_fan_mode(hass, FAN_LOW) await hass.async_block_till_done() @@ -1474,7 +1474,7 @@ async def test_thermostat_invalid_set_preset_mode( assert thermostat.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_NONE] # Set preset mode that is invalid - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await common.async_set_preset_mode(hass, PRESET_SLEEP) await hass.async_block_till_done() diff --git a/tests/components/netatmo/snapshots/test_diagnostics.ambr b/tests/components/netatmo/snapshots/test_diagnostics.ambr index 0dd424ec7d8..f1c54901445 100644 --- a/tests/components/netatmo/snapshots/test_diagnostics.ambr +++ b/tests/components/netatmo/snapshots/test_diagnostics.ambr @@ -590,6 +590,7 @@ }), 'disabled_by': None, 'domain': 'netatmo', + 'minor_version': 1, 'options': dict({ 'weather_areas': dict({ 'Home avg': dict({ diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index 848aad331bd..11e2077f859 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -33,6 +33,7 @@ from homeassistant.components.netatmo.const import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.util import dt as dt_util from .common import selected_platforms, simulate_webhook @@ -879,15 +880,14 @@ async def test_service_preset_mode_invalid( await hass.async_block_till_done() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.cocina", ATTR_PRESET_MODE: "invalid"}, - blocking=True, - ) - await hass.async_block_till_done() - - assert "Preset mode 'invalid' not available" in caplog.text + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "climate.cocina", ATTR_PRESET_MODE: "invalid"}, + blocking=True, + ) + await hass.async_block_till_done() async def test_valves_service_turn_off( diff --git a/tests/components/netgear_lte/__init__.py b/tests/components/netgear_lte/__init__.py new file mode 100644 index 00000000000..6661c92312e --- /dev/null +++ b/tests/components/netgear_lte/__init__.py @@ -0,0 +1 @@ +"""Tests for the Netgear LTE component.""" diff --git a/tests/components/netgear_lte/conftest.py b/tests/components/netgear_lte/conftest.py new file mode 100644 index 00000000000..e32034d660b --- /dev/null +++ b/tests/components/netgear_lte/conftest.py @@ -0,0 +1,85 @@ +"""Configure pytest for Netgear LTE tests.""" +from __future__ import annotations + +from aiohttp.client_exceptions import ClientError +import pytest + +from homeassistant.components.netgear_lte.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONTENT_TYPE_JSON +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +HOST = "192.168.5.1" +PASSWORD = "password" + +CONF_DATA = {CONF_HOST: HOST, CONF_PASSWORD: PASSWORD} + + +@pytest.fixture +def cannot_connect(aioclient_mock: AiohttpClientMocker) -> None: + """Mock cannot connect error.""" + aioclient_mock.get(f"http://{HOST}/model.json", exc=ClientError) + aioclient_mock.post(f"http://{HOST}/Forms/config", exc=ClientError) + + +@pytest.fixture +def unknown(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Netgear LTE unknown error.""" + aioclient_mock.get( + f"http://{HOST}/model.json", + text="something went wrong", + headers={"Content-Type": "application/javascript"}, + ) + + +@pytest.fixture(name="connection") +def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Netgear LTE connection.""" + aioclient_mock.get( + f"http://{HOST}/model.json", + text=load_fixture("netgear_lte/model.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.post( + f"http://{HOST}/Forms/config", + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + aioclient_mock.post( + f"http://{HOST}/Forms/smsSendMsg", + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create Netgear LTE entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, data=CONF_DATA, unique_id="FFFFFFFFFFFFF", title="Netgear LM1200" + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + config_entry: MockConfigEntry, + connection: None, +) -> None: + """Set up the Netgear LTE integration in Home Assistant.""" + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + +@pytest.fixture(name="setup_cannot_connect") +async def setup_cannot_connect( + hass: HomeAssistant, + config_entry: MockConfigEntry, + cannot_connect: None, +) -> None: + """Set up the Netgear LTE integration in Home Assistant.""" + config_entry.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() diff --git a/tests/components/netgear_lte/fixtures/model.json b/tests/components/netgear_lte/fixtures/model.json new file mode 100644 index 00000000000..c5f4a13f3af --- /dev/null +++ b/tests/components/netgear_lte/fixtures/model.json @@ -0,0 +1,450 @@ +{ + "custom": { "AtTcpEnable": false, "end": 0 }, + "webd": { + "adminPassword": "****************", + "ownerModeEnabled": false, + "hideAdminPassword": true, + "end": "" + }, + "lcd": { "end": "" }, + "sim": { + "pin": { "mode": "Disabled", "retry": 3, "end": "" }, + "puk": { "retry": 10 }, + "mep": {}, + "phoneNumber": "(555) 555-5555", + "iccid": "1234567890123456789", + "imsi": "123456789012345", + "SPN": "", + "status": "Ready", + "end": "" + }, + "sms": { + "ready": true, + "sendEnabled": true, + "sendSupported": true, + "alertSupported": true, + "alertEnabled": false, + "alertNumList": "", + "alertCfgList": [ + { "category": "FWUpdate", "enabled": false }, + { "category": "DataUsageWarning", "enabled": false }, + { "category": "DataUsageExceeded", "enabled": false }, + { "category": "LTEFailoverLTE", "enabled": false }, + { "category": "LTEFailoverETH", "enabled": false }, + {} + ], + "unreadMsgs": 1, + "msgCount": 1, + "msgs": [ + { + "id": "1", + "rxTime": "20/01/23 03:39:35 PM", + "text": "text", + "sender": "889", + "read": false + }, + {} + ], + "trans": [{}], + "sendMsg": [ + { + "clientId": "eternalegypt.eternalegypt", + "enc": "Gsm7Bit", + "errorCode": 0, + "msgId": 1, + "receiver": "+15555555555", + "status": "Succeeded", + "text": "test SMS from Home Assistant", + "txTime": "1367252824" + }, + {} + ], + "end": "" + }, + "session": { + "userRole": "Admin", + "lang": "en", + "secToken": "secret" + }, + "general": { + "defaultLanguage": "en", + "PRIid": "12345678", + "genericResetStatus": "NotStarted", + "manufacturer": "Netgear", + "model": "LM1200", + "HWversion": "1.0", + "FWversion": "EC25AFFDR07A09M4G", + "appVersion": "NTG9X07C_20.06.09.00", + "buildDate": "Unknown", + "BLversion": "", + "PRIversion": "04.19", + "IMEI": "123456789012345", + "SVN": "9", + "MEID": "", + "ESN": "0", + "FSN": "FFFFFFFFFFFFF", + "activated": true, + "webAppVersion": "LM1200-HDATA_03.03.103.201", + "HIDenabled": false, + "TCAaccepted": true, + "LEDenabled": true, + "showAdvHelp": true, + "keyLockState": "Unlocked", + "devTemperature": 30, + "verMajor": 1000, + "verMinor": 0, + "environment": "Application", + "currTime": 1367257216, + "timeZoneOffset": -14400, + "deviceName": "LM1200", + "useMetricSystem": true, + "factoryResetStatus": "NotStarted", + "setupCompleted": true, + "languageSelected": false, + "systemAlertList": { "list": [{}], "count": 0 }, + "apiVersion": "2.0", + "companyName": "NETGEAR", + "configURL": "/Forms/config", + "profileURL": "/Forms/profile", + "pinChangeURL": "/Forms/pinChange", + "portCfgURL": "/Forms/portCfg", + "portFilterURL": "/Forms/portFilter", + "wifiACLURL": "/Forms/wifiACL", + "supportedLangList": [ + { + "id": "en", + "isCurrent": "true", + "isDefault": "true", + "label": "English", + "token1": "/romfs/lcd/en_us.tr", + "token2": "" + }, + { + "id": "de_DE", + "isCurrent": "false", + "isDefault": "false", + "label": "Deutsch (German)", + "token1": "/romfs/lcd/de_de.tr", + "token2": "" + }, + { + "id": "ar_AR", + "isCurrent": "false", + "isDefault": "false", + "label": "العربية (Arabic)", + "token1": "/romfs/lcd/ar_AR.tr", + "token2": "" + }, + { + "id": "es_ES", + "isCurrent": "false", + "isDefault": "false", + "label": "Español (Spanish)", + "token1": "/romfs/lcd/es_es.tr", + "token2": "" + }, + { + "id": "fr_FR", + "isCurrent": "false", + "isDefault": "false", + "label": "Français (French)", + "token1": "/romfs/lcd/fr_fr.tr", + "token2": "" + }, + { + "id": "it_IT", + "isCurrent": "false", + "isDefault": "false", + "label": "Italiano (Italian)", + "token1": "/romfs/lcd/it_it.tr", + "token2": "" + }, + { + "id": "pl_PL", + "isCurrent": "false", + "isDefault": "false", + "label": "Polski (Polish)", + "token1": "/romfs/lcd/pl_pl.tr", + "token2": "" + }, + { + "id": "fi_FI", + "isCurrent": "false", + "isDefault": "false", + "label": "Suomi (Finnish)", + "token1": "/romfs/lcd/fi_fi.tr", + "token2": "" + }, + { + "id": "sv_SE", + "isCurrent": "false", + "isDefault": "false", + "label": "Svenska (Swedish)", + "token1": "/romfs/lcd/sv_se.tr", + "token2": "" + }, + { + "id": "tu_TU", + "isCurrent": "false", + "isDefault": "false", + "label": "Türkçe (Turkish)", + "token1": "/romfs/lcd/tu_tu.tr", + "token2": "" + }, + {} + ] + }, + "power": { + "PMState": "Init", + "SmState": "Online", + "autoOff": { + "onUSBdisconnect": { "enable": false, "countdownTimer": 0, "end": "" }, + "onIdle": { "timer": { "onAC": 0, "onBattery": 0, "end": "" } } + }, + "standby": { + "onIdle": { + "timer": { "onAC": 0, "onBattery": 600, "onUSB": 0, "end": "" } + } + }, + "autoOn": { "enable": true, "end": "" }, + "buttonHoldTime": 3, + "deviceTempCritical": false, + "resetreason": 16, + "resetRequired": "NoResetRequired", + "lpm": false, + "end": "" + }, + "wwan": { + "netScanStatus": "NotStarted", + "inactivityCause": 307, + "currentNWserviceType": "LteService", + "registerRejectCode": 0, + "netSelEnabled": "Enabled", + "netRegMode": "Auto", + "IPv6": "1234:abcd::1234:abcd", + "roaming": false, + "IP": "10.0.0.5", + "registerNetworkDisplay": "T-Mobile", + "RAT": "Only4G", + "bandRegion": [ + { "index": 0, "name": "Auto", "current": false }, + { "index": 1, "name": "LTE Only", "current": true }, + { "index": 2, "name": "WCDMA Only", "current": false }, + {} + ], + "autoconnect": "HomeNetwork", + "profileList": [ + { + "index": 1, + "id": "T-Mobile 9", + "name": "T Mobile", + "apn": "fast.t-mobile.com", + "username": "", + "password": "", + "authtype": "None", + "ipaddr": "0.0.0.0", + "type": "IPV4V6", + "pdproamingtype": "IPV4" + }, + { + "index": 2, + "id": "Mint", + "name": "Mint", + "apn": "wholesale", + "username": "", + "password": "", + "authtype": "None", + "ipaddr": "0.0.0.0", + "type": "IPV4V6", + "pdproamingtype": "IPV4" + }, + {} + ], + "profile": { + "default": "T-Mobile 9", + "defaultLTE": "T-Mobile 9", + "full": false, + "promptForApnSelection": false, + "end": "" + }, + "dataUsage": { + "total": { + "lteBillingTx": 0, + "lteBillingRx": 0, + "cdmaBillingTx": 0, + "cdmaBillingRx": 0, + "gwBillingTx": 0, + "gwBillingRx": 0, + "lteLifeTx": 0, + "lteLifeRx": 0, + "cdmaLifeTx": 0, + "cdmaLifeRx": 0, + "gwLifeTx": 0, + "gwLifeRx": 0, + "end": "" + }, + "server": { "accountType": "", "subAccountType": "", "end": "" }, + "serverDataRemaining": 0, + "serverDataTransferred": 0, + "serverDataTransferredIntl": 0, + "serverDataValidState": "Invalid", + "serverDaysLeft": 0, + "serverErrorCode": "", + "serverLowBalance": false, + "serverMsisdn": "", + "serverRechargeUrl": "", + "dataWarnEnable": true, + "prepaidAccountState": "Hot", + "accountType": "Unknown", + "share": { + "enabled": false, + "dataTransferredOthers": 0, + "lastSync": "0", + "end": "" + }, + "generic": { + "dataLimitValid": false, + "usageHighWarning": 80, + "lastSucceeded": "0", + "billingDay": 1, + "nextBillingDate": "1369627200", + "lastSync": "0", + "billingCycleRemainder": 27, + "billingCycleLimit": 0, + "dataTransferred": 42484315, + "dataTransferredRoaming": 0, + "lastReset": "1366948800", + "end": "" + } + }, + "netManualNoCvg": false, + "connection": "Connected", + "connectionType": "IPv4AndIPv6", + "currentPSserviceType": "LTE", + "ca": { "end": "" }, + "connectionText": "4G", + "sessDuration": 4282, + "sessStartTime": 1367252934, + "dataTransferred": { "totalb": "345036", "rxb": "184700", "txb": "160336" }, + "signalStrength": { + "rssi": 0, + "rscp": 0, + "ecio": 0, + "rsrp": -113, + "rsrq": -20, + "bars": 2, + "sinr": 0, + "end": "" + } + }, + "wwanadv": { + "curBand": "LTE B4", + "radioQuality": 52, + "country": "USA", + "RAC": 0, + "LAC": 12345, + "MCC": "123", + "MNC": "456", + "MNCFmt": 3, + "cellId": 12345678, + "chanId": 2300, + "primScode": -1, + "plmnSrvErrBitMask": 0, + "chanIdUl": 20300, + "txLevel": 4, + "rxLevel": -113, + "end": "" + }, + "ethernet": { + "offload": { "ipv4Addr": "0.0.0.0", "ipv6Addr": "", "end": "" } + }, + "wifi": { + "enabled": true, + "maxClientSupported": 0, + "maxClientLimit": 0, + "maxClientCnt": 0, + "channel": 0, + "hiddenSSID": true, + "passPhrase": "", + "RTSthreshold": 0, + "fragThreshold": 0, + "SSID": "", + "clientCount": 0, + "country": "", + "wps": { "supported": "Disabled", "end": "" }, + "guest": { + "maxClientCnt": 0, + "enabled": false, + "SSID": "", + "passPhrase": "", + "generatePassphrase": false, + "hiddenSSID": true, + "chan": 0, + "DHCP": { "range": { "end": "" } } + }, + "offload": { "end": "" }, + "end": "" + }, + "router": { + "gatewayIP": "192.168.5.1", + "DMZaddress": "192.168.5.4", + "DMZenabled": false, + "forceSetup": false, + "DHCP": { + "serverEnabled": true, + "DNS1": "1.1.1.1", + "DNS2": "1.1.2.2", + "DNSmode": "Auto", + "USBpcIP": "0.0.0.0", + "leaseTime": 43200, + "range": { "high": "192.168.5.99", "low": "192.168.5.20", "end": "" } + }, + "usbMode": "None", + "usbNetworkTethering": true, + "portFwdEnabled": false, + "portFwdList": [{}], + "portFilteringEnabled": false, + "portFilteringMode": "None", + "portFilterWhiteList": [{}], + "portFilterBlackList": [{}], + "hostName": "routerlogin", + "domainName": "net", + "ipPassThroughEnabled": false, + "ipPassThroughSupported": true, + "Ipv6Supported": true, + "UPNPsupported": false, + "UPNPenabled": false, + "clientList": { "list": [{}], "count": 0 }, + "end": "" + }, + "fota": { + "fwupdater": { + "available": false, + "chkallow": true, + "chkstatus": "Initial", + "dloadProg": 0, + "error": false, + "lastChkDate": 1367200419, + "state": "NoNewFw", + "isPostponable": false, + "statusCode": 200, + "chkTimeLeft": 0, + "dloadSize": 0, + "end": "" + } + }, + "failover": { + "mode": "Auto", + "backhaul": "LTE", + "supported": true, + "monitorPeriod": 10, + "wanConnected": false, + "keepaliveEnable": false, + "keepaliveSleep": 15, + "ipv4Targets": [{ "id": "0", "string": "8.8.8.8" }, {}], + "ipv6Targets": [{}], + "end": "" + }, + "eventlog": { "level": 0, "end": 0 }, + "ui": { "serverDaysLeftHide": false, "promptActivation": true, "end": 0 } +} diff --git a/tests/components/netgear_lte/test_binary_sensor.py b/tests/components/netgear_lte/test_binary_sensor.py new file mode 100644 index 00000000000..8ed43c8c887 --- /dev/null +++ b/tests/components/netgear_lte/test_binary_sensor.py @@ -0,0 +1,19 @@ +"""The tests for Netgear LTE binary sensor platform.""" +import pytest + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + + +@pytest.mark.usefixtures("setup_integration", "entity_registry_enabled_by_default") +async def test_binary_sensors(hass: HomeAssistant) -> None: + """Test for successfully setting up the Netgear LTE binary sensor platform.""" + state = hass.states.get("binary_sensor.netgear_lte_mobile_connected") + assert state.state == STATE_ON + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CONNECTIVITY + state = hass.states.get("binary_sensor.netgear_lte_wire_connected") + assert state.state == STATE_OFF + assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.CONNECTIVITY + state = hass.states.get("binary_sensor.netgear_lte_roaming") + assert state.state == STATE_OFF diff --git a/tests/components/netgear_lte/test_config_flow.py b/tests/components/netgear_lte/test_config_flow.py new file mode 100644 index 00000000000..97a624a14e7 --- /dev/null +++ b/tests/components/netgear_lte/test_config_flow.py @@ -0,0 +1,110 @@ +"""Test Netgear LTE config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.netgear_lte.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_SOURCE +from homeassistant.core import HomeAssistant + +from .conftest import CONF_DATA + + +def _patch_setup(): + return patch( + "homeassistant.components.netgear_lte.async_setup_entry", return_value=True + ) + + +async def test_flow_user_form(hass: HomeAssistant, connection: None) -> None: + """Test that the user set up form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with _patch_setup(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Netgear LM1200" + assert result["data"] == CONF_DATA + assert result["context"]["unique_id"] == "FFFFFFFFFFFFF" + + +@pytest.mark.parametrize("source", (SOURCE_USER, SOURCE_IMPORT)) +async def test_flow_already_configured( + hass: HomeAssistant, setup_integration: None, source: str +) -> None: + """Test config flow aborts when already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: source}, + data=CONF_DATA, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_cannot_connect( + hass: HomeAssistant, cannot_connect: None +) -> None: + """Test connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=CONF_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "cannot_connect" + + +async def test_flow_user_unknown_error(hass: HomeAssistant, unknown: None) -> None: + """Test unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "unknown" + + +async def test_flow_import(hass: HomeAssistant, connection: None) -> None: + """Test import step.""" + with _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=CONF_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Netgear LM1200" + assert result["data"] == CONF_DATA + + +async def test_flow_import_failure(hass: HomeAssistant, cannot_connect: None) -> None: + """Test import step failure.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_IMPORT}, + data=CONF_DATA, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" diff --git a/tests/components/netgear_lte/test_init.py b/tests/components/netgear_lte/test_init.py new file mode 100644 index 00000000000..7c48d9d87d2 --- /dev/null +++ b/tests/components/netgear_lte/test_init.py @@ -0,0 +1,28 @@ +"""Test Netgear LTE integration.""" +from homeassistant.components.netgear_lte.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import CONF_DATA + + +async def test_setup_unload(hass: HomeAssistant, setup_integration: None) -> None: + """Test setup and unload.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.state == ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.data == CONF_DATA + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_not_ready( + hass: HomeAssistant, setup_cannot_connect: None +) -> None: + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/netgear_lte/test_notify.py b/tests/components/netgear_lte/test_notify.py new file mode 100644 index 00000000000..12d906138c3 --- /dev/null +++ b/tests/components/netgear_lte/test_notify.py @@ -0,0 +1,29 @@ +"""The tests for the Netgear LTE notify platform.""" +from unittest.mock import patch + +from homeassistant.components.notify import ( + ATTR_MESSAGE, + ATTR_TARGET, + DOMAIN as NOTIFY_DOMAIN, +) +from homeassistant.core import HomeAssistant + +ICON_PATH = "/some/path" +MESSAGE = "one, two, testing, testing" + + +async def test_notify(hass: HomeAssistant, setup_integration: None) -> None: + """Test sending a message.""" + assert hass.services.has_service(NOTIFY_DOMAIN, "netgear_lm1200") + + with patch("homeassistant.components.netgear_lte.eternalegypt.Modem.sms") as mock: + await hass.services.async_call( + NOTIFY_DOMAIN, + "netgear_lm1200", + { + ATTR_MESSAGE: MESSAGE, + ATTR_TARGET: "5555555556", + }, + blocking=True, + ) + assert len(mock.mock_calls) == 1 diff --git a/tests/components/netgear_lte/test_sensor.py b/tests/components/netgear_lte/test_sensor.py new file mode 100644 index 00000000000..8682af9a5c3 --- /dev/null +++ b/tests/components/netgear_lte/test_sensor.py @@ -0,0 +1,56 @@ +"""The tests for Netgear LTE sensor platform.""" +import pytest + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfInformation, +) +from homeassistant.core import HomeAssistant + + +@pytest.mark.usefixtures("setup_integration", "entity_registry_enabled_by_default") +async def test_sensors(hass: HomeAssistant) -> None: + """Test for successfully setting up the Netgear LTE sensor platform.""" + state = hass.states.get("sensor.netgear_lte_cell_id") + assert state.state == "12345678" + state = hass.states.get("sensor.netgear_lte_connection_text") + assert state.state == "4G" + state = hass.states.get("sensor.netgear_lte_connection_type") + assert state.state == "IPv4AndIPv6" + state = hass.states.get("sensor.netgear_lte_current_band") + assert state.state == "LTE B4" + state = hass.states.get("sensor.netgear_lte_current_ps_service_type") + assert state.state == "LTE" + state = hass.states.get("sensor.netgear_lte_radio_quality") + assert state.state == "52" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE + state = hass.states.get("sensor.netgear_lte_register_network_display") + assert state.state == "T-Mobile" + state = hass.states.get("sensor.netgear_lte_rx_level") + assert state.state == "-113" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == SIGNAL_STRENGTH_DECIBELS_MILLIWATT + ) + state = hass.states.get("sensor.netgear_lte_sms") + assert state.state == "1" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "unread" + state = hass.states.get("sensor.netgear_lte_sms_total") + assert state.state == "1" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "messages" + state = hass.states.get("sensor.netgear_lte_tx_level") + assert state.state == "4" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == SIGNAL_STRENGTH_DECIBELS_MILLIWATT + ) + state = hass.states.get("sensor.netgear_lte_upstream") + assert state.state == "LTE" + state = hass.states.get("sensor.netgear_lte_usage") + assert state.state == "40.5" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfInformation.MEBIBYTES + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.DATA_SIZE diff --git a/tests/components/netgear_lte/test_services.py b/tests/components/netgear_lte/test_services.py new file mode 100644 index 00000000000..5c5c33be980 --- /dev/null +++ b/tests/components/netgear_lte/test_services.py @@ -0,0 +1,55 @@ +"""Services tests for the Netgear LTE integration.""" +from unittest.mock import patch + +from homeassistant.components.netgear_lte.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from .conftest import HOST + + +async def test_set_option(hass: HomeAssistant, setup_integration: None) -> None: + """Test service call set option.""" + with patch( + "homeassistant.components.netgear_lte.eternalegypt.Modem.set_failover_mode" + ) as mock_client: + await hass.services.async_call( + DOMAIN, + "set_option", + {CONF_HOST: HOST, "failover": "auto", "autoconnect": "home"}, + blocking=True, + ) + assert len(mock_client.mock_calls) == 1 + + with patch( + "homeassistant.components.netgear_lte.eternalegypt.Modem.connect_lte" + ) as mock_client: + await hass.services.async_call( + DOMAIN, + "connect_lte", + {CONF_HOST: HOST}, + blocking=True, + ) + assert len(mock_client.mock_calls) == 1 + + with patch( + "homeassistant.components.netgear_lte.eternalegypt.Modem.disconnect_lte" + ) as mock_client: + await hass.services.async_call( + DOMAIN, + "disconnect_lte", + {CONF_HOST: HOST}, + blocking=True, + ) + assert len(mock_client.mock_calls) == 1 + + with patch( + "homeassistant.components.netgear_lte.eternalegypt.Modem.delete_sms" + ) as mock_client: + await hass.services.async_call( + DOMAIN, + "delete_sms", + {CONF_HOST: HOST, "sms_id": 1}, + blocking=True, + ) + assert len(mock_client.mock_calls) == 1 diff --git a/tests/components/nextbus/test_config_flow.py b/tests/components/nextbus/test_config_flow.py index 9f427757183..0b67f817eb2 100644 --- a/tests/components/nextbus/test_config_flow.py +++ b/tests/components/nextbus/test_config_flow.py @@ -5,13 +5,8 @@ from unittest.mock import MagicMock, patch import pytest from homeassistant import config_entries, setup -from homeassistant.components.nextbus.const import ( - CONF_AGENCY, - CONF_ROUTE, - CONF_STOP, - DOMAIN, -) -from homeassistant.const import CONF_NAME +from homeassistant.components.nextbus.const import CONF_AGENCY, CONF_ROUTE, DOMAIN +from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/nextbus/test_sensor.py b/tests/components/nextbus/test_sensor.py index a4d04997e15..92da27783bc 100644 --- a/tests/components/nextbus/test_sensor.py +++ b/tests/components/nextbus/test_sensor.py @@ -8,15 +8,10 @@ from py_nextbus.client import NextBusFormatError, NextBusHTTPError, RouteStop import pytest from homeassistant.components import sensor -from homeassistant.components.nextbus.const import ( - CONF_AGENCY, - CONF_ROUTE, - CONF_STOP, - DOMAIN, -) +from homeassistant.components.nextbus.const import CONF_AGENCY, CONF_ROUTE, DOMAIN from homeassistant.components.nextbus.coordinator import NextBusDataUpdateCoordinator from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_STOP from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.update_coordinator import UpdateFailed diff --git a/tests/components/nextdns/snapshots/test_diagnostics.ambr b/tests/components/nextdns/snapshots/test_diagnostics.ambr index 071d14f183b..5040c6e052e 100644 --- a/tests/components/nextdns/snapshots/test_diagnostics.ambr +++ b/tests/components/nextdns/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'nextdns', 'entry_id': 'd9aa37407ddac7b964a99e86312288d6', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index b5d718b61aa..a27898629ad 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -6,13 +6,9 @@ from nextdns import ApiError, InvalidApiKeyError import pytest from homeassistant import data_entry_flow -from homeassistant.components.nextdns.const import ( - CONF_PROFILE_ID, - CONF_PROFILE_NAME, - DOMAIN, -) +from homeassistant.components.nextdns.const import CONF_PROFILE_ID, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME from homeassistant.core import HomeAssistant from . import PROFILES, init_integration diff --git a/tests/components/notion/test_diagnostics.py b/tests/components/notion/test_diagnostics.py index 14a1a0e1768..07a67cb1429 100644 --- a/tests/components/notion/test_diagnostics.py +++ b/tests/components/notion/test_diagnostics.py @@ -18,6 +18,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 1, + "minor_version": 1, "domain": DOMAIN, "title": REDACTED, "data": {"username": REDACTED, "password": REDACTED}, 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 673ac1a72d4..bf56cb8a985 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 @@ -3,6 +3,7 @@ import datetime from unittest.mock import ANY, MagicMock, call, patch from aio_geojson_nsw_rfs_incidents import NswRuralFireServiceIncidentsFeed +from freezegun.api import FrozenDateTimeFactory from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE @@ -90,7 +91,7 @@ def _generate_mock_feed_entry( return feed_entry -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test the general setup of the platform.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -114,11 +115,10 @@ async def test_setup(hass: HomeAssistant) -> None: mock_entry_3 = _generate_mock_feed_entry("3456", "Title 3", 25.5, (-31.2, 150.2)) mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (-31.3, 150.3)) - # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update: + freezer.move_to(utcnow) + + with patch("aio_geojson_client.feed.GeoJsonFeed.update") as mock_feed_update: mock_feed_update.return_value = ( "OK", [mock_entry_1, mock_entry_2, mock_entry_3], diff --git a/tests/components/number/test_const.py b/tests/components/number/test_const.py new file mode 100644 index 00000000000..e4b47e17e6e --- /dev/null +++ b/tests/components/number/test_const.py @@ -0,0 +1,16 @@ +"""Test the number const module.""" + +import pytest + +from homeassistant.components.number import const + +from tests.common import import_and_test_deprecated_constant_enum + + +@pytest.mark.parametrize(("enum"), list(const.NumberMode)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: const.NumberMode, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, const, enum, "MODE_", "2025.1") diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 601a34d4271..4de47b9b844 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -131,6 +131,31 @@ class MockNumberEntityDescr(NumberEntity): return None +class MockNumberEntityAttrWithDescription(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class sets an entity description and overrides + all the values with _attr members to ensure the _attr + members take precedence over the entity description. + """ + + def __init__(self): + """Initialize the clas instance.""" + self.entity_description = NumberEntityDescription( + "test", + native_max_value=10.0, + native_min_value=-10.0, + native_step=2.0, + native_unit_of_measurement="native_rabbits", + ) + + _attr_native_max_value = 1000.0 + _attr_native_min_value = -1000.0 + _attr_native_step = 100.0 + _attr_native_unit_of_measurement = "native_dogs" + _attr_native_value = 500.0 + + class MockDefaultNumberEntityDeprecated(NumberEntity): """Mock NumberEntity device to use in tests. @@ -277,6 +302,21 @@ async def test_attributes(hass: HomeAssistant) -> None: ATTR_STEP: 2.0, } + number_5 = MockNumberEntityAttrWithDescription() + number_5.hass = hass + assert number_5.max_value == 1000.0 + assert number_5.min_value == -1000.0 + assert number_5.step == 100.0 + assert number_5.native_step == 100.0 + assert number_5.unit_of_measurement == "native_dogs" + assert number_5.value == 500.0 + assert number_5.capability_attributes == { + ATTR_MAX: 1000.0, + ATTR_MIN: -1000.0, + ATTR_MODE: NumberMode.AUTO, + ATTR_STEP: 100.0, + } + async def test_sync_set_value(hass: HomeAssistant) -> None: """Test if async set_value calls sync set_value.""" diff --git a/tests/components/number/test_significant_change.py b/tests/components/number/test_significant_change.py new file mode 100644 index 00000000000..1a6491f3de9 --- /dev/null +++ b/tests/components/number/test_significant_change.py @@ -0,0 +1,94 @@ +"""Test the Number significant change platform.""" +import pytest + +from homeassistant.components.number import NumberDeviceClass +from homeassistant.components.number.significant_change import ( + async_check_significant_change, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + UnitOfTemperature, +) + +AQI_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.AQI} +BATTERY_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.BATTERY} +CO_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.CO} +CO2_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.CO2} +HUMIDITY_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.HUMIDITY} +MOISTURE_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.MOISTURE} +PM1_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.PM1} +PM10_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.PM10} +PM25_ATTRS = {ATTR_DEVICE_CLASS: NumberDeviceClass.PM25} +POWER_FACTOR_ATTRS = { + ATTR_DEVICE_CLASS: NumberDeviceClass.POWER_FACTOR, +} +POWER_FACTOR_ATTRS_PERCENTAGE = { + ATTR_DEVICE_CLASS: NumberDeviceClass.POWER_FACTOR, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, +} +TEMP_CELSIUS_ATTRS = { + ATTR_DEVICE_CLASS: NumberDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, +} +TEMP_FREEDOM_ATTRS = { + ATTR_DEVICE_CLASS: NumberDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT, +} +VOLATILE_ORGANIC_COMPOUNDS_ATTRS = { + ATTR_DEVICE_CLASS: NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS +} + + +@pytest.mark.parametrize( + ("old_state", "new_state", "attrs", "result"), + [ + ("0", "0.9", {}, None), + ("0", "1", AQI_ATTRS, True), + ("1", "0", AQI_ATTRS, True), + ("0.1", "0.5", AQI_ATTRS, False), + ("0.5", "0.1", AQI_ATTRS, False), + ("99", "100", AQI_ATTRS, False), + ("100", "99", AQI_ATTRS, False), + ("101", "99", AQI_ATTRS, False), + ("99", "101", AQI_ATTRS, True), + ("100", "100", BATTERY_ATTRS, False), + ("100", "99", BATTERY_ATTRS, True), + ("0", "1", CO_ATTRS, True), + ("0.1", "0.5", CO_ATTRS, False), + ("0", "1", CO2_ATTRS, True), + ("0.1", "0.5", CO2_ATTRS, False), + ("100", "100", HUMIDITY_ATTRS, False), + ("100", "99", HUMIDITY_ATTRS, True), + ("100", "100", MOISTURE_ATTRS, False), + ("100", "99", MOISTURE_ATTRS, True), + ("0", "1", PM1_ATTRS, True), + ("0.1", "0.5", PM1_ATTRS, False), + ("0", "1", PM10_ATTRS, True), + ("0.1", "0.5", PM10_ATTRS, False), + ("0", "1", PM25_ATTRS, True), + ("0.1", "0.5", PM25_ATTRS, False), + ("0.1", "0.2", POWER_FACTOR_ATTRS, True), + ("0.1", "0.19", POWER_FACTOR_ATTRS, False), + ("1", "2", POWER_FACTOR_ATTRS_PERCENTAGE, True), + ("1", "1.9", POWER_FACTOR_ATTRS_PERCENTAGE, False), + ("12", "12", TEMP_CELSIUS_ATTRS, False), + ("12", "13", TEMP_CELSIUS_ATTRS, True), + ("12.1", "12.2", TEMP_CELSIUS_ATTRS, False), + ("70", "71", TEMP_FREEDOM_ATTRS, True), + ("70", "70.5", TEMP_FREEDOM_ATTRS, False), + ("fail", "70", TEMP_FREEDOM_ATTRS, True), + ("70", "fail", TEMP_FREEDOM_ATTRS, False), + ("0", "1", VOLATILE_ORGANIC_COMPOUNDS_ATTRS, True), + ("0.1", "0.5", VOLATILE_ORGANIC_COMPOUNDS_ATTRS, False), + ], +) +async def test_significant_change_temperature( + old_state, new_state, attrs, result +) -> None: + """Detect temperature significant changes.""" + assert ( + async_check_significant_change(None, old_state, attrs, new_state, attrs) + is result + ) diff --git a/tests/components/octoprint/test_sensor.py b/tests/components/octoprint/test_sensor.py index 2ba657c77d5..3d3efd04da0 100644 --- a/tests/components/octoprint/test_sensor.py +++ b/tests/components/octoprint/test_sensor.py @@ -1,6 +1,7 @@ """The tests for Octoptint binary sensor module.""" from datetime import UTC, datetime -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -8,7 +9,7 @@ from homeassistant.helpers import entity_registry as er from . import init_integration -async def test_sensors(hass: HomeAssistant) -> None: +async def test_sensors(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test the underlying sensors.""" printer = { "state": { @@ -22,11 +23,8 @@ async def test_sensors(hass: HomeAssistant) -> None: "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Printing", } - with patch( - "homeassistant.util.dt.utcnow", - return_value=datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=UTC), - ): - await init_integration(hass, "sensor", printer=printer, job=job) + freezer.move_to(datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=UTC)) + await init_integration(hass, "sensor", printer=printer, job=job) entity_registry = er.async_get(hass) @@ -80,7 +78,9 @@ async def test_sensors(hass: HomeAssistant) -> None: assert entry.unique_id == "Estimated Finish Time-uuid" -async def test_sensors_no_target_temp(hass: HomeAssistant) -> None: +async def test_sensors_no_target_temp( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the underlying sensors.""" printer = { "state": { @@ -89,10 +89,8 @@ async def test_sensors_no_target_temp(hass: HomeAssistant) -> None: }, "temperature": {"tool1": {"actual": 18.83136, "target": None}}, } - with patch( - "homeassistant.util.dt.utcnow", return_value=datetime(2020, 2, 20, 9, 10, 0) - ): - await init_integration(hass, "sensor", printer=printer) + freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) + await init_integration(hass, "sensor", printer=printer) entity_registry = er.async_get(hass) @@ -111,7 +109,9 @@ async def test_sensors_no_target_temp(hass: HomeAssistant) -> None: assert entry.unique_id == "target tool1 temp-uuid" -async def test_sensors_paused(hass: HomeAssistant) -> None: +async def test_sensors_paused( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the underlying sensors.""" printer = { "state": { @@ -125,10 +125,8 @@ async def test_sensors_paused(hass: HomeAssistant) -> None: "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Paused", } - with patch( - "homeassistant.util.dt.utcnow", return_value=datetime(2020, 2, 20, 9, 10, 0) - ): - await init_integration(hass, "sensor", printer=printer, job=job) + freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) + await init_integration(hass, "sensor", printer=printer, job=job) entity_registry = er.async_get(hass) @@ -147,17 +145,17 @@ async def test_sensors_paused(hass: HomeAssistant) -> None: assert entry.unique_id == "Estimated Finish Time-uuid" -async def test_sensors_printer_disconnected(hass: HomeAssistant) -> None: +async def test_sensors_printer_disconnected( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test the underlying sensors.""" job = { "job": {}, "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Paused", } - with patch( - "homeassistant.util.dt.utcnow", return_value=datetime(2020, 2, 20, 9, 10, 0) - ): - await init_integration(hass, "sensor", printer=None, job=job) + freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) + await init_integration(hass, "sensor", printer=None, job=job) entity_registry = er.async_get(hass) diff --git a/tests/components/onvif/snapshots/test_diagnostics.ambr b/tests/components/onvif/snapshots/test_diagnostics.ambr index e10c8791ba9..c4f692a4e61 100644 --- a/tests/components/onvif/snapshots/test_diagnostics.ambr +++ b/tests/components/onvif/snapshots/test_diagnostics.ambr @@ -13,6 +13,7 @@ 'disabled_by': None, 'domain': 'onvif', 'entry_id': '1', + 'minor_version': 1, 'options': dict({ 'enable_webhooks': True, 'extra_arguments': '-pred 1', diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 40f2eb33f08..a83c660e509 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -25,7 +25,7 @@ def mock_config_entry(hass): async def mock_init_component(hass, mock_config_entry): """Initialize integration.""" with patch( - "openai.Model.list", + "openai.resources.models.AsyncModels.list", ): assert await async_setup_component(hass, "openai_conversation", {}) await hass.async_block_till_done() diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 43dfc26ca82..dd218e88c12 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -1,7 +1,8 @@ """Test the OpenAI Conversation config flow.""" from unittest.mock import patch -from openai.error import APIConnectionError, AuthenticationError, InvalidRequestError +from httpx import Response +from openai import APIConnectionError, AuthenticationError, BadRequestError import pytest from homeassistant import config_entries @@ -32,7 +33,7 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "homeassistant.components.openai_conversation.config_flow.openai.Model.list", + "homeassistant.components.openai_conversation.config_flow.openai.resources.models.AsyncModels.list", ), patch( "homeassistant.components.openai_conversation.async_setup_entry", return_value=True, @@ -76,9 +77,19 @@ async def test_options( @pytest.mark.parametrize( ("side_effect", "error"), [ - (APIConnectionError(""), "cannot_connect"), - (AuthenticationError, "invalid_auth"), - (InvalidRequestError, "unknown"), + (APIConnectionError(request=None), "cannot_connect"), + ( + AuthenticationError( + response=Response(status_code=None, request=""), body=None, message=None + ), + "invalid_auth", + ), + ( + BadRequestError( + response=Response(status_code=None, request=""), body=None, message=None + ), + "unknown", + ), ], ) async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> None: @@ -88,7 +99,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ) with patch( - "homeassistant.components.openai_conversation.config_flow.openai.Model.list", + "homeassistant.components.openai_conversation.config_flow.openai.resources.models.AsyncModels.list", side_effect=side_effect, ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 61fe33e5469..d3a06cabeb3 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1,7 +1,18 @@ """Tests for the OpenAI integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from openai import error +from httpx import Response +from openai import ( + APIConnectionError, + AuthenticationError, + BadRequestError, + RateLimitError, +) +from openai.types.chat.chat_completion import ChatCompletion, Choice +from openai.types.chat.chat_completion_message import ChatCompletionMessage +from openai.types.completion_usage import CompletionUsage +from openai.types.image import Image +from openai.types.images_response import ImagesResponse import pytest from syrupy.assertion import SnapshotAssertion @@ -9,6 +20,7 @@ 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 homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -94,17 +106,30 @@ async def test_default_prompt( suggested_area="Test Area 2", ) with patch( - "openai.ChatCompletion.acreate", - return_value={ - "choices": [ - { - "message": { - "role": "assistant", - "content": "Hello, how can I help you?", - } - } - ] - }, + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + return_value=ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ), ) as mock_create: result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id @@ -119,7 +144,11 @@ async def test_error_handling( ) -> None: """Test that the default prompt works.""" with patch( - "openai.ChatCompletion.acreate", side_effect=error.ServiceUnavailableError + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + side_effect=RateLimitError( + response=Response(status_code=None, request=""), body=None, message=None + ), ): result = await conversation.async_converse( hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id @@ -140,8 +169,11 @@ async def test_template_error( }, ) with patch( - "openai.Model.list", - ), patch("openai.ChatCompletion.acreate"): + "openai.resources.models.AsyncModels.list", + ), patch( + "openai.resources.chat.completions.AsyncCompletions.create", + new_callable=AsyncMock, + ): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse( @@ -169,15 +201,67 @@ async def test_conversation_agent( [ ( {"prompt": "Picture of a dog"}, - {"prompt": "Picture of a dog", "size": "512x512"}, + { + "prompt": "Picture of a dog", + "size": "1024x1024", + "quality": "standard", + "style": "vivid", + }, + ), + ( + { + "prompt": "Picture of a dog", + "size": "1024x1792", + "quality": "hd", + "style": "vivid", + }, + { + "prompt": "Picture of a dog", + "size": "1024x1792", + "quality": "hd", + "style": "vivid", + }, + ), + ( + { + "prompt": "Picture of a dog", + "size": "1792x1024", + "quality": "standard", + "style": "natural", + }, + { + "prompt": "Picture of a dog", + "size": "1792x1024", + "quality": "standard", + "style": "natural", + }, ), ( {"prompt": "Picture of a dog", "size": "256"}, - {"prompt": "Picture of a dog", "size": "256x256"}, + { + "prompt": "Picture of a dog", + "size": "1024x1024", + "quality": "standard", + "style": "vivid", + }, + ), + ( + {"prompt": "Picture of a dog", "size": "512"}, + { + "prompt": "Picture of a dog", + "size": "1024x1024", + "quality": "standard", + "style": "vivid", + }, ), ( {"prompt": "Picture of a dog", "size": "1024"}, - {"prompt": "Picture of a dog", "size": "1024x1024"}, + { + "prompt": "Picture of a dog", + "size": "1024x1024", + "quality": "standard", + "style": "vivid", + }, ), ], ) @@ -190,11 +274,22 @@ async def test_generate_image_service( ) -> 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["model"] = "dall-e-3" + expected_args["response_format"] = "url" expected_args["n"] = 1 with patch( - "openai.Image.acreate", return_value={"data": [{"url": "A"}]} + "openai.resources.images.AsyncImages.generate", + return_value=ImagesResponse( + created=1700000000, + data=[ + Image( + b64_json=None, + revised_prompt="A clear and detailed picture of an ordinary canine", + url="A", + ) + ], + ), ) as mock_create: response = await hass.services.async_call( "openai_conversation", @@ -204,7 +299,10 @@ async def test_generate_image_service( return_response=True, ) - assert response == {"url": "A"} + assert response == { + "url": "A", + "revised_prompt": "A clear and detailed picture of an ordinary canine", + } assert len(mock_create.mock_calls) == 1 assert mock_create.mock_calls[0][2] == expected_args @@ -216,7 +314,10 @@ async def test_generate_image_service_error( ) -> None: """Test generate image service handles errors.""" with patch( - "openai.Image.acreate", side_effect=error.ServiceUnavailableError("Reason") + "openai.resources.images.AsyncImages.generate", + side_effect=RateLimitError( + response=Response(status_code=None, request=""), body=None, message="Reason" + ), ), pytest.raises(HomeAssistantError, match="Error generating image: Reason"): await hass.services.async_call( "openai_conversation", @@ -228,3 +329,34 @@ async def test_generate_image_service_error( blocking=True, return_response=True, ) + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (APIConnectionError(request=None), "Connection error"), + ( + AuthenticationError( + response=Response(status_code=None, request=""), body=None, message=None + ), + "Invalid API key", + ), + ( + BadRequestError( + response=Response(status_code=None, request=""), body=None, message=None + ), + "openai_conversation integration not ready yet: None", + ), + ], +) +async def test_init_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, caplog, side_effect, error +) -> None: + """Test initialization errors.""" + with patch( + "openai.resources.models.AsyncModels.list", + side_effect=side_effect, + ): + assert await async_setup_component(hass, "openai_conversation", {}) + await hass.async_block_till_done() + assert error in caplog.text diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index fa7c7898037..e7efc459630 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -19,6 +19,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 2, + "minor_version": 1, "domain": "openuv", "title": REDACTED, "data": { diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py index 2bd62936fe5..87f76817044 100644 --- a/tests/components/openweathermap/test_config_flow.py +++ b/tests/components/openweathermap/test_config_flow.py @@ -5,7 +5,6 @@ from pyowm.commons.exceptions import APIRequestError, UnauthorizedError from homeassistant import data_entry_flow from homeassistant.components.openweathermap.const import ( - CONF_LANGUAGE, DEFAULT_FORECAST_MODE, DEFAULT_LANGUAGE, DOMAIN, @@ -13,6 +12,7 @@ from homeassistant.components.openweathermap.const import ( from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import ( CONF_API_KEY, + CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index 49de6db6e13..b48ccad2fe2 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -2,7 +2,6 @@ from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -24,6 +23,7 @@ from tests.components.bluetooth import ( inject_bluetooth_service_info, inject_bluetooth_service_info_bleak, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -63,9 +63,8 @@ async def test_sensors( # 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, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -114,9 +113,8 @@ async def test_sensors_io_series_4( # Fast-forward 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, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/osoenergy/__init__.py b/tests/components/osoenergy/__init__.py new file mode 100644 index 00000000000..76d134ef0f5 --- /dev/null +++ b/tests/components/osoenergy/__init__.py @@ -0,0 +1 @@ +"""Tests for the OSO Hotwater integration.""" diff --git a/tests/components/osoenergy/test_config_flow.py b/tests/components/osoenergy/test_config_flow.py new file mode 100644 index 00000000000..5c7e0b3442c --- /dev/null +++ b/tests/components/osoenergy/test_config_flow.py @@ -0,0 +1,164 @@ +"""Test the OSO Energy config flow.""" +from unittest.mock import patch + +from apyosoenergyapi.helper import osoenergy_exceptions + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.osoenergy.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +SUBSCRIPTION_KEY = "valid subscription key" +SCAN_INTERVAL = 120 +TEST_USER_EMAIL = "test_user_email@domain.com" +UPDATED_SCAN_INTERVAL = 60 + + +async def test_user_flow(hass: HomeAssistant) -> None: + """Test the user flow.""" + 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["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ), patch( + "homeassistant.components.osoenergy.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_USER_EMAIL + assert result2["data"] == { + CONF_API_KEY: SUBSCRIPTION_KEY, + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test the reauth flow.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + data={CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_config.unique_id, + "entry_id": mock_config.entry_id, + }, + data=mock_config.data, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: SUBSCRIPTION_KEY, + }, + ) + await hass.async_block_till_done() + + assert mock_config.data.get(CONF_API_KEY) == SUBSCRIPTION_KEY + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: + """Check flow abort when an entry already exist.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_USER_EMAIL, + data={CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=TEST_USER_EMAIL, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_API_KEY: SUBSCRIPTION_KEY, + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_invalid_subscription_key(hass: HomeAssistant) -> None: + """Test user flow with invalid username.""" + 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["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_user_flow_exception_on_subscription_key_check( + hass: HomeAssistant, +) -> None: + """Test user flow with invalid username.""" + 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["errors"] == {} + + with patch( + "homeassistant.components.osoenergy.config_flow.OSOEnergy.get_user_email", + side_effect=osoenergy_exceptions.OSOEnergyReauthRequired(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: SUBSCRIPTION_KEY}, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index 603e278d592..8229f4e8fa9 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -160,6 +160,7 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) "data": MOCK_CONFIG_PAIRED, "version": 1, "options": {}, + "minor_version": 1, } await hass.async_block_till_done() diff --git a/tests/components/pi_hole/snapshots/test_diagnostics.ambr b/tests/components/pi_hole/snapshots/test_diagnostics.ambr index 69c77acc64a..865494b5e9f 100644 --- a/tests/components/pi_hole/snapshots/test_diagnostics.ambr +++ b/tests/components/pi_hole/snapshots/test_diagnostics.ambr @@ -25,6 +25,7 @@ 'disabled_by': None, 'domain': 'pi_hole', 'entry_id': 'pi_hole_mock_entry', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/picnic/conftest.py b/tests/components/picnic/conftest.py index 1ca6413fc42..5bb84c7a1c1 100644 --- a/tests/components/picnic/conftest.py +++ b/tests/components/picnic/conftest.py @@ -58,7 +58,7 @@ async def init_integration( @pytest.fixture async def get_items( - hass_ws_client: WebSocketGenerator + hass_ws_client: WebSocketGenerator, ) -> Callable[[], Awaitable[dict[str, str]]]: """Fixture to fetch items from the todo websocket.""" diff --git a/tests/components/picnic/test_config_flow.py b/tests/components/picnic/test_config_flow.py index a649240bd21..d90551b01df 100644 --- a/tests/components/picnic/test_config_flow.py +++ b/tests/components/picnic/test_config_flow.py @@ -6,8 +6,8 @@ from python_picnic_api.session import PicnicAuthError import requests from homeassistant import config_entries, data_entry_flow -from homeassistant.components.picnic.const import CONF_COUNTRY_CODE, DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.components.picnic.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY_CODE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index cae10320fb9..fb1fbe9f009 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -9,11 +9,12 @@ import requests from homeassistant import config_entries from homeassistant.components.picnic import const -from homeassistant.components.picnic.const import CONF_COUNTRY_CODE, DOMAIN +from homeassistant.components.picnic.const import DOMAIN from homeassistant.components.picnic.sensor import SENSOR_TYPES from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( CONF_ACCESS_TOKEN, + CONF_COUNTRY_CODE, CURRENCY_EURO, STATE_UNAVAILABLE, STATE_UNKNOWN, diff --git a/tests/components/ping/conftest.py b/tests/components/ping/conftest.py index 4ad06a09c1c..24dd3314e3c 100644 --- a/tests/components/ping/conftest.py +++ b/tests/components/ping/conftest.py @@ -4,6 +4,7 @@ from unittest.mock import patch from icmplib import Host import pytest +from homeassistant.components.device_tracker.const import CONF_CONSIDER_HOME from homeassistant.components.ping import DOMAIN from homeassistant.components.ping.const import CONF_PING_COUNT from homeassistant.const import CONF_HOST @@ -39,7 +40,11 @@ async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, title="10.10.10.10", - options={CONF_HOST: "10.10.10.10", CONF_PING_COUNT: 10.0}, + options={ + CONF_HOST: "10.10.10.10", + CONF_PING_COUNT: 10.0, + CONF_CONSIDER_HOME: 180, + }, ) diff --git a/tests/components/ping/const.py b/tests/components/ping/const.py index cf002dc7ca6..048924292c7 100644 --- a/tests/components/ping/const.py +++ b/tests/components/ping/const.py @@ -1,4 +1,6 @@ """Constants for tests.""" +from datetime import timedelta + from icmplib import Host BINARY_SENSOR_IMPORT_DATA = { @@ -6,6 +8,7 @@ BINARY_SENSOR_IMPORT_DATA = { "host": "127.0.0.1", "count": 1, "scan_interval": 50, + "consider_home": timedelta(seconds=240), } NON_AVAILABLE_HOST_PING = Host("192.168.178.1", 10, []) diff --git a/tests/components/ping/test_config_flow.py b/tests/components/ping/test_config_flow.py index 6fff4ae7c71..8757a5b5e0d 100644 --- a/tests/components/ping/test_config_flow.py +++ b/tests/components/ping/test_config_flow.py @@ -42,6 +42,7 @@ async def test_form(hass: HomeAssistant, host, expected_title) -> None: assert result["options"] == { "count": 5, "host": host, + "consider_home": 180, } @@ -58,7 +59,7 @@ async def test_options(hass: HomeAssistant, host, count, expected_title) -> None source=config_entries.SOURCE_USER, data={}, domain=DOMAIN, - options={"count": count, "host": host}, + options={"count": count, "host": host, "consider_home": 180}, title=expected_title, ) config_entry.add_to_hass(hass) @@ -83,6 +84,7 @@ async def test_options(hass: HomeAssistant, host, count, expected_title) -> None assert result["data"] == { "count": count, "host": "10.10.10.1", + "consider_home": 180, } @@ -103,6 +105,7 @@ async def test_step_import(hass: HomeAssistant) -> None: assert result["options"] == { "host": "127.0.0.1", "count": 1, + "consider_home": 240, } # test import without name @@ -119,4 +122,5 @@ async def test_step_import(hass: HomeAssistant) -> None: assert result["options"] == { "host": "10.10.10.10", "count": 5, + "consider_home": 180, } diff --git a/tests/components/ping/test_device_tracker.py b/tests/components/ping/test_device_tracker.py index 5f5bb2132c1..d91cb46da0c 100644 --- a/tests/components/ping/test_device_tracker.py +++ b/tests/components/ping/test_device_tracker.py @@ -1,6 +1,9 @@ """Test the binary sensor platform of ping.""" +from datetime import timedelta from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory +from icmplib import Host import pytest from homeassistant.components.device_tracker import legacy @@ -11,7 +14,7 @@ from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util.yaml import dump -from tests.common import MockConfigEntry, patch_yaml_files +from tests.common import MockConfigEntry, async_fire_time_changed, patch_yaml_files @pytest.mark.usefixtures("setup_integration") @@ -19,6 +22,7 @@ async def test_setup_and_update( hass: HomeAssistant, entity_registry: er.EntityRegistry, config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, ) -> None: """Test sensor setup and update.""" @@ -42,10 +46,32 @@ async def test_setup_and_update( await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() - # check device tracker is now "home" state = hass.states.get("device_tracker.10_10_10_10") assert state.state == "home" + with patch( + "homeassistant.components.ping.helpers.async_ping", + return_value=Host(address="10.10.10.10", packets_sent=10, rtts=[]), + ): + # we need to travel two times into the future to run the update twice + freezer.tick(timedelta(minutes=1, seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + freezer.tick(timedelta(minutes=4, seconds=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("device_tracker.10_10_10_10")) + assert state.state == "not_home" + + freezer.tick(timedelta(minutes=1, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("device_tracker.10_10_10_10")) + assert state.state == "home" + async def test_import_issue_creation( hass: HomeAssistant, diff --git a/tests/components/plex/test_button.py b/tests/components/plex/test_button.py index e8e734143b3..a37a3ea2df2 100644 --- a/tests/components/plex/test_button.py +++ b/tests/components/plex/test_button.py @@ -30,7 +30,7 @@ async def test_scan_clients_button_schedule( BUTTON_DOMAIN, SERVICE_PRESS, { - ATTR_ENTITY_ID: "button.scan_clients_plex_server_1", + ATTR_ENTITY_ID: "button.plex_server_1_scan_clients", }, True, ) diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index 6e1043b5c52..e12759b8a1f 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -120,7 +120,7 @@ async def test_setup_with_photo_session( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "0" @@ -142,7 +142,7 @@ async def test_setup_with_live_tv_session( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "1" @@ -164,7 +164,7 @@ async def test_setup_with_transient_session( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "1" @@ -186,7 +186,7 @@ async def test_setup_with_unknown_session( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "1" diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index 5b9729792f4..93014dfedd1 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -110,7 +110,7 @@ async def test_library_sensor_values( mock_plex_server = await setup_plex_server() await wait_for_debouncer(hass) - activity_sensor = hass.states.get("sensor.plex_plex_server_1") + activity_sensor = hass.states.get("sensor.plex_server_1") assert activity_sensor.state == "1" # Ensure sensor is created as disabled diff --git a/tests/components/plex/test_server.py b/tests/components/plex/test_server.py index 1041caa298f..511025988ed 100644 --- a/tests/components/plex/test_server.py +++ b/tests/components/plex/test_server.py @@ -87,7 +87,7 @@ async def test_new_ignored_users_available( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) @@ -101,7 +101,7 @@ async def test_network_error_during_refresh( await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) with patch("plexapi.server.PlexServer.clients", side_effect=RequestException): @@ -126,7 +126,7 @@ async def test_gdm_client_failure( active_sessions = mock_plex_server._plex_server.sessions() await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) with patch("plexapi.server.PlexServer.clients", side_effect=RequestException): @@ -146,7 +146,7 @@ async def test_mark_sessions_idle( active_sessions = mock_plex_server._plex_server.sessions() - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) url = mock_plex_server.url_in_use @@ -157,7 +157,7 @@ async def test_mark_sessions_idle( await hass.async_block_till_done() await wait_for_debouncer(hass) - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == "0" @@ -175,7 +175,7 @@ async def test_ignore_plex_web_client( await wait_for_debouncer(hass) active_sessions = mock_plex_server._plex_server.sessions() - sensor = hass.states.get("sensor.plex_plex_server_1") + sensor = hass.states.get("sensor.plex_server_1") assert sensor.state == str(len(active_sessions)) media_players = hass.states.async_entity_ids("media_player") diff --git a/tests/components/private_ble_device/__init__.py b/tests/components/private_ble_device/__init__.py index df9929293a1..967f422872b 100644 --- a/tests/components/private_ble_device/__init__.py +++ b/tests/components/private_ble_device/__init__.py @@ -2,7 +2,6 @@ from datetime import timedelta import time -from unittest.mock import patch from home_assistant_bluetooth import BluetoothServiceInfoBleak @@ -16,6 +15,7 @@ from tests.components.bluetooth import ( generate_advertisement_data, generate_ble_device, inject_bluetooth_service_info_bleak, + patch_bluetooth_time, ) MAC_RPA_VALID_1 = "40:01:02:0a:c4:a6" @@ -70,9 +70,8 @@ async def async_inject_broadcast( async def async_move_time_forwards(hass: HomeAssistant, offset: float): """Mock time advancing from now to now+offset.""" - with patch( - "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", - return_value=time.monotonic() + offset, + with patch_bluetooth_time( + time.monotonic() + offset, ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=offset)) await hass.async_block_till_done() diff --git a/tests/components/private_ble_device/test_device_tracker.py b/tests/components/private_ble_device/test_device_tracker.py index d8b30738865..3834254ac7f 100644 --- a/tests/components/private_ble_device/test_device_tracker.py +++ b/tests/components/private_ble_device/test_device_tracker.py @@ -3,9 +3,8 @@ import time -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED + from homeassistant.components.bluetooth.api import ( async_get_fallback_availability_interval, ) diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py index 65f08d5653d..15e205c8c86 100644 --- a/tests/components/private_ble_device/test_sensor.py +++ b/tests/components/private_ble_device/test_sensor.py @@ -1,10 +1,9 @@ """Tests for sensors.""" +from habluetooth.advertisement_tracker import ADVERTISING_TIMES_NEEDED + from homeassistant.components.bluetooth import async_set_fallback_availability_interval -from homeassistant.components.bluetooth.advertisement_tracker import ( - ADVERTISING_TIMES_NEEDED, -) from homeassistant.core import HomeAssistant from . import ( @@ -82,7 +81,7 @@ async def test_estimated_broadcast_interval( "sensor.private_ble_device_000000_estimated_broadcast_interval" ) assert state - assert state.state == "90" + assert state.state == "90.0" # Learned broadcast interval takes over from fallback interval @@ -95,7 +94,7 @@ async def test_estimated_broadcast_interval( "sensor.private_ble_device_000000_estimated_broadcast_interval" ) assert state - assert state.state == "10" + assert state.state == "10.0" # MAC address changes, the broadcast interval is kept @@ -105,4 +104,4 @@ async def test_estimated_broadcast_interval( "sensor.private_ble_device_000000_estimated_broadcast_interval" ) assert state - assert state.state == "10" + assert state.state == "10.0" diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 7c2aeb2a29a..b8a81a40e37 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -5,7 +5,7 @@ import os from pathlib import Path from unittest.mock import patch -from lru import LRU # pylint: disable=no-name-in-module +from lru import LRU import pytest from homeassistant.components.profiler import ( diff --git a/tests/components/prusalink/conftest.py b/tests/components/prusalink/conftest.py index 8beb67b0ed4..1e514342068 100644 --- a/tests/components/prusalink/conftest.py +++ b/tests/components/prusalink/conftest.py @@ -1,9 +1,10 @@ """Fixtures for PrusaLink.""" - from unittest.mock import patch import pytest +from homeassistant.components.prusalink import DOMAIN + from tests.common import MockConfigEntry @@ -11,7 +12,10 @@ from tests.common import MockConfigEntry def mock_config_entry(hass): """Mock a PrusaLink config entry.""" entry = MockConfigEntry( - domain="prusalink", data={"host": "http://example.com", "api_key": "abcdefgh"} + domain=DOMAIN, + data={"host": "http://example.com", "username": "dummy", "password": "dummypw"}, + version=1, + minor_version=2, ) entry.add_to_hass(hass) return entry @@ -23,96 +27,138 @@ def mock_version_api(hass): resp = { "api": "2.0.0", "server": "2.1.2", - "text": "PrusaLink MINI", - "hostname": "PrusaMINI", + "text": "PrusaLink", + "hostname": "PrusaXL", } with patch("pyprusalink.PrusaLink.get_version", return_value=resp): yield resp @pytest.fixture -def mock_printer_api(hass): +def mock_info_api(hass): + """Mock PrusaLink info API.""" + resp = { + "nozzle_diameter": 0.40, + "mmu": False, + "serial": "serial-1337", + "hostname": "PrusaXL", + "min_extrusion_temp": 170, + } + with patch("pyprusalink.PrusaLink.get_info", return_value=resp): + yield resp + + +@pytest.fixture +def mock_get_legacy_printer(hass): + """Mock PrusaLink printer API.""" + resp = {"telemetry": {"material": "PLA"}} + with patch("pyprusalink.PrusaLink.get_legacy_printer", return_value=resp): + yield resp + + +@pytest.fixture +def mock_get_status_idle(hass): """Mock PrusaLink printer API.""" resp = { - "telemetry": { - "temp-bed": 41.9, - "temp-nozzle": 47.8, - "print-speed": 100, - "z-height": 1.8, - "material": "PLA", + "storage": { + "path": "/usb/", + "name": "usb", + "read_only": False, }, - "temperature": { - "tool0": {"actual": 47.8, "target": 210.1, "display": 0.0, "offset": 0}, - "bed": {"actual": 41.9, "target": 60.5, "offset": 0}, - }, - "state": { - "text": "Operational", - "flags": { - "operational": True, - "paused": False, - "printing": False, - "cancelling": False, - "pausing": False, - "sdReady": False, - "error": False, - "closedOnError": False, - "ready": True, - "busy": False, - }, + "printer": { + "state": "IDLE", + "temp_bed": 41.9, + "target_bed": 60.5, + "temp_nozzle": 47.8, + "target_nozzle": 210.1, + "axis_z": 1.8, + "axis_x": 7.9, + "axis_y": 8.4, + "flow": 100, + "speed": 100, + "fan_hotend": 100, + "fan_print": 75, }, } - with patch("pyprusalink.PrusaLink.get_printer", return_value=resp): + with patch("pyprusalink.PrusaLink.get_status", return_value=resp): + yield resp + + +@pytest.fixture +def mock_get_status_printing(hass): + """Mock PrusaLink printer API.""" + resp = { + "job": { + "id": 129, + "progress": 37.00, + "time_remaining": 73020, + "time_printing": 43987, + }, + "storage": {"path": "/usb/", "name": "usb", "read_only": False}, + "printer": { + "state": "PRINTING", + "temp_bed": 53.9, + "target_bed": 85.0, + "temp_nozzle": 6.0, + "target_nozzle": 0.0, + "axis_z": 5.0, + "flow": 100, + "speed": 100, + "fan_hotend": 5000, + "fan_print": 2500, + }, + } + with patch("pyprusalink.PrusaLink.get_status", return_value=resp): yield resp @pytest.fixture def mock_job_api_idle(hass): """Mock PrusaLink job API having no job.""" + resp = {} + with patch("pyprusalink.PrusaLink.get_job", return_value=resp): + yield resp + + +@pytest.fixture +def mock_job_api_printing(hass): + """Mock PrusaLink printing.""" resp = { - "state": "Operational", - "job": None, - "progress": None, + "id": 129, + "state": "PRINTING", + "progress": 37.00, + "time_remaining": 73020, + "time_printing": 43987, + "file": { + "refs": { + "icon": "/thumb/s/usb/TabletStand3~4.BGC", + "thumbnail": "/thumb/l/usb/TabletStand3~4.BGC", + "download": "/usb/TabletStand3~4.BGC", + }, + "name": "TabletStand3~4.BGC", + "display_name": "TabletStand3.bgcode", + "path": "/usb", + "size": 754535, + "m_timestamp": 1698686881, + }, } with patch("pyprusalink.PrusaLink.get_job", return_value=resp): yield resp @pytest.fixture -def mock_job_api_printing(hass, mock_printer_api, mock_job_api_idle): - """Mock PrusaLink printing.""" - mock_printer_api["state"]["text"] = "Printing" - mock_printer_api["state"]["flags"]["printing"] = True - - mock_job_api_idle.update( - { - "state": "Printing", - "job": { - "estimatedPrintTime": 117007, - "file": { - "name": "TabletStand3.gcode", - "path": "/usb/TABLET~1.GCO", - "display": "TabletStand3.gcode", - }, - }, - "progress": { - "completion": 0.37, - "printTime": 43987, - "printTimeLeft": 73020, - }, - } - ) - - -@pytest.fixture -def mock_job_api_paused(hass, mock_printer_api, mock_job_api_idle): +def mock_job_api_paused(hass, mock_get_status_printing, mock_job_api_printing): """Mock PrusaLink paused printing.""" - mock_printer_api["state"]["text"] = "Paused" - mock_printer_api["state"]["flags"]["printing"] = False - mock_printer_api["state"]["flags"]["paused"] = True - - mock_job_api_idle["state"] = "Paused" + mock_job_api_printing["state"] = "PAUSED" + mock_get_status_printing["printer"]["state"] = "PAUSED" @pytest.fixture -def mock_api(mock_version_api, mock_printer_api, mock_job_api_idle): +def mock_api( + mock_version_api, + mock_info_api, + mock_get_legacy_printer, + mock_get_status_idle, + mock_job_api_idle, +): """Mock PrusaLink API.""" diff --git a/tests/components/prusalink/test_button.py b/tests/components/prusalink/test_button.py index 658587327dd..5324e337780 100644 --- a/tests/components/prusalink/test_button.py +++ b/tests/components/prusalink/test_button.py @@ -1,7 +1,7 @@ """Test Prusalink buttons.""" from unittest.mock import patch -from pyprusalink import Conflict +from pyprusalink.types import Conflict import pytest from homeassistant.const import Platform @@ -32,6 +32,7 @@ async def test_button_pause_cancel( mock_api, hass_client: ClientSessionGenerator, mock_job_api_printing, + mock_get_status_printing, object_id, method, ) -> None: @@ -66,9 +67,12 @@ async def test_button_pause_cancel( @pytest.mark.parametrize( ("object_id", "method"), - (("mock_title_resume_job", "resume_job"),), + ( + ("mock_title_cancel_job", "cancel_job"), + ("mock_title_resume_job", "resume_job"), + ), ) -async def test_button_resume( +async def test_button_resume_cancel( hass: HomeAssistant, mock_config_entry, mock_api, diff --git a/tests/components/prusalink/test_camera.py b/tests/components/prusalink/test_camera.py index 010758bcca8..b84a13a3df8 100644 --- a/tests/components/prusalink/test_camera.py +++ b/tests/components/prusalink/test_camera.py @@ -49,13 +49,13 @@ async def test_camera_active_job( client = await hass_client() - with patch("pyprusalink.PrusaLink.get_large_thumbnail", return_value=b"hello"): + with patch("pyprusalink.PrusaLink.get_file", return_value=b"hello"): resp = await client.get("/api/camera_proxy/camera.mock_title_preview") assert resp.status == 200 assert await resp.read() == b"hello" # Make sure we hit cached value. - with patch("pyprusalink.PrusaLink.get_large_thumbnail", side_effect=ValueError): + with patch("pyprusalink.PrusaLink.get_file", side_effect=ValueError): resp = await client.get("/api/camera_proxy/camera.mock_title_preview") assert resp.status == 200 assert await resp.read() == b"hello" diff --git a/tests/components/prusalink/test_config_flow.py b/tests/components/prusalink/test_config_flow.py index 4810ea82166..6a23e05adf9 100644 --- a/tests/components/prusalink/test_config_flow.py +++ b/tests/components/prusalink/test_config_flow.py @@ -25,16 +25,18 @@ async def test_form(hass: HomeAssistant, mock_version_api) -> None: result["flow_id"], { "host": "http://1.1.1.1/", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "PrusaMINI" + assert result2["title"] == "PrusaXL" assert result2["data"] == { "host": "http://1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", } assert len(mock_setup_entry.mock_calls) == 1 @@ -53,7 +55,8 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) @@ -75,7 +78,8 @@ async def test_form_unknown(hass: HomeAssistant) -> None: result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) @@ -95,7 +99,8 @@ async def test_form_too_low_version(hass: HomeAssistant, mock_version_api) -> No result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) @@ -115,7 +120,8 @@ async def test_form_invalid_version_2(hass: HomeAssistant, mock_version_api) -> result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) @@ -137,7 +143,8 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: result["flow_id"], { "host": "1.1.1.1", - "api_key": "abcdefg", + "username": "abcdefg", + "password": "abcdefg", }, ) diff --git a/tests/components/prusalink/test_init.py b/tests/components/prusalink/test_init.py index 543ee68d5dd..5b261207e93 100644 --- a/tests/components/prusalink/test_init.py +++ b/tests/components/prusalink/test_init.py @@ -2,20 +2,25 @@ from datetime import timedelta from unittest.mock import patch -from pyprusalink import InvalidAuth, PrusaLinkError +from pyprusalink.types import InvalidAuth, PrusaLinkError import pytest +from homeassistant.components.prusalink import DOMAIN +from homeassistant.components.prusalink.config_flow import ConfigFlow from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.util.dt import utcnow -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed + +pytestmark = pytest.mark.usefixtures("mock_api") async def test_unloading( hass: HomeAssistant, mock_config_entry: ConfigEntry, - mock_api, ) -> None: """Test unloading prusalink.""" assert await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -32,14 +37,20 @@ async def test_unloading( @pytest.mark.parametrize("exception", [InvalidAuth, PrusaLinkError]) async def test_failed_update( - hass: HomeAssistant, mock_config_entry: ConfigEntry, mock_api, exception + hass: HomeAssistant, mock_config_entry: ConfigEntry, exception ) -> None: """Test failed update marks prusalink unavailable.""" assert await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state == ConfigEntryState.LOADED with patch( - "homeassistant.components.prusalink.PrusaLink.get_printer", + "homeassistant.components.prusalink.PrusaLink.get_version", + side_effect=exception, + ), patch( + "homeassistant.components.prusalink.PrusaLink.get_status", + side_effect=exception, + ), patch( + "homeassistant.components.prusalink.PrusaLink.get_legacy_printer", side_effect=exception, ), patch( "homeassistant.components.prusalink.PrusaLink.get_job", @@ -50,3 +61,86 @@ async def test_failed_update( for state in hass.states.async_all(): assert state.state == "unavailable" + + +async def test_migration_from_1_1_to_1_2( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test migrating from version 1 to 2.""" + data = { + CONF_HOST: "http://prusaxl.local", + CONF_API_KEY: "api-key", + } + entry = MockConfigEntry( + domain=DOMAIN, + data=data, + version=1, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + config_entries = hass.config_entries.async_entries(DOMAIN) + + # Ensure that we have username, password after migration + assert len(config_entries) == 1 + assert config_entries[0].data == { + **data, + CONF_USERNAME: "maker", + CONF_PASSWORD: "api-key", + } + # Make sure that we don't have any issues + assert len(issue_registry.issues) == 0 + + +async def test_migration_from_1_1_to_1_2_outdated_firmware( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test migrating from version 1.1 to 1.2.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "http://prusaxl.local", + CONF_API_KEY: "api-key", + }, + version=1, + ) + entry.add_to_hass(hass) + + with patch( + "pyprusalink.PrusaLink.get_info", + side_effect=InvalidAuth, # Simulate firmware update required + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.SETUP_ERROR + assert entry.minor_version == 1 + assert (DOMAIN, "firmware_5_1_required") in issue_registry.issues + + # Reloading the integration with a working API (e.g. User updated firmware) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Integration should be running now, the issue should be gone + assert entry.state == ConfigEntryState.LOADED + assert entry.minor_version == 2 + assert (DOMAIN, "firmware_5_1_required") not in issue_registry.issues + + +async def test_migration_fails_on_future_version( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test migrating fails on a version higher than the current one.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + version=ConfigFlow.VERSION + 1, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.MIGRATION_ERROR diff --git a/tests/components/prusalink/test_sensor.py b/tests/components/prusalink/test_sensor.py index 0f2a966b4e4..366f2d3abc8 100644 --- a/tests/components/prusalink/test_sensor.py +++ b/tests/components/prusalink/test_sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, + REVOLUTIONS_PER_MINUTE, Platform, UnitOfLength, UnitOfTemperature, @@ -44,11 +45,15 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) assert state.state == "idle" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM assert state.attributes[ATTR_OPTIONS] == [ - "cancelling", "idle", - "paused", - "pausing", + "busy", "printing", + "paused", + "finished", + "stopped", + "error", + "attention", + "ready", ] state = hass.states.get("sensor.mock_title_heatbed_temperature") @@ -95,6 +100,11 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) assert state is not None assert state.state == "PLA" + state = hass.states.get("sensor.mock_title_print_flow") + assert state is not None + assert state.state == "100" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + state = hass.states.get("sensor.mock_title_progress") assert state is not None assert state.state == "unavailable" @@ -114,12 +124,22 @@ async def test_sensors_no_job(hass: HomeAssistant, mock_config_entry, mock_api) assert state.state == "unavailable" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + state = hass.states.get("sensor.mock_title_hotend_fan") + assert state is not None + assert state.state == "100" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE + + state = hass.states.get("sensor.mock_title_print_fan") + assert state is not None + assert state.state == "75" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE + async def test_sensors_active_job( hass: HomeAssistant, mock_config_entry, mock_api, - mock_printer_api, + mock_get_status_printing, mock_job_api_printing, ) -> None: """Test sensors while active job.""" @@ -140,7 +160,7 @@ async def test_sensors_active_job( state = hass.states.get("sensor.mock_title_filename") assert state is not None - assert state.state == "TabletStand3.gcode" + assert state.state == "TabletStand3.bgcode" state = hass.states.get("sensor.mock_title_print_start") assert state is not None @@ -151,3 +171,13 @@ async def test_sensors_active_job( assert state is not None assert state.state == "2022-08-28T10:17:00+00:00" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP + + state = hass.states.get("sensor.mock_title_hotend_fan") + assert state is not None + assert state.state == "5000" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE + + state = hass.states.get("sensor.mock_title_print_fan") + assert state is not None + assert state.state == "2500" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == REVOLUTIONS_PER_MINUTE diff --git a/tests/components/ps4/test_init.py b/tests/components/ps4/test_init.py index 3b8dc5e1e24..1252348b3e0 100644 --- a/tests/components/ps4/test_init.py +++ b/tests/components/ps4/test_init.py @@ -44,6 +44,7 @@ MOCK_DATA = {CONF_TOKEN: MOCK_CREDS, "devices": [MOCK_DEVICE]} MOCK_FLOW_RESULT = { "version": VERSION, + "minor_version": 1, "handler": DOMAIN, "type": data_entry_flow.FlowResultType.CREATE_ENTRY, "title": "test_ps4", diff --git a/tests/components/purpleair/test_diagnostics.py b/tests/components/purpleair/test_diagnostics.py index 35dc515241c..85b078d0765 100644 --- a/tests/components/purpleair/test_diagnostics.py +++ b/tests/components/purpleair/test_diagnostics.py @@ -17,6 +17,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 1, + "minor_version": 1, "domain": "purpleair", "title": REDACTED, "data": { diff --git a/tests/components/pvpc_hourly_pricing/conftest.py b/tests/components/pvpc_hourly_pricing/conftest.py index efe15547c13..3bf1b08a51d 100644 --- a/tests/components/pvpc_hourly_pricing/conftest.py +++ b/tests/components/pvpc_hourly_pricing/conftest.py @@ -11,6 +11,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker FIXTURE_JSON_PUBLIC_DATA_2023_01_06 = "PVPC_DATA_2023_01_06.json" FIXTURE_JSON_ESIOS_DATA_PVPC_2023_01_06 = "PRICES_ESIOS_1001_2023_01_06.json" +_ESIOS_INDICATORS_FOR_EACH_SENSOR = ("1001", "1739", "1900", "10211") def check_valid_state(state, tariff: str, value=None, key_attr=None): @@ -43,18 +44,19 @@ def pvpc_aioclient_mock(aioclient_mock: AiohttpClientMocker): "https://api.esios.ree.es/archives/70/download_json?locale=es&date={0}" ) mask_url_esios = ( - "https://api.esios.ree.es/indicators/1001" - "?start_date={0}T00:00&end_date={0}T23:59" + "https://api.esios.ree.es/indicators/{0}" + "?start_date={1}T00:00&end_date={1}T23:59" ) example_day = "2023-01-06" aioclient_mock.get( mask_url_public.format(example_day), text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_PUBLIC_DATA_2023_01_06}"), ) - aioclient_mock.get( - mask_url_esios.format(example_day), - text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_ESIOS_DATA_PVPC_2023_01_06}"), - ) + for esios_ind in _ESIOS_INDICATORS_FOR_EACH_SENSOR: + aioclient_mock.get( + mask_url_esios.format(esios_ind, example_day), + text=load_fixture(f"{DOMAIN}/{FIXTURE_JSON_ESIOS_DATA_PVPC_2023_01_06}"), + ) # simulate missing days aioclient_mock.get( @@ -62,22 +64,24 @@ def pvpc_aioclient_mock(aioclient_mock: AiohttpClientMocker): status=HTTPStatus.OK, text='{"message":"No values for specified archive"}', ) - aioclient_mock.get( - mask_url_esios.format("2023-01-07"), - status=HTTPStatus.OK, - text=( - '{"indicator":{"name":"Término de facturación de energía activa del ' - 'PVPC 2.0TD","short_name":"PVPC T. 2.0TD","id":1001,"composited":false,' - '"step_type":"linear","disaggregated":true,"magnitud":' - '[{"name":"Precio","id":23}],"tiempo":[{"name":"Hora","id":4}],"geos":[],' - '"values_updated_at":null,"values":[]}}' - ), - ) + for esios_ind in _ESIOS_INDICATORS_FOR_EACH_SENSOR: + aioclient_mock.get( + mask_url_esios.format(esios_ind, "2023-01-07"), + status=HTTPStatus.OK, + text=( + '{"indicator":{"name":"Término de facturación de energía activa del ' + 'PVPC 2.0TD","short_name":"PVPC T. 2.0TD","id":1001,"composited":false,' + '"step_type":"linear","disaggregated":true,"magnitud":' + '[{"name":"Precio","id":23}],"tiempo":[{"name":"Hora","id":4}],"geos":[],' + '"values_updated_at":null,"values":[]}}' + ).replace("1001", esios_ind), + ) # simulate bad authentication - aioclient_mock.get( - mask_url_esios.format("2023-01-08"), - status=HTTPStatus.UNAUTHORIZED, - text="HTTP Token: Access denied.", - ) + for esios_ind in _ESIOS_INDICATORS_FOR_EACH_SENSOR: + aioclient_mock.get( + mask_url_esios.format(esios_ind, "2023-01-08"), + status=HTTPStatus.UNAUTHORIZED, + text="HTTP Token: Access denied.", + ) return aioclient_mock diff --git a/tests/components/pvpc_hourly_pricing/test_config_flow.py b/tests/components/pvpc_hourly_pricing/test_config_flow.py index 950aea8e32c..087edcc1557 100644 --- a/tests/components/pvpc_hourly_pricing/test_config_flow.py +++ b/tests/components/pvpc_hourly_pricing/test_config_flow.py @@ -64,6 +64,10 @@ async def test_config_flow( check_valid_state(state, tariff=TARIFFS[1]) assert pvpc_aioclient_mock.call_count == 1 + # no extra sensors created without enabled API token + state_inyection = hass.states.get("sensor.injection_price") + assert state_inyection is None + # Check abort when configuring another with same tariff result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -117,18 +121,27 @@ async def test_config_flow( assert pvpc_aioclient_mock.call_count == 2 result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_API_TOKEN: "good-token"} + result["flow_id"], user_input={CONF_API_TOKEN: "test-token"} ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert pvpc_aioclient_mock.call_count == 2 await hass.async_block_till_done() state = hass.states.get("sensor.esios_pvpc") check_valid_state(state, tariff=TARIFFS[1]) - assert pvpc_aioclient_mock.call_count == 3 + assert pvpc_aioclient_mock.call_count == 4 assert state.attributes["period"] == "P3" assert state.attributes["next_period"] == "P2" assert state.attributes["available_power"] == 4600 + state_inyection = hass.states.get("sensor.esios_injection_price") + state_mag = hass.states.get("sensor.esios_mag_tax") + state_omie = hass.states.get("sensor.esios_omie_price") + assert state_inyection + assert not state_mag + assert not state_omie + assert "period" not in state_inyection.attributes + assert "available_power" not in state_inyection.attributes + # check update failed freezer.tick(timedelta(days=1)) async_fire_time_changed(hass) @@ -136,7 +149,7 @@ async def test_config_flow( state = hass.states.get("sensor.esios_pvpc") check_valid_state(state, tariff=TARIFFS[0], value="unavailable") assert "period" not in state.attributes - assert pvpc_aioclient_mock.call_count == 4 + assert pvpc_aioclient_mock.call_count == 6 # disable api token in options result = await hass.config_entries.options.async_init(config_entry.entry_id) @@ -148,8 +161,18 @@ async def test_config_flow( user_input={ATTR_POWER: 3.0, ATTR_POWER_P3: 4.6, CONF_USE_API_TOKEN: False}, ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert pvpc_aioclient_mock.call_count == 4 + assert pvpc_aioclient_mock.call_count == 6 await hass.async_block_till_done() + assert pvpc_aioclient_mock.call_count == 7 + + state = hass.states.get("sensor.esios_pvpc") + state_inyection = hass.states.get("sensor.esios_injection_price") + state_mag = hass.states.get("sensor.esios_mag_tax") + state_omie = hass.states.get("sensor.esios_omie_price") + check_valid_state(state, tariff=TARIFFS[1]) + assert state_inyection.state == "unavailable" + assert not state_mag + assert not state_omie async def test_reauth( @@ -181,7 +204,7 @@ async def test_reauth( assert pvpc_aioclient_mock.call_count == 0 result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_API_TOKEN: "bad-token"} + result["flow_id"], user_input={CONF_API_TOKEN: "test-token"} ) assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "api_token" @@ -190,17 +213,17 @@ async def test_reauth( freezer.move_to(_MOCK_TIME_VALID_RESPONSES) result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_API_TOKEN: "good-token"} + result["flow_id"], user_input={CONF_API_TOKEN: "test-token"} ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY config_entry = result["result"] - assert pvpc_aioclient_mock.call_count == 3 + assert pvpc_aioclient_mock.call_count == 4 # check reauth trigger with bad-auth responses freezer.move_to(_MOCK_TIME_BAD_AUTH_RESPONSES) async_fire_time_changed(hass, _MOCK_TIME_BAD_AUTH_RESPONSES) await hass.async_block_till_done() - assert pvpc_aioclient_mock.call_count == 4 + assert pvpc_aioclient_mock.call_count == 6 result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] assert result["context"]["entry_id"] == config_entry.entry_id @@ -208,11 +231,11 @@ async def test_reauth( assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_API_TOKEN: "bad-token"} + result["flow_id"], user_input={CONF_API_TOKEN: "test-token"} ) assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - assert pvpc_aioclient_mock.call_count == 5 + assert pvpc_aioclient_mock.call_count == 7 result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] assert result["context"]["entry_id"] == config_entry.entry_id @@ -222,11 +245,11 @@ async def test_reauth( freezer.move_to(_MOCK_TIME_VALID_RESPONSES) async_fire_time_changed(hass, _MOCK_TIME_VALID_RESPONSES) result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_API_TOKEN: "good-token"} + result["flow_id"], user_input={CONF_API_TOKEN: "test-token"} ) assert result["type"] == data_entry_flow.FlowResultType.ABORT assert result["reason"] == "reauth_successful" - assert pvpc_aioclient_mock.call_count == 6 + assert pvpc_aioclient_mock.call_count == 8 await hass.async_block_till_done() - assert pvpc_aioclient_mock.call_count == 7 + assert pvpc_aioclient_mock.call_count == 10 diff --git a/tests/components/qingping/test_binary_sensor.py b/tests/components/qingping/test_binary_sensor.py index 9b83cd8c590..f201b3b55ff 100644 --- a/tests/components/qingping/test_binary_sensor.py +++ b/tests/components/qingping/test_binary_sensor.py @@ -1,7 +1,6 @@ """Test the Qingping binary sensors.""" from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -17,6 +16,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -72,9 +72,8 @@ async def test_binary_sensor_restore_state(hass: HomeAssistant) -> None: # 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, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/qingping/test_sensor.py b/tests/components/qingping/test_sensor.py index 2fedbba9e5c..12e3ec85c52 100644 --- a/tests/components/qingping/test_sensor.py +++ b/tests/components/qingping/test_sensor.py @@ -1,7 +1,6 @@ """Test the Qingping sensors.""" from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -22,6 +21,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -82,9 +82,8 @@ async def test_binary_sensor_restore_state(hass: HomeAssistant) -> None: # 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, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/qld_bushfire/test_geo_location.py b/tests/components/qld_bushfire/test_geo_location.py index 18b33a6ef0c..5a9821dd52d 100644 --- a/tests/components/qld_bushfire/test_geo_location.py +++ b/tests/components/qld_bushfire/test_geo_location.py @@ -2,6 +2,8 @@ import datetime from unittest.mock import MagicMock, call, patch +from freezegun.api import FrozenDateTimeFactory + from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.components.qld_bushfire.geo_location import ( @@ -70,7 +72,7 @@ def _generate_mock_feed_entry( return feed_entry -async def test_setup(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test the general setup of the platform.""" # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry( @@ -88,11 +90,10 @@ async def test_setup(hass: HomeAssistant) -> None: mock_entry_3 = _generate_mock_feed_entry("3456", "Title 3", 25.5, (38.2, -3.2)) mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (38.3, -3.3)) - # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "georss_qld_bushfire_alert_client.QldBushfireAlertFeed" - ) as mock_feed: + freezer.move_to(utcnow) + + with patch("georss_qld_bushfire_alert_client.QldBushfireAlertFeed") as mock_feed: mock_feed.return_value.update.return_value = ( "OK", [mock_entry_1, mock_entry_2, mock_entry_3], diff --git a/tests/components/radarr/__init__.py b/tests/components/radarr/__init__.py index f7bdf232c9e..47204ebf537 100644 --- a/tests/components/radarr/__init__.py +++ b/tests/components/radarr/__init__.py @@ -102,6 +102,18 @@ def mock_connection( ) +def mock_calendar( + aioclient_mock: AiohttpClientMocker, + url: str = URL, +) -> None: + """Mock radarr connection.""" + aioclient_mock.get( + f"{url}/api/v3/calendar", + text=load_fixture("radarr/calendar.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) + + def mock_connection_error( aioclient_mock: AiohttpClientMocker, url: str = URL, @@ -120,6 +132,7 @@ def mock_connection_invalid_auth( aioclient_mock.get(f"{url}/api/v3/queue", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/rootfolder", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/system/status", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/calendar", status=HTTPStatus.UNAUTHORIZED) def mock_connection_server_error( @@ -136,6 +149,9 @@ def mock_connection_server_error( aioclient_mock.get( f"{url}/api/v3/system/status", status=HTTPStatus.INTERNAL_SERVER_ERROR ) + aioclient_mock.get( + f"{url}/api/v3/calendar", status=HTTPStatus.INTERNAL_SERVER_ERROR + ) async def setup_integration( @@ -172,6 +188,8 @@ async def setup_integration( single_return=single_return, ) + mock_calendar(aioclient_mock, url) + if not skip_entry_setup: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/radarr/fixtures/calendar.json b/tests/components/radarr/fixtures/calendar.json new file mode 100644 index 00000000000..2bf0338d639 --- /dev/null +++ b/tests/components/radarr/fixtures/calendar.json @@ -0,0 +1,111 @@ +[ + { + "title": "test", + "originalTitle": "string", + "alternateTitles": [], + "secondaryYearSourceId": 0, + "sortTitle": "string", + "sizeOnDisk": 0, + "status": "string", + "overview": "test2", + "physicalRelease": "2021-12-03T00:00:00Z", + "digitalRelease": "2020-08-11T00:00:00Z", + "images": [ + { + "coverType": "poster", + "url": "string" + } + ], + "website": "string", + "year": 0, + "hasFile": true, + "youTubeTrailerId": "string", + "studio": "string", + "path": "string", + "qualityProfileId": 0, + "monitored": true, + "minimumAvailability": "string", + "isAvailable": true, + "folderName": "string", + "runtime": 0, + "cleanTitle": "string", + "imdbId": "string", + "tmdbId": 0, + "titleSlug": "0", + "genres": ["string"], + "tags": [], + "added": "2020-07-16T13:25:37Z", + "ratings": { + "imdb": { + "votes": 0, + "value": 0.0, + "type": "string" + }, + "tmdb": { + "votes": 0, + "value": 0.0, + "type": "string" + }, + "metacritic": { + "votes": 0, + "value": 0, + "type": "string" + }, + "rottenTomatoes": { + "votes": 0, + "value": 0, + "type": "string" + } + }, + "movieFile": { + "movieId": 0, + "relativePath": "string", + "path": "string", + "size": 0, + "dateAdded": "2021-06-01T04:08:20Z", + "sceneName": "string", + "indexerFlags": 0, + "quality": { + "quality": { + "id": 0, + "name": "string", + "source": "string", + "resolution": 0, + "modifier": "string" + }, + "revision": { + "version": 0, + "real": 0, + "isRepack": false + } + }, + "mediaInfo": { + "audioBitrate": 0, + "audioChannels": 0.0, + "audioCodec": "string", + "audioLanguages": "string", + "audioStreamCount": 0, + "videoBitDepth": 0, + "videoBitrate": 0, + "videoCodec": "string", + "videoFps": 0.0, + "resolution": "string", + "runTime": "00:00:00", + "scanType": "string", + "subtitles": "string" + }, + "originalFilePath": "string", + "qualityCutoffNotMet": false, + "languages": [ + { + "id": 0, + "name": "string" + } + ], + "releaseGroup": "string", + "edition": "string", + "id": 0 + }, + "id": 0 + } +] diff --git a/tests/components/radarr/test_binary_sensor.py b/tests/components/radarr/test_binary_sensor.py index b6303de4a48..cd1df721d5f 100644 --- a/tests/components/radarr/test_binary_sensor.py +++ b/tests/components/radarr/test_binary_sensor.py @@ -1,4 +1,6 @@ """The tests for Radarr binary sensor platform.""" +import pytest + from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON from homeassistant.core import HomeAssistant @@ -8,6 +10,7 @@ from . import setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_binary_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_calendar.py b/tests/components/radarr/test_calendar.py new file mode 100644 index 00000000000..61e9bc27c9b --- /dev/null +++ b/tests/components/radarr/test_calendar.py @@ -0,0 +1,41 @@ +"""The tests for Radarr calendar platform.""" +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.radarr.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import setup_integration + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_calendar( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, +) -> None: + """Test for successfully setting up the Radarr platform.""" + freezer.move_to("2021-12-02 00:00:00-08:00") + entry = await setup_integration(hass, aioclient_mock) + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["calendar"] + + state = hass.states.get("calendar.mock_title") + assert state.state == STATE_ON + assert state.attributes.get("all_day") is True + assert state.attributes.get("description") == "test2" + assert state.attributes.get("end_time") == "2021-12-03 00:00:00" + assert state.attributes.get("message") == "test" + assert state.attributes.get("release_type") == "physicalRelease" + assert state.attributes.get("start_time") == "2021-12-02 00:00:00" + + freezer.tick(timedelta(hours=16)) + await coordinator.async_refresh() + + state = hass.states.get("calendar.mock_title") + assert state.state == STATE_OFF + assert len(state.attributes) == 1 + assert state.attributes.get("release_type") is None diff --git a/tests/components/radarr/test_config_flow.py b/tests/components/radarr/test_config_flow.py index 5527e311114..5eab7c02bb9 100644 --- a/tests/components/radarr/test_config_flow.py +++ b/tests/components/radarr/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from aiopyarr import exceptions +import pytest from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER @@ -135,6 +136,7 @@ async def test_zero_conf(hass: HomeAssistant) -> None: assert result["data"] == CONF_DATA +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_full_reauth_flow_implementation( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_init.py b/tests/components/radarr/test_init.py index f16e5895633..62660c12874 100644 --- a/tests/components/radarr/test_init.py +++ b/tests/components/radarr/test_init.py @@ -1,4 +1,6 @@ """Test Radarr integration.""" +import pytest + from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -9,6 +11,7 @@ from . import create_entry, mock_connection_invalid_auth, setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Test unload.""" entry = await setup_integration(hass, aioclient_mock) @@ -43,6 +46,7 @@ async def test_async_setup_entry_auth_failed( assert not hass.data.get(DOMAIN) +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_device_info( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index 90ab683037b..11f55b712cd 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -14,6 +14,7 @@ from . import setup_integration from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") @pytest.mark.parametrize( ("windows", "single", "root_folder"), [ @@ -65,6 +66,7 @@ async def test_sensors( assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL +@pytest.mark.freeze_time("2021-12-03 00:00:00+00:00") async def test_windows( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py index 922ec7b0a5a..44baf09fd55 100644 --- a/tests/components/rainbird/test_calendar.py +++ b/tests/components/rainbird/test_calendar.py @@ -115,7 +115,7 @@ def mock_insert_schedule_response( @pytest.fixture(name="get_events") def get_events_fixture( - hass_client: Callable[..., Awaitable[ClientSession]] + hass_client: Callable[..., Awaitable[ClientSession]], ) -> GetEventsFn: """Fetch calendar events from the HTTP API.""" diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 5fa457bf771..631f1d5a3f8 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -8,6 +8,7 @@ from regenmaschine.errors import RainMachineError from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components import zeroconf from homeassistant.components.rainmachine import ( + CONF_ALLOW_INACTIVE_ZONES_TO_RUN, CONF_DEFAULT_ZONE_RUN_TIME, CONF_USE_APP_RUN_TIMES, DOMAIN, @@ -106,12 +107,17 @@ async def test_options_flow(hass: HomeAssistant, config, config_entry) -> None: result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_DEFAULT_ZONE_RUN_TIME: 600, CONF_USE_APP_RUN_TIMES: False}, + user_input={ + CONF_DEFAULT_ZONE_RUN_TIME: 600, + CONF_USE_APP_RUN_TIMES: False, + CONF_ALLOW_INACTIVE_ZONES_TO_RUN: False, + }, ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert config_entry.options == { CONF_DEFAULT_ZONE_RUN_TIME: 600, CONF_USE_APP_RUN_TIMES: False, + CONF_ALLOW_INACTIVE_ZONES_TO_RUN: False, } diff --git a/tests/components/rainmachine/test_diagnostics.py b/tests/components/rainmachine/test_diagnostics.py index 9c3aa6cd7de..2180bf2a20e 100644 --- a/tests/components/rainmachine/test_diagnostics.py +++ b/tests/components/rainmachine/test_diagnostics.py @@ -2,6 +2,7 @@ from regenmaschine.errors import RainMachineError from homeassistant.components.diagnostics import REDACTED +from homeassistant.components.rainmachine.const import DEFAULT_ZONE_RUN from homeassistant.core import HomeAssistant from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -19,6 +20,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 2, + "minor_version": 1, "domain": "rainmachine", "title": "Mock Title", "data": { @@ -27,7 +29,11 @@ async def test_entry_diagnostics( "port": 8080, "ssl": True, }, - "options": {"use_app_run_times": False}, + "options": { + "zone_run_time": DEFAULT_ZONE_RUN, + "use_app_run_times": False, + "allow_inactive_zones_to_run": False, + }, "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", @@ -645,6 +651,7 @@ async def test_entry_diagnostics_failed_controller_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 2, + "minor_version": 1, "domain": "rainmachine", "title": "Mock Title", "data": { @@ -653,7 +660,11 @@ async def test_entry_diagnostics_failed_controller_diagnostics( "port": 8080, "ssl": True, }, - "options": {"use_app_run_times": False}, + "options": { + "zone_run_time": DEFAULT_ZONE_RUN, + "use_app_run_times": False, + "allow_inactive_zones_to_run": False, + }, "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", diff --git a/tests/components/recollect_waste/test_diagnostics.py b/tests/components/recollect_waste/test_diagnostics.py index e905f4a5606..69ff1596d7c 100644 --- a/tests/components/recollect_waste/test_diagnostics.py +++ b/tests/components/recollect_waste/test_diagnostics.py @@ -19,6 +19,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 2, + "minor_version": 1, "domain": "recollect_waste", "title": REDACTED, "data": {"place_id": REDACTED, "service_id": TEST_SERVICE_ID}, diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index 21016a65cc2..21af6b01182 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -118,7 +118,7 @@ def _add_db_entries( def test_get_full_significant_states_with_session_entity_no_matches( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" hass = hass_recorder() @@ -246,7 +246,7 @@ def test_state_changes_during_period( def test_state_changes_during_period_descending( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period descending.""" hass = hass_recorder() @@ -410,7 +410,7 @@ def test_get_last_state_change(hass_recorder: Callable[..., HomeAssistant]) -> N def test_ensure_state_can_be_copied( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Ensure a state can pass though copy(). @@ -455,7 +455,7 @@ def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> def test_get_significant_states_minimal_response( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -554,7 +554,7 @@ def test_get_significant_states_with_initial( def test_get_significant_states_without_initial( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -588,7 +588,7 @@ def test_get_significant_states_without_initial( def test_get_significant_states_entity_id( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -604,7 +604,7 @@ def test_get_significant_states_entity_id( def test_get_significant_states_multiple_entity_ids( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -626,7 +626,7 @@ def test_get_significant_states_multiple_entity_ids( def test_get_significant_states_are_ordered( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test order of results from get_significant_states. @@ -644,7 +644,7 @@ def test_get_significant_states_are_ordered( def test_get_significant_states_only( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test significant states when significant_states_only is set.""" hass = hass_recorder() @@ -1082,7 +1082,7 @@ async def test_get_full_significant_states_handles_empty_last_changed( def test_state_changes_during_period_multiple_entities_single_test( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period with multiple entities in the same test. @@ -1141,7 +1141,7 @@ async def test_get_full_significant_states_past_year_2038( def test_get_significant_states_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for get_significant_states.""" hass = hass_recorder() @@ -1151,7 +1151,7 @@ def test_get_significant_states_without_entity_ids_raises( def test_state_changes_during_period_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for state_changes_during_period.""" hass = hass_recorder() @@ -1161,7 +1161,7 @@ def test_state_changes_during_period_without_entity_ids_raises( def test_get_significant_states_with_filters_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test passing filters is no longer supported.""" hass = hass_recorder() @@ -1173,7 +1173,7 @@ def test_get_significant_states_with_filters_raises( def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_significant_states returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -1182,7 +1182,7 @@ def test_get_significant_states_with_non_existent_entity_ids_returns_empty( def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state_changes_during_period returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -1193,7 +1193,7 @@ def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_last_state_changes returns an empty dict when entities not in the db.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index 0ed6061de98..4f75dc15b15 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -37,7 +37,7 @@ def db_schema_30(): def test_get_full_significant_states_with_session_entity_no_matches( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" hass = hass_recorder() @@ -152,7 +152,7 @@ def test_state_changes_during_period( def test_state_changes_during_period_descending( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period descending.""" hass = hass_recorder() @@ -240,7 +240,7 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> def test_ensure_state_can_be_copied( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Ensure a state can pass though copy(). @@ -293,7 +293,7 @@ def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> def test_get_significant_states_minimal_response( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -362,7 +362,7 @@ def test_get_significant_states_minimal_response( def test_get_significant_states_with_initial( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -399,7 +399,7 @@ def test_get_significant_states_with_initial( def test_get_significant_states_without_initial( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -435,7 +435,7 @@ def test_get_significant_states_without_initial( def test_get_significant_states_entity_id( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -453,7 +453,7 @@ def test_get_significant_states_entity_id( def test_get_significant_states_multiple_entity_ids( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -480,7 +480,7 @@ def test_get_significant_states_multiple_entity_ids( def test_get_significant_states_are_ordered( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test order of results from get_significant_states. @@ -501,7 +501,7 @@ def test_get_significant_states_are_ordered( def test_get_significant_states_only( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test significant states when significant_states_only is set.""" hass = hass_recorder() @@ -644,7 +644,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: def test_state_changes_during_period_multiple_entities_single_test( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period with multiple entities in the same test. @@ -669,7 +669,7 @@ def test_state_changes_during_period_multiple_entities_single_test( def test_get_significant_states_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for get_significant_states.""" hass = hass_recorder() @@ -679,7 +679,7 @@ def test_get_significant_states_without_entity_ids_raises( def test_state_changes_during_period_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for state_changes_during_period.""" hass = hass_recorder() @@ -689,7 +689,7 @@ def test_state_changes_during_period_without_entity_ids_raises( def test_get_significant_states_with_filters_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test passing filters is no longer supported.""" hass = hass_recorder() @@ -701,7 +701,7 @@ def test_get_significant_states_with_filters_raises( def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_significant_states returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -710,7 +710,7 @@ def test_get_significant_states_with_non_existent_entity_ids_returns_empty( def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state_changes_during_period returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -721,7 +721,7 @@ def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_last_state_changes returns an empty dict when entities not in the db.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index 5b721cd4c87..477c13d6166 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -37,7 +37,7 @@ def db_schema_32(): def test_get_full_significant_states_with_session_entity_no_matches( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test getting states at a specific point in time for entities that never have been recorded.""" hass = hass_recorder() @@ -152,7 +152,7 @@ def test_state_changes_during_period( def test_state_changes_during_period_descending( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period descending.""" hass = hass_recorder() @@ -239,7 +239,7 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> def test_ensure_state_can_be_copied( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Ensure a state can pass though copy(). @@ -292,7 +292,7 @@ def test_get_significant_states(hass_recorder: Callable[..., HomeAssistant]) -> def test_get_significant_states_minimal_response( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -389,7 +389,7 @@ def test_get_significant_states_with_initial( def test_get_significant_states_without_initial( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned. @@ -425,7 +425,7 @@ def test_get_significant_states_without_initial( def test_get_significant_states_entity_id( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -443,7 +443,7 @@ def test_get_significant_states_entity_id( def test_get_significant_states_multiple_entity_ids( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that only significant states are returned for one entity.""" hass = hass_recorder() @@ -470,7 +470,7 @@ def test_get_significant_states_multiple_entity_ids( def test_get_significant_states_are_ordered( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test order of results from get_significant_states. @@ -491,7 +491,7 @@ def test_get_significant_states_are_ordered( def test_get_significant_states_only( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test significant states when significant_states_only is set.""" hass = hass_recorder() @@ -634,7 +634,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: def test_state_changes_during_period_multiple_entities_single_test( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state change during period with multiple entities in the same test. @@ -659,7 +659,7 @@ def test_state_changes_during_period_multiple_entities_single_test( def test_get_significant_states_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for get_significant_states.""" hass = hass_recorder() @@ -669,7 +669,7 @@ def test_get_significant_states_without_entity_ids_raises( def test_state_changes_during_period_without_entity_ids_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test at least one entity id is required for state_changes_during_period.""" hass = hass_recorder() @@ -679,7 +679,7 @@ def test_state_changes_during_period_without_entity_ids_raises( def test_get_significant_states_with_filters_raises( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test passing filters is no longer supported.""" hass = hass_recorder() @@ -691,7 +691,7 @@ def test_get_significant_states_with_filters_raises( def test_get_significant_states_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_significant_states returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -700,7 +700,7 @@ def test_get_significant_states_with_non_existent_entity_ids_returns_empty( def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test state_changes_during_period returns an empty dict when entities not in the db.""" hass = hass_recorder() @@ -711,7 +711,7 @@ def test_state_changes_during_period_with_non_existent_entity_ids_returns_empty( def test_get_last_state_changes_with_non_existent_entity_ids_returns_empty( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test get_last_state_changes returns an empty dict when entities not in the db.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 0dfbb6005c4..a9a12d72c41 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -537,7 +537,7 @@ def test_saving_event(hass_recorder: Callable[..., HomeAssistant]) -> None: def test_saving_state_with_commit_interval_zero( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving a state with a commit interval of zero.""" hass = hass_recorder({"commit_interval": 0}) @@ -594,7 +594,7 @@ def test_setup_without_migration(hass_recorder: Callable[..., HomeAssistant]) -> def test_saving_state_include_domains( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder({"include": {"domains": "test2"}}) @@ -604,7 +604,7 @@ def test_saving_state_include_domains( def test_saving_state_include_domains_globs( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -627,7 +627,7 @@ def test_saving_state_include_domains_globs( def test_saving_state_incl_entities( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder({"include": {"entities": "test2.recorder"}}) @@ -688,7 +688,7 @@ async def test_saving_event_exclude_event_type( def test_saving_state_exclude_domains( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder({"exclude": {"domains": "test"}}) @@ -698,7 +698,7 @@ def test_saving_state_exclude_domains( def test_saving_state_exclude_domains_globs( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -712,7 +712,7 @@ def test_saving_state_exclude_domains_globs( def test_saving_state_exclude_entities( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder({"exclude": {"entities": "test.recorder"}}) @@ -722,7 +722,7 @@ def test_saving_state_exclude_entities( def test_saving_state_exclude_domain_include_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -733,7 +733,7 @@ def test_saving_state_exclude_domain_include_entity( def test_saving_state_exclude_domain_glob_include_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -749,7 +749,7 @@ def test_saving_state_exclude_domain_glob_include_entity( def test_saving_state_include_domain_exclude_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -762,7 +762,7 @@ def test_saving_state_include_domain_exclude_entity( def test_saving_state_include_domain_glob_exclude_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( @@ -780,7 +780,7 @@ def test_saving_state_include_domain_glob_exclude_entity( def test_saving_state_and_removing_entity( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving the state of a removed entity.""" hass = hass_recorder() @@ -1025,7 +1025,7 @@ def test_auto_purge(hass_recorder: Callable[..., HomeAssistant]) -> None: @pytest.mark.parametrize("enable_nightly_purge", [True]) def test_auto_purge_auto_repack_on_second_sunday( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test periodic purge scheduling does a repack on the 2nd sunday.""" hass = hass_recorder() @@ -1065,7 +1065,7 @@ def test_auto_purge_auto_repack_on_second_sunday( @pytest.mark.parametrize("enable_nightly_purge", [True]) def test_auto_purge_auto_repack_disabled_on_second_sunday( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test periodic purge scheduling does not auto repack on the 2nd sunday if disabled.""" hass = hass_recorder({CONF_AUTO_REPACK: False}) @@ -1105,7 +1105,7 @@ def test_auto_purge_auto_repack_disabled_on_second_sunday( @pytest.mark.parametrize("enable_nightly_purge", [True]) def test_auto_purge_no_auto_repack_on_not_second_sunday( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test periodic purge scheduling does not do a repack unless its the 2nd sunday.""" hass = hass_recorder() @@ -1431,7 +1431,7 @@ def test_has_services(hass_recorder: Callable[..., HomeAssistant]) -> None: def test_service_disable_events_not_recording( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that events are not recorded when recorder is disabled using service.""" hass = hass_recorder() @@ -1515,7 +1515,7 @@ def test_service_disable_events_not_recording( def test_service_disable_states_not_recording( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test that state changes are not recorded when recorder is disabled using service.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 03dc7b84caa..69b7f9316f7 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -432,7 +432,7 @@ def test_rename_entity(hass_recorder: Callable[..., HomeAssistant]) -> None: def test_statistics_during_period_set_back_compat( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test statistics_during_period can handle a list instead of a set.""" hass = hass_recorder() diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 0a30895adc9..66daced2ca8 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -883,7 +883,7 @@ def test_build_mysqldb_conv() -> None: @patch("homeassistant.components.recorder.util.QUERY_RETRY_WAIT", 0) def test_execute_stmt_lambda_element( - hass_recorder: Callable[..., HomeAssistant] + hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test executing with execute_stmt_lambda_element.""" hass = hass_recorder() diff --git a/tests/components/refoss/__init__.py b/tests/components/refoss/__init__.py new file mode 100644 index 00000000000..34df1b41714 --- /dev/null +++ b/tests/components/refoss/__init__.py @@ -0,0 +1,107 @@ +"""Common helpers for refoss test cases.""" +import asyncio +import logging +from unittest.mock import AsyncMock, Mock + +from refoss_ha.discovery import Listener + +from homeassistant.components.refoss.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +class FakeDiscovery: + """Mock class replacing refoss device discovery.""" + + def __init__(self) -> None: + """Initialize the class.""" + self.mock_devices = {"abc": build_device_mock()} + self.last_mock_infos = {} + self._listeners = [] + + def add_listener(self, listener: Listener) -> None: + """Add an event listener.""" + self._listeners.append(listener) + + async def initialize(self) -> None: + """Initialize socket server.""" + self.sock = Mock() + + async def broadcast_msg(self, wait_for: int = 0): + """Search for devices, return mocked data.""" + + mock_infos = self.mock_devices + last_mock_infos = self.last_mock_infos + + new_infos = [] + updated_infos = [] + + for info in mock_infos.values(): + uuid = info.uuid + if uuid not in last_mock_infos: + new_infos.append(info) + else: + last_info = self.last_mock_infos[uuid] + if info.inner_ip != last_info.inner_ip: + updated_infos.append(info) + + self.last_mock_infos = mock_infos + for listener in self._listeners: + [await listener.device_found(x) for x in new_infos] + [await listener.device_update(x) for x in updated_infos] + + if wait_for: + await asyncio.sleep(wait_for) + + return new_infos + + +def build_device_mock(name="r10", ip="1.1.1.1", mac="aabbcc112233"): + """Build mock device object.""" + mock = Mock( + uuid="abc", + dev_name=name, + device_type="r10", + fmware_version="1.1.1", + hdware_version="1.1.2", + inner_ip=ip, + port="80", + mac=mac, + sub_type="eu", + channels=[0], + ) + return mock + + +def build_base_device_mock(name="r10", ip="1.1.1.1", mac="aabbcc112233"): + """Build mock base device object.""" + mock = Mock( + device_info=build_device_mock(name=name, ip=ip, mac=mac), + uuid="abc", + dev_name=name, + device_type="r10", + fmware_version="1.1.1", + hdware_version="1.1.2", + inner_ip=ip, + port="80", + mac=mac, + sub_type="eu", + channels=[0], + async_handle_update=AsyncMock(), + async_turn_on=AsyncMock(), + async_turn_off=AsyncMock(), + async_toggle=AsyncMock(), + ) + mock.status = {0: True} + return mock + + +async def async_setup_refoss(hass: HomeAssistant) -> MockConfigEntry: + """Set up the refoss platform.""" + entry = MockConfigEntry(domain=DOMAIN) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry diff --git a/tests/components/refoss/conftest.py b/tests/components/refoss/conftest.py new file mode 100644 index 00000000000..2fc695bbb2e --- /dev/null +++ b/tests/components/refoss/conftest.py @@ -0,0 +1,14 @@ +"""Pytest module configuration.""" +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.refoss.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/refoss/test_config_flow.py b/tests/components/refoss/test_config_flow.py new file mode 100644 index 00000000000..2a5842ffe46 --- /dev/null +++ b/tests/components/refoss/test_config_flow.py @@ -0,0 +1,65 @@ +"""Tests for the refoss Integration.""" +from unittest.mock import AsyncMock, patch + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.refoss.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import FakeDiscovery, build_base_device_mock + + +@patch("homeassistant.components.refoss.config_flow.DISCOVERY_TIMEOUT", 0) +async def test_creating_entry_sets_up( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test setting up refoss.""" + with patch( + "homeassistant.components.refoss.util.Discovery", + return_value=FakeDiscovery(), + ), patch( + "homeassistant.components.refoss.bridge.async_build_base_device", + return_value=build_base_device_mock(), + ), patch( + "homeassistant.components.refoss.switch.isinstance", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + + +@patch("homeassistant.components.refoss.config_flow.DISCOVERY_TIMEOUT", 0) +async def test_creating_entry_has_no_devices( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test setting up Refoss no devices.""" + with patch( + "homeassistant.components.refoss.util.Discovery", + return_value=FakeDiscovery(), + ) as discovery: + discovery.return_value.mock_devices = {} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py index 6219943693b..a75ff858483 100644 --- a/tests/components/remote/test_init.py +++ b/tests/components/remote/test_init.py @@ -1,4 +1,6 @@ """The tests for the Remote component, adapted from Light Test.""" +import pytest + import homeassistant.components.remote as remote from homeassistant.components.remote import ( ATTR_ALTERNATIVE, @@ -20,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from tests.common import async_mock_service +from tests.common import async_mock_service, import_and_test_deprecated_constant_enum TEST_PLATFORM = {DOMAIN: {CONF_PLATFORM: "test"}} SERVICE_SEND_COMMAND = "send_command" @@ -139,3 +141,32 @@ async def test_delete_command(hass: HomeAssistant) -> None: assert call.domain == remote.DOMAIN assert call.service == SERVICE_DELETE_COMMAND assert call.data[ATTR_ENTITY_ID] == ENTITY_ID + + +@pytest.mark.parametrize(("enum"), list(remote.RemoteEntityFeature)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: remote.RemoteEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, remote, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockRemote(remote.RemoteEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockRemote() + assert entity.supported_features_compat is remote.RemoteEntityFeature(1) + assert "MockRemote" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "RemoteEntityFeature.LEARN_COMMAND" in caplog.text + caplog.clear() + assert entity.supported_features_compat is remote.RemoteEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/remote/test_significant_change.py b/tests/components/remote/test_significant_change.py new file mode 100644 index 00000000000..dcbfce213d6 --- /dev/null +++ b/tests/components/remote/test_significant_change.py @@ -0,0 +1,62 @@ +"""Test the Remote significant change platform.""" +from homeassistant.components.remote import ATTR_ACTIVITY_LIST, ATTR_CURRENT_ACTIVITY +from homeassistant.components.remote.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_change() -> None: + """Detect Remote significant changes.""" + # no change at all + attrs = { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + } + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + + # change of state is significant + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + # change of current activity is significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "paused", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + } + assert async_check_significant_change(None, "on", attrs["old"], "on", attrs["new"]) + + # change of list of possible activities is not significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing"], + }, + } + assert not async_check_significant_change( + None, "on", attrs["old"], "on", attrs["new"] + ) + + # change of any not official attribute is not significant + attrs = { + "old": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + }, + "new": { + ATTR_CURRENT_ACTIVITY: "playing", + ATTR_ACTIVITY_LIST: ["playing", "paused"], + "not_official": "changed", + }, + } + assert not async_check_significant_change( + None, "on", attrs["old"], "on", attrs["new"] + ) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 75d2dc0c661..3f81a30f898 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -6,7 +6,13 @@ import pytest from homeassistant.components.reolink import const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac @@ -25,6 +31,8 @@ TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" TEST_NVR_NAME2 = "test2_reolink_name" TEST_USE_HTTPS = True +TEST_HOST_MODEL = "RLN8-410" +TEST_CAM_MODEL = "RLC-123" @pytest.fixture @@ -70,8 +78,8 @@ def reolink_connect_class( host_mock.hardware_version = "IPC_00000" host_mock.sw_version = "v1.0.0.0.0.0000" host_mock.manufacturer = "Reolink" - host_mock.model = "RLC-123" - host_mock.camera_model.return_value = "RLC-123" + host_mock.model = TEST_HOST_MODEL + host_mock.camera_model.return_value = TEST_CAM_MODEL host_mock.camera_name.return_value = TEST_NVR_NAME host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" host_mock.session_active = True @@ -119,7 +127,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME, ) diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 604a9364320..9f70673695c 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -41,7 +41,7 @@ 'event connection': 'Fast polling', 'firmware version': 'v1.0.0.0.0.0000', 'hardware version': 'IPC_00000', - 'model': 'RLC-123', + 'model': 'RLN8-410', 'stream channels': list([ 0, ]), diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 9b449d4b851..dd9949a5dce 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -14,7 +14,13 @@ from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.exceptions import ReolinkWebhookException from homeassistant.components.reolink.host import DEFAULT_TIMEOUT from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac from homeassistant.util.dt import utcnow @@ -68,7 +74,7 @@ async def test_config_flow_manual_success( const.CONF_USE_HTTPS: TEST_USE_HTTPS, } assert result["options"] == { - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, } @@ -195,7 +201,7 @@ async def test_config_flow_errors( const.CONF_USE_HTTPS: TEST_USE_HTTPS, } assert result["options"] == { - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, } @@ -212,7 +218,7 @@ async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: "rtsp", + CONF_PROTOCOL: "rtsp", }, title=TEST_NVR_NAME, ) @@ -228,12 +234,12 @@ async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={const.CONF_PROTOCOL: "rtmp"}, + user_input={CONF_PROTOCOL: "rtmp"}, ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert config_entry.options == { - const.CONF_PROTOCOL: "rtmp", + CONF_PROTOCOL: "rtmp", } @@ -252,7 +258,7 @@ async def test_change_connection_settings( const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME, ) @@ -295,7 +301,7 @@ async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME, ) @@ -376,7 +382,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No const.CONF_USE_HTTPS: TEST_USE_HTTPS, } assert result["options"] == { - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, } @@ -435,7 +441,7 @@ async def test_dhcp_ip_update( const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME, ) diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index e2bd622bb43..65490129486 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -11,11 +11,15 @@ from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .conftest import TEST_NVR_NAME +from .conftest import TEST_CAM_MODEL, TEST_HOST_MODEL, TEST_MAC, TEST_NVR_NAME from tests.common import MockConfigEntry, async_fire_time_changed @@ -102,6 +106,7 @@ async def test_entry_reloading( reolink_connect: MagicMock, ) -> None: """Test the entry is reloaded correctly when settings change.""" + reolink_connect.is_nvr = False assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -115,6 +120,94 @@ async def test_entry_reloading( assert config_entry.title == "New Name" +@pytest.mark.parametrize( + ("attr", "value", "expected_models"), + [ + ( + None, + None, + [TEST_HOST_MODEL, TEST_CAM_MODEL], + ), + ("channels", [], [TEST_HOST_MODEL]), + ( + "camera_model", + Mock(return_value="RLC-567"), + [TEST_HOST_MODEL, "RLC-567"], + ), + ], +) +async def test_cleanup_disconnected_cams( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + attr: str | None, + value: Any, + expected_models: list[str], +) -> None: + """Test device and entity registry are cleaned up when camera is disconnected from NVR.""" + reolink_connect.channels = [0] + # setup CH 0 and NVR switch entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + device_models = [device.model for device in device_entries] + assert sorted(device_models) == sorted([TEST_HOST_MODEL, TEST_CAM_MODEL]) + + # reload integration after 'disconnecting' a camera. + if attr is not None: + setattr(reolink_connect, attr, value) + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_reload(config_entry.entry_id) + + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + device_models = [device.model for device in device_entries] + assert sorted(device_models) == sorted(expected_models) + + +async def test_cleanup_deprecated_entities( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test deprecated ir_lights light entity is cleaned.""" + reolink_connect.channels = [0] + ir_id = f"{TEST_MAC}_0_ir_lights" + + entity_registry.async_get_or_create( + domain=Platform.LIGHT, + platform=const.DOMAIN, + unique_id=ir_id, + config_entry=config_entry, + suggested_object_id=ir_id, + disabled_by=None, + ) + + assert entity_registry.async_get_entity_id(Platform.LIGHT, const.DOMAIN, ir_id) + assert ( + entity_registry.async_get_entity_id(Platform.SWITCH, const.DOMAIN, ir_id) + is None + ) + + # setup CH 0 and NVR switch entities/device + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + entity_registry.async_get_entity_id(Platform.LIGHT, const.DOMAIN, ir_id) is None + ) + assert entity_registry.async_get_entity_id(Platform.SWITCH, const.DOMAIN, ir_id) + + async def test_no_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 7fe3570564a..ddb66463419 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -21,6 +21,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, + CONF_PROTOCOL, CONF_USERNAME, Platform, ) @@ -271,7 +272,7 @@ async def test_browsing_not_loaded( const.CONF_USE_HTTPS: TEST_USE_HTTPS, }, options={ - const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + CONF_PROTOCOL: DEFAULT_PROTOCOL, }, title=TEST_NVR_NAME2, ) diff --git a/tests/components/repairs/test_websocket_api.py b/tests/components/repairs/test_websocket_api.py index 6c9b51a7cf6..1f68c9a28d3 100644 --- a/tests/components/repairs/test_websocket_api.py +++ b/tests/components/repairs/test_websocket_api.py @@ -338,6 +338,7 @@ async def test_fix_issue( "description_placeholders": None, "flow_id": flow_id, "handler": domain, + "minor_version": 1, "type": "create_entry", "version": 1, } diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index cc591573bd6..7be2ce4c63e 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -52,6 +52,22 @@ RESOURCE = "http://localhost/" STATE_RESOURCE = RESOURCE +@pytest.fixture( + params=( + HTTPStatus.OK, + HTTPStatus.CREATED, + HTTPStatus.ACCEPTED, + HTTPStatus.NON_AUTHORITATIVE_INFORMATION, + HTTPStatus.NO_CONTENT, + HTTPStatus.RESET_CONTENT, + HTTPStatus.PARTIAL_CONTENT, + ) +) +def http_success_code(request: pytest.FixtureRequest) -> HTTPStatus: + """Fixture providing different successful HTTP response code.""" + return request.param + + async def test_setup_missing_config( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -61,8 +77,8 @@ async def test_setup_missing_config( await hass.async_block_till_done() assert_setup_component(0, SWITCH_DOMAIN) assert ( - "Invalid config for 'switch.rest': required key 'resource' not provided" - in caplog.text + "Invalid config for 'switch' from integration 'rest': required key 'resource' " + "not provided" in caplog.text ) @@ -74,7 +90,10 @@ async def test_setup_missing_schema( assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() assert_setup_component(0, SWITCH_DOMAIN) - assert "Invalid config for 'switch.rest': invalid url" in caplog.text + assert ( + "Invalid config for 'switch' from integration 'rest': invalid url" + in caplog.text + ) @respx.mock @@ -258,11 +277,14 @@ async def test_is_on_before_update(hass: HomeAssistant) -> None: @respx.mock -async def test_turn_on_success(hass: HomeAssistant) -> None: +async def test_turn_on_success( + hass: HomeAssistant, + http_success_code: HTTPStatus, +) -> None: """Test turn_on.""" await _async_setup_test_switch(hass) - route = respx.post(RESOURCE) % HTTPStatus.OK + route = respx.post(RESOURCE) % http_success_code respx.get(RESOURCE).mock(side_effect=httpx.RequestError) await hass.services.async_call( SWITCH_DOMAIN, @@ -316,11 +338,14 @@ async def test_turn_on_timeout(hass: HomeAssistant) -> None: @respx.mock -async def test_turn_off_success(hass: HomeAssistant) -> None: +async def test_turn_off_success( + hass: HomeAssistant, + http_success_code: HTTPStatus, +) -> None: """Test turn_off.""" await _async_setup_test_switch(hass) - route = respx.post(RESOURCE) % HTTPStatus.OK + route = respx.post(RESOURCE) % http_success_code respx.get(RESOURCE).mock(side_effect=httpx.RequestError) await hass.services.async_call( SWITCH_DOMAIN, diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index 4562bf928c8..e5a5c73de39 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -216,7 +216,7 @@ async def test_setup_network_fail(transport_mock, hass: HomeAssistant) -> None: @patch("serial.tools.list_ports.comports", return_value=[com_port()]) @patch( "homeassistant.components.rfxtrx.rfxtrxmod.PySerialTransport.connect", - side_effect=serial.serialutil.SerialException, + side_effect=serial.SerialException, ) async def test_setup_serial_fail(com_mock, connect_mock, hass: HomeAssistant) -> None: """Test setup serial failed connection.""" diff --git a/tests/components/ridwell/snapshots/test_diagnostics.ambr b/tests/components/ridwell/snapshots/test_diagnostics.ambr index a98374d2941..d32b1d3f446 100644 --- a/tests/components/ridwell/snapshots/test_diagnostics.ambr +++ b/tests/components/ridwell/snapshots/test_diagnostics.ambr @@ -36,6 +36,7 @@ 'disabled_by': None, 'domain': 'ridwell', 'entry_id': '11554ec901379b9cc8f5a6c1d11ce978', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/rituals_perfume_genie/test_select.py b/tests/components/rituals_perfume_genie/test_select.py index 3153005d094..a055e8fed05 100644 --- a/tests/components/rituals_perfume_genie/test_select.py +++ b/tests/components/rituals_perfume_genie/test_select.py @@ -15,6 +15,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -84,7 +85,7 @@ async def test_select_invalid_option(hass: HomeAssistant) -> None: assert state assert state.state == "60" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 5d4568ce7ac..c186741aac9 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError @@ -153,6 +154,7 @@ async def test_availability( hass: HomeAssistant, mock_roku: MagicMock, mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, error: RokuError, ) -> None: """Test entity availability.""" @@ -160,23 +162,22 @@ async def test_availability( future = now + timedelta(minutes=1) mock_config_entry.add_to_hass(hass) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + freezer.move_to(now) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - with patch("homeassistant.util.dt.utcnow", return_value=future): - mock_roku.update.side_effect = error - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE + freezer.move_to(future) + mock_roku.update.side_effect = error + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE future += timedelta(minutes=1) - - with patch("homeassistant.util.dt.utcnow", return_value=future): - mock_roku.update.side_effect = None - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert hass.states.get(MAIN_ENTITY_ID).state == STATE_IDLE + freezer.move_to(future) + mock_roku.update.side_effect = None + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert hass.states.get(MAIN_ENTITY_ID).state == STATE_IDLE async def test_supported_features( diff --git a/tests/components/rtsp_to_webrtc/conftest.py b/tests/components/rtsp_to_webrtc/conftest.py index f6ee0d1a628..edb8c7c4aca 100644 --- a/tests/components/rtsp_to_webrtc/conftest.py +++ b/tests/components/rtsp_to_webrtc/conftest.py @@ -51,9 +51,6 @@ async def mock_camera(hass) -> AsyncGenerator[None, None]: ), patch( "homeassistant.components.camera.Camera.stream_source", return_value=STREAM_SOURCE, - ), patch( - "homeassistant.components.camera.Camera.supported_features", - return_value=camera.SUPPORT_STREAM, ): yield diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 874697bf777..6754faf2da6 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -233,7 +233,7 @@ def remotews_fixture() -> Mock: remotews.app_list_data = None async def _start_listening( - ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None + ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None, ): remotews.ws_event_callback = ws_event_callback @@ -272,7 +272,7 @@ def remoteencws_fixture() -> Mock: remoteencws.__aexit__ = AsyncMock() def _start_listening( - ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None + ws_event_callback: Callable[[str, Any], Awaitable[None] | None] | None = None, ): remoteencws.ws_event_callback = ws_event_callback diff --git a/tests/components/samsungtv/test_diagnostics.py b/tests/components/samsungtv/test_diagnostics.py index 231880a009b..651b6f27a44 100644 --- a/tests/components/samsungtv/test_diagnostics.py +++ b/tests/components/samsungtv/test_diagnostics.py @@ -41,6 +41,7 @@ async def test_entry_diagnostics( "disabled_by": None, "domain": "samsungtv", "entry_id": "123456", + "minor_version": 1, "options": {}, "pref_disable_new_entities": False, "pref_disable_polling": False, @@ -77,6 +78,7 @@ async def test_entry_diagnostics_encrypted( "disabled_by": None, "domain": "samsungtv", "entry_id": "123456", + "minor_version": 1, "options": {}, "pref_disable_new_entities": False, "pref_disable_polling": False, @@ -112,6 +114,7 @@ async def test_entry_diagnostics_encrypte_offline( "disabled_by": None, "domain": "samsungtv", "entry_id": "123456", + "minor_version": 1, "options": {}, "pref_disable_new_entities": False, "pref_disable_polling": False, diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 674dea752a0..27a06ef3a13 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -11,6 +11,7 @@ from async_upnp_client.exceptions import ( UpnpError, UpnpResponseError, ) +from freezegun.api import FrozenDateTimeFactory import pytest from samsungctl import exceptions from samsungtvws.async_remote import SamsungTVWSAsyncRemote @@ -165,7 +166,9 @@ async def test_setup_websocket(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("rest_api") -async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_setup_websocket_2( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Test setup of platform from config entry.""" entity_id = f"{DOMAIN}.fake" @@ -194,9 +197,9 @@ async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> Non assert config_entries[0].data[CONF_MAC] == "aa:bb:ww:ii:ff:ii" next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state @@ -205,7 +208,7 @@ async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> Non @pytest.mark.usefixtures("rest_api") async def test_setup_encrypted_websocket( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Test setup of platform from config entry.""" with patch( @@ -219,9 +222,9 @@ async def test_setup_encrypted_websocket( await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state @@ -229,21 +232,25 @@ async def test_setup_encrypted_websocket( @pytest.mark.usefixtures("remote") -async def test_update_on(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_on( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv on.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @pytest.mark.usefixtures("remote") -async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_off( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -252,16 +259,20 @@ async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: side_effect=[OSError("Boom"), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE async def test_update_off_ws_no_power_state( - hass: HomeAssistant, remotews: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -276,9 +287,9 @@ async def test_update_off_ws_no_power_state( remotews.is_alive.return_value = False next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -287,7 +298,11 @@ async def test_update_off_ws_no_power_state( @pytest.mark.usefixtures("remotews") async def test_update_off_ws_with_power_state( - hass: HomeAssistant, remotews: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" with patch.object( @@ -308,9 +323,9 @@ async def test_update_off_ws_with_power_state( device_info["device"]["PowerState"] = "on" rest_api.rest_device_info.return_value = device_info next_update = mock_now + timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() remotews.start_listening.assert_called_once() rest_api.rest_device_info.assert_called_once() @@ -324,9 +339,9 @@ async def test_update_off_ws_with_power_state( # Second update uses device_info(ON) rest_api.rest_device_info.reset_mock() next_update = mock_now + timedelta(minutes=2) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() rest_api.rest_device_info.assert_called_once() @@ -337,9 +352,9 @@ async def test_update_off_ws_with_power_state( rest_api.rest_device_info.reset_mock() device_info["device"]["PowerState"] = "off" next_update = mock_now + timedelta(minutes=3) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() rest_api.rest_device_info.assert_called_once() @@ -350,7 +365,11 @@ async def test_update_off_ws_with_power_state( async def test_update_off_encryptedws( - hass: HomeAssistant, remoteencws: Mock, rest_api: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remoteencws: Mock, + rest_api: Mock, + mock_now: datetime, ) -> None: """Testing update tv off.""" await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) @@ -364,9 +383,9 @@ async def test_update_off_encryptedws( remoteencws.is_alive.return_value = False next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -374,7 +393,9 @@ async def test_update_off_encryptedws( @pytest.mark.usefixtures("remote") -async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> None: +async def test_update_access_denied( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime +) -> None: """Testing update tv access denied exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -383,13 +404,14 @@ async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> side_effect=exceptions.AccessDenied("Boom"), ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() assert [ flow @@ -403,6 +425,7 @@ async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> @pytest.mark.usefixtures("rest_api") async def test_update_ws_connection_failure( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_now: datetime, remotews: Mock, caplog: pytest.LogCaptureFixture, @@ -416,8 +439,8 @@ async def test_update_ws_connection_failure( side_effect=ConnectionFailure('{"event": "ms.voiceApp.hide"}'), ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert ( @@ -432,7 +455,10 @@ async def test_update_ws_connection_failure( @pytest.mark.usefixtures("rest_api") async def test_update_ws_connection_closed( - hass: HomeAssistant, mock_now: datetime, remotews: Mock + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_now: datetime, + remotews: Mock, ) -> None: """Testing update tv connection failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -441,8 +467,8 @@ async def test_update_ws_connection_closed( remotews, "start_listening", side_effect=ConnectionClosedError(None, None) ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) @@ -451,7 +477,10 @@ async def test_update_ws_connection_closed( @pytest.mark.usefixtures("rest_api") async def test_update_ws_unauthorized_error( - hass: HomeAssistant, mock_now: datetime, remotews: Mock + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_now: datetime, + remotews: Mock, ) -> None: """Testing update tv unauthorized failure exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIGWS) @@ -460,8 +489,8 @@ async def test_update_ws_unauthorized_error( remotews, "start_listening", side_effect=UnauthorizedError ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) await hass.async_block_till_done() assert [ @@ -475,7 +504,7 @@ async def test_update_ws_unauthorized_error( @pytest.mark.usefixtures("remote") async def test_update_unhandled_response( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Testing update tv unhandled response exception.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -485,9 +514,9 @@ async def test_update_unhandled_response( side_effect=[exceptions.UnhandledResponse("Boom"), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -495,7 +524,7 @@ async def test_update_unhandled_response( @pytest.mark.usefixtures("remote") async def test_connection_closed_during_update_can_recover( - hass: HomeAssistant, mock_now: datetime + hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_now: datetime ) -> None: """Testing update tv connection closed exception can recover.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) @@ -505,17 +534,17 @@ async def test_connection_closed_during_update_can_recover( side_effect=[exceptions.ConnectionClosed(), DEFAULT_MOCK], ): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -653,7 +682,7 @@ async def test_name(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("remote") -async def test_state(hass: HomeAssistant) -> None: +async def test_state(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test for state property.""" await setup_samsungtv_entry(hass, MOCK_CONFIG) await hass.services.async_call( @@ -672,7 +701,8 @@ async def test_state(hass: HomeAssistant) -> None: with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError, - ), patch("homeassistant.util.dt.utcnow", return_value=next_update): + ): + freezer.move_to(next_update) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() @@ -1393,7 +1423,11 @@ async def test_upnp_subscribe_events_upnpresponseerror( @pytest.mark.usefixtures("rest_api", "upnp_notify_server") async def test_upnp_re_subscribe_events( - hass: HomeAssistant, remotews: Mock, dmr_device: Mock, mock_now: datetime + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remotews: Mock, + dmr_device: Mock, + mock_now: datetime, ) -> None: """Test for Upnp event feedback.""" await setup_samsungtv_entry(hass, MOCK_ENTRY_WS) @@ -1407,9 +1441,9 @@ async def test_upnp_re_subscribe_events( remotews, "start_listening", side_effect=WebSocketException("Boom") ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -1417,9 +1451,9 @@ async def test_upnp_re_subscribe_events( assert dmr_device.async_unsubscribe_services.call_count == 1 next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON @@ -1434,6 +1468,7 @@ async def test_upnp_re_subscribe_events( ) async def test_upnp_failed_re_subscribe_events( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, remotews: Mock, dmr_device: Mock, mock_now: datetime, @@ -1452,9 +1487,9 @@ async def test_upnp_failed_re_subscribe_events( remotews, "start_listening", side_effect=WebSocketException("Boom") ), patch.object(remotews, "is_alive", return_value=False): next_update = mock_now + timedelta(minutes=5) - with patch("homeassistant.util.dt.utcnow", return_value=next_update): - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() + freezer.move_to(next_update) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state.state == STATE_OFF @@ -1462,9 +1497,8 @@ async def test_upnp_failed_re_subscribe_events( assert dmr_device.async_unsubscribe_services.call_count == 1 next_update = mock_now + timedelta(minutes=10) - with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch.object( - dmr_device, "async_subscribe_services", side_effect=error - ): + with patch.object(dmr_device, "async_subscribe_services", side_effect=error): + freezer.move_to(next_update) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() diff --git a/tests/components/samsungtv/test_trigger.py b/tests/components/samsungtv/test_trigger.py index 27f6d7a8e51..12af639b251 100644 --- a/tests/components/samsungtv/test_trigger.py +++ b/tests/components/samsungtv/test_trigger.py @@ -57,7 +57,7 @@ async def test_turn_on_trigger_device_id( assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) calls.clear() diff --git a/tests/components/screenlogic/fixtures/data_full_chem.json b/tests/components/screenlogic/fixtures/data_full_chem.json index 6c9ece22fcf..8cef1e7d769 100644 --- a/tests/components/screenlogic/fixtures/data_full_chem.json +++ b/tests/components/screenlogic/fixtures/data_full_chem.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { @@ -152,6 +154,14 @@ "value": 0, "device_type": "alarm" } + }, + "date_time": { + "timestamp": 1700489169.0, + "timestamp_host": 1700517812.0, + "auto_dst": { + "name": "Automatic Daylight Saving Time", + "value": 1 + } } }, "circuit": { @@ -681,32 +691,44 @@ "ph_setpoint": { "name": "pH Setpoint", "value": 7.6, - "unit": "pH" + "unit": "pH", + "max_setpoint": 7.6, + "min_setpoint": 7.2 }, "orp_setpoint": { "name": "ORP Setpoint", "value": 720, - "unit": "mV" + "unit": "mV", + "max_setpoint": 800, + "min_setpoint": 400 }, "calcium_harness": { "name": "Calcium Hardness", "value": 800, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "cya": { "name": "Cyanuric Acid", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 201, + "min_setpoint": 0 }, "total_alkalinity": { "name": "Total Alkalinity", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "salt_tds_ppm": { "name": "Salt/TDS", "value": 1000, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 6500, + "min_setpoint": 500 }, "probe_is_celsius": 0, "flags": 32 @@ -814,7 +836,9 @@ }, "firmware": { "name": "IntelliChem Firmware", - "value": "1.060" + "value": "1.060", + "major": 1, + "minor": 60 }, "water_balance": { "flags": 0, @@ -875,6 +899,10 @@ "step": 1 } }, - "flags": 0 + "flags": 0, + "super_chlorinate": { + "name": "Super Chlorinate", + "value": 0 + } } } diff --git a/tests/components/screenlogic/fixtures/data_full_no_gpm.json b/tests/components/screenlogic/fixtures/data_full_no_gpm.json index 93e3040f911..521d77cdb5c 100644 --- a/tests/components/screenlogic/fixtures/data_full_no_gpm.json +++ b/tests/components/screenlogic/fixtures/data_full_no_gpm.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 738.0 Rel" + "value": "POOL: 5.2 Build 738.0 Rel", + "major": 5.2, + "minor": 738.0 } }, "controller": { @@ -146,6 +148,14 @@ "value": 0, "device_type": "alarm" } + }, + "date_time": { + "timestamp": 1700489169.0, + "timestamp_host": 1700517812.0, + "auto_dst": { + "name": "Automatic Daylight Saving Time", + "value": 1 + } } }, "circuit": { @@ -585,32 +595,44 @@ "ph_setpoint": { "name": "pH Setpoint", "value": 0.0, - "unit": "pH" + "unit": "pH", + "max_setpoint": 7.6, + "min_setpoint": 7.2 }, "orp_setpoint": { "name": "ORP Setpoint", "value": 0, - "unit": "mV" + "unit": "mV", + "max_setpoint": 800, + "min_setpoint": 400 }, "calcium_harness": { "name": "Calcium Hardness", "value": 0, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "cya": { "name": "Cyanuric Acid", "value": 0, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 201, + "min_setpoint": 0 }, "total_alkalinity": { "name": "Total Alkalinity", "value": 0, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "salt_tds_ppm": { "name": "Salt/TDS", "value": 0, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 6500, + "min_setpoint": 500 }, "probe_is_celsius": 0, "flags": 0 @@ -718,7 +740,9 @@ }, "firmware": { "name": "IntelliChem Firmware", - "value": "0.000" + "value": "0.000", + "major": 0, + "minor": 0 }, "water_balance": { "flags": 0, @@ -779,6 +803,10 @@ "step": 1 } }, - "flags": 0 + "flags": 0, + "super_chlorinate": { + "name": "Super Chlorinate", + "value": 0 + } } } diff --git a/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json b/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json index d17d0e41170..c37f20f35ab 100644 --- a/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json +++ b/tests/components/screenlogic/fixtures/data_full_no_salt_ppm.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { @@ -146,6 +148,14 @@ "value": 0, "device_type": "alarm" } + }, + "date_time": { + "timestamp": 1700489169.0, + "timestamp_host": 1700517812.0, + "auto_dst": { + "name": "Automatic Daylight Saving Time", + "value": 1 + } } }, "circuit": { @@ -675,32 +685,44 @@ "ph_setpoint": { "name": "pH Setpoint", "value": 7.6, - "unit": "pH" + "unit": "pH", + "max_setpoint": 7.6, + "min_setpoint": 7.2 }, "orp_setpoint": { "name": "ORP Setpoint", "value": 720, - "unit": "mV" + "unit": "mV", + "max_setpoint": 800, + "min_setpoint": 400 }, "calcium_harness": { "name": "Calcium Hardness", "value": 800, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "cya": { "name": "Cyanuric Acid", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 201, + "min_setpoint": 0 }, "total_alkalinity": { "name": "Total Alkalinity", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "salt_tds_ppm": { "name": "Salt/TDS", "value": 1000, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 6500, + "min_setpoint": 500 }, "probe_is_celsius": 0, "flags": 32 @@ -808,7 +830,9 @@ }, "firmware": { "name": "IntelliChem Firmware", - "value": "1.060" + "value": "1.060", + "major": 1, + "minor": 60 }, "water_balance": { "flags": 0, @@ -854,6 +878,10 @@ "step": 1 } }, - "flags": 0 + "flags": 0, + "super_chlorinate": { + "name": "Super Chlorinate", + "value": 0 + } } } diff --git a/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json b/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json index 40f7dbe4ad5..25a52074011 100644 --- a/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json +++ b/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { diff --git a/tests/components/screenlogic/fixtures/data_min_migration.json b/tests/components/screenlogic/fixtures/data_min_migration.json index 335c98db0ae..6796eb301c4 100644 --- a/tests/components/screenlogic/fixtures/data_min_migration.json +++ b/tests/components/screenlogic/fixtures/data_min_migration.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { diff --git a/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json index c30ee690f8a..aa0df6e3df6 100644 --- a/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json +++ b/tests/components/screenlogic/fixtures/data_missing_values_chem_chlor.json @@ -2,7 +2,9 @@ "adapter": { "firmware": { "name": "Protocol Adapter Firmware", - "value": "POOL: 5.2 Build 736.0 Rel" + "value": "POOL: 5.2 Build 736.0 Rel", + "major": 5.2, + "minor": 736.0 } }, "controller": { @@ -142,6 +144,14 @@ "value": 0, "device_type": "alarm" } + }, + "date_time": { + "timestamp": 1700489169.0, + "timestamp_host": 1700517812.0, + "auto_dst": { + "name": "Automatic Daylight Saving Time", + "value": 1 + } } }, "circuit": { @@ -659,32 +669,44 @@ "ph_setpoint": { "name": "pH Setpoint", "value": 7.6, - "unit": "pH" + "unit": "pH", + "max_setpoint": 7.6, + "min_setpoint": 7.2 }, "orp_setpoint": { "name": "ORP Setpoint", "value": 720, - "unit": "mV" + "unit": "mV", + "max_setpoint": 800, + "min_setpoint": 400 }, "calcium_harness": { "name": "Calcium Hardness", "value": 800, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "cya": { "name": "Cyanuric Acid", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 201, + "min_setpoint": 0 }, "total_alkalinity": { "name": "Total Alkalinity", "value": 45, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 800, + "min_setpoint": 25 }, "salt_tds_ppm": { "name": "Salt/TDS", "value": 1000, - "unit": "ppm" + "unit": "ppm", + "max_setpoint": 6500, + "min_setpoint": 500 }, "probe_is_celsius": 0, "flags": 32 @@ -792,7 +814,9 @@ }, "firmware": { "name": "IntelliChem Firmware", - "value": "1.060" + "value": "1.060", + "major": 1, + "minor": 60 }, "water_balance": { "flags": 0, @@ -844,6 +868,10 @@ "step": 1 } }, - "flags": 0 + "flags": 0, + "super_chlorinate": { + "name": "Super Chlorinate", + "value": 0 + } } } diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr index 05320c147e5..9f1cc421a99 100644 --- a/tests/components/screenlogic/snapshots/test_diagnostics.ambr +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -9,6 +9,7 @@ 'disabled_by': None, 'domain': 'screenlogic', 'entry_id': 'screenlogictest', + 'minor_version': 1, 'options': dict({ 'scan_interval': 30, }), @@ -22,6 +23,8 @@ 'data': dict({ 'adapter': dict({ 'firmware': dict({ + 'major': 5.2, + 'minor': 736.0, 'name': 'Protocol Adapter Firmware', 'value': 'POOL: 5.2 Build 736.0 Rel', }), @@ -453,6 +456,14 @@ 'unknown_at_offset_11': 0, }), 'controller_id': 100, + 'date_time': dict({ + 'auto_dst': dict({ + 'name': 'Automatic Daylight Saving Time', + 'value': 1, + }), + 'timestamp': 1700489169.0, + 'timestamp_host': 1700517812.0, + }), 'equipment': dict({ 'flags': 98360, 'list': list([ @@ -604,33 +615,45 @@ }), 'configuration': dict({ 'calcium_harness': dict({ + 'max_setpoint': 800, + 'min_setpoint': 25, 'name': 'Calcium Hardness', 'unit': 'ppm', 'value': 800, }), 'cya': dict({ + 'max_setpoint': 201, + 'min_setpoint': 0, 'name': 'Cyanuric Acid', 'unit': 'ppm', 'value': 45, }), 'flags': 32, 'orp_setpoint': dict({ + 'max_setpoint': 800, + 'min_setpoint': 400, 'name': 'ORP Setpoint', 'unit': 'mV', 'value': 720, }), 'ph_setpoint': dict({ + 'max_setpoint': 7.6, + 'min_setpoint': 7.2, 'name': 'pH Setpoint', 'unit': 'pH', 'value': 7.6, }), 'probe_is_celsius': 0, 'salt_tds_ppm': dict({ + 'max_setpoint': 6500, + 'min_setpoint': 500, 'name': 'Salt/TDS', 'unit': 'ppm', 'value': 1000, }), 'total_alkalinity': dict({ + 'max_setpoint': 800, + 'min_setpoint': 25, 'name': 'Total Alkalinity', 'unit': 'ppm', 'value': 45, @@ -688,6 +711,8 @@ }), }), 'firmware': dict({ + 'major': 1, + 'minor': 60, 'name': 'IntelliChem Firmware', 'value': '1.060', }), @@ -952,6 +977,10 @@ 'value': 0, }), }), + 'super_chlorinate': dict({ + 'name': 'Super Chlorinate', + 'value': 0, + }), }), }), 'debug': dict({ diff --git a/tests/components/select/test_init.py b/tests/components/select/test_init.py index 585972a0953..604bf3f0fb9 100644 --- a/tests/components/select/test_init.py +++ b/tests/components/select/test_init.py @@ -17,6 +17,7 @@ from homeassistant.components.select import ( ) from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.setup import async_setup_component @@ -111,8 +112,8 @@ async def test_custom_integration_and_validation( await hass.async_block_till_done() assert hass.states.get("select.select_1").state == "option 2" - # test ValueError trigger - with pytest.raises(ValueError): + # test ServiceValidationError trigger + with pytest.raises(ServiceValidationError) as exc: await hass.services.async_call( DOMAIN, SERVICE_SELECT_OPTION, @@ -120,11 +121,14 @@ async def test_custom_integration_and_validation( blocking=True, ) await hass.async_block_till_done() + assert exc.value.translation_domain == DOMAIN + assert exc.value.translation_key == "not_valid_option" + assert hass.states.get("select.select_1").state == "option 2" assert hass.states.get("select.select_2").state == STATE_UNKNOWN - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, SERVICE_SELECT_OPTION, diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 9cf0a8972a9..bf0113cb22b 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -55,7 +55,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -438,7 +438,7 @@ async def test_climate_temperature_is_none( with patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - ), pytest.raises(ValueError): + ), pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, @@ -1330,10 +1330,7 @@ async def test_climate_fan_mode_and_swing_mode_not_supported( with patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - ), pytest.raises( - HomeAssistantError, - match="Climate swing mode faulty_swing_mode is not supported by the integration, please open an issue", - ): + ), pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, @@ -1343,10 +1340,7 @@ async def test_climate_fan_mode_and_swing_mode_not_supported( with patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", - ), pytest.raises( - HomeAssistantError, - match="Climate fan mode faulty_fan_mode is not supported by the integration, please open an issue", - ): + ), pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index fc714a543bf..829bb5af827 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -4,10 +4,12 @@ from __future__ import annotations from collections.abc import Generator from datetime import UTC, date, datetime from decimal import Decimal +from types import ModuleType from typing import Any import pytest +from homeassistant.components import sensor from homeassistant.components.number import NumberDeviceClass from homeassistant.components.sensor import ( DEVICE_CLASS_STATE_CLASSES, @@ -50,6 +52,7 @@ from tests.common import ( MockModule, MockPlatform, async_mock_restore_state_shutdown_restart, + import_and_test_deprecated_constant_enum, mock_config_flow, mock_integration, mock_platform, @@ -2424,7 +2427,7 @@ async def test_name(hass: HomeAssistant) -> None: config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up test stt platform via config entry.""" + """Set up test sensor platform via config entry.""" async_add_entities([entity1, entity2, entity3, entity4]) mock_platform( @@ -2519,3 +2522,59 @@ async def test_entity_category_config_raises_error( ) assert not hass.states.get("sensor.test") + + +@pytest.mark.parametrize(("enum"), list(sensor.SensorStateClass)) +@pytest.mark.parametrize(("module"), [sensor, sensor.const]) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: sensor.SensorStateClass, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, module, enum, "STATE_CLASS_", "2025.1" + ) + + +@pytest.mark.parametrize( + ("enum"), + [ + sensor.SensorDeviceClass.AQI, + sensor.SensorDeviceClass.BATTERY, + sensor.SensorDeviceClass.CO, + sensor.SensorDeviceClass.CO2, + sensor.SensorDeviceClass.CURRENT, + sensor.SensorDeviceClass.DATE, + sensor.SensorDeviceClass.ENERGY, + sensor.SensorDeviceClass.FREQUENCY, + sensor.SensorDeviceClass.GAS, + sensor.SensorDeviceClass.HUMIDITY, + sensor.SensorDeviceClass.ILLUMINANCE, + sensor.SensorDeviceClass.MONETARY, + sensor.SensorDeviceClass.NITROGEN_DIOXIDE, + sensor.SensorDeviceClass.NITROGEN_MONOXIDE, + sensor.SensorDeviceClass.NITROUS_OXIDE, + sensor.SensorDeviceClass.OZONE, + sensor.SensorDeviceClass.PM1, + sensor.SensorDeviceClass.PM10, + sensor.SensorDeviceClass.PM25, + sensor.SensorDeviceClass.POWER_FACTOR, + sensor.SensorDeviceClass.POWER, + sensor.SensorDeviceClass.PRESSURE, + sensor.SensorDeviceClass.SIGNAL_STRENGTH, + sensor.SensorDeviceClass.SULPHUR_DIOXIDE, + sensor.SensorDeviceClass.TEMPERATURE, + sensor.SensorDeviceClass.TIMESTAMP, + sensor.SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + sensor.SensorDeviceClass.VOLTAGE, + ], +) +def test_deprecated_constants_sensor_device_class( + caplog: pytest.LogCaptureFixture, + enum: sensor.SensorStateClass, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, sensor, enum, "DEVICE_CLASS_", "2025.1" + ) diff --git a/tests/components/sensorpush/test_sensor.py b/tests/components/sensorpush/test_sensor.py index e00b626b20b..2e7a0867309 100644 --- a/tests/components/sensorpush/test_sensor.py +++ b/tests/components/sensorpush/test_sensor.py @@ -1,7 +1,6 @@ """Test the SensorPush sensors.""" from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -22,6 +21,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -55,9 +55,8 @@ async def test_sensors(hass: HomeAssistant) -> None: # 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, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/shelly/bluetooth/test_scanner.py b/tests/components/shelly/bluetooth/test_scanner.py index bd44782f928..9fe5f77f00c 100644 --- a/tests/components/shelly/bluetooth/test_scanner.py +++ b/tests/components/shelly/bluetooth/test_scanner.py @@ -108,19 +108,6 @@ async def test_scanner_ignores_wrong_version_and_logs( assert "Unsupported BLE scan result version: 0" in caplog.text -async def test_scanner_minimum_firmware_log_error( - hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog: pytest.LogCaptureFixture -) -> None: - """Test scanner log error if device firmware incompatible.""" - monkeypatch.setattr(mock_rpc_device, "version", "0.11.0") - await init_integration( - hass, 2, options={CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE} - ) - assert mock_rpc_device.initialized is True - - assert "BLE not supported on device" in caplog.text - - async def test_scanner_warns_on_corrupt_event( hass: HomeAssistant, mock_rpc_device, monkeypatch, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 6eb74e26dcb..8a863a852f5 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -157,14 +157,13 @@ MOCK_CONFIG = { "sys": { "ui_data": {}, "device": {"name": "Test name"}, - "wakeup_period": 0, }, } MOCK_SHELLY_COAP = { "mac": MOCK_MAC, "auth": False, - "fw": "20201124-092854/v1.9.0@57ac4ad8", + "fw": "20210715-092854/v1.11.0@57ac4ad8", "num_outputs": 2, } @@ -174,8 +173,8 @@ MOCK_SHELLY_RPC = { "mac": MOCK_MAC, "model": MODEL_PLUS_2PM, "gen": 2, - "fw_id": "20220830-130540/0.11.0-gfa1bc37", - "ver": "0.11.0", + "fw_id": "20230803-130540/1.0.0-gfa1bc37", + "ver": "1.0.0", "app": "Plus2PM", "auth_en": False, "auth_domain": None, @@ -290,7 +289,7 @@ async def mock_block_device(): blocks=MOCK_BLOCKS, settings=MOCK_SETTINGS, shelly=MOCK_SHELLY_COAP, - version="0.10.0", + version="1.11.0", status=MOCK_STATUS_COAP, firmware_version="some fw string", initialized=True, @@ -314,7 +313,7 @@ def _mock_rpc_device(version: str | None = None): config=MOCK_CONFIG, event={}, shelly=MOCK_SHELLY_RPC, - version=version or "0.12.0", + version=version or "1.0.0", hostname="test-host", status=MOCK_STATUS_RPC, firmware_version="some fw string", @@ -324,23 +323,6 @@ def _mock_rpc_device(version: str | None = None): return device -@pytest.fixture -async def mock_pre_ble_rpc_device(): - """Mock rpc (Gen2, Websocket) device pre BLE.""" - with patch("aioshelly.rpc_device.RpcDevice.create") as rpc_device_mock: - - def update(): - rpc_device_mock.return_value.subscribe_updates.call_args[0][0]( - {}, RpcUpdateType.STATUS - ) - - device = _mock_rpc_device("0.11.0") - rpc_device_mock.return_value = device - rpc_device_mock.return_value.mock_update = Mock(side_effect=update) - - yield rpc_device_mock.return_value - - @pytest.fixture async def mock_rpc_device(): """Mock rpc (Gen2, Websocket) device with BLE support.""" @@ -363,7 +345,7 @@ async def mock_rpc_device(): {}, RpcUpdateType.DISCONNECTED ) - device = _mock_rpc_device("0.12.0") + device = _mock_rpc_device() rpc_device_mock.return_value = device rpc_device_mock.return_value.mock_disconnected = Mock(side_effect=disconnected) rpc_device_mock.return_value.mock_update = Mock(side_effect=update) diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index fe518b8509c..980981de754 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -25,7 +25,7 @@ from homeassistant.components.shelly.const import DOMAIN, MODEL_WALL_DISPLAY 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 +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er import homeassistant.helpers.issue_registry as ir from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -382,12 +382,13 @@ async def test_block_restored_climate_set_preset_before_online( assert hass.states.get(entity_id).state == HVACMode.HEAT - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: "Profile1"}, - blocking=True, - ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: "Profile1"}, + blocking=True, + ) mock_block_device.http_request.assert_not_called() @@ -503,11 +504,12 @@ async def test_block_restored_climate_auth_error( async def test_device_not_calibrated( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, + mock_block_device, + monkeypatch, + issue_registry: ir.IssueRegistry, ) -> 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=MODEL_VALVE) # Make device online diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 9482080a1a3..1bccd3570cf 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -55,6 +55,7 @@ DISCOVERY_INFO_WITH_MAC = zeroconf.ZeroconfServiceInfo( [ (1, MODEL_1), (2, MODEL_PLUS_2PM), + (3, MODEL_PLUS_2PM), ], ) async def test_form( @@ -109,6 +110,12 @@ async def test_form( {"password": "test2 password"}, "admin", ), + ( + 3, + MODEL_PLUS_2PM, + {"password": "test2 password"}, + "admin", + ), ], ) async def test_form_auth( @@ -465,6 +472,11 @@ async def test_form_auth_errors_test_connection_gen2( MODEL_PLUS_2PM, {"mac": "test-mac", "model": MODEL_PLUS_2PM, "auth": False, "gen": 2}, ), + ( + 3, + MODEL_PLUS_2PM, + {"mac": "test-mac", "model": MODEL_PLUS_2PM, "auth": False, "gen": 3}, + ), ], ) async def test_zeroconf( @@ -742,6 +754,7 @@ async def test_zeroconf_require_auth(hass: HomeAssistant, mock_block_device) -> [ (1, {"username": "test user", "password": "test1 password"}), (2, {"password": "test2 password"}), + (3, {"password": "test2 password"}), ], ) async def test_reauth_successful( @@ -780,6 +793,7 @@ async def test_reauth_successful( [ (1, {"username": "test user", "password": "test1 password"}), (2, {"password": "test2 password"}), + (3, {"password": "test2 password"}), ], ) async def test_reauth_unsuccessful(hass: HomeAssistant, gen, user_input) -> None: @@ -967,62 +981,6 @@ async def test_options_flow_ble(hass: HomeAssistant, mock_rpc_device) -> None: await hass.config_entries.async_unload(entry.entry_id) -async def test_options_flow_pre_ble_device( - hass: HomeAssistant, mock_pre_ble_rpc_device -) -> None: - """Test setting ble options for gen2 devices with pre ble firmware.""" - entry = await init_integration(hass, 2) - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.DISABLED, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["data"][CONF_BLE_SCANNER_MODE] == BLEScannerMode.DISABLED - - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.ACTIVE, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "ble_unsupported" - - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "init" - assert result["errors"] is None - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_BLE_SCANNER_MODE: BLEScannerMode.PASSIVE, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.FlowResultType.ABORT - assert result["reason"] == "ble_unsupported" - - await hass.config_entries.async_unload(entry.entry_id) - - async def test_zeroconf_already_configured_triggers_refresh_mac_in_name( hass: HomeAssistant, mock_rpc_device, monkeypatch ) -> None: diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index e73168c6b20..27aa8710621 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -250,11 +250,12 @@ async def test_block_sleeping_device_no_periodic_updates( async def test_block_device_push_updates_failure( - hass: HomeAssistant, mock_block_device, monkeypatch + hass: HomeAssistant, + mock_block_device, + monkeypatch, + issue_registry: ir.IssueRegistry, ) -> None: """Test block device with push updates failure.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) - await init_integration(hass, 1) # Updates with COAP_REPLAY type should create an issue diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index 13126db0a0e..3a9b548757b 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -130,7 +130,6 @@ async def test_rpc_config_entry_diagnostics( "scanning": True, "start_time": ANY, "source": "12:34:56:78:9A:BC", - "storage": None, "time_since_last_device_detection": {"AA:BB:CC:DD:EE:FF": ANY}, "type": "ShellyBLEScanner", } diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 2ead9cba198..8f6599b39e4 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -41,7 +41,7 @@ async def test_custom_coap_port( assert "Starting CoAP context with UDP port 7632" in caplog.text -@pytest.mark.parametrize("gen", [1, 2]) +@pytest.mark.parametrize("gen", [1, 2, 3]) async def test_shared_device_mac( hass: HomeAssistant, gen, @@ -74,7 +74,7 @@ async def test_setup_entry_not_shelly( assert "probably comes from a custom integration" in caplog.text -@pytest.mark.parametrize("gen", [1, 2]) +@pytest.mark.parametrize("gen", [1, 2, 3]) async def test_device_connection_error( hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch ) -> None: @@ -90,7 +90,7 @@ async def test_device_connection_error( assert entry.state == ConfigEntryState.SETUP_RETRY -@pytest.mark.parametrize("gen", [1, 2]) +@pytest.mark.parametrize("gen", [1, 2, 3]) async def test_mac_mismatch_error( hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch ) -> None: @@ -106,7 +106,7 @@ async def test_mac_mismatch_error( assert entry.state == ConfigEntryState.SETUP_RETRY -@pytest.mark.parametrize("gen", [1, 2]) +@pytest.mark.parametrize("gen", [1, 2, 3]) async def test_device_auth_error( hass: HomeAssistant, gen, mock_block_device, mock_rpc_device, monkeypatch ) -> None: diff --git a/tests/components/shelly/test_light.py b/tests/components/shelly/test_light.py index e3aea966230..77b65ad3bb5 100644 --- a/tests/components/shelly/test_light.py +++ b/tests/components/shelly/test_light.py @@ -134,7 +134,10 @@ async def test_block_device_rgb_bulb( ColorMode.COLOR_TEMP, ColorMode.RGB, ] - assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.EFFECT + assert ( + attributes[ATTR_SUPPORTED_FEATURES] + == LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION + ) assert len(attributes[ATTR_EFFECT_LIST]) == 4 assert attributes[ATTR_EFFECT] == "Off" @@ -232,7 +235,7 @@ async def test_block_device_white_bulb( assert state.state == STATE_ON assert attributes[ATTR_BRIGHTNESS] == 128 assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - assert attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION # Turn off mock_block_device.blocks[LIGHT_BLOCK_ID].set_state.reset_mock() diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index e19416706e1..9a99116e66c 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -6,7 +6,10 @@ from aioshelly.const import MODEL_GAS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.script import scripts_with_entity from homeassistant.components.shelly.const import DOMAIN, MODEL_WALL_DISPLAY from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState @@ -21,6 +24,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component from . import init_integration, register_entity @@ -238,9 +243,14 @@ async def test_block_device_gas_valve( hass: HomeAssistant, mock_block_device, monkeypatch ) -> None: """Test block device Shelly Gas with Valve addon.""" + entity_id = register_entity( + hass, + SWITCH_DOMAIN, + "test_name_valve", + "valve_0-valve", + ) registry = er.async_get(hass) await init_integration(hass, 1, MODEL_GAS) - entity_id = "switch.test_name_valve" entry = registry.async_get(entity_id) assert entry @@ -316,3 +326,63 @@ async def test_wall_display_relay_mode( # the climate entity should be removed assert hass.states.get(entity_id) is None + + +async def test_create_issue_valve_switch( + hass: HomeAssistant, + mock_block_device, + entity_registry_enabled_by_default: None, + monkeypatch, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) + entity_id = register_entity( + hass, + SWITCH_DOMAIN, + "test_name_valve", + "valve_0-valve", + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": {"service": "switch.turn_on", "entity_id": entity_id}, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "service": "switch.turn_on", + "data": {"entity_id": entity_id}, + }, + ], + } + } + }, + ) + + await init_integration(hass, 1, MODEL_GAS) + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + + assert issue_registry.async_get_issue(DOMAIN, "deprecated_valve_switch") + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_valve_switch.test_name_valve_automation.test" + ) + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_valve_switch.test_name_valve_script.test" + ) + + assert len(issue_registry.issues) == 3 diff --git a/tests/components/shelly/test_valve.py b/tests/components/shelly/test_valve.py new file mode 100644 index 00000000000..0db79b63a9d --- /dev/null +++ b/tests/components/shelly/test_valve.py @@ -0,0 +1,72 @@ +"""Tests for Shelly valve platform.""" +from aioshelly.const import MODEL_GAS + +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +GAS_VALVE_BLOCK_ID = 6 + + +async def test_block_device_gas_valve( + hass: HomeAssistant, mock_block_device, monkeypatch +) -> None: + """Test block device Shelly Gas with Valve addon.""" + registry = er.async_get(hass) + await init_integration(hass, 1, MODEL_GAS) + entity_id = "valve.test_name_valve" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-valve_0-valve" + + assert hass.states.get(entity_id).state == STATE_CLOSED + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPENING + + monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "opened") + mock_block_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSING + + monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "closed") + mock_block_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSED diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index 7722bd8b6da..373c449497c 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from tests.typing import WebSocketGenerator @@ -338,7 +339,7 @@ async def test_update_invalid_item( ) -> None: """Test updating a todo item that does not exist.""" - with pytest.raises(ValueError, match="Unable to find"): + with pytest.raises(ServiceValidationError, match="Unable to find"): await hass.services.async_call( TODO_DOMAIN, "update_item", diff --git a/tests/components/simplisafe/test_diagnostics.py b/tests/components/simplisafe/test_diagnostics.py index 3153674ce57..538165bd769 100644 --- a/tests/components/simplisafe/test_diagnostics.py +++ b/tests/components/simplisafe/test_diagnostics.py @@ -17,6 +17,7 @@ async def test_entry_diagnostics( "entry": { "entry_id": config_entry.entry_id, "version": 1, + "minor_version": 1, "domain": "simplisafe", "title": REDACTED, "data": {"token": REDACTED, "username": REDACTED}, diff --git a/tests/components/siren/test_init.py b/tests/components/siren/test_init.py index 267b1c1e30d..abc5b0fac38 100644 --- a/tests/components/siren/test_init.py +++ b/tests/components/siren/test_init.py @@ -1,8 +1,10 @@ """The tests for the siren component.""" +from types import ModuleType from unittest.mock import MagicMock import pytest +from homeassistant.components import siren from homeassistant.components.siren import ( SirenEntity, SirenEntityDescription, @@ -11,6 +13,8 @@ from homeassistant.components.siren import ( from homeassistant.components.siren.const import SirenEntityFeature from homeassistant.core import HomeAssistant +from tests.common import import_and_test_deprecated_constant_enum + class MockSirenEntity(SirenEntity): """Mock siren device to use in tests.""" @@ -104,3 +108,31 @@ async def test_missing_tones_dict(hass: HomeAssistant) -> None: siren.hass = hass with pytest.raises(ValueError): process_turn_on_params(siren, {"tone": 3}) + + +@pytest.mark.parametrize(("enum"), list(SirenEntityFeature)) +@pytest.mark.parametrize(("module"), [siren, siren.const]) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: SirenEntityFeature, + module: ModuleType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, module, enum, "SUPPORT_", "2025.1") + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockSirenEntity(siren.SirenEntity): + _attr_supported_features = 1 + + entity = MockSirenEntity() + assert entity.supported_features is siren.SirenEntityFeature(1) + assert "MockSirenEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "SirenEntityFeature.TURN_ON" in caplog.text + caplog.clear() + assert entity.supported_features is siren.SirenEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/sleepiq/conftest.py b/tests/components/sleepiq/conftest.py index 05104546f0d..58718edcafb 100644 --- a/tests/components/sleepiq/conftest.py +++ b/tests/components/sleepiq/conftest.py @@ -6,9 +6,11 @@ from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from asyncsleepiq import ( BED_PRESETS, + FootWarmingTemps, Side, SleepIQActuator, SleepIQBed, + SleepIQFootWarmer, SleepIQFoundation, SleepIQLight, SleepIQPreset, @@ -34,6 +36,7 @@ SLEEPER_L_NAME_LOWER = SLEEPER_L_NAME.lower().replace(" ", "_") SLEEPER_R_NAME_LOWER = SLEEPER_R_NAME.lower().replace(" ", "_") PRESET_L_STATE = "Watch TV" PRESET_R_STATE = "Flat" +FOOT_WARM_TIME = 120 SLEEPIQ_CONFIG = { CONF_USERNAME: "user@email.com", @@ -86,6 +89,7 @@ def mock_bed() -> MagicMock: light_2.is_on = False bed.foundation.lights = [light_1, light_2] + bed.foundation.foot_warmers = [] return bed @@ -120,6 +124,8 @@ def mock_asyncsleepiq_single_foundation( preset.side = Side.NONE preset.side_full = "Right" preset.options = BED_PRESETS + + mock_bed.foundation.foot_warmers = [] yield client @@ -166,6 +172,18 @@ def mock_asyncsleepiq(mock_bed: MagicMock) -> Generator[MagicMock, None, None]: preset_r.side_full = "Right" preset_r.options = BED_PRESETS + foot_warmer_l = create_autospec(SleepIQFootWarmer) + foot_warmer_r = create_autospec(SleepIQFootWarmer) + mock_bed.foundation.foot_warmers = [foot_warmer_l, foot_warmer_r] + + foot_warmer_l.side = Side.LEFT + foot_warmer_l.timer = FOOT_WARM_TIME + foot_warmer_l.temperature = FootWarmingTemps.MEDIUM + + foot_warmer_r.side = Side.RIGHT + foot_warmer_r.timer = FOOT_WARM_TIME + foot_warmer_r.temperature = FootWarmingTemps.OFF + yield client diff --git a/tests/components/sleepiq/test_number.py b/tests/components/sleepiq/test_number.py index fe03a4d9c3f..4676cf94174 100644 --- a/tests/components/sleepiq/test_number.py +++ b/tests/components/sleepiq/test_number.py @@ -156,3 +156,41 @@ async def test_actuators(hass: HomeAssistant, mock_asyncsleepiq) -> None: mock_asyncsleepiq.beds[BED_ID].foundation.actuators[ 0 ].set_position.assert_called_with(42) + + +async def test_foot_warmer_timer(hass: HomeAssistant, mock_asyncsleepiq) -> None: + """Test the SleepIQ foot warmer number values for a bed with two sides.""" + entry = await setup_platform(hass, DOMAIN) + entity_registry = er.async_get(hass) + + state = hass.states.get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warming_timer" + ) + assert state.state == "120.0" + assert state.attributes.get(ATTR_ICON) == "mdi:timer" + assert state.attributes.get(ATTR_MIN) == 30 + assert state.attributes.get(ATTR_MAX) == 360 + assert state.attributes.get(ATTR_STEP) == 30 + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Foot Warming Timer" + ) + + entry = entity_registry.async_get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warming_timer" + ) + assert entry + assert entry.unique_id == f"{BED_ID}_L_foot_warming_timer" + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warming_timer", + ATTR_VALUE: 300, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[0].timer == 300 diff --git a/tests/components/sleepiq/test_select.py b/tests/components/sleepiq/test_select.py index d0e2a0e828d..c4ec3896bd7 100644 --- a/tests/components/sleepiq/test_select.py +++ b/tests/components/sleepiq/test_select.py @@ -1,6 +1,8 @@ """Tests for the SleepIQ select platform.""" from unittest.mock import MagicMock +from asyncsleepiq import FootWarmingTemps + from homeassistant.components.select import DOMAIN, SERVICE_SELECT_OPTION from homeassistant.const import ( ATTR_ENTITY_ID, @@ -15,8 +17,15 @@ from .conftest import ( BED_ID, BED_NAME, BED_NAME_LOWER, + FOOT_WARM_TIME, PRESET_L_STATE, PRESET_R_STATE, + SLEEPER_L_ID, + SLEEPER_L_NAME, + SLEEPER_L_NAME_LOWER, + SLEEPER_R_ID, + SLEEPER_R_NAME, + SLEEPER_R_NAME_LOWER, setup_platform, ) @@ -115,3 +124,74 @@ async def test_single_foundation_preset( mock_asyncsleepiq_single_foundation.beds[BED_ID].foundation.presets[ 0 ].set_preset.assert_called_with("Zero G") + + +async def test_foot_warmer(hass: HomeAssistant, mock_asyncsleepiq: MagicMock) -> None: + """Test the SleepIQ select entity for foot warmers.""" + entry = await setup_platform(hass, DOMAIN) + entity_registry = er.async_get(hass) + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warmer" + ) + assert state.state == FootWarmingTemps.MEDIUM.name.lower() + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Foot Warmer" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warmer" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_L_ID}_foot_warmer" + + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_foot_warmer", + ATTR_OPTION: "off", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[ + 0 + ].turn_off.assert_called_once() + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_foot_warmer" + ) + assert state.state == FootWarmingTemps.OFF.name.lower() + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_R_NAME} Foot Warmer" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_foot_warmer" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_R_ID}_foot_warmer" + + await hass.services.async_call( + DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_foot_warmer", + ATTR_OPTION: "high", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[ + 1 + ].turn_on.assert_called_once() + mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[ + 1 + ].turn_on.assert_called_with(FootWarmingTemps.HIGH, FOOT_WARM_TIME) diff --git a/tests/components/smarttub/test_climate.py b/tests/components/smarttub/test_climate.py index 601015ca681..40e3c05b509 100644 --- a/tests/components/smarttub/test_climate.py +++ b/tests/components/smarttub/test_climate.py @@ -58,7 +58,7 @@ async def test_thermostat_update( assert state.attributes[ATTR_TEMPERATURE] == 39 assert state.attributes[ATTR_MAX_TEMP] == DEFAULT_MAX_TEMP assert state.attributes[ATTR_MIN_TEMP] == DEFAULT_MIN_TEMP - assert state.attributes[ATTR_PRESET_MODES] == ["none", "eco", "day"] + assert state.attributes[ATTR_PRESET_MODES] == ["none", "eco", "day", "ready"] await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/smhi/snapshots/test_weather.ambr b/tests/components/smhi/snapshots/test_weather.ambr index fa9d76c68ba..eb7378b5cba 100644 --- a/tests/components/smhi/snapshots/test_weather.ambr +++ b/tests/components/smhi/snapshots/test_weather.ambr @@ -669,7 +669,6 @@ # --- # name: test_setup_hass ReadOnlyDict({ - 'apparent_temperature': 18.0, 'attribution': 'Swedish weather institute (SMHI)', 'cloud_coverage': 100, 'forecast': list([ diff --git a/tests/components/snmp/conftest.py b/tests/components/snmp/conftest.py deleted file mode 100644 index 05a518ad7f3..00000000000 --- a/tests/components/snmp/conftest.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Skip test collection for Python 3.12.""" -import sys - -if sys.version_info >= (3, 12): - collect_ignore_glob = ["test_*.py"] diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index 550040a9b25..de9aab016ee 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -2,6 +2,7 @@ from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components.solaredge.const import ( CONF_SITE_ID, @@ -9,7 +10,6 @@ from homeassistant.components.solaredge.const import ( DOMAIN, OVERVIEW_UPDATE_DELAY, ) -from homeassistant.components.solaredge.sensor import SENSOR_TYPES from homeassistant.const import CONF_API_KEY, CONF_NAME, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -19,6 +19,11 @@ SITE_ID = "1a2b3c4d5e6f7g8h" API_KEY = "a1b2c3d4e5f6g7h8" +@pytest.fixture(autouse=True) +def enable_all_entities(entity_registry_enabled_by_default): + """Make sure all entities are enabled.""" + + @patch("homeassistant.components.solaredge.Solaredge") async def test_solaredgeoverviewdataservice_energy_values_validity( mock_solaredge, hass: HomeAssistant, freezer: FrozenDateTimeFactory @@ -31,8 +36,6 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( ) mock_solaredge().get_details.return_value = {"details": {"status": "active"}} mock_config_entry.add_to_hass(hass) - for description in SENSOR_TYPES: - description.entity_registry_enabled_default = True await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index 9f27e593657..e44081f94bf 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -1,8 +1,9 @@ """Tests for the Sonarr sensor platform.""" from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from aiopyarr import ArrException +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -122,6 +123,7 @@ async def test_disabled_by_default_sensors( async def test_availability( hass: HomeAssistant, + freezer: FrozenDateTimeFactory, mock_config_entry: MockConfigEntry, mock_sonarr: MagicMock, ) -> None: @@ -129,9 +131,9 @@ async def test_availability( now = dt_util.utcnow() mock_config_entry.add_to_hass(hass) - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + freezer.move_to(now) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == "1" @@ -140,9 +142,9 @@ async def test_availability( mock_sonarr.async_get_calendar.side_effect = ArrException future = now + timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == STATE_UNAVAILABLE @@ -151,9 +153,9 @@ async def test_availability( mock_sonarr.async_get_calendar.side_effect = None future += timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == "1" @@ -162,9 +164,9 @@ async def test_availability( mock_sonarr.async_get_calendar.side_effect = ArrException future += timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == STATE_UNAVAILABLE @@ -173,9 +175,9 @@ async def test_availability( mock_sonarr.async_get_calendar.side_effect = None future += timedelta(minutes=1) - with patch("homeassistant.util.dt.utcnow", return_value=future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + freezer.move_to(future) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() assert hass.states.get(UPCOMING_ENTITY_ID) assert hass.states.get(UPCOMING_ENTITY_ID).state == "1" diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 648ca12803c..8bd8224e726 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -108,8 +108,26 @@ def config_entry_fixture(): class MockSoCo(MagicMock): """Mock the Soco Object.""" + uid = "RINCON_test" + play_mode = "NORMAL" + mute = False + night_mode = True + dialog_level = True + loudness = True + volume = 19 audio_delay = 2 + balance = (61, 100) + bass = 1 + treble = -1 + mic_enabled = False + sub_crossover = None # Default to None for non-Amp devices + sub_enabled = False sub_gain = 5 + surround_enabled = True + surround_mode = True + surround_level = 3 + music_surround_level = 4 + soundbar_audio_input_format = "Dolby 5.1" @property def visible_zones(self): @@ -143,10 +161,7 @@ class SoCoMockFactory: mock_soco.mock_add_spec(SoCo) mock_soco.ip_address = ip_address if ip_address != "192.168.42.2": - mock_soco.uid = f"RINCON_test_{ip_address}" - else: - mock_soco.uid = "RINCON_test" - mock_soco.play_mode = "NORMAL" + mock_soco.uid += f"_{ip_address}" mock_soco.music_library = self.music_library mock_soco.get_current_track_info.return_value = self.current_track_info mock_soco.music_source_from_uri = SoCo.music_source_from_uri @@ -161,23 +176,6 @@ class SoCoMockFactory: mock_soco.contentDirectory = SonosMockService("ContentDirectory", ip_address) mock_soco.deviceProperties = SonosMockService("DeviceProperties", ip_address) mock_soco.alarmClock = self.alarm_clock - mock_soco.mute = False - mock_soco.night_mode = True - mock_soco.dialog_level = True - mock_soco.loudness = True - mock_soco.volume = 19 - mock_soco.audio_delay = 2 - mock_soco.balance = (61, 100) - mock_soco.bass = 1 - mock_soco.treble = -1 - mock_soco.mic_enabled = False - mock_soco.sub_enabled = False - mock_soco.sub_gain = 5 - mock_soco.surround_enabled = True - mock_soco.surround_mode = True - mock_soco.surround_level = 3 - mock_soco.music_surround_level = 4 - mock_soco.soundbar_audio_input_format = "Dolby 5.1" mock_soco.get_battery_info.return_value = self.battery_info mock_soco.all_zones = {mock_soco} mock_soco.group.coordinator = mock_soco diff --git a/tests/components/sonos/test_number.py b/tests/components/sonos/test_number.py index 38456058d8a..d58b84ab6cb 100644 --- a/tests/components/sonos/test_number.py +++ b/tests/components/sonos/test_number.py @@ -6,6 +6,8 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +CROSSOVER_ENTITY = "number.zone_a_sub_crossover_frequency" + async def test_number_entities( hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry @@ -62,3 +64,32 @@ async def test_number_entities( blocking=True, ) mock_sub_gain.assert_called_once_with(-8) + + # sub_crossover is only available on Sonos Amp devices, see test_amp_number_entities + assert CROSSOVER_ENTITY not in entity_registry.entities + + +async def test_amp_number_entities( + hass: HomeAssistant, async_setup_sonos, soco, entity_registry: er.EntityRegistry +) -> None: + """Test the sub_crossover feature only available on Sonos Amp devices. + + The sub_crossover value will be None on all other device types. + """ + with patch.object(soco, "sub_crossover", 50): + await async_setup_sonos() + + sub_crossover_number = entity_registry.entities[CROSSOVER_ENTITY] + sub_crossover_state = hass.states.get(sub_crossover_number.entity_id) + assert sub_crossover_state.state == "50" + + with patch.object( + type(soco), "sub_crossover", new_callable=PropertyMock + ) as mock_sub_crossover: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: sub_crossover_number.entity_id, "value": 110}, + blocking=True, + ) + mock_sub_crossover.assert_called_once_with(110) diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 6a629f9603d..9cdd026bd3b 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -46,17 +46,104 @@ ENTRY_CONFIG_WITH_VALUE_TEMPLATE = { ENTRY_CONFIG_INVALID_QUERY = { CONF_NAME: "Get Value", + CONF_QUERY: "SELECT 5 FROM as value", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_INVALID_QUERY_2 = { + CONF_NAME: "Get Value", + CONF_QUERY: "SELECT5 FROM as value", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_INVALID_QUERY_3 = { + CONF_NAME: "Get Value", + CONF_QUERY: ";;", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_INVALID_QUERY_OPT = { + CONF_QUERY: "SELECT 5 FROM as value", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_INVALID_QUERY_2_OPT = { + CONF_QUERY: "SELECT5 FROM as value", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_INVALID_QUERY_3_OPT = { + CONF_QUERY: ";;", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_QUERY_READ_ONLY_CTE = { + CONF_NAME: "Get Value", + CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + +ENTRY_CONFIG_QUERY_NO_READ_ONLY = { + CONF_NAME: "Get Value", + CONF_QUERY: "UPDATE states SET state = 999999 WHERE state_id = 11125", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + +ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE = { + CONF_NAME: "Get Value", + CONF_QUERY: "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;", + CONF_COLUMN_NAME: "size", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + +ENTRY_CONFIG_QUERY_READ_ONLY_CTE_OPT = { + CONF_QUERY: "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + +ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT = { CONF_QUERY: "UPDATE 5 as value", CONF_COLUMN_NAME: "size", CONF_UNIT_OF_MEASUREMENT: "MiB", } -ENTRY_CONFIG_INVALID_QUERY_OPT = { - CONF_QUERY: "UPDATE 5 as value", +ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT = { + CONF_QUERY: "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;", CONF_COLUMN_NAME: "size", CONF_UNIT_OF_MEASUREMENT: "MiB", } + +ENTRY_CONFIG_MULTIPLE_QUERIES = { + CONF_NAME: "Get Value", + CONF_QUERY: "SELECT 5 as state; UPDATE states SET state = 10;", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + +ENTRY_CONFIG_MULTIPLE_QUERIES_OPT = { + CONF_QUERY: "SELECT 5 as state; UPDATE states SET state = 10;", + CONF_COLUMN_NAME: "state", + CONF_UNIT_OF_MEASUREMENT: "MiB", +} + + ENTRY_CONFIG_INVALID_COLUMN_NAME = { CONF_NAME: "Get Value", CONF_QUERY: "SELECT 5 as value", diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index 6517e319fe4..43608d0d32a 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -17,8 +17,18 @@ from . import ( ENTRY_CONFIG_INVALID_COLUMN_NAME, ENTRY_CONFIG_INVALID_COLUMN_NAME_OPT, ENTRY_CONFIG_INVALID_QUERY, + ENTRY_CONFIG_INVALID_QUERY_2, + ENTRY_CONFIG_INVALID_QUERY_2_OPT, + ENTRY_CONFIG_INVALID_QUERY_3, + ENTRY_CONFIG_INVALID_QUERY_3_OPT, ENTRY_CONFIG_INVALID_QUERY_OPT, + ENTRY_CONFIG_MULTIPLE_QUERIES, + ENTRY_CONFIG_MULTIPLE_QUERIES_OPT, ENTRY_CONFIG_NO_RESULTS, + ENTRY_CONFIG_QUERY_NO_READ_ONLY, + ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE, + ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT, + ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT, ENTRY_CONFIG_WITH_VALUE_TEMPLATE, ) @@ -132,6 +142,56 @@ async def test_flow_fails_invalid_query( "query": "query_invalid", } + result6 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_INVALID_QUERY_2, + ) + + assert result6["type"] == FlowResultType.FORM + assert result6["errors"] == { + "query": "query_invalid", + } + + result6 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_INVALID_QUERY_3, + ) + + assert result6["type"] == FlowResultType.FORM + assert result6["errors"] == { + "query": "query_invalid", + } + + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY, + ) + + assert result5["type"] == FlowResultType.FORM + assert result5["errors"] == { + "query": "query_no_read_only", + } + + result6 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE, + ) + + assert result6["type"] == FlowResultType.FORM + assert result6["errors"] == { + "query": "query_no_read_only", + } + + result6 = await hass.config_entries.flow.async_configure( + result4["flow_id"], + user_input=ENTRY_CONFIG_MULTIPLE_QUERIES, + ) + + assert result6["type"] == FlowResultType.FORM + assert result6["errors"] == { + "query": "multiple_queries", + } + result5 = await hass.config_entries.flow.async_configure( result4["flow_id"], user_input=ENTRY_CONFIG_NO_RESULTS, @@ -380,6 +440,56 @@ async def test_options_flow_fails_invalid_query( "query": "query_invalid", } + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_INVALID_QUERY_2_OPT, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == { + "query": "query_invalid", + } + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_INVALID_QUERY_3_OPT, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == { + "query": "query_invalid", + } + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_OPT, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == { + "query": "query_no_read_only", + } + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_QUERY_NO_READ_ONLY_CTE_OPT, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == { + "query": "query_no_read_only", + } + + result3 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG_MULTIPLE_QUERIES_OPT, + ) + + assert result3["type"] == FlowResultType.FORM + assert result3["errors"] == { + "query": "multiple_queries", + } + result4 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ diff --git a/tests/components/sql/test_init.py b/tests/components/sql/test_init.py index 50de8aba7b3..2ae6010e0c5 100644 --- a/tests/components/sql/test_init.py +++ b/tests/components/sql/test_init.py @@ -58,6 +58,32 @@ async def test_invalid_query(hass: HomeAssistant) -> None: with pytest.raises(vol.Invalid): validate_sql_select("DROP TABLE *") + with pytest.raises(vol.Invalid): + validate_sql_select("SELECT5 as value") + + with pytest.raises(vol.Invalid): + validate_sql_select(";;") + + +async def test_query_no_read_only(hass: HomeAssistant) -> None: + """Test query no read only.""" + with pytest.raises(vol.Invalid): + validate_sql_select("UPDATE states SET state = 999999 WHERE state_id = 11125") + + +async def test_query_no_read_only_cte(hass: HomeAssistant) -> None: + """Test query no read only CTE.""" + with pytest.raises(vol.Invalid): + validate_sql_select( + "WITH test AS (SELECT state FROM states) UPDATE states SET states.state = test.state;" + ) + + +async def test_multiple_queries(hass: HomeAssistant) -> None: + """Test multiple queries.""" + with pytest.raises(vol.Invalid): + validate_sql_select("SELECT 5 as value; UPDATE states SET state = 10;") + async def test_remove_configured_db_url_if_not_needed_when_not_needed( recorder_mock: Recorder, diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index cdc9a8e07a6..9ac22f48312 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -57,6 +57,22 @@ async def test_query_basic(recorder_mock: Recorder, hass: HomeAssistant) -> None assert state.attributes["value"] == 5 +async def test_query_cte(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test the SQL sensor with CTE.""" + config = { + "db_url": "sqlite://", + "query": "WITH test AS (SELECT 1 AS row_num, 10 AS state) SELECT state FROM test WHERE row_num = 1 LIMIT 1;", + "column": "state", + "name": "Select value SQL query CTE", + "unique_id": "very_unique_id", + } + await init_integration(hass, config) + + state = hass.states.get("sensor.select_value_sql_query_cte") + assert state.state == "10" + assert state.attributes["state"] == 10 + + async def test_query_value_template( recorder_mock: Recorder, hass: HomeAssistant ) -> None: diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py index 87ba2d3be73..f12c7750cdf 100644 --- a/tests/components/squeezebox/test_config_flow.py +++ b/tests/components/squeezebox/test_config_flow.py @@ -6,7 +6,7 @@ from pysqueezebox import Server from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.squeezebox.const import DOMAIN +from homeassistant.components.squeezebox.const import CONF_HTTPS, DOMAIN from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -59,7 +59,13 @@ async def test_user_form(hass: HomeAssistant) -> None: # test the edit step result = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: HOST, CONF_PORT: PORT, CONF_USERNAME: "", CONF_PASSWORD: ""}, + { + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_USERNAME: "", + CONF_PASSWORD: "", + CONF_HTTPS: False, + }, ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == HOST @@ -68,6 +74,7 @@ async def test_user_form(hass: HomeAssistant) -> None: CONF_PORT: PORT, CONF_USERNAME: "", CONF_PASSWORD: "", + CONF_HTTPS: False, } await hass.async_block_till_done() @@ -107,7 +114,11 @@ async def test_user_form_duplicate(hass: HomeAssistant) -> None: "homeassistant.components.squeezebox.async_setup_entry", return_value=True, ): - entry = MockConfigEntry(domain=DOMAIN, unique_id=UUID) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UUID, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_HTTPS: False}, + ) await hass.config_entries.async_add(entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -186,7 +197,7 @@ async def test_discovery_no_uuid(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, - data={CONF_HOST: HOST, CONF_PORT: PORT}, + data={CONF_HOST: HOST, CONF_PORT: PORT, CONF_HTTPS: False}, ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "edit" diff --git a/tests/components/srp_energy/__init__.py b/tests/components/srp_energy/__init__.py index 99a5da84fe2..634d589195e 100644 --- a/tests/components/srp_energy/__init__.py +++ b/tests/components/srp_energy/__init__.py @@ -1,21 +1,36 @@ """Tests for the SRP Energy integration.""" +from typing import Final + from homeassistant.components.srp_energy.const import CONF_IS_TOU -from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_USERNAME -ACCNT_ID = "123456789" -ACCNT_IS_TOU = False -ACCNT_USERNAME = "abba" -ACCNT_PASSWORD = "ana" -ACCNT_NAME = "Home" +ACCNT_ID: Final = "123456789" +ACCNT_IS_TOU: Final = False +ACCNT_USERNAME: Final = "test_username" +ACCNT_PASSWORD: Final = "test_password" +ACCNT_NAME: Final = "Test Home" -TEST_USER_INPUT = { + +TEST_CONFIG_HOME: Final[dict[str, str]] = { + CONF_NAME: ACCNT_NAME, CONF_ID: ACCNT_ID, CONF_USERNAME: ACCNT_USERNAME, CONF_PASSWORD: ACCNT_PASSWORD, CONF_IS_TOU: ACCNT_IS_TOU, } +ACCNT_ID_2: Final = "987654321" +ACCNT_NAME_2: Final = "Test Cabin" + +TEST_CONFIG_CABIN: Final[dict[str, str]] = { + CONF_NAME: ACCNT_NAME_2, + CONF_ID: ACCNT_ID_2, + CONF_USERNAME: ACCNT_USERNAME, + CONF_PASSWORD: ACCNT_PASSWORD, + CONF_IS_TOU: ACCNT_IS_TOU, +} + MOCK_USAGE = [ ("7/31/2022", "00:00 AM", "2022-07-31T00:00:00", "1.2", "0.19"), ("7/31/2022", "01:00 AM", "2022-07-31T01:00:00", "1.3", "0.20"), diff --git a/tests/components/srp_energy/conftest.py b/tests/components/srp_energy/conftest.py index 3ffebe167c2..e3597081d77 100644 --- a/tests/components/srp_energy/conftest.py +++ b/tests/components/srp_energy/conftest.py @@ -9,10 +9,11 @@ from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.srp_energy.const import DOMAIN, PHOENIX_TIME_ZONE +from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import MOCK_USAGE, TEST_USER_INPUT +from . import MOCK_USAGE, TEST_CONFIG_HOME from tests.common import MockConfigEntry @@ -42,8 +43,7 @@ def fixture_test_date(hass: HomeAssistant, hass_tz_info) -> dt.datetime | None: def fixture_mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( - domain=DOMAIN, - data=TEST_USER_INPUT, + domain=DOMAIN, data=TEST_CONFIG_HOME, unique_id=TEST_CONFIG_HOME[CONF_ID] ) @@ -81,7 +81,6 @@ async def init_integration( mock_srp_energy_config_flow, ) -> MockConfigEntry: """Set up the Srp Energy integration for testing.""" - freezer.move_to(test_date) mock_config_entry.add_to_hass(hass) diff --git a/tests/components/srp_energy/test_config_flow.py b/tests/components/srp_energy/test_config_flow.py index dfd1d41e820..572b67259f1 100644 --- a/tests/components/srp_energy/test_config_flow.py +++ b/tests/components/srp_energy/test_config_flow.py @@ -1,13 +1,23 @@ """Test the SRP Energy config flow.""" from unittest.mock import MagicMock, patch -from homeassistant import config_entries from homeassistant.components.srp_energy.const import CONF_IS_TOU, DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_SOURCE, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import ACCNT_ID, ACCNT_IS_TOU, ACCNT_PASSWORD, ACCNT_USERNAME, TEST_USER_INPUT +from . import ( + ACCNT_ID, + ACCNT_ID_2, + ACCNT_IS_TOU, + ACCNT_NAME, + ACCNT_NAME_2, + ACCNT_PASSWORD, + ACCNT_USERNAME, + TEST_CONFIG_CABIN, + TEST_CONFIG_HOME, +) from tests.common import MockConfigEntry @@ -17,7 +27,7 @@ async def test_show_form( ) -> None: """Test show configuration form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) assert result["type"] == FlowResultType.FORM @@ -29,12 +39,12 @@ async def test_show_form( return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT + flow_id=result["flow_id"], user_input=TEST_CONFIG_HOME ) await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "test home" + assert result["title"] == ACCNT_NAME assert "data" in result assert result["data"][CONF_ID] == ACCNT_ID @@ -56,11 +66,11 @@ async def test_form_invalid_account( mock_srp_energy_config_flow.validate.side_effect = ValueError result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT + flow_id=result["flow_id"], user_input=TEST_CONFIG_HOME ) assert result["type"] == FlowResultType.FORM @@ -75,11 +85,11 @@ async def test_form_invalid_auth( mock_srp_energy_config_flow.validate.return_value = False result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT + flow_id=result["flow_id"], user_input=TEST_CONFIG_HOME ) assert result["type"] == FlowResultType.FORM @@ -94,11 +104,11 @@ async def test_form_unknown_error( mock_srp_energy_config_flow.validate.side_effect = Exception result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( - flow_id=result["flow_id"], user_input=TEST_USER_INPUT + flow_id=result["flow_id"], user_input=TEST_CONFIG_HOME ) assert result["type"] == FlowResultType.ABORT @@ -109,18 +119,52 @@ async def test_flow_entry_already_configured( hass: HomeAssistant, init_integration: MockConfigEntry ) -> None: """Test user input for config_entry that already exists.""" - user_input = { - CONF_ID: init_integration.data[CONF_ID], - CONF_USERNAME: "abba2", - CONF_PASSWORD: "ana2", - CONF_IS_TOU: False, - } + # Verify mock config setup from fixture + assert init_integration.state == ConfigEntryState.LOADED + assert init_integration.data[CONF_ID] == ACCNT_ID + assert init_integration.unique_id == ACCNT_ID - assert user_input[CONF_ID] == ACCNT_ID + # Attempt a second config using same account id. This is the unique id between configs. + user_input_second = TEST_CONFIG_HOME + user_input_second[CONF_ID] = init_integration.data[CONF_ID] + + assert user_input_second[CONF_ID] == ACCNT_ID result = await hass.config_entries.flow.async_init( - DOMAIN, context={CONF_SOURCE: config_entries.SOURCE_USER}, data=user_input + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input_second ) assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" + + +async def test_flow_multiple_configs( + hass: HomeAssistant, init_integration: MockConfigEntry, capsys +) -> None: + """Test multiple config entries.""" + # Verify mock config setup from fixture + assert init_integration.state == ConfigEntryState.LOADED + assert init_integration.data[CONF_ID] == ACCNT_ID + assert init_integration.unique_id == ACCNT_ID + + # Attempt a second config using different account id. This is the unique id between configs. + assert TEST_CONFIG_CABIN[CONF_ID] != ACCNT_ID + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=TEST_CONFIG_CABIN + ) + + # Verify created + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == ACCNT_NAME_2 + + assert "data" in result + assert result["data"][CONF_ID] == ACCNT_ID_2 + assert result["data"][CONF_USERNAME] == ACCNT_USERNAME + assert result["data"][CONF_PASSWORD] == ACCNT_PASSWORD + assert result["data"][CONF_IS_TOU] == ACCNT_IS_TOU + + # Verify multiple configs + entries = hass.config_entries.async_entries() + domain_entries = [entry for entry in entries if entry.domain == DOMAIN] + assert len(domain_entries) == 2 diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py index 32d2d971d2c..2d49fd13bf1 100644 --- a/tests/components/srp_energy/test_sensor.py +++ b/tests/components/srp_energy/test_sensor.py @@ -28,7 +28,7 @@ async def test_loading_sensors(hass: HomeAssistant, init_integration) -> None: async def test_srp_entity(hass: HomeAssistant, init_integration) -> None: """Test the SrpEntity.""" - usage_state = hass.states.get("sensor.srp_energy_energy_usage") + usage_state = hass.states.get("sensor.srp_energy_mock_title_energy_usage") assert usage_state.state == "150.8" # Validate attributions @@ -61,7 +61,7 @@ async def test_srp_entity_update_failed( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - usage_state = hass.states.get("sensor.home_energy_usage") + usage_state = hass.states.get("sensor.srp_energy_mock_title_energy_usage") assert usage_state is None @@ -84,5 +84,5 @@ async def test_srp_entity_timeout( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - usage_state = hass.states.get("sensor.home_energy_usage") + usage_state = hass.states.get("sensor.srp_energy_mock_title_energy_usage") assert usage_state is None diff --git a/tests/components/streamlabswater/__init__.py b/tests/components/streamlabswater/__init__.py new file mode 100644 index 00000000000..16b2e5f0974 --- /dev/null +++ b/tests/components/streamlabswater/__init__.py @@ -0,0 +1 @@ +"""Tests for the StreamLabs integration.""" diff --git a/tests/components/streamlabswater/conftest.py b/tests/components/streamlabswater/conftest.py new file mode 100644 index 00000000000..f871332e5f6 --- /dev/null +++ b/tests/components/streamlabswater/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the StreamLabs 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.streamlabswater.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/streamlabswater/test_config_flow.py b/tests/components/streamlabswater/test_config_flow.py new file mode 100644 index 00000000000..68f671d3b8c --- /dev/null +++ b/tests/components/streamlabswater/test_config_flow.py @@ -0,0 +1,193 @@ +"""Test the StreamLabs config flow.""" +from unittest.mock import AsyncMock, patch + +from homeassistant import config_entries +from homeassistant.components.streamlabswater.const import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +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("homeassistant.components.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Streamlabs" + assert result["data"] == {CONF_API_KEY: "abc"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.streamlabswater.config_flow.StreamlabsClient.get_locations", + return_value={}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch("homeassistant.components.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Streamlabs" + assert result["data"] == {CONF_API_KEY: "abc"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_unknown(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.streamlabswater.config_flow.StreamlabsClient.get_locations", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + with patch("homeassistant.components.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Streamlabs" + assert result["data"] == {CONF_API_KEY: "abc"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_entry_already_exists(hass: HomeAssistant) -> None: + """Test we handle if the entry already exists.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "abc"}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.streamlabswater.config_flow.StreamlabsClient.get_locations", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "abc"}, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test import flow.""" + with patch("homeassistant.components.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Streamlabs" + assert result["data"] == {CONF_API_KEY: "abc"} + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + with patch( + "homeassistant.components.streamlabswater.config_flow.StreamlabsClient.get_locations", + return_value={}, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_import_unknown(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we handle unknown error.""" + with patch( + "homeassistant.components.streamlabswater.config_flow.StreamlabsClient.get_locations", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_import_entry_already_exists(hass: HomeAssistant) -> None: + """Test we handle if the entry already exists.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "abc"}, + ) + entry.add_to_hass(hass) + with patch("homeassistant.components.streamlabswater.config_flow.StreamlabsClient"): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_API_KEY: "abc"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index 4100df94b9e..9764451c5d5 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -121,12 +121,20 @@ class STTFlow(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") +@pytest.fixture(name="config_flow_test_domain") +def config_flow_test_domain_fixture() -> str: + """Test domain fixture.""" + return TEST_DOMAIN - with mock_config_flow(TEST_DOMAIN, STTFlow): + +@pytest.fixture(autouse=True) +def config_flow_fixture( + hass: HomeAssistant, config_flow_test_domain: str +) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{config_flow_test_domain}.config_flow") + + with mock_config_flow(config_flow_test_domain, STTFlow): yield @@ -137,6 +145,7 @@ async def setup_fixture( request: pytest.FixtureRequest, ) -> MockProvider | MockProviderEntity: """Set up the test environment.""" + provider: MockProvider | MockProviderEntity if request.param == "mock_setup": provider = MockProvider() await mock_setup(hass, tmp_path, provider) @@ -166,7 +175,10 @@ async def mock_setup( async def mock_config_entry_setup( - hass: HomeAssistant, tmp_path: Path, mock_provider_entity: MockProviderEntity + hass: HomeAssistant, + tmp_path: Path, + mock_provider_entity: MockProviderEntity, + test_domain: str = TEST_DOMAIN, ) -> MockConfigEntry: """Set up a test provider via config entry.""" @@ -187,7 +199,7 @@ async def mock_config_entry_setup( mock_integration( hass, MockModule( - TEST_DOMAIN, + test_domain, async_setup_entry=async_setup_entry_init, async_unload_entry=async_unload_entry_init, ), @@ -201,9 +213,9 @@ async def mock_config_entry_setup( """Set up test stt platform via config entry.""" async_add_entities([mock_provider_entity]) - mock_stt_entity_platform(hass, tmp_path, TEST_DOMAIN, async_setup_entry_platform) + mock_stt_entity_platform(hass, tmp_path, test_domain, async_setup_entry_platform) - config_entry = MockConfigEntry(domain=TEST_DOMAIN) + 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() @@ -456,7 +468,11 @@ async def test_default_engine_none(hass: HomeAssistant, tmp_path: Path) -> None: assert async_default_engine(hass) is None -async def test_default_engine(hass: HomeAssistant, tmp_path: Path) -> None: +async def test_default_engine( + hass: HomeAssistant, + tmp_path: Path, + mock_provider: MockProvider, +) -> None: """Test async_default_engine.""" mock_stt_platform( hass, @@ -479,26 +495,31 @@ async def test_default_engine_entity( assert async_default_engine(hass) == f"{DOMAIN}.{TEST_DOMAIN}" -async def test_default_engine_prefer_cloud(hass: HomeAssistant, tmp_path: Path) -> None: +@pytest.mark.parametrize("config_flow_test_domain", ["new_test"]) +async def test_default_engine_prefer_provider( + hass: HomeAssistant, + tmp_path: Path, + mock_provider_entity: MockProviderEntity, + mock_provider: MockProvider, + config_flow_test_domain: str, +) -> None: """Test async_default_engine.""" - mock_stt_platform( - hass, - tmp_path, - TEST_DOMAIN, - async_get_engine=AsyncMock(return_value=mock_provider), - ) - mock_stt_platform( - hass, - tmp_path, - "cloud", - async_get_engine=AsyncMock(return_value=mock_provider), - ) - assert await async_setup_component( - hass, "stt", {"stt": [{"platform": TEST_DOMAIN}, {"platform": "cloud"}]} + mock_provider_entity.url_path = "stt.new_test" + mock_provider_entity._attr_name = "New test" + + await mock_setup(hass, tmp_path, mock_provider) + await mock_config_entry_setup( + hass, tmp_path, mock_provider_entity, test_domain=config_flow_test_domain ) await hass.async_block_till_done() - assert async_default_engine(hass) == "cloud" + entity_engine = async_get_speech_to_text_engine(hass, "stt.new_test") + assert entity_engine is not None + assert entity_engine.name == "New test" + provider_engine = async_get_speech_to_text_engine(hass, "test") + assert provider_engine is not None + assert provider_engine.name == "test" + assert async_default_engine(hass) == "test" async def test_get_engine_legacy( diff --git a/tests/components/subaru/conftest.py b/tests/components/subaru/conftest.py index 8bed67cb15f..4927525d896 100644 --- a/tests/components/subaru/conftest.py +++ b/tests/components/subaru/conftest.py @@ -8,7 +8,6 @@ from subarulink.const import COUNTRY_USA from homeassistant import config_entries from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN from homeassistant.components.subaru.const import ( - CONF_COUNTRY, CONF_UPDATE_ENABLED, DOMAIN, FETCH_INTERVAL, @@ -22,7 +21,13 @@ from homeassistant.components.subaru.const import ( VEHICLE_NAME, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_PIN, CONF_USERNAME +from homeassistant.const import ( + CONF_COUNTRY, + CONF_DEVICE_ID, + CONF_PASSWORD, + CONF_PIN, + CONF_USERNAME, +) from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util diff --git a/tests/components/subaru/test_config_flow.py b/tests/components/subaru/test_config_flow.py index c3df10ed618..7e892d2c99a 100644 --- a/tests/components/subaru/test_config_flow.py +++ b/tests/components/subaru/test_config_flow.py @@ -130,6 +130,7 @@ async def test_user_form_pin_not_required( "version": 1, "data": deepcopy(TEST_CONFIG), "options": {}, + "minor_version": 1, } expected["data"][CONF_PIN] = None @@ -316,6 +317,7 @@ async def test_pin_form_success(hass: HomeAssistant, pin_form) -> None: "version": 1, "data": TEST_CONFIG, "options": {}, + "minor_version": 1, } result["data"][CONF_DEVICE_ID] = TEST_DEVICE_ID assert result == expected diff --git a/tests/components/suez_water/__init__.py b/tests/components/suez_water/__init__.py new file mode 100644 index 00000000000..4605e06344a --- /dev/null +++ b/tests/components/suez_water/__init__.py @@ -0,0 +1 @@ +"""Tests for the Suez Water integration.""" diff --git a/tests/components/suez_water/conftest.py b/tests/components/suez_water/conftest.py new file mode 100644 index 00000000000..8a67cfe97d7 --- /dev/null +++ b/tests/components/suez_water/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Suez Water 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.suez_water.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/suez_water/test_config_flow.py b/tests/components/suez_water/test_config_flow.py new file mode 100644 index 00000000000..c18b8a927e9 --- /dev/null +++ b/tests/components/suez_water/test_config_flow.py @@ -0,0 +1,211 @@ +"""Test the Suez Water config flow.""" +from unittest.mock import AsyncMock, patch + +from pysuez.client import PySuezError +import pytest + +from homeassistant import config_entries +from homeassistant.components.suez_water.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_DATA = { + "username": "test-username", + "password": "test-password", + "counter_id": "test-counter", +} + + +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("homeassistant.components.suez_water.config_flow.SuezClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["result"].unique_id == "test-username" + assert result["data"] == MOCK_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.suez_water.config_flow.SuezClient.__init__", + return_value=None, + ), patch( + "homeassistant.components.suez_water.config_flow.SuezClient.check_credentials", + return_value=False, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + with patch("homeassistant.components.suez_water.config_flow.SuezClient"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["result"].unique_id == "test-username" + assert result["data"] == MOCK_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured(hass: HomeAssistant) -> None: + """Test we abort when entry is already configured.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data=MOCK_DATA, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), [(PySuezError, "cannot_connect"), (Exception, "unknown")] +) +async def test_form_error( + hass: HomeAssistant, mock_setup_entry: AsyncMock, exception: Exception, error: str +) -> None: + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.suez_water.config_flow.SuezClient", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + with patch( + "homeassistant.components.suez_water.config_flow.SuezClient", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_DATA, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == MOCK_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test import flow.""" + with patch("homeassistant.components.suez_water.config_flow.SuezClient"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-username" + assert result["result"].unique_id == "test-username" + assert result["data"] == MOCK_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "reason"), [(PySuezError, "cannot_connect"), (Exception, "unknown")] +) +async def test_import_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + exception: Exception, + reason: str, +) -> None: + """Test we handle errors while importing.""" + + with patch( + "homeassistant.components.suez_water.config_flow.SuezClient", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_importing_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth when importing.""" + + with patch( + "homeassistant.components.suez_water.config_flow.SuezClient.__init__", + return_value=None, + ), patch( + "homeassistant.components.suez_water.config_flow.SuezClient.check_credentials", + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "invalid_auth" + + +async def test_import_already_configured(hass: HomeAssistant) -> None: + """Test we abort import when entry is already configured.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data=MOCK_DATA, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=MOCK_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/sunweg/__init__.py b/tests/components/sunweg/__init__.py new file mode 100644 index 00000000000..1453483a3fd --- /dev/null +++ b/tests/components/sunweg/__init__.py @@ -0,0 +1 @@ +"""Tests for the sunweg component.""" diff --git a/tests/components/sunweg/common.py b/tests/components/sunweg/common.py new file mode 100644 index 00000000000..616f5c0137f --- /dev/null +++ b/tests/components/sunweg/common.py @@ -0,0 +1,21 @@ +"""Common functions needed to setup tests for Sun WEG.""" + +from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +SUNWEG_USER_INPUT = { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", +} + +SUNWEG_MOCK_ENTRY = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + CONF_PLANT_ID: 0, + CONF_NAME: "Name", + }, +) diff --git a/tests/components/sunweg/conftest.py b/tests/components/sunweg/conftest.py new file mode 100644 index 00000000000..68c4cab86c5 --- /dev/null +++ b/tests/components/sunweg/conftest.py @@ -0,0 +1,70 @@ +"""Conftest for SunWEG tests.""" + +from datetime import datetime + +import pytest +from sunweg.device import MPPT, Inverter, Phase, String +from sunweg.plant import Plant + + +@pytest.fixture +def string_fixture() -> String: + """Define String fixture.""" + return String("STR1", 450.3, 23.4, 0) + + +@pytest.fixture +def mppt_fixture(string_fixture) -> MPPT: + """Define MPPT fixture.""" + mppt = MPPT("mppt") + mppt.strings.append(string_fixture) + return mppt + + +@pytest.fixture +def phase_fixture() -> Phase: + """Define Phase fixture.""" + return Phase("PhaseA", 120.0, 3.2, 0, 0) + + +@pytest.fixture +def inverter_fixture(phase_fixture, mppt_fixture) -> Inverter: + """Define inverter fixture.""" + inverter = Inverter( + 21255, + "INVERSOR01", + "J63T233018RE074", + 23.2, + 0.0, + 0.0, + "MWh", + 0, + "kWh", + 0.0, + 1, + 0, + "kW", + ) + inverter.phases.append(phase_fixture) + inverter.mppts.append(mppt_fixture) + return inverter + + +@pytest.fixture +def plant_fixture(inverter_fixture) -> Plant: + """Define Plant fixture.""" + plant = Plant( + 123456, + "Plant #123", + 29.5, + 0.5, + 0, + 12.786912, + 24.0, + "kWh", + 332.2, + 0.012296, + datetime(2023, 2, 16, 14, 22, 37), + ) + plant.inverters.append(inverter_fixture) + return plant diff --git a/tests/components/sunweg/test_config_flow.py b/tests/components/sunweg/test_config_flow.py new file mode 100644 index 00000000000..1298d7e93fb --- /dev/null +++ b/tests/components/sunweg/test_config_flow.py @@ -0,0 +1,129 @@ +"""Tests for the Sun WEG server config flow.""" +from unittest.mock import patch + +from sunweg.api import APIHelper + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.sunweg.const import CONF_PLANT_ID, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .common import SUNWEG_USER_INPUT + +from tests.common import MockConfigEntry + + +async def test_show_authenticate_form(hass: HomeAssistant) -> None: + """Test that the setup form is served.""" + 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"] == "user" + + +async def test_incorrect_login(hass: HomeAssistant) -> None: + """Test that it shows the appropriate error when an incorrect username/password/server is entered.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch.object(APIHelper, "authenticate", return_value=False): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], SUNWEG_USER_INPUT + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_no_plants_on_account(hass: HomeAssistant) -> None: + """Test registering an integration with no plants available.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( + APIHelper, "listPlants", return_value=[] + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], SUNWEG_USER_INPUT + ) + + assert result["type"] == "abort" + assert result["reason"] == "no_plants" + + +async def test_multiple_plant_ids(hass: HomeAssistant, plant_fixture) -> None: + """Test registering an integration and finishing flow with an selected plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = SUNWEG_USER_INPUT.copy() + plant_list = [plant_fixture, plant_fixture] + + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( + APIHelper, "listPlants", return_value=plant_list + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "plant" + + user_input = {CONF_PLANT_ID: 123456} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == 123456 + + +async def test_one_plant_on_account(hass: HomeAssistant, plant_fixture) -> None: + """Test registering an integration and finishing flow with current plant_id.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = SUNWEG_USER_INPUT.copy() + + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( + APIHelper, + "listPlants", + return_value=[plant_fixture], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"][CONF_USERNAME] == SUNWEG_USER_INPUT[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == SUNWEG_USER_INPUT[CONF_PASSWORD] + assert result["data"][CONF_PLANT_ID] == 123456 + + +async def test_existing_plant_configured(hass: HomeAssistant, plant_fixture) -> None: + """Test entering an existing plant_id.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id=123456) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + user_input = SUNWEG_USER_INPUT.copy() + + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( + APIHelper, + "listPlants", + return_value=[plant_fixture], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/sunweg/test_init.py b/tests/components/sunweg/test_init.py new file mode 100644 index 00000000000..0295e778f9c --- /dev/null +++ b/tests/components/sunweg/test_init.py @@ -0,0 +1,177 @@ +"""Tests for the Sun WEG init.""" + +import json +from unittest.mock import MagicMock, patch + +from sunweg.api import APIHelper, SunWegApiError + +from homeassistant.components.sunweg import SunWEGData +from homeassistant.components.sunweg.const import DOMAIN, DeviceType +from homeassistant.components.sunweg.sensor_types.sensor_entity_description import ( + SunWEGSensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import SUNWEG_MOCK_ENTRY + + +async def test_methods(hass: HomeAssistant, plant_fixture, inverter_fixture) -> None: + """Test methods.""" + mock_entry = SUNWEG_MOCK_ENTRY + mock_entry.add_to_hass(hass) + + with patch.object(APIHelper, "authenticate", return_value=True), patch.object( + APIHelper, "listPlants", return_value=[plant_fixture] + ), patch.object(APIHelper, "plant", return_value=plant_fixture), patch.object( + APIHelper, "inverter", return_value=inverter_fixture + ), patch.object(APIHelper, "complete_inverter"): + assert await async_setup_component(hass, DOMAIN, mock_entry.data) + await hass.async_block_till_done() + assert await hass.config_entries.async_unload(mock_entry.entry_id) + + +async def test_setup_wrongpass(hass: HomeAssistant) -> None: + """Test setup with wrong pass.""" + mock_entry = SUNWEG_MOCK_ENTRY + mock_entry.add_to_hass(hass) + with patch.object(APIHelper, "authenticate", return_value=False): + assert await async_setup_component(hass, DOMAIN, mock_entry.data) + await hass.async_block_till_done() + + +async def test_setup_error_500(hass: HomeAssistant) -> None: + """Test setup with wrong pass.""" + mock_entry = SUNWEG_MOCK_ENTRY + mock_entry.add_to_hass(hass) + with patch.object( + APIHelper, "authenticate", side_effect=SunWegApiError("Error 500") + ): + assert await async_setup_component(hass, DOMAIN, mock_entry.data) + await hass.async_block_till_done() + + +async def test_sunwegdata_update_exception() -> None: + """Test SunWEGData exception on update.""" + api = MagicMock() + api.plant = MagicMock(side_effect=json.decoder.JSONDecodeError("Message", "Doc", 1)) + data = SunWEGData(api, 0) + data.update() + assert data.data is None + + +async def test_sunwegdata_update_success(plant_fixture) -> None: + """Test SunWEGData success on update.""" + api = MagicMock() + api.plant = MagicMock(return_value=plant_fixture) + api.complete_inverter = MagicMock() + data = SunWEGData(api, 0) + data.update() + assert data.data.id == plant_fixture.id + assert data.data.name == plant_fixture.name + assert data.data.kwh_per_kwp == plant_fixture.kwh_per_kwp + assert data.data.last_update == plant_fixture.last_update + assert data.data.performance_rate == plant_fixture.performance_rate + assert data.data.saving == plant_fixture.saving + assert len(data.data.inverters) == 1 + + +async def test_sunwegdata_get_api_value_none(plant_fixture) -> None: + """Test SunWEGData none return on get_api_value.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.data = plant_fixture + assert data.get_api_value("variable", DeviceType.INVERTER, 0, "deep_name") is None + assert data.get_api_value("variable", DeviceType.STRING, 21255, "deep_name") is None + + +async def test_sunwegdata_get_data_drop_threshold() -> None: + """Test SunWEGData get_data with drop threshold.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.get_api_value = MagicMock() + entity_description = SunWEGSensorEntityDescription( + api_variable_key="variable", key="key", previous_value_drop_threshold=0.1 + ) + data.get_api_value.return_value = 3.0 + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (3.0, None) + data.get_api_value.return_value = 2.91 + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (3.0, None) + data.get_api_value.return_value = 2.8 + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (2.8, None) + + +async def test_sunwegdata_get_data_never_reset() -> None: + """Test SunWEGData get_data with never reset.""" + api = MagicMock() + data = SunWEGData(api, 123456) + data.get_api_value = MagicMock() + entity_description = SunWEGSensorEntityDescription( + api_variable_key="variable", key="key", never_resets=True + ) + data.get_api_value.return_value = 3.0 + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (3.0, None) + data.get_api_value.return_value = 0 + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (3.0, None) + data.get_api_value.return_value = 2.8 + assert data.get_data( + api_variable_key=entity_description.api_variable_key, + api_variable_unit=entity_description.api_variable_unit, + deep_name=None, + device_type=DeviceType.TOTAL, + inverter_id=0, + name=entity_description.name, + native_unit_of_measurement=entity_description.native_unit_of_measurement, + never_resets=entity_description.never_resets, + previous_value_drop_threshold=entity_description.previous_value_drop_threshold, + ) == (2.8, None) diff --git a/tests/components/swiss_public_transport/__init__.py b/tests/components/swiss_public_transport/__init__.py new file mode 100644 index 00000000000..3859a630c31 --- /dev/null +++ b/tests/components/swiss_public_transport/__init__.py @@ -0,0 +1 @@ +"""Tests for the swiss_public_transport integration.""" diff --git a/tests/components/swiss_public_transport/conftest.py b/tests/components/swiss_public_transport/conftest.py new file mode 100644 index 00000000000..d84446db086 --- /dev/null +++ b/tests/components/swiss_public_transport/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the swiss_public_transport 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.swiss_public_transport.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/swiss_public_transport/test_config_flow.py b/tests/components/swiss_public_transport/test_config_flow.py new file mode 100644 index 00000000000..55ad51c45c4 --- /dev/null +++ b/tests/components/swiss_public_transport/test_config_flow.py @@ -0,0 +1,200 @@ +"""Test the swiss_public_transport config flow.""" +from unittest.mock import AsyncMock, patch + +from opendata_transport.exceptions import ( + OpendataTransportConnectionError, + OpendataTransportError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.swiss_public_transport import config_flow +from homeassistant.components.swiss_public_transport.const import ( + CONF_DESTINATION, + CONF_START, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +MOCK_DATA_STEP = { + CONF_START: "test_start", + CONF_DESTINATION: "test_destination", +} + + +async def test_flow_user_init_data_success(hass: HomeAssistant) -> None: + """Test success response.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["handler"] == "swiss_public_transport" + assert result["data_schema"] == config_flow.DATA_SCHEMA + + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == "create_entry" + assert result["result"].title == "test_start test_destination" + + assert result["data"] == MOCK_DATA_STEP + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (OpendataTransportConnectionError(), "cannot_connect"), + (OpendataTransportError(), "bad_config"), + (IndexError(), "unknown"), + ], +) +async def test_flow_user_init_data_unknown_error_and_recover( + hass: HomeAssistant, raise_error, text_error +) -> None: + """Test unknown errors.""" + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + side_effect=raise_error, + ) as mock_OpendataTransport: + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == text_error + + # Recover + mock_OpendataTransport.side_effect = None + mock_OpendataTransport.return_value = True + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == "create_entry" + assert result["result"].title == "test_start test_destination" + + assert result["data"] == MOCK_DATA_STEP + + +async def test_flow_user_init_data_already_configured(hass: HomeAssistant) -> None: + """Test we abort user data set when entry is already configured.""" + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data=MOCK_DATA_STEP, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_DATA_STEP, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +MOCK_DATA_IMPORT = { + CONF_START: "test_start", + CONF_DESTINATION: "test_destination", + CONF_NAME: "test_name", +} + + +async def test_import( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test import flow.""" + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_DATA_IMPORT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == MOCK_DATA_IMPORT + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (OpendataTransportConnectionError(), "cannot_connect"), + (OpendataTransportError(), "bad_config"), + (IndexError(), "unknown"), + ], +) +async def test_import_cannot_connect_error( + hass: HomeAssistant, raise_error, text_error +) -> None: + """Test import flow cannot_connect error.""" + with patch( + "homeassistant.components.swiss_public_transport.config_flow.OpendataTransport.async_get_data", + autospec=True, + side_effect=raise_error, + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_DATA_IMPORT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == text_error + + +async def test_import_already_configured(hass: HomeAssistant) -> None: + """Test we abort import when entry is already configured.""" + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data=MOCK_DATA_IMPORT, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_DATA_IMPORT, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py index cbc91d24e41..7a43e0bf50e 100644 --- a/tests/components/switch/test_init.py +++ b/tests/components/switch/test_init.py @@ -9,7 +9,7 @@ from homeassistant.setup import async_setup_component from . import common -from tests.common import MockUser +from tests.common import MockUser, import_and_test_deprecated_constant_enum @pytest.fixture(autouse=True) @@ -80,3 +80,14 @@ async def test_switch_context( assert state2 is not None assert state.state != state2.state assert state2.context.user_id == hass_admin_user.id + + +@pytest.mark.parametrize(("enum"), list(switch.SwitchDeviceClass)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: switch.SwitchDeviceClass, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, switch, enum, "DEVICE_CLASS_", "2025.1" + ) diff --git a/tests/components/switch_as_x/test_config_flow.py b/tests/components/switch_as_x/test_config_flow.py index 412cbc4333b..51efbf99892 100644 --- a/tests/components/switch_as_x/test_config_flow.py +++ b/tests/components/switch_as_x/test_config_flow.py @@ -20,6 +20,7 @@ PLATFORMS_TO_TEST = ( Platform.LIGHT, Platform.LOCK, Platform.SIREN, + Platform.VALVE, ) diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index a0c0bfca825..738127faf43 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -36,6 +36,7 @@ PLATFORMS_TO_TEST = ( Platform.LIGHT, Platform.LOCK, Platform.SIREN, + Platform.VALVE, ) @@ -72,6 +73,7 @@ async def test_config_entry_unregistered_uuid( (Platform.LIGHT, STATE_ON, STATE_OFF), (Platform.LOCK, STATE_UNLOCKED, STATE_LOCKED), (Platform.SIREN, STATE_ON, STATE_OFF), + (Platform.VALVE, STATE_OPEN, STATE_CLOSED), ), ) async def test_entity_registry_events( diff --git a/tests/components/switch_as_x/test_valve.py b/tests/components/switch_as_x/test_valve.py new file mode 100644 index 00000000000..da20c544f64 --- /dev/null +++ b/tests/components/switch_as_x/test_valve.py @@ -0,0 +1,122 @@ +"""Tests for the Switch as X Valve platform.""" +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switch_as_x.const import CONF_TARGET_DOMAIN, DOMAIN +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN +from homeassistant.const import ( + CONF_ENTITY_ID, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_CLOSED, + STATE_OFF, + STATE_ON, + STATE_OPEN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_default_state(hass: HomeAssistant) -> None: + """Test valve switch default state.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.test", + CONF_TARGET_DOMAIN: Platform.VALVE, + }, + title="Garage Door", + ) + 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("valve.garage_door") + assert state is not None + assert state.state == "unavailable" + assert state.attributes["supported_features"] == 3 + + +async def test_service_calls(hass: HomeAssistant) -> None: + """Test service calls to valve.""" + await async_setup_component(hass, "switch", {"switch": [{"platform": "demo"}]}) + await hass.async_block_till_done() + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: "switch.decorative_lights", + CONF_TARGET_DOMAIN: Platform.VALVE, + }, + title="Title is ignored", + ) + 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("valve.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "valve.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {CONF_ENTITY_ID: "valve.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {CONF_ENTITY_ID: "valve.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("valve.decorative_lights").state == STATE_OPEN + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_OFF + assert hass.states.get("valve.decorative_lights").state == STATE_CLOSED + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TOGGLE, + {CONF_ENTITY_ID: "switch.decorative_lights"}, + blocking=True, + ) + + assert hass.states.get("switch.decorative_lights").state == STATE_ON + assert hass.states.get("valve.decorative_lights").state == STATE_OPEN diff --git a/tests/components/switcher_kis/test_climate.py b/tests/components/switcher_kis/test_climate.py index f998dbe294b..1919261109e 100644 --- a/tests/components/switcher_kis/test_climate.py +++ b/tests/components/switcher_kis/test_climate.py @@ -25,7 +25,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util import slugify from . import init_integration @@ -336,9 +336,8 @@ async def test_climate_control_errors( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 24}, blocking=True, ) - # Test exception when trying set fan level - with pytest.raises(HomeAssistantError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, @@ -347,7 +346,7 @@ async def test_climate_control_errors( ) # Test exception when trying set swing mode - with pytest.raises(HomeAssistantError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, diff --git a/tests/components/switcher_kis/test_diagnostics.py b/tests/components/switcher_kis/test_diagnostics.py index 0af89cd238c..f238bceb39e 100644 --- a/tests/components/switcher_kis/test_diagnostics.py +++ b/tests/components/switcher_kis/test_diagnostics.py @@ -48,6 +48,7 @@ async def test_diagnostics( "entry": { "entry_id": entry.entry_id, "version": 1, + "minor_version": 1, "domain": "switcher_kis", "title": "Mock Title", "data": {}, diff --git a/tests/components/systemmonitor/__init__.py b/tests/components/systemmonitor/__init__.py new file mode 100644 index 00000000000..92e60c1dbb2 --- /dev/null +++ b/tests/components/systemmonitor/__init__.py @@ -0,0 +1 @@ +"""Tests for the System Monitor component.""" diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py new file mode 100644 index 00000000000..ca21c971cf1 --- /dev/null +++ b/tests/components/systemmonitor/conftest.py @@ -0,0 +1,17 @@ +"""Fixtures for the System Monitor integration.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setup entry.""" + with patch( + "homeassistant.components.systemmonitor.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/systemmonitor/test_config_flow.py b/tests/components/systemmonitor/test_config_flow.py new file mode 100644 index 00000000000..367d38b91aa --- /dev/null +++ b/tests/components/systemmonitor/test_config_flow.py @@ -0,0 +1,270 @@ +"""Test the System Monitor config flow.""" +from __future__ import annotations + +from unittest.mock import AsyncMock + +from homeassistant import config_entries +from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.components.systemmonitor.const import CONF_PROCESS, DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.util import slugify + +from tests.common import MockConfigEntry + + +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["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["options"] == {} + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import( + hass: HomeAssistant, mock_setup_entry: AsyncMock, issue_registry: ir.IssueRegistry +) -> None: + """Test import.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "processes": ["systemd", "octave-cli"], + "legacy_resources": [ + "disk_use_percent_/", + "memory_free_", + "network_out_eth0", + "process_systemd", + "process_octave-cli", + ], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["options"] == { + "sensor": {"process": ["systemd", "octave-cli"]}, + "resources": [ + "disk_use_percent_/", + "memory_free_", + "network_out_eth0", + "process_systemd", + "process_octave-cli", + ], + } + + assert len(mock_setup_entry.mock_calls) == 1 + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue.issue_domain == DOMAIN + assert issue.translation_placeholders == { + "domain": DOMAIN, + "integration_title": "System Monitor", + } + + +async def test_form_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test abort when already configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_USER, + options={}, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_import_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, issue_registry: ir.IssueRegistry +) -> None: + """Test abort when already configured for import.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_USER, + options={ + "sensor": [{CONF_PROCESS: "systemd"}], + "resources": [ + "disk_use_percent_/", + "memory_free_", + "network_out_eth0", + "process_systemd", + "process_octave-cli", + ], + }, + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "processes": ["systemd", "octave-cli"], + "legacy_resources": [ + "disk_use_percent_/", + "memory_free_", + "network_out_eth0", + "process_systemd", + "process_octave-cli", + ], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue.issue_domain == DOMAIN + assert issue.translation_placeholders == { + "domain": DOMAIN, + "integration_title": "System Monitor", + } + + +async def test_add_and_remove_processes( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test adding and removing process sensors.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=config_entries.SOURCE_USER, + options={}, + entry_id="1", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_PROCESS: ["systemd"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": { + CONF_PROCESS: ["systemd"], + } + } + + # Add another + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_PROCESS: ["systemd", "octave-cli"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": { + CONF_PROCESS: ["systemd", "octave-cli"], + }, + } + + entity_reg = er.async_get(hass) + entity_reg.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=slugify("process_systemd"), + config_entry=config_entry, + ) + entity_reg.async_get_or_create( + domain=Platform.SENSOR, + platform=DOMAIN, + unique_id=slugify("process_octave-cli"), + config_entry=config_entry, + ) + assert entity_reg.async_get("sensor.systemmonitor_process_systemd") is not None + assert entity_reg.async_get("sensor.systemmonitor_process_octave_cli") is not None + + # Remove one + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_PROCESS: ["systemd"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": { + CONF_PROCESS: ["systemd"], + }, + } + + # Remove last + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_PROCESS: [], + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "sensor": {CONF_PROCESS: []}, + } + + assert entity_reg.async_get("sensor.systemmonitor_process_systemd") is None + assert entity_reg.async_get("sensor.systemmonitor_process_octave_cli") is None diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index c4a39914e53..ac04777dc1c 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -3,6 +3,7 @@ from http import HTTPStatus from ipaddress import ip_address from unittest.mock import MagicMock, patch +import PyTado import pytest import requests @@ -260,3 +261,141 @@ async def test_form_homekit(hass: HomeAssistant) -> None: ), ) assert result["type"] == "abort" + + +async def test_import_step(hass: HomeAssistant) -> None: + """Test import step.""" + mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]}) + + with patch( + "homeassistant.components.tado.config_flow.Tado", + return_value=mock_tado_api, + ), patch( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "username": "test-username", + "password": "test-password", + "home_id": "1", + } + assert mock_setup_entry.call_count == 1 + + +async def test_import_step_existing_entry(hass: HomeAssistant) -> None: + """Test import step with existing entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_setup_entry.call_count == 0 + + +async def test_import_step_validation_failed(hass: HomeAssistant) -> None: + """Test import step with validation failed.""" + with patch( + "homeassistant.components.tado.config_flow.Tado", + side_effect=RuntimeError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "import_failed" + + +async def test_import_step_device_authentication_failed(hass: HomeAssistant) -> None: + """Test import step with device tracker authentication failed.""" + with patch( + "homeassistant.components.tado.config_flow.Tado", + side_effect=PyTado.exceptions.TadoWrongCredentialsException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "import_failed_invalid_auth" + + +async def test_import_step_unique_id_configured(hass: HomeAssistant) -> None: + """Test import step with unique ID already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + unique_id="unique_id", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.tado.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + "home_id": 1, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_setup_entry.call_count == 0 diff --git a/tests/components/tag/test_event.py b/tests/components/tag/test_event.py index 7112a0cda4f..0338ed504d7 100644 --- a/tests/components/tag/test_event.py +++ b/tests/components/tag/test_event.py @@ -1,6 +1,6 @@ """Tests for the tag component.""" -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.tag import DOMAIN, EVENT_TAG_SCANNED, async_scan_tag @@ -40,7 +40,10 @@ def storage_setup_named_tag( async def test_named_tag_scanned_event( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup_named_tag + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + storage_setup_named_tag, ) -> None: """Test scanning named tag triggering event.""" assert await storage_setup_named_tag() @@ -50,8 +53,8 @@ async def test_named_tag_scanned_event( events = async_capture_events(hass, EVENT_TAG_SCANNED) now = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - await async_scan_tag(hass, TEST_TAG_ID, TEST_DEVICE_ID) + freezer.move_to(now) + await async_scan_tag(hass, TEST_TAG_ID, TEST_DEVICE_ID) assert len(events) == 1 @@ -83,7 +86,10 @@ def storage_setup_unnamed_tag(hass, hass_storage): async def test_unnamed_tag_scanned_event( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup_unnamed_tag + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + storage_setup_unnamed_tag, ) -> None: """Test scanning named tag triggering event.""" assert await storage_setup_unnamed_tag() @@ -93,8 +99,8 @@ async def test_unnamed_tag_scanned_event( events = async_capture_events(hass, EVENT_TAG_SCANNED) now = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - await async_scan_tag(hass, TEST_TAG_ID, TEST_DEVICE_ID) + freezer.move_to(now) + await async_scan_tag(hass, TEST_TAG_ID, TEST_DEVICE_ID) assert len(events) == 1 diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py index 5d54f31b13a..d7f77c0d2e2 100644 --- a/tests/components/tag/test_init.py +++ b/tests/components/tag/test_init.py @@ -1,6 +1,6 @@ """Tests for the tag component.""" -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.tag import DOMAIN, TAGS, async_scan_tag @@ -76,7 +76,10 @@ async def test_ws_update( async def test_tag_scanned( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator, storage_setup + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + storage_setup, ) -> None: """Test scanning tags.""" assert await storage_setup() @@ -93,8 +96,8 @@ async def test_tag_scanned( assert "test tag" in result now = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - await async_scan_tag(hass, "new tag", "some_scanner") + freezer.move_to(now) + await async_scan_tag(hass, "new tag", "some_scanner") await client.send_json({"id": 7, "type": f"{DOMAIN}/list"}) resp = await client.receive_json() diff --git a/tests/components/tailwind/__init__.py b/tests/components/tailwind/__init__.py new file mode 100644 index 00000000000..48c1de3d421 --- /dev/null +++ b/tests/components/tailwind/__init__.py @@ -0,0 +1 @@ +"""Integration tests for the Tailwind integration.""" diff --git a/tests/components/tailwind/conftest.py b/tests/components/tailwind/conftest.py new file mode 100644 index 00000000000..b39a3598a3e --- /dev/null +++ b/tests/components/tailwind/conftest.py @@ -0,0 +1,74 @@ +"""Fixtures for the Tailwind integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from gotailwind import TailwindDeviceStatus +import pytest + +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def device_fixture() -> str: + """Return the device fixtures for a specific device.""" + return "iq3" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Tailwind iQ3", + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.127", + CONF_TOKEN: "123456", + }, + unique_id="3c:e9:0e:6d:21:84", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.tailwind.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture +def mock_tailwind(device_fixture: str) -> Generator[MagicMock, None, None]: + """Return a mocked Tailwind client.""" + with patch( + "homeassistant.components.tailwind.coordinator.Tailwind", autospec=True + ) as tailwind_mock, patch( + "homeassistant.components.tailwind.config_flow.Tailwind", + new=tailwind_mock, + ): + tailwind = tailwind_mock.return_value + tailwind.status.return_value = TailwindDeviceStatus.from_json( + load_fixture(f"{device_fixture}.json", DOMAIN) + ) + yield tailwind + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> MockConfigEntry: + """Set up the Tailwind 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/tailwind/fixtures/iq3.json b/tests/components/tailwind/fixtures/iq3.json new file mode 100644 index 00000000000..1c8b2d5e0d4 --- /dev/null +++ b/tests/components/tailwind/fixtures/iq3.json @@ -0,0 +1,24 @@ +{ + "result": "OK", + "product": "iQ3", + "dev_id": "_3c_e9_e_6d_21_84_", + "proto_ver": "0.1", + "door_num": 2, + "night_mode_en": 0, + "fw_ver": "10.10", + "led_brightness": 100, + "data": { + "door1": { + "index": 0, + "status": "open", + "lockup": 0, + "disabled": 0 + }, + "door2": { + "index": 1, + "status": "open", + "lockup": 0, + "disabled": 0 + } + } +} diff --git a/tests/components/tailwind/snapshots/test_binary_sensor.ambr b/tests/components/tailwind/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..aafd15501ee --- /dev/null +++ b/tests/components/tailwind/snapshots/test_binary_sensor.ambr @@ -0,0 +1,147 @@ +# serializer version: 1 +# name: test_number_entities[binary_sensor.door_1_operational_problem] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Door 1 Operational problem', + 'icon': 'mdi:garage-alert', + }), + 'context': , + 'entity_id': 'binary_sensor.door_1_operational_problem', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_number_entities[binary_sensor.door_1_operational_problem].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.door_1_operational_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:garage-alert', + 'original_name': 'Operational problem', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_problem', + 'unique_id': '_3c_e9_e_6d_21_84_-door1-locked_out', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[binary_sensor.door_1_operational_problem].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door1', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': , + }) +# --- +# name: test_number_entities[binary_sensor.door_2_operational_problem] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Door 2 Operational problem', + 'icon': 'mdi:garage-alert', + }), + 'context': , + 'entity_id': 'binary_sensor.door_2_operational_problem', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_number_entities[binary_sensor.door_2_operational_problem].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.door_2_operational_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:garage-alert', + 'original_name': 'Operational problem', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'operational_problem', + 'unique_id': '_3c_e9_e_6d_21_84_-door2-locked_out', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities[binary_sensor.door_2_operational_problem].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door2', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 2', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': , + }) +# --- diff --git a/tests/components/tailwind/snapshots/test_button.ambr b/tests/components/tailwind/snapshots/test_button.ambr new file mode 100644 index 00000000000..b92b482e23d --- /dev/null +++ b/tests/components/tailwind/snapshots/test_button.ambr @@ -0,0 +1,77 @@ +# serializer version: 1 +# name: test_number_entities + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'identify', + 'friendly_name': 'Tailwind iQ3 Identify', + }), + 'context': , + 'entity_id': 'button.tailwind_iq3_identify', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_number_entities.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.tailwind_iq3_identify', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Identify', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '_3c_e9_e_6d_21_84_-identify', + 'unit_of_measurement': None, + }) +# --- +# name: test_number_entities.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:e9:0e:6d:21:84', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Tailwind iQ3', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tailwind/snapshots/test_config_flow.ambr b/tests/components/tailwind/snapshots/test_config_flow.ambr new file mode 100644 index 00000000000..5c01f35e09c --- /dev/null +++ b/tests/components/tailwind/snapshots/test_config_flow.ambr @@ -0,0 +1,85 @@ +# serializer version: 1 +# name: test_user_flow + FlowResultSnapshot({ + 'context': dict({ + 'source': 'user', + 'unique_id': '3c:e9:0e:6d:21:84', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'token': '987654', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'tailwind', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'token': '987654', + }), + 'disabled_by': None, + 'domain': 'tailwind', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Tailwind iQ3', + 'unique_id': '3c:e9:0e:6d:21:84', + 'version': 1, + }), + 'title': 'Tailwind iQ3', + 'type': , + 'version': 1, + }) +# --- +# name: test_zeroconf_flow + FlowResultSnapshot({ + 'context': dict({ + 'configuration_url': 'https://web.gotailwind.com/client/integration/local-control-key', + 'source': 'zeroconf', + 'title_placeholders': dict({ + 'name': 'Tailwind iQ3', + }), + 'unique_id': '3c:e9:0e:6d:21:84', + }), + 'data': dict({ + 'host': '127.0.0.1', + 'token': '987654', + }), + 'description': None, + 'description_placeholders': None, + 'flow_id': , + 'handler': 'tailwind', + 'minor_version': 1, + 'options': dict({ + }), + 'result': ConfigEntrySnapshot({ + 'data': dict({ + 'host': '127.0.0.1', + 'token': '987654', + }), + 'disabled_by': None, + 'domain': 'tailwind', + 'entry_id': , + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'zeroconf', + 'title': 'Tailwind iQ3', + 'unique_id': '3c:e9:0e:6d:21:84', + 'version': 1, + }), + 'title': 'Tailwind iQ3', + 'type': , + 'version': 1, + }) +# --- diff --git a/tests/components/tailwind/snapshots/test_cover.ambr b/tests/components/tailwind/snapshots/test_cover.ambr new file mode 100644 index 00000000000..e5d6306778f --- /dev/null +++ b/tests/components/tailwind/snapshots/test_cover.ambr @@ -0,0 +1,147 @@ +# serializer version: 1 +# name: test_cover_entities[cover.door_1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Door 1', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.door_1', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entities[cover.door_1].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.door_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '_3c_e9_e_6d_21_84_-door1', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities[cover.door_1].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door1', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 1', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': , + }) +# --- +# name: test_cover_entities[cover.door_2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'garage', + 'friendly_name': 'Door 2', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.door_2', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover_entities[cover.door_2].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.door_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '_3c_e9_e_6d_21_84_-door2', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_entities[cover.door_2].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_-door2', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Door 2', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': , + }) +# --- diff --git a/tests/components/tailwind/snapshots/test_diagnostics.ambr b/tests/components/tailwind/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..1ddfe08a4e3 --- /dev/null +++ b/tests/components/tailwind/snapshots/test_diagnostics.ambr @@ -0,0 +1,28 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'door1': dict({ + 'disabled': 0, + 'door_id': 'door1', + 'index': 0, + 'lockup': 0, + 'status': 'open', + }), + 'door2': dict({ + 'disabled': 0, + 'door_id': 'door2', + 'index': 1, + 'lockup': 0, + 'status': 'open', + }), + }), + 'dev_id': '_3c_e9_e_6d_21_84_', + 'door_num': 2, + 'fw_ver': '10.10', + 'led_brightness': 100, + 'night_mode_en': 0, + 'product': 'iQ3', + 'proto_ver': '0.1', + }) +# --- diff --git a/tests/components/tailwind/snapshots/test_number.ambr b/tests/components/tailwind/snapshots/test_number.ambr new file mode 100644 index 00000000000..1d1444461ff --- /dev/null +++ b/tests/components/tailwind/snapshots/test_number.ambr @@ -0,0 +1,87 @@ +# serializer version: 1 +# name: test_number_entities + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tailwind iQ3 Status LED brightness', + 'icon': 'mdi:led-on', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.tailwind_iq3_status_led_brightness', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_number_entities.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.tailwind_iq3_status_led_brightness', + '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': 'Status LED brightness', + 'platform': 'tailwind', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'brightness', + 'unique_id': '_3c_e9_e_6d_21_84_-brightness', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number_entities.2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '3c:e9:0e:6d:21:84', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'tailwind', + '_3c_e9_e_6d_21_84_', + ), + }), + 'is_new': False, + 'manufacturer': 'Tailwind', + 'model': 'iQ3', + 'name': 'Tailwind iQ3', + 'name_by_user': None, + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '10.10', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/tailwind/test_binary_sensor.py b/tests/components/tailwind/test_binary_sensor.py new file mode 100644 index 00000000000..a2bb574986c --- /dev/null +++ b/tests/components/tailwind/test_binary_sensor.py @@ -0,0 +1,35 @@ +"""Tests for binary sensor entities provided by the Tailwind integration.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +@pytest.mark.parametrize( + "entity_id", + [ + "binary_sensor.door_1_operational_problem", + "binary_sensor.door_2_operational_problem", + ], +) +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_id: str, +) -> None: + """Test binary sensor entities provided by the Tailwind integration.""" + assert (state := hass.states.get(entity_id)) + assert snapshot == state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry diff --git a/tests/components/tailwind/test_button.py b/tests/components/tailwind/test_button.py new file mode 100644 index 00000000000..a0128d5f498 --- /dev/null +++ b/tests/components/tailwind/test_button.py @@ -0,0 +1,65 @@ +"""Tests for button entities provided by the Tailwind integration.""" +from unittest.mock import MagicMock + +from gotailwind import TailwindError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = [ + pytest.mark.usefixtures("init_integration"), + pytest.mark.freeze_time("2023-12-17 15:25:00"), +] + + +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_tailwind: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test button entities provided by the Tailwind integration.""" + assert (state := hass.states.get("button.tailwind_iq3_identify")) + assert snapshot == state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry + + assert len(mock_tailwind.identify.mock_calls) == 0 + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state.entity_id}, + blocking=True, + ) + + assert len(mock_tailwind.identify.mock_calls) == 1 + mock_tailwind.identify.assert_called_with() + + assert (state := hass.states.get(state.entity_id)) + assert state.state == "2023-12-17T15:25:00+00:00" + + # Test error handling + mock_tailwind.identify.side_effect = TailwindError("Some error") + + with pytest.raises(HomeAssistantError, match="Some error") as excinfo: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state.entity_id}, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "communication_error" diff --git a/tests/components/tailwind/test_config_flow.py b/tests/components/tailwind/test_config_flow.py new file mode 100644 index 00000000000..6d35ccea85a --- /dev/null +++ b/tests/components/tailwind/test_config_flow.py @@ -0,0 +1,428 @@ +"""Configuration flow tests for the Tailwind integration.""" +from ipaddress import ip_address +from unittest.mock import MagicMock + +from gotailwind import ( + TailwindAuthenticationError, + TailwindConnectionError, + TailwindUnsupportedFirmwareVersionError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import zeroconf +from homeassistant.components.dhcp import DhcpServiceInfo +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.config_entries import ( + SOURCE_DHCP, + SOURCE_REAUTH, + SOURCE_USER, + SOURCE_ZEROCONF, +) +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_user_flow( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the full happy path user flow from start to finish.""" + 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" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + }, + ) + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2 == snapshot + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TailwindConnectionError, {CONF_HOST: "cannot_connect"}), + (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_user_flow_errors( + hass: HomeAssistant, + mock_tailwind: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show user form on a connection error.""" + mock_tailwind.status.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + }, + ) + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "user" + assert result.get("errors") == expected_error + + mock_tailwind.status.side_effect = None + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "127.0.0.2", + CONF_TOKEN: "123456", + }, + ) + assert result2.get("type") == FlowResultType.CREATE_ENTRY + + +async def test_user_flow_unsupported_firmware_version( + hass: HomeAssistant, mock_tailwind: MagicMock +) -> None: + """Test configuration flow aborts when the firmware version is not supported.""" + mock_tailwind.status.side_effect = TailwindUnsupportedFirmwareVersionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + }, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "unsupported_firmware" + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_user_flow_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test configuration flow aborts when the device is already configured. + + Also, ensures the existing config entry is updated with the new host. + """ + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_HOST] == "127.0.0.127" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: "127.0.0.1", + CONF_TOKEN: "987654", + }, + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + assert mock_config_entry.data[CONF_TOKEN] == "987654" + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_zeroconf_flow( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Test the zeroconf happy flow from start to finish.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="tailwind-3ce90e6d2184.local.", + name="mock_name", + properties={ + "device_id": "_3c_e9_e_6d_21_84_", + "product": "iQ3", + "SW ver": "10.10", + "vendor": "tailwind", + }, + type="mock_type", + ), + ) + + assert result.get("step_id") == "zeroconf_confirm" + assert result.get("type") == FlowResultType.FORM + + progress = hass.config_entries.flow.async_progress() + assert len(progress) == 1 + assert progress[0].get("flow_id") == result["flow_id"] + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_TOKEN: "987654"} + ) + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2 == snapshot + + +@pytest.mark.parametrize( + ("properties", "expected_reason"), + [ + ({"SW ver": "10.10"}, "no_device_id"), + ({"device_id": "_3c_e9_e_6d_21_84_", "SW ver": "0.0"}, "unsupported_firmware"), + ], +) +async def test_zeroconf_flow_abort_incompatible_properties( + hass: HomeAssistant, properties: dict[str, str], expected_reason: str +) -> None: + """Test the zeroconf aborts when it advertises incompatible data.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="tailwind-3ce90e6d2184.local.", + name="mock_name", + properties=properties, + type="mock_type", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == expected_reason + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TailwindConnectionError, {"base": "cannot_connect"}), + (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_zeroconf_flow_errors( + hass: HomeAssistant, + mock_tailwind: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show form on a error.""" + mock_tailwind.status.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="tailwind-3ce90e6d2184.local.", + name="mock_name", + properties={ + "device_id": "_3c_e9_e_6d_21_84_", + "product": "iQ3", + "SW ver": "10.10", + "vendor": "tailwind", + }, + type="mock_type", + ), + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "123456", + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "zeroconf_confirm" + assert result2.get("errors") == expected_error + + mock_tailwind.status.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "123456", + }, + ) + assert result3.get("type") == FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_zeroconf_flow_not_discovered_again( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the zeroconf doesn't re-discover an existing device. + + Also, ensures the existing config entry is updated with the new host. + """ + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_HOST] == "127.0.0.127" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + port=80, + hostname="tailwind-3ce90e6d2184.local.", + name="mock_name", + properties={ + "device_id": "_3c_e9_e_6d_21_84_", + "product": "iQ3", + "SW ver": "10.10", + "vendor": "tailwind", + }, + type="mock_type", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + + +@pytest.mark.usefixtures("mock_tailwind") +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauthentication configuration flow.""" + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_TOKEN] == "123456" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "987654"}, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + + assert mock_config_entry.data[CONF_TOKEN] == "987654" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TailwindConnectionError, {"base": "cannot_connect"}), + (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), + (Exception, {"base": "unknown"}), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, + side_effect: Exception, + expected_error: dict[str, str], +) -> None: + """Test we show form on a error.""" + mock_config_entry.add_to_hass(hass) + mock_tailwind.status.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "123456", + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "reauth_confirm" + assert result2.get("errors") == expected_error + + mock_tailwind.status.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_TOKEN: "123456", + }, + ) + + assert result3.get("type") == FlowResultType.ABORT + assert result3.get("reason") == "reauth_successful" + + +async def test_dhcp_discovery_updates_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test DHCP discovery updates config entries.""" + mock_config_entry.add_to_hass(hass) + assert mock_config_entry.data[CONF_HOST] == "127.0.0.127" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="tailwind-3ce90e6d2184.local.", + ip="127.0.0.1", + macaddress="3c:e9:0e:6d:21:84", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_dhcp_discovery_ignores_unknown(hass: HomeAssistant) -> None: + """Test DHCP discovery is only used for updates. + + Anything else will just abort the flow. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + hostname="tailwind-3ce90e6d2184.local.", + ip="127.0.0.1", + macaddress="3c:e9:0e:6d:21:84", + ), + ) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "unknown" diff --git a/tests/components/tailwind/test_cover.py b/tests/components/tailwind/test_cover.py new file mode 100644 index 00000000000..9620d6149b7 --- /dev/null +++ b/tests/components/tailwind/test_cover.py @@ -0,0 +1,170 @@ +"""Tests for cover entities provided by the Tailwind integration.""" +from unittest.mock import ANY, MagicMock + +from gotailwind import ( + TailwindDoorDisabledError, + TailwindDoorLockedOutError, + TailwindDoorOperationCommand, + TailwindError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, +) +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +@pytest.mark.parametrize( + "entity_id", + [ + "cover.door_1", + "cover.door_2", + ], +) +async def test_cover_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_id: str, +) -> None: + """Test cover entities provided by the Tailwind integration.""" + assert (state := hass.states.get(entity_id)) + assert state == snapshot + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert entity_entry == snapshot + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert device_entry == snapshot + + +async def test_cover_operations( + hass: HomeAssistant, + mock_tailwind: MagicMock, +) -> None: + """Test operating the doors.""" + assert len(mock_tailwind.operate.mock_calls) == 0 + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + mock_tailwind.operate.assert_called_with( + door=ANY, operation=TailwindDoorOperationCommand.OPEN + ) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + mock_tailwind.operate.assert_called_with( + door=ANY, operation=TailwindDoorOperationCommand.CLOSE + ) + + # Test door disabled error handling + mock_tailwind.operate.side_effect = TailwindDoorDisabledError("Door disabled") + + with pytest.raises(HomeAssistantError, match="Door disabled") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "door_disabled" + + with pytest.raises(HomeAssistantError, match="Door disabled") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "door_disabled" + + # Test door locked out error handling + mock_tailwind.operate.side_effect = TailwindDoorLockedOutError("Door locked out") + + with pytest.raises(HomeAssistantError, match="Door locked out") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "door_locked_out" + + with pytest.raises(HomeAssistantError, match="Door locked out") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "door_locked_out" + + # Test door error handling + mock_tailwind.operate.side_effect = TailwindError("Some error") + + with pytest.raises(HomeAssistantError, match="Some error") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "communication_error" + + with pytest.raises(HomeAssistantError, match="Some error") as excinfo: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: "cover.door_1", + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "communication_error" diff --git a/tests/components/tailwind/test_diagnostics.py b/tests/components/tailwind/test_diagnostics.py new file mode 100644 index 00000000000..3151d323bce --- /dev/null +++ b/tests/components/tailwind/test_diagnostics.py @@ -0,0 +1,22 @@ +"""Tests for diagnostics provided by the Tailwind integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/tailwind/test_init.py b/tests/components/tailwind/test_init.py new file mode 100644 index 00000000000..fb61d155008 --- /dev/null +++ b/tests/components/tailwind/test_init.py @@ -0,0 +1,73 @@ +"""Integration tests for the Tailwind integration.""" +from unittest.mock import MagicMock + +from gotailwind import TailwindAuthenticationError, TailwindConnectionError + +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> None: + """Test the Tailwind 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.state is ConfigEntryState.LOADED + assert len(mock_tailwind.status.mock_calls) == 1 + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_config_entry_not_ready( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> None: + """Test the Tailwind configuration entry not ready.""" + mock_tailwind.status.side_effect = TailwindConnectionError + + 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 len(mock_tailwind.status.mock_calls) == 1 + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_authentication_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tailwind: MagicMock, +) -> None: + """Test trigger reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + mock_tailwind.status.side_effect = TailwindAuthenticationError + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == mock_config_entry.entry_id diff --git a/tests/components/tailwind/test_number.py b/tests/components/tailwind/test_number.py new file mode 100644 index 00000000000..e16c940b85d --- /dev/null +++ b/tests/components/tailwind/test_number.py @@ -0,0 +1,66 @@ +"""Tests for number entities provided by the Tailwind integration.""" +from unittest.mock import MagicMock + +from gotailwind import TailwindError +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import number +from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE +from homeassistant.components.tailwind.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er + +pytestmark = pytest.mark.usefixtures("init_integration") + + +async def test_number_entities( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mock_tailwind: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test number entities provided by the Tailwind integration.""" + assert (state := hass.states.get("number.tailwind_iq3_status_led_brightness")) + assert snapshot == state + + assert (entity_entry := entity_registry.async_get(state.entity_id)) + assert snapshot == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry + + assert len(mock_tailwind.status_led.mock_calls) == 0 + await hass.services.async_call( + number.DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: state.entity_id, + ATTR_VALUE: 42, + }, + blocking=True, + ) + + assert len(mock_tailwind.status_led.mock_calls) == 1 + mock_tailwind.status_led.assert_called_with(brightness=42) + + # Test error handling + mock_tailwind.status_led.side_effect = TailwindError("Some error") + + with pytest.raises(HomeAssistantError, match="Some error") as excinfo: + await hass.services.async_call( + number.DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: state.entity_id, + ATTR_VALUE: 42, + }, + blocking=True, + ) + + assert excinfo.value.translation_domain == DOMAIN + assert excinfo.value.translation_key == "communication_error" diff --git a/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f52cb3a88a5 --- /dev/null +++ b/tests/components/tankerkoenig/snapshots/test_diagnostics.ambr @@ -0,0 +1,43 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'data': dict({ + '3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8': dict({ + 'diesel': 1.659, + 'e10': 1.659, + 'e5': 1.719, + 'status': 'open', + }), + }), + 'entry': dict({ + 'data': dict({ + 'api_key': '**REDACTED**', + 'fuel_types': list([ + 'e5', + ]), + 'location': dict({ + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + }), + 'name': 'Home', + 'radius': 2.0, + 'stations': list([ + '3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8', + ]), + }), + 'disabled_by': None, + 'domain': 'tankerkoenig', + 'entry_id': '8036b4412f2fae6bb9dbab7fe8e37f87', + 'minor_version': 1, + 'options': dict({ + 'show_on_map': True, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '**REDACTED**', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/tankerkoenig/test_diagnostics.py b/tests/components/tankerkoenig/test_diagnostics.py new file mode 100644 index 00000000000..59f273683a2 --- /dev/null +++ b/tests/components/tankerkoenig/test_diagnostics.py @@ -0,0 +1,103 @@ +"""Tests for the Tankerkoening integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.tankerkoenig.const import ( + CONF_FUEL_TYPES, + CONF_STATIONS, + DOMAIN, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, + CONF_RADIUS, + CONF_SHOW_ON_MAP, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +MOCK_USER_DATA = { + CONF_NAME: "Home", + CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", + CONF_FUEL_TYPES: ["e5"], + CONF_LOCATION: {CONF_LATITUDE: 51.0, CONF_LONGITUDE: 13.0}, + CONF_RADIUS: 2.0, + CONF_STATIONS: [ + "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + ], +} +MOCK_OPTIONS = { + CONF_SHOW_ON_MAP: True, +} + +MOCK_STATION_DATA = { + "ok": True, + "station": { + "id": "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + "name": "Station ABC", + "brand": "Station", + "street": "Somewhere Street", + "houseNumber": "1", + "postCode": "01234", + "place": "Somewhere", + "openingTimes": [], + "overrides": [], + "wholeDay": True, + "isOpen": True, + "e5": 1.719, + "e10": 1.659, + "diesel": 1.659, + "lat": 51.1, + "lng": 13.1, + "state": "xxXX", + }, +} +MOCK_STATION_PRICES = { + "ok": True, + "prices": { + "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8": { + "status": "open", + "e5": 1.719, + "e10": 1.659, + "diesel": 1.659, + }, + }, +} + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + with patch( + "homeassistant.components.tankerkoenig.coordinator.pytankerkoenig.getStationData", + return_value=MOCK_STATION_DATA, + ), patch( + "homeassistant.components.tankerkoenig.coordinator.pytankerkoenig.getPriceList", + return_value=MOCK_STATION_PRICES, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_USER_DATA, + options=MOCK_OPTIONS, + unique_id="mock.tankerkoenig", + entry_id="8036b4412f2fae6bb9dbab7fe8e37f87", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + + assert result == snapshot diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index cae65521e21..26f8dee4a9d 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -35,16 +35,16 @@ from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient, MqttMockPahoClient COVER_SUPPORT = ( - cover.SUPPORT_OPEN - | cover.SUPPORT_CLOSE - | cover.SUPPORT_STOP - | cover.SUPPORT_SET_POSITION + cover.CoverEntityFeature.OPEN + | cover.CoverEntityFeature.CLOSE + | cover.CoverEntityFeature.STOP + | cover.CoverEntityFeature.SET_POSITION ) TILT_SUPPORT = ( - cover.SUPPORT_OPEN_TILT - | cover.SUPPORT_CLOSE_TILT - | cover.SUPPORT_STOP_TILT - | cover.SUPPORT_SET_TILT_POSITION + cover.CoverEntityFeature.OPEN_TILT + | cover.CoverEntityFeature.CLOSE_TILT + | cover.CoverEntityFeature.STOP_TILT + | cover.CoverEntityFeature.SET_TILT_POSITION ) diff --git a/tests/components/tasmota/test_fan.py b/tests/components/tasmota/test_fan.py index 05e3151be2e..727fddc9bd3 100644 --- a/tests/components/tasmota/test_fan.py +++ b/tests/components/tasmota/test_fan.py @@ -60,7 +60,7 @@ async def test_controlling_state_via_mqtt( state = hass.states.get("fan.tasmota") assert state.state == STATE_OFF assert state.attributes["percentage"] is None - assert state.attributes["supported_features"] == fan.SUPPORT_SET_SPEED + assert state.attributes["supported_features"] == fan.FanEntityFeature.SET_SPEED assert not state.attributes.get(ATTR_ASSUMED_STATE) async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"FanSpeed":1}') diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index bfdb9352767..ece568eee49 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -1,6 +1,7 @@ """The tests for the Template button platform.""" import datetime as dt -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory from homeassistant import setup from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS @@ -59,7 +60,9 @@ async def test_missing_required_keys(hass: HomeAssistant) -> None: assert hass.states.async_all("button") == [] -async def test_all_optional_config(hass: HomeAssistant, calls) -> None: +async def test_all_optional_config( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test: including all optional templates is ok.""" with assert_setup_component(1, "template"): assert await setup.async_setup_component( @@ -98,14 +101,13 @@ async def test_all_optional_config(hass: HomeAssistant, calls) -> None: ) now = dt.datetime.now(dt.UTC) - - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {CONF_ENTITY_ID: _TEST_OPTIONS_BUTTON}, - blocking=True, - ) + freezer.move_to(now) + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {CONF_ENTITY_ID: _TEST_OPTIONS_BUTTON}, + blocking=True, + ) assert len(calls) == 1 assert calls[0].data["caller"] == _TEST_OPTIONS_BUTTON diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 35f03ee9508..88f0fc366a3 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -424,7 +424,7 @@ async def test_template_open_or_position( ) -> None: """Test that at least one of open_cover or set_position is used.""" assert hass.states.async_all("cover") == [] - assert "Invalid config for 'cover.template'" in caplog_setup_text + assert "Invalid config for 'cover' from integration 'template'" in caplog_setup_text @pytest.mark.parametrize(("count", "domain"), [(1, DOMAIN)]) diff --git a/tests/components/template/test_trigger.py b/tests/components/template/test_trigger.py index 84fdadfec0d..af010c57e2e 100644 --- a/tests/components/template/test_trigger.py +++ b/tests/components/template/test_trigger.py @@ -1,8 +1,8 @@ """The tests for the Template automation.""" from datetime import timedelta from unittest import mock -from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import homeassistant.components.automation as automation @@ -803,56 +803,56 @@ async def test_invalid_for_template_1(hass: HomeAssistant, start_ha, calls) -> N assert mock_logger.error.called -async def test_if_fires_on_time_change(hass: HomeAssistant, calls) -> None: +async def test_if_fires_on_time_change( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, calls +) -> None: """Test for firing on time changes.""" start_time = dt_util.utcnow() + timedelta(hours=24) time_that_will_not_match_right_away = start_time.replace(minute=1, second=0) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "trigger": { - "platform": "template", - "value_template": "{{ utcnow().minute % 2 == 0 }}", - }, - "action": {"service": "test.automation"}, - } - }, - ) - await hass.async_block_till_done() - assert len(calls) == 0 + freezer.move_to(time_that_will_not_match_right_away) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "template", + "value_template": "{{ utcnow().minute % 2 == 0 }}", + }, + "action": {"service": "test.automation"}, + } + }, + ) + await hass.async_block_till_done() + assert len(calls) == 0 # Trigger once (match template) first_time = start_time.replace(minute=2, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=first_time): - async_fire_time_changed(hass, first_time) - await hass.async_block_till_done() + freezer.move_to(first_time) + async_fire_time_changed(hass, first_time) + await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (match template) second_time = start_time.replace(minute=4, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=second_time): - async_fire_time_changed(hass, second_time) - await hass.async_block_till_done() + freezer.move_to(second_time) + async_fire_time_changed(hass, second_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (do not match template) third_time = start_time.replace(minute=5, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=third_time): - async_fire_time_changed(hass, third_time) - await hass.async_block_till_done() + freezer.move_to(third_time) + async_fire_time_changed(hass, third_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 1 # Trigger again (match template) forth_time = start_time.replace(minute=8, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=forth_time): - async_fire_time_changed(hass, forth_time) - await hass.async_block_till_done() + freezer.move_to(forth_time) + async_fire_time_changed(hass, forth_time) + await hass.async_block_till_done() await hass.async_block_till_done() assert len(calls) == 2 diff --git a/tests/components/tessie/__init__.py b/tests/components/tessie/__init__.py new file mode 100644 index 00000000000..df17fe027d9 --- /dev/null +++ b/tests/components/tessie/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tessie integration.""" diff --git a/tests/components/tessie/common.py b/tests/components/tessie/common.py new file mode 100644 index 00000000000..ae80526e5d9 --- /dev/null +++ b/tests/components/tessie/common.py @@ -0,0 +1,62 @@ +"""Tessie common helpers for tests.""" + +from http import HTTPStatus +from unittest.mock import patch + +from aiohttp import ClientConnectionError, ClientResponseError +from aiohttp.client import RequestInfo + +from homeassistant.components.tessie.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + +TEST_STATE_OF_ALL_VEHICLES = load_json_object_fixture("vehicles.json", DOMAIN) +TEST_VEHICLE_STATE_ONLINE = load_json_object_fixture("online.json", DOMAIN) +TEST_VEHICLE_STATE_ASLEEP = load_json_object_fixture("asleep.json", DOMAIN) +TEST_RESPONSE = {"result": True} +TEST_RESPONSE_ERROR = {"result": False, "reason": "reason why"} + +TEST_CONFIG = {CONF_ACCESS_TOKEN: "1234567890"} +TESSIE_URL = "https://api.tessie.com/" + +TEST_REQUEST_INFO = RequestInfo( + url=TESSIE_URL, method="GET", headers={}, real_url=TESSIE_URL +) + +ERROR_AUTH = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.UNAUTHORIZED +) +ERROR_TIMEOUT = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.REQUEST_TIMEOUT +) +ERROR_UNKNOWN = ClientResponseError( + request_info=TEST_REQUEST_INFO, history=None, status=HTTPStatus.BAD_REQUEST +) +ERROR_VIRTUAL_KEY = ClientResponseError( + request_info=TEST_REQUEST_INFO, + history=None, + status=HTTPStatus.INTERNAL_SERVER_ERROR, +) +ERROR_CONNECTION = ClientConnectionError() + + +async def setup_platform(hass: HomeAssistant, side_effect=None): + """Set up the Tessie platform.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONFIG, + ) + mock_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.tessie.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + side_effect=side_effect, + ): + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + return mock_entry diff --git a/tests/components/tessie/conftest.py b/tests/components/tessie/conftest.py new file mode 100644 index 00000000000..c7a344d54c5 --- /dev/null +++ b/tests/components/tessie/conftest.py @@ -0,0 +1,28 @@ +"""Fixtures for Tessie.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from .common import TEST_STATE_OF_ALL_VEHICLES, TEST_VEHICLE_STATE_ONLINE + + +@pytest.fixture +def mock_get_state(): + """Mock get_state function.""" + with patch( + "homeassistant.components.tessie.coordinator.get_state", + return_value=TEST_VEHICLE_STATE_ONLINE, + ) as mock_get_state: + yield mock_get_state + + +@pytest.fixture +def mock_get_state_of_all_vehicles(): + """Mock get_state_of_all_vehicles function.""" + with patch( + "homeassistant.components.tessie.config_flow.get_state_of_all_vehicles", + return_value=TEST_STATE_OF_ALL_VEHICLES, + ) as mock_get_state_of_all_vehicles: + yield mock_get_state_of_all_vehicles diff --git a/tests/components/tessie/fixtures/asleep.json b/tests/components/tessie/fixtures/asleep.json new file mode 100644 index 00000000000..4f78efafcf1 --- /dev/null +++ b/tests/components/tessie/fixtures/asleep.json @@ -0,0 +1 @@ +{ "state": "asleep" } diff --git a/tests/components/tessie/fixtures/online.json b/tests/components/tessie/fixtures/online.json new file mode 100644 index 00000000000..863e9bca783 --- /dev/null +++ b/tests/components/tessie/fixtures/online.json @@ -0,0 +1,276 @@ +{ + "user_id": 234567890, + "vehicle_id": 345678901, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "granular_access": { + "hide_private": false + }, + "tokens": ["beef", "c0ffee"], + "state": "online", + "in_service": false, + "id_s": "123456789", + "calendar_enabled": true, + "api_version": 67, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "charge_state": { + "battery_heater_on": false, + "battery_level": 75, + "battery_range": 263.68, + "charge_amps": 32, + "charge_current_request": 32, + "charge_current_request_max": 32, + "charge_enable_request": true, + "charge_energy_added": 18.47, + "charge_limit_soc": 80, + "charge_limit_soc_max": 100, + "charge_limit_soc_min": 50, + "charge_limit_soc_std": 80, + "charge_miles_added_ideal": 84, + "charge_miles_added_rated": 84, + "charge_port_cold_weather_mode": false, + "charge_port_color": "", + "charge_port_door_open": true, + "charge_port_latch": "Engaged", + "charge_rate": 30.6, + "charger_actual_current": 32, + "charger_phases": 1, + "charger_pilot_current": 32, + "charger_power": 7, + "charger_voltage": 224, + "charging_state": "Charging", + "conn_charge_cable": "IEC", + "est_battery_range": 324.73, + "fast_charger_brand": "", + "fast_charger_present": false, + "fast_charger_type": "ACSingleWireCAN", + "ideal_battery_range": 263.68, + "max_range_charge_counter": 0, + "minutes_to_full_charge": 30, + "not_enough_power_to_heat": null, + "off_peak_charging_enabled": false, + "off_peak_charging_times": "all_week", + "off_peak_hours_end_time": 900, + "preconditioning_enabled": false, + "preconditioning_times": "all_week", + "scheduled_charging_mode": "StartAt", + "scheduled_charging_pending": false, + "scheduled_charging_start_time": 1701216000, + "scheduled_charging_start_time_app": 600, + "scheduled_charging_start_time_minutes": 600, + "scheduled_departure_time": 1694899800, + "scheduled_departure_time_minutes": 450, + "supercharger_session_trip_planner": false, + "time_to_full_charge": 0.5, + "timestamp": 1701139037461, + "trip_charging": false, + "usable_battery_level": 75, + "user_charge_enable_request": null + }, + "climate_state": { + "allow_cabin_overheat_protection": true, + "auto_seat_climate_left": true, + "auto_seat_climate_right": true, + "auto_steering_wheel_heat": true, + "battery_heater": false, + "battery_heater_no_power": null, + "cabin_overheat_protection": "On", + "cabin_overheat_protection_actively_cooling": false, + "climate_keeper_mode": "off", + "cop_activation_temperature": "High", + "defrost_mode": 0, + "driver_temp_setting": 22.5, + "fan_status": 0, + "hvac_auto_request": "On", + "inside_temp": 30.4, + "is_auto_conditioning_on": false, + "is_climate_on": false, + "is_front_defroster_on": false, + "is_preconditioning": false, + "is_rear_defroster_on": false, + "left_temp_direction": 234, + "max_avail_temp": 28, + "min_avail_temp": 15, + "outside_temp": 30.5, + "passenger_temp_setting": 22.5, + "remote_heater_control_enabled": false, + "right_temp_direction": 234, + "seat_heater_left": 0, + "seat_heater_rear_center": 0, + "seat_heater_rear_left": 0, + "seat_heater_rear_right": 0, + "seat_heater_right": 0, + "side_mirror_heaters": false, + "steering_wheel_heat_level": 0, + "steering_wheel_heater": false, + "supports_fan_only_cabin_overheat_protection": true, + "timestamp": 1701139037461, + "wiper_blade_heater": false + }, + "drive_state": { + "active_route_latitude": 30.2226265, + "active_route_longitude": -97.6236871, + "active_route_traffic_minutes_delay": 0, + "gps_as_of": 1701129612, + "heading": 185, + "latitude": -30.222626, + "longitude": -97.6236871, + "native_latitude": -30.222626, + "native_location_supported": 1, + "native_longitude": -97.6236871, + "native_type": "wgs", + "power": -7, + "shift_state": null, + "speed": null, + "timestamp": 1701139037461 + }, + "gui_settings": { + "gui_24_hour_time": false, + "gui_charge_rate_units": "kW", + "gui_distance_units": "km/hr", + "gui_range_display": "Rated", + "gui_temperature_units": "C", + "gui_tirepressure_units": "Psi", + "show_range_units": false, + "timestamp": 1701139037461 + }, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": false, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1701139037461, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "vehicle_state": { + "api_version": 67, + "autopark_state_v2": "unavailable", + "calendar_supported": true, + "car_version": "2023.38.6 c1f85ddb415f", + "center_display_state": 0, + "dashcam_clip_save_available": true, + "dashcam_state": "Recording", + "df": 0, + "dr": 0, + "fd_window": 0, + "feature_bitmask": "fbdffbff,7f", + "fp_window": 0, + "ft": 0, + "is_user_present": false, + "locked": true, + "media_info": { + "audio_volume": 2.3333, + "audio_volume_increment": 0.333333, + "audio_volume_max": 10.333333, + "media_playback_status": "Playing", + "now_playing_album": "Album", + "now_playing_artist": "Artist", + "now_playing_duration": 60000, + "now_playing_elapsed": 30000, + "now_playing_source": "Spotify", + "now_playing_station": "Playlist", + "now_playing_title": "Song" + }, + "media_state": { + "remote_control_enabled": false + }, + "notifications_supported": true, + "odometer": 5454.495383, + "parsed_calendar_supported": true, + "pf": 0, + "pr": 0, + "rd_window": 0, + "remote_start": false, + "remote_start_enabled": true, + "remote_start_supported": true, + "rp_window": 0, + "rt": 0, + "santa_mode": 0, + "sentry_mode": false, + "sentry_mode_available": true, + "service_mode": false, + "service_mode_plus": false, + "software_update": { + "download_perc": 0, + "expected_duration_sec": 2700, + "install_perc": 1, + "status": "", + "version": " " + }, + "speed_limit_mode": { + "active": false, + "current_limit_mph": 74.564543, + "max_limit_mph": 120, + "min_limit_mph": 50, + "pin_code_set": true + }, + "timestamp": 1701139037461, + "tpms_hard_warning_fl": false, + "tpms_hard_warning_fr": false, + "tpms_hard_warning_rl": false, + "tpms_hard_warning_rr": false, + "tpms_last_seen_pressure_time_fl": 1701062077, + "tpms_last_seen_pressure_time_fr": 1701062047, + "tpms_last_seen_pressure_time_rl": 1701062077, + "tpms_last_seen_pressure_time_rr": 1701062047, + "tpms_pressure_fl": 2.975, + "tpms_pressure_fr": 2.975, + "tpms_pressure_rl": 2.95, + "tpms_pressure_rr": 2.95, + "tpms_rcp_front_value": 2.9, + "tpms_rcp_rear_value": 2.9, + "tpms_soft_warning_fl": false, + "tpms_soft_warning_fr": false, + "tpms_soft_warning_rl": false, + "tpms_soft_warning_rr": false, + "valet_mode": false, + "valet_pin_needed": false, + "vehicle_name": "Test", + "vehicle_self_test_progress": 0, + "vehicle_self_test_requested": false, + "webcam_available": true + }, + "display_name": "Test" +} diff --git a/tests/components/tessie/fixtures/vehicles.json b/tests/components/tessie/fixtures/vehicles.json new file mode 100644 index 00000000000..e150b9e60e7 --- /dev/null +++ b/tests/components/tessie/fixtures/vehicles.json @@ -0,0 +1,292 @@ +{ + "results": [ + { + "vin": "VINVINVIN", + "is_active": true, + "is_archived_manually": false, + "last_charge_created_at": null, + "last_charge_updated_at": null, + "last_drive_created_at": null, + "last_drive_updated_at": null, + "last_idle_created_at": null, + "last_idle_updated_at": null, + "last_state": { + "id": 123456789, + "user_id": 234567890, + "vehicle_id": 345678901, + "vin": "VINVINVIN", + "color": null, + "access_type": "OWNER", + "granular_access": { + "hide_private": false + }, + "tokens": ["beef", "c0ffee"], + "state": "online", + "in_service": false, + "id_s": "123456789", + "calendar_enabled": true, + "api_version": 67, + "backseat_token": null, + "backseat_token_updated_at": null, + "ble_autopair_enrolled": false, + "charge_state": { + "battery_heater_on": false, + "battery_level": 75, + "battery_range": 263.68, + "charge_amps": 32, + "charge_current_request": 32, + "charge_current_request_max": 32, + "charge_enable_request": true, + "charge_energy_added": 18.47, + "charge_limit_soc": 80, + "charge_limit_soc_max": 100, + "charge_limit_soc_min": 50, + "charge_limit_soc_std": 80, + "charge_miles_added_ideal": 84, + "charge_miles_added_rated": 84, + "charge_port_cold_weather_mode": false, + "charge_port_color": "", + "charge_port_door_open": true, + "charge_port_latch": "Engaged", + "charge_rate": 30.6, + "charger_actual_current": 32, + "charger_phases": 1, + "charger_pilot_current": 32, + "charger_power": 7, + "charger_voltage": 224, + "charging_state": "Charging", + "conn_charge_cable": "IEC", + "est_battery_range": 324.73, + "fast_charger_brand": "", + "fast_charger_present": false, + "fast_charger_type": "ACSingleWireCAN", + "ideal_battery_range": 263.68, + "max_range_charge_counter": 0, + "minutes_to_full_charge": 30, + "not_enough_power_to_heat": null, + "off_peak_charging_enabled": false, + "off_peak_charging_times": "all_week", + "off_peak_hours_end_time": 900, + "preconditioning_enabled": false, + "preconditioning_times": "all_week", + "scheduled_charging_mode": "StartAt", + "scheduled_charging_pending": false, + "scheduled_charging_start_time": 1701216000, + "scheduled_charging_start_time_app": 600, + "scheduled_charging_start_time_minutes": 600, + "scheduled_departure_time": 1694899800, + "scheduled_departure_time_minutes": 450, + "supercharger_session_trip_planner": false, + "time_to_full_charge": 0.5, + "timestamp": 1701139037461, + "trip_charging": false, + "usable_battery_level": 75, + "user_charge_enable_request": null + }, + "climate_state": { + "allow_cabin_overheat_protection": true, + "auto_seat_climate_left": true, + "auto_seat_climate_right": true, + "auto_steering_wheel_heat": true, + "battery_heater": false, + "battery_heater_no_power": null, + "cabin_overheat_protection": "On", + "cabin_overheat_protection_actively_cooling": false, + "climate_keeper_mode": "off", + "cop_activation_temperature": "High", + "defrost_mode": 0, + "driver_temp_setting": 22.5, + "fan_status": 0, + "hvac_auto_request": "On", + "inside_temp": 30.4, + "is_auto_conditioning_on": false, + "is_climate_on": false, + "is_front_defroster_on": false, + "is_preconditioning": false, + "is_rear_defroster_on": false, + "left_temp_direction": 234, + "max_avail_temp": 28, + "min_avail_temp": 15, + "outside_temp": 30.5, + "passenger_temp_setting": 22.5, + "remote_heater_control_enabled": false, + "right_temp_direction": 234, + "seat_heater_left": 0, + "seat_heater_rear_center": 0, + "seat_heater_rear_left": 0, + "seat_heater_rear_right": 0, + "seat_heater_right": 0, + "side_mirror_heaters": false, + "steering_wheel_heat_level": 0, + "steering_wheel_heater": false, + "supports_fan_only_cabin_overheat_protection": true, + "timestamp": 1701139037461, + "wiper_blade_heater": false + }, + "drive_state": { + "active_route_latitude": 30.2226265, + "active_route_longitude": -97.6236871, + "active_route_traffic_minutes_delay": 0, + "gps_as_of": 1701129612, + "heading": 185, + "latitude": -30.222626, + "longitude": -97.6236871, + "native_latitude": -30.222626, + "native_location_supported": 1, + "native_longitude": -97.6236871, + "native_type": "wgs", + "power": -7, + "shift_state": null, + "speed": null, + "timestamp": 1701139037461 + }, + "gui_settings": { + "gui_24_hour_time": false, + "gui_charge_rate_units": "kW", + "gui_distance_units": "km/hr", + "gui_range_display": "Rated", + "gui_temperature_units": "C", + "gui_tirepressure_units": "Psi", + "show_range_units": false, + "timestamp": 1701139037461 + }, + "vehicle_config": { + "aux_park_lamps": "Eu", + "badge_version": 1, + "can_accept_navigation_requests": true, + "can_actuate_trunks": true, + "car_special_type": "base", + "car_type": "model3", + "charge_port_type": "CCS", + "cop_user_set_temp_supported": false, + "dashcam_clip_save_supported": true, + "default_charge_to_max": false, + "driver_assist": "TeslaAP3", + "ece_restrictions": false, + "efficiency_package": "M32021", + "eu_vehicle": true, + "exterior_color": "DeepBlue", + "exterior_trim": "Black", + "exterior_trim_override": "", + "has_air_suspension": false, + "has_ludicrous_mode": false, + "has_seat_cooling": false, + "headlamp_type": "Global", + "interior_trim_type": "White2", + "key_version": 2, + "motorized_charge_port": true, + "paint_color_override": "0,9,25,0.7,0.04", + "performance_package": "Base", + "plg": true, + "pws": false, + "rear_drive_unit": "PM216MOSFET", + "rear_seat_heaters": 1, + "rear_seat_type": 0, + "rhd": true, + "roof_color": "RoofColorGlass", + "seat_type": null, + "spoiler_type": "None", + "sun_roof_installed": null, + "supports_qr_pairing": false, + "third_row_seats": "None", + "timestamp": 1701139037461, + "trim_badging": "74d", + "use_range_badging": true, + "utc_offset": 36000, + "webcam_selfie_supported": true, + "webcam_supported": true, + "wheel_type": "Pinwheel18CapKit" + }, + "vehicle_state": { + "api_version": 67, + "autopark_state_v2": "unavailable", + "calendar_supported": true, + "car_version": "2023.38.6 c1f85ddb415f", + "center_display_state": 0, + "dashcam_clip_save_available": true, + "dashcam_state": "Recording", + "df": 0, + "dr": 0, + "fd_window": 0, + "feature_bitmask": "fbdffbff,7f", + "fp_window": 0, + "ft": 0, + "is_user_present": false, + "locked": true, + "media_info": { + "audio_volume": 2.3333, + "audio_volume_increment": 0.333333, + "audio_volume_max": 10.333333, + "media_playback_status": "Stopped", + "now_playing_album": "", + "now_playing_artist": "", + "now_playing_duration": 0, + "now_playing_elapsed": 0, + "now_playing_source": "", + "now_playing_station": "", + "now_playing_title": "" + }, + "media_state": { + "remote_control_enabled": false + }, + "notifications_supported": true, + "odometer": 5454.495383, + "parsed_calendar_supported": true, + "pf": 0, + "pr": 0, + "rd_window": 0, + "remote_start": false, + "remote_start_enabled": true, + "remote_start_supported": true, + "rp_window": 0, + "rt": 0, + "santa_mode": 0, + "sentry_mode": false, + "sentry_mode_available": true, + "service_mode": false, + "service_mode_plus": false, + "software_update": { + "download_perc": 100, + "expected_duration_sec": 2700, + "install_perc": 1, + "status": "available", + "version": "2023.44.30.4" + }, + "speed_limit_mode": { + "active": false, + "current_limit_mph": 74.564543, + "max_limit_mph": 120, + "min_limit_mph": 50, + "pin_code_set": true + }, + "timestamp": 1701139037461, + "tpms_hard_warning_fl": false, + "tpms_hard_warning_fr": false, + "tpms_hard_warning_rl": false, + "tpms_hard_warning_rr": false, + "tpms_last_seen_pressure_time_fl": 1701062077, + "tpms_last_seen_pressure_time_fr": 1701062047, + "tpms_last_seen_pressure_time_rl": 1701062077, + "tpms_last_seen_pressure_time_rr": 1701062047, + "tpms_pressure_fl": 2.975, + "tpms_pressure_fr": 2.975, + "tpms_pressure_rl": 2.95, + "tpms_pressure_rr": 2.95, + "tpms_rcp_front_value": 2.9, + "tpms_rcp_rear_value": 2.9, + "tpms_soft_warning_fl": false, + "tpms_soft_warning_fr": false, + "tpms_soft_warning_rl": false, + "tpms_soft_warning_rr": false, + "valet_mode": false, + "valet_pin_needed": false, + "vehicle_name": "Test", + "vehicle_self_test_progress": 0, + "vehicle_self_test_requested": false, + "webcam_available": true + }, + "display_name": "Test" + } + } + ] +} diff --git a/tests/components/tessie/snapshots/test_cover.ambr b/tests/components/tessie/snapshots/test_cover.ambr new file mode 100644 index 00000000000..ae5e95be68d --- /dev/null +++ b/tests/components/tessie/snapshots/test_cover.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_covers[cover.test_charge_port_door-open_unlock_charge_port-close_charge_port][cover.test_charge_port_door] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Charge port door', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_charge_port_door', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_covers[cover.test_frunk-open_front_trunk-False][cover.test_frunk] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Frunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_frunk', + 'last_changed': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_covers[cover.test_trunk-open_close_rear_trunk-open_close_rear_trunk][cover.test_trunk] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Trunk', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_trunk', + 'last_changed': , + 'last_updated': , + 'state': 'closed', + }) +# --- +# name: test_covers[cover.test_vent_windows-vent_windows-close_windows][cover.test_vent_windows] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Vent windows', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_vent_windows', + 'last_changed': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/tessie/snapshots/test_media_player.ambr b/tests/components/tessie/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..e4c7f37c4ce --- /dev/null +++ b/tests/components/tessie/snapshots/test_media_player.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_media_player_idle + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'supported_features': , + 'volume_level': 0.22580323309042688, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_media_player_idle.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'supported_features': , + 'volume_level': 0.22580323309042688, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_media_player_playing + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test', + 'supported_features': , + 'volume_level': 0.22580323309042688, + }), + 'context': , + 'entity_id': 'media_player.test', + 'last_changed': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_sensors + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test', + 'supported_features': , + 'volume_level': 0.22580323309042688, + }), + 'context': , + 'entity_id': 'media_player.test', + 'last_changed': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/tessie/test_binary_sensors.py b/tests/components/tessie/test_binary_sensors.py new file mode 100644 index 00000000000..7f1eb1805a2 --- /dev/null +++ b/tests/components/tessie/test_binary_sensors.py @@ -0,0 +1,33 @@ +"""Test the Tessie binary sensor platform.""" + +from homeassistant.components.tessie.binary_sensor import DESCRIPTIONS +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + +OFFON = [STATE_OFF, STATE_ON] + + +async def test_binary_sensors(hass: HomeAssistant) -> None: + """Tests that the binary sensor entities are correct.""" + + assert len(hass.states.async_all("binary_sensor")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("binary_sensor")) == len(DESCRIPTIONS) + + state = hass.states.get("binary_sensor.test_battery_heater").state + is_on = state == STATE_ON + assert is_on == TEST_VEHICLE_STATE_ONLINE["charge_state"]["battery_heater_on"] + + state = hass.states.get("binary_sensor.test_charging").state + is_on = state == STATE_ON + assert is_on == ( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charging_state"] == "Charging" + ) + + state = hass.states.get("binary_sensor.test_auto_seat_climate_left").state + is_on = state == STATE_ON + assert is_on == TEST_VEHICLE_STATE_ONLINE["climate_state"]["auto_seat_climate_left"] diff --git a/tests/components/tessie/test_button.py b/tests/components/tessie/test_button.py new file mode 100644 index 00000000000..153171c8b9f --- /dev/null +++ b/tests/components/tessie/test_button.py @@ -0,0 +1,39 @@ +"""Test the Tessie button platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .common import setup_platform + + +@pytest.mark.parametrize( + ("entity_id", "func"), + [ + ("button.test_wake", "wake"), + ("button.test_flash_lights", "flash_lights"), + ("button.test_honk_horn", "honk"), + ("button.test_homelink", "trigger_homelink"), + ("button.test_keyless_driving", "enable_keyless_driving"), + ("button.test_play_fart", "boombox"), + ], +) +async def test_buttons(hass: HomeAssistant, entity_id, func) -> None: + """Tests that the button entities are correct.""" + + await setup_platform(hass) + + # Test wake button + with patch( + f"homeassistant.components.tessie.button.{func}", + ) as mock_wake: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_wake.assert_called_once() diff --git a/tests/components/tessie/test_climate.py b/tests/components/tessie/test_climate.py new file mode 100644 index 00000000000..341e4714470 --- /dev/null +++ b/tests/components/tessie/test_climate.py @@ -0,0 +1,124 @@ +"""Test the Tessie climate platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_PRESET_MODE, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_TEMPERATURE, + SERVICE_TURN_ON, + HVACMode, +) +from homeassistant.components.tessie.const import TessieClimateKeeper +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .common import ( + ERROR_UNKNOWN, + TEST_RESPONSE, + TEST_VEHICLE_STATE_ONLINE, + setup_platform, +) + + +async def test_climate(hass: HomeAssistant) -> None: + """Tests that the climate entity is correct.""" + + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all(CLIMATE_DOMAIN)) == 1 + + entity_id = "climate.test_climate" + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + assert ( + state.attributes.get(ATTR_MIN_TEMP) + == TEST_VEHICLE_STATE_ONLINE["climate_state"]["min_avail_temp"] + ) + assert ( + state.attributes.get(ATTR_MAX_TEMP) + == TEST_VEHICLE_STATE_ONLINE["climate_state"]["max_avail_temp"] + ) + + # Test setting climate on + with patch( + "homeassistant.components.tessie.climate.start_climate_preconditioning", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + mock_set.assert_called_once() + + # Test setting climate temp + with patch( + "homeassistant.components.tessie.climate.set_temperature", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: [entity_id], ATTR_TEMPERATURE: 20}, + blocking=True, + ) + mock_set.assert_called_once() + + # Test setting climate preset + with patch( + "homeassistant.components.tessie.climate.set_climate_keeper_mode", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_PRESET_MODE: TessieClimateKeeper.ON}, + blocking=True, + ) + mock_set.assert_called_once() + + # Test setting climate off + with patch( + "homeassistant.components.tessie.climate.stop_climate", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.OFF}, + blocking=True, + ) + mock_set.assert_called_once() + + +async def test_errors(hass: HomeAssistant) -> None: + """Tests virtual key error is handled.""" + + await setup_platform(hass) + entity_id = "climate.test_climate" + + # Test setting climate on with unknown error + with patch( + "homeassistant.components.tessie.climate.start_climate_preconditioning", + side_effect=ERROR_UNKNOWN, + ) as mock_set, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_set.assert_called_once() + assert error.from_exception == ERROR_UNKNOWN diff --git a/tests/components/tessie/test_config_flow.py b/tests/components/tessie/test_config_flow.py new file mode 100644 index 00000000000..7bc3efa24fc --- /dev/null +++ b/tests/components/tessie/test_config_flow.py @@ -0,0 +1,168 @@ +"""Test the Tessie config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.tessie.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .common import ( + ERROR_AUTH, + ERROR_CONNECTION, + ERROR_UNKNOWN, + TEST_CONFIG, + setup_platform, +) + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> None: + """Test we get the form.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result1["type"] == FlowResultType.FORM + assert not result1["errors"] + + with patch( + "homeassistant.components.tessie.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_get_state_of_all_vehicles.mock_calls) == 1 + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Tessie" + assert result2["data"] == TEST_CONFIG + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (ERROR_AUTH, {CONF_ACCESS_TOKEN: "invalid_access_token"}), + (ERROR_UNKNOWN, {"base": "unknown"}), + (ERROR_CONNECTION, {"base": "cannot_connect"}), + ], +) +async def test_form_errors( + hass: HomeAssistant, side_effect, error, mock_get_state_of_all_vehicles +) -> None: + """Test errors are handled.""" + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + mock_get_state_of_all_vehicles.side_effect = side_effect + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + + # Complete the flow + mock_get_state_of_all_vehicles.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + + +async def test_reauth(hass: HomeAssistant, mock_get_state_of_all_vehicles) -> None: + """Test reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONFIG, + ) + mock_entry.add_to_hass(hass) + + result1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_entry.entry_id, + }, + data=TEST_CONFIG, + ) + + assert result1["type"] == FlowResultType.FORM + assert result1["step_id"] == "reauth_confirm" + assert not result1["errors"] + + with patch( + "homeassistant.components.tessie.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_get_state_of_all_vehicles.mock_calls) == 1 + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + assert mock_entry.data == TEST_CONFIG + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (ERROR_AUTH, {CONF_ACCESS_TOKEN: "invalid_access_token"}), + (ERROR_UNKNOWN, {"base": "unknown"}), + (ERROR_CONNECTION, {"base": "cannot_connect"}), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, mock_get_state_of_all_vehicles, side_effect, error +) -> None: + """Test reauth flows that failscript/.""" + + mock_entry = await setup_platform(hass) + mock_get_state_of_all_vehicles.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data=TEST_CONFIG, + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONFIG, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == error + + # Complete the flow + mock_get_state_of_all_vehicles.side_effect = None + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + TEST_CONFIG, + ) + assert "errors" not in result3 + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + assert mock_entry.data == TEST_CONFIG diff --git a/tests/components/tessie/test_coordinator.py b/tests/components/tessie/test_coordinator.py new file mode 100644 index 00000000000..311222466fd --- /dev/null +++ b/tests/components/tessie/test_coordinator.py @@ -0,0 +1,78 @@ +"""Test the Tessie sensor platform.""" +from datetime import timedelta + +from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .common import ( + ERROR_AUTH, + ERROR_CONNECTION, + ERROR_UNKNOWN, + TEST_VEHICLE_STATE_ASLEEP, + TEST_VEHICLE_STATE_ONLINE, + setup_platform, +) + +from tests.common import async_fire_time_changed + +WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL) + + +async def test_coordinator_online(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles online vehicles.""" + + mock_get_state.return_value = TEST_VEHICLE_STATE_ONLINE + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("binary_sensor.test_status").state == STATE_ON + + +async def test_coordinator_asleep(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles asleep vehicles.""" + + mock_get_state.return_value = TEST_VEHICLE_STATE_ASLEEP + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("binary_sensor.test_status").state == STATE_OFF + + +async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles client errors.""" + + mock_get_state.side_effect = ERROR_UNKNOWN + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("binary_sensor.test_status").state == STATE_UNAVAILABLE + + +async def test_coordinator_auth(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles timeout errors.""" + + mock_get_state.side_effect = ERROR_AUTH + await setup_platform(hass) + + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + + +async def test_coordinator_connection(hass: HomeAssistant, mock_get_state) -> None: + """Tests that the coordinator handles connection errors.""" + + mock_get_state.side_effect = ERROR_CONNECTION + await setup_platform(hass) + async_fire_time_changed(hass, utcnow() + WAIT) + await hass.async_block_till_done() + mock_get_state.assert_called_once() + assert hass.states.get("binary_sensor.test_status").state == STATE_UNAVAILABLE diff --git a/tests/components/tessie/test_cover.py b/tests/components/tessie/test_cover.py new file mode 100644 index 00000000000..713108b962a --- /dev/null +++ b/tests/components/tessie/test_cover.py @@ -0,0 +1,113 @@ +"""Test the Tessie cover platform.""" +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + STATE_CLOSED, + STATE_OPEN, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .common import ERROR_UNKNOWN, TEST_RESPONSE, TEST_RESPONSE_ERROR, setup_platform + + +@pytest.mark.parametrize( + ("entity_id", "openfunc", "closefunc"), + [ + ("cover.test_vent_windows", "vent_windows", "close_windows"), + ("cover.test_charge_port_door", "open_unlock_charge_port", "close_charge_port"), + ("cover.test_frunk", "open_front_trunk", False), + ("cover.test_trunk", "open_close_rear_trunk", "open_close_rear_trunk"), + ], +) +async def test_covers( + hass: HomeAssistant, + entity_id: str, + openfunc: str, + closefunc: str, + snapshot: SnapshotAssertion, +) -> None: + """Tests that the window cover entity is correct.""" + + await setup_platform(hass) + + assert hass.states.get(entity_id) == snapshot(name=entity_id) + + # Test open windows + if openfunc: + with patch( + f"homeassistant.components.tessie.cover.{openfunc}", + return_value=TEST_RESPONSE, + ) as mock_open: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_open.assert_called_once() + assert hass.states.get(entity_id).state == STATE_OPEN + + # Test close windows + if closefunc: + with patch( + f"homeassistant.components.tessie.cover.{closefunc}", + return_value=TEST_RESPONSE, + ) as mock_close: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_close.assert_called_once() + assert hass.states.get(entity_id).state == STATE_CLOSED + + +async def test_errors(hass: HomeAssistant) -> None: + """Tests errors are handled.""" + + await setup_platform(hass) + entity_id = "cover.test_charge_port_door" + + # Test setting cover open with unknown error + with patch( + "homeassistant.components.tessie.cover.open_unlock_charge_port", + side_effect=ERROR_UNKNOWN, + ) as mock_set, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_set.assert_called_once() + assert error.from_exception == ERROR_UNKNOWN + + +async def test_response_error(hass: HomeAssistant) -> None: + """Tests response errors are handled.""" + + await setup_platform(hass) + entity_id = "cover.test_charge_port_door" + + # Test setting cover open with unknown error + with patch( + "homeassistant.components.tessie.cover.open_unlock_charge_port", + return_value=TEST_RESPONSE_ERROR, + ) as mock_set, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_set.assert_called_once() + assert str(error) == TEST_RESPONSE_ERROR["reason"] diff --git a/tests/components/tessie/test_device_tracker.py b/tests/components/tessie/test_device_tracker.py new file mode 100644 index 00000000000..d737b02b40e --- /dev/null +++ b/tests/components/tessie/test_device_tracker.py @@ -0,0 +1,36 @@ +"""Test the Tessie device tracker platform.""" + + +from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import HomeAssistant + +from .common import TEST_STATE_OF_ALL_VEHICLES, setup_platform + +STATES = TEST_STATE_OF_ALL_VEHICLES["results"][0]["last_state"] + + +async def test_device_tracker(hass: HomeAssistant) -> None: + """Tests that the device tracker entities are correct.""" + + assert len(hass.states.async_all(DEVICE_TRACKER_DOMAIN)) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all(DEVICE_TRACKER_DOMAIN)) == 2 + + entity_id = "device_tracker.test_location" + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_LATITUDE) == STATES["drive_state"]["latitude"] + assert state.attributes.get(ATTR_LONGITUDE) == STATES["drive_state"]["longitude"] + + entity_id = "device_tracker.test_route" + state = hass.states.get(entity_id) + assert ( + state.attributes.get(ATTR_LATITUDE) + == STATES["drive_state"]["active_route_latitude"] + ) + assert ( + state.attributes.get(ATTR_LONGITUDE) + == STATES["drive_state"]["active_route_longitude"] + ) diff --git a/tests/components/tessie/test_init.py b/tests/components/tessie/test_init.py new file mode 100644 index 00000000000..68d6fcf7777 --- /dev/null +++ b/tests/components/tessie/test_init.py @@ -0,0 +1,37 @@ +"""Test the Tessie init.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .common import ERROR_AUTH, ERROR_CONNECTION, ERROR_UNKNOWN, setup_platform + + +async def test_load_unload(hass: HomeAssistant) -> None: + """Test load and unload.""" + + entry = await setup_platform(hass) + assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_auth_failure(hass: HomeAssistant) -> None: + """Test init with an authentication error.""" + + entry = await setup_platform(hass, side_effect=ERROR_AUTH) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_unknown_failure(hass: HomeAssistant) -> None: + """Test init with an client response error.""" + + entry = await setup_platform(hass, side_effect=ERROR_UNKNOWN) + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_connection_failure(hass: HomeAssistant) -> None: + """Test init with a network connection error.""" + + entry = await setup_platform(hass, side_effect=ERROR_CONNECTION) + assert entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/tessie/test_lock.py b/tests/components/tessie/test_lock.py new file mode 100644 index 00000000000..93a1151a850 --- /dev/null +++ b/tests/components/tessie/test_lock.py @@ -0,0 +1,50 @@ +"""Test the Tessie lock platform.""" + +from unittest.mock import patch + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_UNLOCK, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + + +async def test_locks(hass: HomeAssistant) -> None: + """Tests that the lock entity is correct.""" + + assert len(hass.states.async_all("lock")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("lock")) == 1 + + entity_id = "lock.test_lock" + + assert ( + hass.states.get(entity_id).state == STATE_LOCKED + ) == TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["locked"] + + # Test lock set value functions + with patch("homeassistant.components.tessie.lock.lock") as mock_run: + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert hass.states.get(entity_id).state == STATE_LOCKED + mock_run.assert_called_once() + + with patch("homeassistant.components.tessie.lock.unlock") as mock_run: + await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + assert hass.states.get(entity_id).state == STATE_UNLOCKED + mock_run.assert_called_once() diff --git a/tests/components/tessie/test_media_player.py b/tests/components/tessie/test_media_player.py new file mode 100644 index 00000000000..f658fe28acd --- /dev/null +++ b/tests/components/tessie/test_media_player.py @@ -0,0 +1,46 @@ +"""Test the Tessie media player platform.""" + +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.components.tessie.coordinator import TESSIE_SYNC_INTERVAL +from homeassistant.core import HomeAssistant + +from .common import ( + TEST_STATE_OF_ALL_VEHICLES, + TEST_VEHICLE_STATE_ONLINE, + setup_platform, +) + +from tests.common import async_fire_time_changed + +WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL) + +MEDIA_INFO_1 = TEST_STATE_OF_ALL_VEHICLES["results"][0]["last_state"]["vehicle_state"][ + "media_info" +] +MEDIA_INFO_2 = TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["media_info"] + + +async def test_media_player_idle( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion +) -> None: + """Tests that the media player entity is correct when idle.""" + + assert len(hass.states.async_all("media_player")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("media_player")) == 1 + + state = hass.states.get("media_player.test_media_player") + assert state == snapshot + + # Trigger coordinator refresh since it has a different fixture. + freezer.tick(WAIT) + async_fire_time_changed(hass) + + state = hass.states.get("media_player.test_media_player") + assert state == snapshot diff --git a/tests/components/tessie/test_number.py b/tests/components/tessie/test_number.py new file mode 100644 index 00000000000..116c9a2657d --- /dev/null +++ b/tests/components/tessie/test_number.py @@ -0,0 +1,71 @@ +"""Test the Tessie number platform.""" + +from unittest.mock import patch + +from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.tessie.number import DESCRIPTIONS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + + +async def test_numbers(hass: HomeAssistant) -> None: + """Tests that the number entities are correct.""" + + assert len(hass.states.async_all("number")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("number")) == len(DESCRIPTIONS) + + assert hass.states.get("number.test_charge_current").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_current_request"] + ) + + assert hass.states.get("number.test_charge_limit").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_limit_soc"] + ) + + assert hass.states.get("number.test_speed_limit").state == str( + TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["speed_limit_mode"][ + "current_limit_mph" + ] + ) + + # Test number set value functions + with patch( + "homeassistant.components.tessie.number.set_charging_amps", + ) as mock_set_charging_amps: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ["number.test_charge_current"], "value": 16}, + blocking=True, + ) + assert hass.states.get("number.test_charge_current").state == "16.0" + mock_set_charging_amps.assert_called_once() + + with patch( + "homeassistant.components.tessie.number.set_charge_limit", + ) as mock_set_charge_limit: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ["number.test_charge_limit"], "value": 80}, + blocking=True, + ) + assert hass.states.get("number.test_charge_limit").state == "80.0" + mock_set_charge_limit.assert_called_once() + + with patch( + "homeassistant.components.tessie.number.set_speed_limit", + ) as mock_set_speed_limit: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ["number.test_speed_limit"], "value": 60}, + blocking=True, + ) + assert hass.states.get("number.test_speed_limit").state == "60.0" + mock_set_speed_limit.assert_called_once() diff --git a/tests/components/tessie/test_select.py b/tests/components/tessie/test_select.py new file mode 100644 index 00000000000..09afa9306a7 --- /dev/null +++ b/tests/components/tessie/test_select.py @@ -0,0 +1,65 @@ +"""Test the Tessie select platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.tessie.const import TessieSeatHeaterOptions +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .common import ERROR_UNKNOWN, TEST_RESPONSE, setup_platform + + +async def test_select(hass: HomeAssistant) -> None: + """Tests that the select entities are correct.""" + + assert len(hass.states.async_all(SELECT_DOMAIN)) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all(SELECT_DOMAIN)) == 5 + + entity_id = "select.test_seat_heater_left" + assert hass.states.get(entity_id).state == STATE_OFF + + # Test changing select + with patch( + "homeassistant.components.tessie.select.set_seat_heat", + return_value=TEST_RESPONSE, + ) as mock_set: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: [entity_id], ATTR_OPTION: TessieSeatHeaterOptions.LOW}, + blocking=True, + ) + mock_set.assert_called_once() + assert mock_set.call_args[1]["seat"] == "front_left" + assert mock_set.call_args[1]["level"] == 1 + assert hass.states.get(entity_id).state == TessieSeatHeaterOptions.LOW + + +async def test_errors(hass: HomeAssistant) -> None: + """Tests unknown error is handled.""" + + await setup_platform(hass) + entity_id = "select.test_seat_heater_left" + + # Test setting cover open with unknown error + with patch( + "homeassistant.components.tessie.select.set_seat_heat", + side_effect=ERROR_UNKNOWN, + ) as mock_set, pytest.raises(HomeAssistantError) as error: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: [entity_id], ATTR_OPTION: TessieSeatHeaterOptions.LOW}, + blocking=True, + ) + mock_set.assert_called_once() + assert error.from_exception == ERROR_UNKNOWN diff --git a/tests/components/tessie/test_sensor.py b/tests/components/tessie/test_sensor.py new file mode 100644 index 00000000000..0c719f66136 --- /dev/null +++ b/tests/components/tessie/test_sensor.py @@ -0,0 +1,24 @@ +"""Test the Tessie sensor platform.""" +from homeassistant.components.tessie.sensor import DESCRIPTIONS +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + + +async def test_sensors(hass: HomeAssistant) -> None: + """Tests that the sensor entities are correct.""" + + assert len(hass.states.async_all("sensor")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("sensor")) == len(DESCRIPTIONS) + + assert hass.states.get("sensor.test_battery_level").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["battery_level"] + ) + assert hass.states.get("sensor.test_charge_energy_added").state == str( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_energy_added"] + ) + assert hass.states.get("sensor.test_shift_state").state == STATE_UNKNOWN diff --git a/tests/components/tessie/test_switch.py b/tests/components/tessie/test_switch.py new file mode 100644 index 00000000000..5bc24d12e5c --- /dev/null +++ b/tests/components/tessie/test_switch.py @@ -0,0 +1,53 @@ +"""Test the Tessie switch platform.""" +from unittest.mock import patch + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.components.tessie.switch import DESCRIPTIONS +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.core import HomeAssistant + +from .common import TEST_VEHICLE_STATE_ONLINE, setup_platform + + +async def test_switches(hass: HomeAssistant) -> None: + """Tests that the switche entities are correct.""" + + assert len(hass.states.async_all("switch")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("switch")) == len(DESCRIPTIONS) + + assert (hass.states.get("switch.test_charge").state == STATE_ON) == ( + TEST_VEHICLE_STATE_ONLINE["charge_state"]["charge_enable_request"] + ) + assert (hass.states.get("switch.test_sentry_mode").state == STATE_ON) == ( + TEST_VEHICLE_STATE_ONLINE["vehicle_state"]["sentry_mode"] + ) + + with patch( + "homeassistant.components.tessie.switch.start_charging", + ) as mock_start_charging: + # Test Switch On + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ["switch.test_charge"]}, + blocking=True, + ) + mock_start_charging.assert_called_once() + with patch( + "homeassistant.components.tessie.switch.stop_charging", + ) as mock_stop_charging: + # Test Switch Off + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ["switch.test_charge"]}, + blocking=True, + ) + mock_stop_charging.assert_called_once() diff --git a/tests/components/tessie/test_update.py b/tests/components/tessie/test_update.py new file mode 100644 index 00000000000..182acdf17ff --- /dev/null +++ b/tests/components/tessie/test_update.py @@ -0,0 +1,42 @@ +"""Test the Tessie update platform.""" +from unittest.mock import patch + +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.core import HomeAssistant + +from .common import setup_platform + + +async def test_updates(hass: HomeAssistant) -> None: + """Tests that update entity is correct.""" + + assert len(hass.states.async_all("update")) == 0 + + await setup_platform(hass) + + assert len(hass.states.async_all("update")) == 1 + + entity_id = "update.test_update" + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes.get(ATTR_IN_PROGRESS) is False + + with patch( + "homeassistant.components.tessie.update.schedule_software_update" + ) as mock_update: + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_update.assert_called_once() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes.get(ATTR_IN_PROGRESS) == 1 diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index 96c7edf422b..f9ef8a7cfe9 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -1,29 +1,30 @@ """The tests for time_date sensor platform.""" -from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory import homeassistant.components.time_date.sensor as time_date from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -async def test_intervals(hass: HomeAssistant) -> None: +async def test_intervals(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test timing intervals of sensors.""" device = time_date.TimeDateSensor(hass, "time") now = dt_util.utc_from_timestamp(45.5) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time == dt_util.utc_from_timestamp(60) device = time_date.TimeDateSensor(hass, "beat") now = dt_util.parse_datetime("2020-11-13 00:00:29+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time == dt_util.parse_datetime("2020-11-13 00:01:26.4+01:00") device = time_date.TimeDateSensor(hass, "date_time") now = dt_util.utc_from_timestamp(1495068899) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time == dt_util.utc_from_timestamp(1495068900) now = dt_util.utcnow() @@ -102,14 +103,16 @@ async def test_states_non_default_timezone(hass: HomeAssistant) -> None: assert device.state == "2017-05-17T20:54:00" -async def test_timezone_intervals(hass: HomeAssistant) -> None: +async def test_timezone_intervals( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test date sensor behavior in a timezone besides UTC.""" hass.config.set_time_zone("America/New_York") device = time_date.TimeDateSensor(hass, "date") now = dt_util.utc_from_timestamp(50000) - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() # start of local day in EST was 18000.0 # so the second day was 18000 + 86400 assert next_time.timestamp() == 104400 @@ -117,43 +120,40 @@ async def test_timezone_intervals(hass: HomeAssistant) -> None: hass.config.set_time_zone("America/Edmonton") now = dt_util.parse_datetime("2017-11-13 19:47:19-07:00") device = time_date.TimeDateSensor(hass, "date") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2017-11-14 00:00:00-07:00") # Entering DST hass.config.set_time_zone("Europe/Prague") now = dt_util.parse_datetime("2020-03-29 00:00+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-03-30 00:00+02:00") now = dt_util.parse_datetime("2020-03-29 03:00+02:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-03-30 00:00+02:00") # Leaving DST now = dt_util.parse_datetime("2020-10-25 00:00+02:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-10-26 00:00:00+01:00") now = dt_util.parse_datetime("2020-10-25 23:59+01:00") - with patch("homeassistant.util.dt.utcnow", return_value=now): - next_time = device.get_next_interval() + freezer.move_to(now) + next_time = device.get_next_interval() assert next_time.timestamp() == dt_util.as_timestamp("2020-10-26 00:00:00+01:00") -@patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.parse_datetime("2017-11-14 02:47:19-00:00"), -) async def test_timezone_intervals_empty_parameter( - utcnow_mock, hass: HomeAssistant + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test get_interval() without parameters.""" + freezer.move_to(dt_util.parse_datetime("2017-11-14 02:47:19-00:00")) hass.config.set_time_zone("America/Edmonton") device = time_date.TimeDateSensor(hass, "date") next_time = device.get_next_interval() diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 0edca7a7ef6..5a8f6183cbb 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -20,7 +20,7 @@ from homeassistant.components.todo import ( from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -146,15 +146,19 @@ async def create_mock_platform( return config_entry +@pytest.fixture(name="test_entity_items") +def mock_test_entity_items() -> list[TodoItem]: + """Fixture that creates the items returned by the test entity.""" + return [ + TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), + ] + + @pytest.fixture(name="test_entity") -def mock_test_entity() -> TodoListEntity: +def mock_test_entity(test_entity_items: list[TodoItem]) -> TodoListEntity: """Fixture that creates a test TodoList entity with mock service calls.""" - entity1 = MockTodoListEntity( - [ - TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), - TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), - ] - ) + entity1 = MockTodoListEntity(test_entity_items) entity1.entity_id = "todo.entity1" entity1._attr_supported_features = ( TodoListEntityFeature.CREATE_TODO_ITEM @@ -343,12 +347,12 @@ async def test_add_item_service_raises( ({"item": ""}, vol.Invalid, "length of value must be at least 1"), ( {"item": "Submit forms", "description": "Submit tax forms"}, - ValueError, + ServiceValidationError, "does not support setting field 'description'", ), ( {"item": "Submit forms", "due_date": "2023-11-17"}, - ValueError, + ServiceValidationError, "does not support setting field 'due_date'", ), ( @@ -356,7 +360,7 @@ async def test_add_item_service_raises( "item": "Submit forms", "due_datetime": f"2023-11-17T17:00:00{TEST_OFFSET}", }, - ValueError, + ServiceValidationError, "does not support setting field 'due_datetime'", ), ], @@ -504,7 +508,7 @@ async def test_update_todo_item_service_by_id_status_only( item = args.kwargs.get("item") assert item assert item.uid == "1" - assert item.summary is None + assert item.summary == "Item #1" assert item.status == TodoItemStatus.COMPLETED @@ -530,7 +534,7 @@ async def test_update_todo_item_service_by_id_rename( assert item assert item.uid == "1" assert item.summary == "Updated item" - assert item.status is None + assert item.status == TodoItemStatus.NEEDS_ACTION async def test_update_todo_item_service_raises( @@ -607,7 +611,7 @@ async def test_update_todo_item_service_by_summary_only_status( assert item assert item.uid == "1" assert item.summary == "Something else" - assert item.status is None + assert item.status == TodoItemStatus.NEEDS_ACTION async def test_update_todo_item_service_by_summary_not_found( @@ -618,7 +622,7 @@ async def test_update_todo_item_service_by_summary_not_found( await create_mock_platform(hass, [test_entity]) - with pytest.raises(ValueError, match="Unable to find"): + with pytest.raises(ServiceValidationError, match="Unable to find"): await hass.services.async_call( DOMAIN, "update_item", @@ -677,7 +681,7 @@ async def test_update_todo_item_field_unsupported( await create_mock_platform(hass, [test_entity]) - with pytest.raises(ValueError, match="does not support"): + with pytest.raises(ServiceValidationError, match="does not support"): await hass.services.async_call( DOMAIN, "update_item", @@ -693,20 +697,32 @@ async def test_update_todo_item_field_unsupported( ( TodoListEntityFeature.SET_DUE_DATE_ON_ITEM, {"due_date": "2023-11-13"}, - TodoItem(uid="1", due=datetime.date(2023, 11, 13)), + TodoItem( + uid="1", + summary="Item #1", + status=TodoItemStatus.NEEDS_ACTION, + due=datetime.date(2023, 11, 13), + ), ), ( TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM, {"due_datetime": f"2023-11-13T17:00:00{TEST_OFFSET}"}, TodoItem( uid="1", + summary="Item #1", + status=TodoItemStatus.NEEDS_ACTION, due=datetime.datetime(2023, 11, 13, 17, 0, 0, tzinfo=TEST_TIMEZONE), ), ), ( TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM, {"description": "Submit revised draft"}, - TodoItem(uid="1", description="Submit revised draft"), + TodoItem( + uid="1", + summary="Item #1", + status=TodoItemStatus.NEEDS_ACTION, + description="Submit revised draft", + ), ), ), ) @@ -736,6 +752,96 @@ async def test_update_todo_item_extended_fields( assert item == expected_update +@pytest.mark.parametrize( + ("test_entity_items", "update_data", "expected_update"), + ( + ( + [TodoItem(uid="1", summary="Summary", description="description")], + {"description": "Submit revised draft"}, + TodoItem(uid="1", summary="Summary", description="Submit revised draft"), + ), + ( + [TodoItem(uid="1", summary="Summary", description="description")], + {"description": ""}, + TodoItem(uid="1", summary="Summary", description=""), + ), + ( + [TodoItem(uid="1", summary="Summary", description="description")], + {"description": None}, + TodoItem(uid="1", summary="Summary"), + ), + ( + [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], + {"due_date": datetime.date(2024, 1, 2)}, + TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 2)), + ), + ( + [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], + {"due_date": None}, + TodoItem(uid="1", summary="Summary"), + ), + ( + [TodoItem(uid="1", summary="Summary", due=datetime.date(2024, 1, 1))], + {"due_datetime": datetime.datetime(2024, 1, 1, 10, 0, 0)}, + TodoItem( + uid="1", + summary="Summary", + due=datetime.datetime( + 2024, 1, 1, 10, 0, 0, tzinfo=zoneinfo.ZoneInfo(key="America/Regina") + ), + ), + ), + ( + [ + TodoItem( + uid="1", + summary="Summary", + due=datetime.datetime(2024, 1, 1, 10, 0, 0), + ) + ], + {"due_datetime": None}, + TodoItem(uid="1", summary="Summary"), + ), + ), + ids=[ + "overwrite_description", + "overwrite_empty_description", + "clear_description", + "overwrite_due_date", + "clear_due_date", + "overwrite_due_date_with_time", + "clear_due_date_time", + ], +) +async def test_update_todo_item_extended_fields_overwrite_existing_values( + hass: HomeAssistant, + test_entity: TodoListEntity, + update_data: dict[str, Any], + expected_update: TodoItem, +) -> None: + """Test updating an item in a To-do list.""" + + test_entity._attr_supported_features |= ( + TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATE_ON_ITEM + | TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM + ) + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "1", **update_data}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item == expected_update + + async def test_remove_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, @@ -825,7 +931,7 @@ async def test_remove_todo_item_service_by_summary_not_found( await create_mock_platform(hass, [test_entity]) - with pytest.raises(ValueError, match="Unable to find"): + with pytest.raises(ServiceValidationError, match="Unable to find"): await hass.services.async_call( DOMAIN, "remove_item", diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index 1e94b52149c..5aa1e2af9de 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -80,7 +80,7 @@ async def test_todo_item_state( [], {}, [make_api_task(id="task-id-1", content="Soda", is_completed=False)], - {"content": "Soda"}, + {"content": "Soda", "due_string": "no date", "description": ""}, {"uid": "task-id-1", "summary": "Soda", "status": "needs_action"}, ), ( @@ -94,7 +94,7 @@ async def test_todo_item_state( due=Due(is_recurring=False, date="2023-11-18", string="today"), ) ], - {"due": {"date": "2023-11-18"}}, + {"description": "", "due_date": "2023-11-18"}, { "uid": "task-id-1", "summary": "Soda", @@ -119,7 +119,8 @@ async def test_todo_item_state( ) ], { - "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + "description": "", + "due_datetime": "2023-11-18T06:30:00-06:00", }, { "uid": "task-id-1", @@ -139,7 +140,7 @@ async def test_todo_item_state( is_completed=False, ) ], - {"description": "6-pack"}, + {"description": "6-pack", "due_string": "no date"}, { "uid": "task-id-1", "summary": "Soda", @@ -264,11 +265,35 @@ async def test_update_todo_item_status( ("tasks", "update_data", "tasks_after_update", "update_kwargs", "expected_item"), [ ( - [make_api_task(id="task-id-1", content="Soda", is_completed=False)], + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + description="desc", + ) + ], {"rename": "Milk"}, - [make_api_task(id="task-id-1", content="Milk", is_completed=False)], - {"task_id": "task-id-1", "content": "Milk"}, - {"uid": "task-id-1", "summary": "Milk", "status": "needs_action"}, + [ + make_api_task( + id="task-id-1", + content="Milk", + is_completed=False, + description="desc", + ) + ], + { + "task_id": "task-id-1", + "content": "Milk", + "description": "desc", + "due_string": "no date", + }, + { + "uid": "task-id-1", + "summary": "Milk", + "status": "needs_action", + "description": "desc", + }, ), ( [make_api_task(id="task-id-1", content="Soda", is_completed=False)], @@ -281,7 +306,12 @@ async def test_update_todo_item_status( due=Due(is_recurring=False, date="2023-11-18", string="today"), ) ], - {"task_id": "task-id-1", "due": {"date": "2023-11-18"}}, + { + "task_id": "task-id-1", + "content": "Soda", + "due_date": "2023-11-18", + "description": "", + }, { "uid": "task-id-1", "summary": "Soda", @@ -307,7 +337,9 @@ async def test_update_todo_item_status( ], { "task_id": "task-id-1", - "due": {"date": "2023-11-18", "datetime": "2023-11-18T06:30:00-06:00"}, + "content": "Soda", + "due_datetime": "2023-11-18T06:30:00-06:00", + "description": "", }, { "uid": "task-id-1", @@ -327,7 +359,12 @@ async def test_update_todo_item_status( is_completed=False, ) ], - {"task_id": "task-id-1", "description": "6-pack"}, + { + "task_id": "task-id-1", + "content": "Soda", + "description": "6-pack", + "due_string": "no date", + }, { "uid": "task-id-1", "summary": "Soda", @@ -335,8 +372,38 @@ async def test_update_todo_item_status( "description": "6-pack", }, ), + ( + [ + make_api_task( + id="task-id-1", + content="Soda", + description="6-pack", + is_completed=False, + ) + ], + {"description": None}, + [ + make_api_task( + id="task-id-1", + content="Soda", + is_completed=False, + description="", + ) + ], + { + "task_id": "task-id-1", + "content": "Soda", + "description": "", + "due_string": "no date", + }, + { + "uid": "task-id-1", + "summary": "Soda", + "status": "needs_action", + }, + ), ], - ids=["rename", "due_date", "due_datetime", "description"], + ids=["rename", "due_date", "due_datetime", "description", "clear_description"], ) async def test_update_todo_items( hass: HomeAssistant, diff --git a/tests/components/trafikverket_camera/__init__.py b/tests/components/trafikverket_camera/__init__.py index a9aa3ad70d1..dd23c7bce7e 100644 --- a/tests/components/trafikverket_camera/__init__.py +++ b/tests/components/trafikverket_camera/__init__.py @@ -1,8 +1,7 @@ """Tests for the Trafikverket Camera integration.""" from __future__ import annotations -from homeassistant.components.trafikverket_camera.const import CONF_LOCATION -from homeassistant.const import CONF_API_KEY, CONF_ID +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_LOCATION ENTRY_CONFIG = { CONF_API_KEY: "1234567890", diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index 305066832e5..ca1d8554c4a 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -13,8 +13,8 @@ from pytrafikverket.exceptions import ( from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant import config_entries -from homeassistant.components.trafikverket_camera.const import CONF_LOCATION, DOMAIN -from homeassistant.const import CONF_API_KEY, CONF_ID +from homeassistant.components.trafikverket_camera.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_LOCATION from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType diff --git a/tests/components/trafikverket_train/__init__.py b/tests/components/trafikverket_train/__init__.py index 060b6a344a1..9a02ebbf3b6 100644 --- a/tests/components/trafikverket_train/__init__.py +++ b/tests/components/trafikverket_train/__init__.py @@ -1 +1,28 @@ """Tests for the Trafikverket Train integration.""" +from __future__ import annotations + +from homeassistant.components.trafikverket_ferry.const import ( + CONF_FROM, + CONF_TIME, + CONF_TO, +) +from homeassistant.components.trafikverket_train.const import CONF_FILTER_PRODUCT +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS + +ENTRY_CONFIG = { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: None, + CONF_WEEKDAY: WEEKDAYS, + CONF_NAME: "Stockholm C to Uppsala C", +} +ENTRY_CONFIG2 = { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "11:00:00", + CONF_WEEKDAY: WEEKDAYS, + CONF_NAME: "Stockholm C to Uppsala C", +} +OPTIONS_CONFIG = {CONF_FILTER_PRODUCT: "Regionaltåg"} diff --git a/tests/components/trafikverket_train/conftest.py b/tests/components/trafikverket_train/conftest.py new file mode 100644 index 00000000000..423dee541d2 --- /dev/null +++ b/tests/components/trafikverket_train/conftest.py @@ -0,0 +1,160 @@ +"""Fixtures for Trafikverket Train integration tests.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from unittest.mock import patch + +import pytest +from pytrafikverket.trafikverket_train import TrainStop + +from homeassistant.components.trafikverket_train.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import ENTRY_CONFIG, ENTRY_CONFIG2, OPTIONS_CONFIG + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="load_int") +async def load_integration_from_entry( + hass: HomeAssistant, + get_trains: list[TrainStop], + get_train_stop: TrainStop, +) -> MockConfigEntry: + """Set up the Trafikverket Train integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="stockholmc-uppsalac--['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']", + ) + config_entry.add_to_hass(hass) + config_entry2 = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG2, + entry_id="2", + unique_id="stockholmc-uppsalac-1100-['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']", + ) + config_entry2.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + return_value=get_train_stop, + ), patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="get_trains") +def fixture_get_trains() -> list[TrainStop]: + """Construct TrainStop Mock.""" + + depart1 = TrainStop( + id=13, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + estimated_time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + depart2 = TrainStop( + id=14, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=15), + estimated_time_at_location=None, + time_at_location=None, + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + depart3 = TrainStop( + id=15, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=30), + estimated_time_at_location=None, + time_at_location=None, + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + + return [depart1, depart2, depart3] + + +@pytest.fixture(name="get_trains_next") +def fixture_get_trains_next() -> list[TrainStop]: + """Construct TrainStop Mock.""" + + depart1 = TrainStop( + id=13, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC), + estimated_time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC), + time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC), + other_information=None, + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + depart2 = TrainStop( + id=14, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=15), + estimated_time_at_location=None, + time_at_location=None, + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + depart3 = TrainStop( + id=15, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 17, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=30), + estimated_time_at_location=None, + time_at_location=None, + other_information=["Some other info"], + deviations=None, + modified_time=datetime(2023, 5, 1, 12, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) + + return [depart1, depart2, depart3] + + +@pytest.fixture(name="get_train_stop") +def fixture_get_train_stop() -> TrainStop: + """Construct TrainStop Mock.""" + + return TrainStop( + id=13, + canceled=False, + advertised_time_at_location=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), + estimated_time_at_location=None, + time_at_location=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), + other_information=None, + deviations=None, + modified_time=datetime(2023, 5, 1, 11, 0, tzinfo=dt_util.UTC), + product_description=["Regionaltåg"], + ) diff --git a/tests/components/trafikverket_train/snapshots/test_init.ambr b/tests/components/trafikverket_train/snapshots/test_init.ambr new file mode 100644 index 00000000000..c32995fdb76 --- /dev/null +++ b/tests/components/trafikverket_train/snapshots/test_init.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_auth_failed + FlowResultSnapshot({ + 'context': dict({ + 'entry_id': '1', + 'source': 'reauth', + 'title_placeholders': dict({ + 'name': 'Mock Title', + }), + 'unique_id': '321', + }), + 'flow_id': , + 'handler': 'trafikverket_train', + 'step_id': 'reauth_confirm', + }) +# --- diff --git a/tests/components/trafikverket_train/snapshots/test_sensor.ambr b/tests/components/trafikverket_train/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..6ea0168926e --- /dev/null +++ b/tests/components/trafikverket_train/snapshots/test_sensor.ambr @@ -0,0 +1,231 @@ +# serializer version: 1 +# name: test_sensor_next + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T12:00:00+00:00', + }) +# --- +# name: test_sensor_next.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'enum', + 'friendly_name': 'Stockholm C to Uppsala C Departure state', + 'icon': 'mdi:clock', + 'options': list([ + 'on_time', + 'delayed', + 'canceled', + ]), + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_state', + 'last_changed': , + 'last_updated': , + 'state': 'on_time', + }) +# --- +# name: test_sensor_next.10 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time next', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_next', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T17:15:00+00:00', + }) +# --- +# name: test_sensor_next.11 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time next after', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_next_after', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T17:30:00+00:00', + }) +# --- +# name: test_sensor_next.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Actual time', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_actual_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T12:00:00+00:00', + }) +# --- +# name: test_sensor_next.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'friendly_name': 'Stockholm C to Uppsala C Other information', + 'icon': 'mdi:information-variant', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_other_information', + 'last_changed': , + 'last_updated': , + 'state': 'Some other info', + }) +# --- +# name: test_sensor_next.4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time next', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_next', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T12:15:00+00:00', + }) +# --- +# name: test_sensor_next.5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time next after', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_next_after', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T12:30:00+00:00', + }) +# --- +# name: test_sensor_next.6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T17:00:00+00:00', + }) +# --- +# name: test_sensor_next.7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'enum', + 'friendly_name': 'Stockholm C to Uppsala C Departure state', + 'icon': 'mdi:clock', + 'options': list([ + 'on_time', + 'delayed', + 'canceled', + ]), + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_state', + 'last_changed': , + 'last_updated': , + 'state': 'on_time', + }) +# --- +# name: test_sensor_next.8 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Actual time', + 'icon': 'mdi:clock', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_actual_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T17:00:00+00:00', + }) +# --- +# name: test_sensor_next.9 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'friendly_name': 'Stockholm C to Uppsala C Other information', + 'icon': 'mdi:information-variant', + 'product_filter': 'Regionaltåg', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_other_information', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_single_stop + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by Trafikverket', + 'device_class': 'timestamp', + 'friendly_name': 'Stockholm C to Uppsala C Departure time', + 'icon': 'mdi:clock', + }), + 'context': , + 'entity_id': 'sensor.stockholm_c_to_uppsala_c_departure_time_2', + 'last_changed': , + 'last_updated': , + 'state': '2023-05-01T11:00:00+00:00', + }) +# --- +# name: test_sensor_update_auth_failure + FlowResultSnapshot({ + 'context': dict({ + 'entry_id': '1', + 'source': 'reauth', + 'title_placeholders': dict({ + 'name': 'Mock Title', + }), + 'unique_id': "stockholmc-uppsalac--['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']", + }), + 'flow_id': , + 'handler': 'trafikverket_train', + 'step_id': 'reauth_confirm', + }) +# --- diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index 1accd4b5a55..f56aee163bc 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -11,6 +11,7 @@ from pytrafikverket.exceptions import ( NoTrainStationFound, UnknownError, ) +from pytrafikverket.trafikverket_train import TrainStop from homeassistant import config_entries from homeassistant.components.trafikverket_train.const import ( @@ -196,7 +197,7 @@ async def test_flow_fails_departures( with patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", ), patch( - "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_next_train_stop", + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_next_train_stops", side_effect=side_effect(), ), patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_stop", @@ -442,7 +443,11 @@ async def test_reauth_flow_error_departures( } -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow( + hass: HomeAssistant, + get_trains: list[TrainStop], + get_train_stop: TrainStop, +) -> None: """Test a reauthentication flow.""" entry = MockConfigEntry( domain=DOMAIN, @@ -459,36 +464,41 @@ async def test_options_flow(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.trafikverket_train.async_setup_entry", - return_value=True, + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + return_value=get_train_stop, ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"filter_product": "SJ Regionaltåg"}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"filter_product": "SJ Regionaltåg"}, + ) + await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {"filter_product": "SJ Regionaltåg"} + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {"filter_product": "SJ Regionaltåg"} - result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"filter_product": ""}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"filter_product": ""}, + ) + await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {"filter_product": None} + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {"filter_product": None} diff --git a/tests/components/trafikverket_train/test_init.py b/tests/components/trafikverket_train/test_init.py new file mode 100644 index 00000000000..74b6f30ce61 --- /dev/null +++ b/tests/components/trafikverket_train/test_init.py @@ -0,0 +1,143 @@ +"""Test for Trafikverket Train component Init.""" +from __future__ import annotations + +from unittest.mock import patch + +from pytrafikverket.exceptions import InvalidAuthentication, NoTrainStationFound +from pytrafikverket.trafikverket_train import TrainStop +from syrupy.assertion import SnapshotAssertion + +from homeassistant import config_entries +from homeassistant.components.trafikverket_train.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import ENTRY_CONFIG, OPTIONS_CONFIG + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant, get_trains: list[TrainStop]) -> None: + """Test unload an entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ) as mock_tv_train: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert len(mock_tv_train.mock_calls) == 1 + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def test_auth_failed( + hass: HomeAssistant, + get_trains: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test authentication failed.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + side_effect=InvalidAuthentication, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + active_flows = entry.async_get_active_flows(hass, (SOURCE_REAUTH)) + for flow in active_flows: + assert flow == snapshot + + +async def test_no_stations( + hass: HomeAssistant, + get_trains: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test stations are missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + side_effect=NoTrainStationFound, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + +async def test_migrate_entity_unique_id( + hass: HomeAssistant, + get_trains: list[TrainStop], + snapshot: SnapshotAssertion, + entity_registry: EntityRegistry, +) -> None: + """Test migration of entity unique id in old format.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + options=OPTIONS_CONFIG, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + entity = entity_registry.async_get_or_create( + DOMAIN, + "sensor", + "incorrect_unique_id", + config_entry=entry, + original_name="Stockholm C to Uppsala C", + ) + + with patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + + entity = entity_registry.async_get(entity.entity_id) + assert entity.unique_id == f"{entry.entry_id}-departure_time" diff --git a/tests/components/trafikverket_train/test_sensor.py b/tests/components/trafikverket_train/test_sensor.py new file mode 100644 index 00000000000..819433a6b9c --- /dev/null +++ b/tests/components/trafikverket_train/test_sensor.py @@ -0,0 +1,157 @@ +"""The test for the Trafikverket train sensor platform.""" +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from pytrafikverket.exceptions import InvalidAuthentication, NoTrainAnnouncementFound +from pytrafikverket.trafikverket_train import TrainStop +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_time_changed + + +async def test_sensor_next( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + get_train_stop: TrainStop, + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor.""" + for entity in ( + "sensor.stockholm_c_to_uppsala_c_departure_time", + "sensor.stockholm_c_to_uppsala_c_departure_state", + "sensor.stockholm_c_to_uppsala_c_actual_time", + "sensor.stockholm_c_to_uppsala_c_other_information", + "sensor.stockholm_c_to_uppsala_c_departure_time_next", + "sensor.stockholm_c_to_uppsala_c_departure_time_next_after", + ): + state = hass.states.get(entity) + assert state == snapshot + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains_next, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + return_value=get_train_stop, + ): + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + for entity in ( + "sensor.stockholm_c_to_uppsala_c_departure_time", + "sensor.stockholm_c_to_uppsala_c_departure_state", + "sensor.stockholm_c_to_uppsala_c_actual_time", + "sensor.stockholm_c_to_uppsala_c_other_information", + "sensor.stockholm_c_to_uppsala_c_departure_time_next", + "sensor.stockholm_c_to_uppsala_c_departure_time_next_after", + ): + state = hass.states.get(entity) + assert state == snapshot + + +async def test_sensor_single_stop( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor.""" + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + + assert state.state == "2023-05-01T11:00:00+00:00" + + assert state == snapshot + + +async def test_sensor_update_auth_failure( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor with authentication update failure.""" + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == "2023-05-01T11:00:00+00:00" + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + side_effect=InvalidAuthentication, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + side_effect=InvalidAuthentication, + ): + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == STATE_UNAVAILABLE + active_flows = load_int.async_get_active_flows(hass, (SOURCE_REAUTH)) + for flow in active_flows: + assert flow == snapshot + + +async def test_sensor_update_failure( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor with update failure.""" + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == "2023-05-01T11:00:00+00:00" + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + side_effect=NoTrainAnnouncementFound, + ), patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + side_effect=NoTrainAnnouncementFound, + ): + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == STATE_UNAVAILABLE + + +async def test_sensor_update_failure_no_state( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_trains_next: list[TrainStop], + snapshot: SnapshotAssertion, +) -> None: + """Test the Trafikverket Train sensor with update failure from empty state.""" + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == "2023-05-01T11:00:00+00:00" + + with patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + return_value=None, + ): + freezer.tick(timedelta(minutes=6)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.stockholm_c_to_uppsala_c_departure_time_2") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/trafikverket_train/test_util.py b/tests/components/trafikverket_train/test_util.py new file mode 100644 index 00000000000..e978917adca --- /dev/null +++ b/tests/components/trafikverket_train/test_util.py @@ -0,0 +1,25 @@ +"""The test for the Trafikverket train utils.""" +from __future__ import annotations + +from datetime import datetime + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.trafikverket_train.util import next_departuredate +from homeassistant.const import WEEKDAYS +from homeassistant.util import dt as dt_util + + +async def test_sensor_next( + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Trafikverket Train utils.""" + + assert next_departuredate(WEEKDAYS) == dt_util.now().date() + freezer.move_to(datetime(2023, 12, 22)) # Friday + assert ( + next_departuredate(["mon", "tue", "wed", "thu"]) + == datetime(2023, 12, 25).date() + ) + freezer.move_to(datetime(2023, 12, 25)) # Monday + assert next_departuredate(["fri", "sat", "sun"]) == datetime(2023, 12, 29).date() diff --git a/tests/components/trend/conftest.py b/tests/components/trend/conftest.py new file mode 100644 index 00000000000..cff3831658a --- /dev/null +++ b/tests/components/trend/conftest.py @@ -0,0 +1,51 @@ +"""Fixtures for the trend component tests.""" +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from homeassistant.components.trend.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +ComponentSetup = Callable[[dict[str, Any]], Awaitable[None]] + + +@pytest.fixture(name="config_entry") +async def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a MockConfigEntry for testing.""" + return MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My trend", + "entity_id": "sensor.cpu_temp", + "invert": False, + "max_samples": 2.0, + "min_gradient": 0.0, + "sample_duration": 0.0, + }, + title="My trend", + ) + + +@pytest.fixture(name="setup_component") +async def mock_setup_component( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> ComponentSetup: + """Set up the trend component.""" + + async def _setup_func(component_params: dict[str, Any]) -> None: + config_entry.title = "test_trend_sensor" + config_entry.options = { + **config_entry.options, + **component_params, + "name": "test_trend_sensor", + "entity_id": "sensor.test_state", + } + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return _setup_func diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index cccf1add61b..115bac5ed5d 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -1,421 +1,285 @@ """The test for the Trend sensor platform.""" from datetime import timedelta -from unittest.mock import patch +import logging +from typing import Any +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant import config as hass_config, setup -from homeassistant.components.trend.const import DOMAIN -from homeassistant.const import SERVICE_RELOAD, STATE_UNKNOWN +from homeassistant import setup +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant, State -import homeassistant.util.dt as dt_util +from homeassistant.setup import async_setup_component -from tests.common import ( - assert_setup_component, - get_fixture_path, - get_test_home_assistant, - mock_restore_cache, -) +from .conftest import ComponentSetup + +from tests.common import MockConfigEntry, assert_setup_component, mock_restore_cache -class TestTrendBinarySensor: - """Test the Trend sensor.""" - - hass = None - - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - - def test_up(self): - """Test up trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - def test_up_using_trendline(self): - """Test up trend using multiple samples and trendline calculation.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - } - }, - } - }, - ) - self.hass.block_till_done() - - now = dt_util.utcnow() - for val in [10, 0, 20, 30]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - # have to change state value, otherwise sample will lost - for val in [0, 30, 1, 0]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_down_using_trendline(self): - """Test down trend using multiple samples and trendline calculation.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "sample_duration": 10000, - "min_gradient": 1, - "max_samples": 25, - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - now = dt_util.utcnow() - for val in [30, 20, 30, 10]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - for val in [30, 0, 45, 50]: - with patch("homeassistant.util.dt.utcnow", return_value=now): - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - now += timedelta(seconds=2) - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_down(self): - """Test down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_invert_up(self): - """Test up trend with custom message.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_invert_down(self): - """Test down trend with custom message.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "invert": "Yes", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "2") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "1") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - def test_attribute_up(self): - """Test attribute up trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "attr", - } - }, - } - }, - ) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - - def test_attribute_down(self): - """Test attribute down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "attr", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "off" - - def test_max_samples(self): - """Test that sample count is limited correctly.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "max_samples": 3, - "min_gradient": -1, - } - }, - } - }, - ) - self.hass.block_till_done() - - for val in [0, 1, 2, 3, 2, 1]: - self.hass.states.set("sensor.test_state", val) - self.hass.block_till_done() - - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == "on" - assert state.attributes["sample_count"] == 3 - - def test_non_numeric(self): - """Test up trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "Non") - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "Numeric") - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == STATE_UNKNOWN - - def test_missing_attribute(self): - """Test attribute down trend.""" - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "trend", - "sensors": { - "test_trend_sensor": { - "entity_id": "sensor.test_state", - "attribute": "missing", - } - }, - } - }, - ) - self.hass.block_till_done() - - self.hass.states.set("sensor.test_state", "State", {"attr": "2"}) - self.hass.block_till_done() - self.hass.states.set("sensor.test_state", "State", {"attr": "1"}) - self.hass.block_till_done() - state = self.hass.states.get("binary_sensor.test_trend_sensor") - assert state.state == STATE_UNKNOWN - - def test_invalid_name_does_not_create(self): - """Test invalid name.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test INVALID sensor": {"entity_id": "sensor.test_state"} - }, - } - }, - ) - assert self.hass.states.all("binary_sensor") == [] - - def test_invalid_sensor_does_not_create(self): - """Test invalid sensor.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_trend_sensor": {"not_entity_id": "sensor.test_state"} - }, - } - }, - ) - assert self.hass.states.all("binary_sensor") == [] - - def test_no_sensors_does_not_create(self): - """Test no sensors.""" - with assert_setup_component(0): - assert setup.setup_component( - self.hass, "binary_sensor", {"binary_sensor": {"platform": "trend"}} - ) - assert self.hass.states.all("binary_sensor") == [] - - -async def test_reload(hass: HomeAssistant) -> None: - """Verify we can reload trend sensors.""" - hass.states.async_set("sensor.test_state", 1234) - - await setup.async_setup_component( +async def _setup_legacy_component(hass: HomeAssistant, params: dict[str, Any]) -> None: + """Set up the trend component the legacy way.""" + assert await async_setup_component( hass, "binary_sensor", { "binary_sensor": { "platform": "trend", - "sensors": {"test_trend_sensor": {"entity_id": "sensor.test_state"}}, + "sensors": { + "test_trend_sensor": params, + }, } }, ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 2 - assert hass.states.get("binary_sensor.test_trend_sensor") +@pytest.mark.parametrize( + ("states", "inverted", "expected_state"), + [ + (["1", "2"], False, STATE_ON), + (["2", "1"], False, STATE_OFF), + (["1", "2"], True, STATE_OFF), + (["2", "1"], True, STATE_ON), + ], + ids=["up", "down", "up inverted", "down inverted"], +) +async def test_basic_trend_setup_from_yaml( + hass: HomeAssistant, + states: list[str], + inverted: bool, + expected_state: str, +) -> None: + """Test trend with a basic setup.""" + await _setup_legacy_component( + hass, + { + "friendly_name": "Test state", + "entity_id": "sensor.cpu_temp", + "invert": inverted, + "max_samples": 2.0, + "min_gradient": 0.0, + "sample_duration": 0.0, + }, + ) - yaml_path = get_fixture_path("configuration.yaml", "trend") - with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) + for state in states: + hass.states.async_set("sensor.cpu_temp", state) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 2 + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state - assert hass.states.get("binary_sensor.test_trend_sensor") is None - assert hass.states.get("binary_sensor.second_test_trend_sensor") + +@pytest.mark.parametrize( + ("states", "inverted", "expected_state"), + [ + (["1", "2"], False, STATE_ON), + (["2", "1"], False, STATE_OFF), + (["1", "2"], True, STATE_OFF), + (["2", "1"], True, STATE_ON), + ], + ids=["up", "down", "up inverted", "down inverted"], +) +async def test_basic_trend( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_component: ComponentSetup, + states: list[str], + inverted: bool, + expected_state: str, +) -> None: + """Test trend with a basic setup.""" + await setup_component( + { + "invert": inverted, + }, + ) + + for state in states: + hass.states.async_set("sensor.test_state", state) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state + + +@pytest.mark.parametrize( + ("state_series", "inverted", "expected_states"), + [ + ( + [[10, 0, 20, 30], [100], [0, 30, 1, 0]], + False, + [STATE_UNKNOWN, STATE_ON, STATE_OFF], + ), + ( + [[10, 0, 20, 30], [100], [0, 30, 1, 0]], + True, + [STATE_UNKNOWN, STATE_OFF, STATE_ON], + ), + ( + [[30, 20, 30, 10], [5], [30, 0, 45, 60]], + True, + [STATE_UNKNOWN, STATE_ON, STATE_OFF], + ), + ], + ids=["up", "up inverted", "down"], +) +async def test_using_trendline( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + setup_component: ComponentSetup, + state_series: list[list[str]], + inverted: bool, + expected_states: list[str], +) -> None: + """Test uptrend using multiple samples and trendline calculation.""" + await setup_component( + { + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, + "invert": inverted, + }, + ) + + for idx, states in enumerate(state_series): + for state in states: + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", state) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_states[idx] + + +@pytest.mark.parametrize( + ("attr_values", "expected_state"), + [ + (["1", "2"], STATE_ON), + (["2", "1"], STATE_OFF), + ], + ids=["up", "down"], +) +async def test_attribute_trend( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_component: ComponentSetup, + attr_values: list[str], + expected_state: str, +) -> None: + """Test attribute uptrend.""" + await setup_component( + { + "entity_id": "sensor.test_state", + "attribute": "attr", + }, + ) + + for attr in attr_values: + hass.states.async_set("sensor.test_state", "State", {"attr": attr}) + await hass.async_block_till_done() + + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == expected_state + + +async def test_max_samples( + hass: HomeAssistant, config_entry: MockConfigEntry, setup_component: ComponentSetup +) -> None: + """Test that sample count is limited correctly.""" + await setup_component( + { + "max_samples": 3, + "min_gradient": -1, + }, + ) + + for val in [0, 1, 2, 3, 2, 1]: + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == "on" + assert state.attributes["sample_count"] == 3 + + +async def test_non_numeric( + hass: HomeAssistant, config_entry: MockConfigEntry, setup_component: ComponentSetup +) -> None: + """Test for non-numeric sensor.""" + await setup_component({"entity_id": "sensor.test_state"}) + + for val in ["Non", "Numeric"]: + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == STATE_UNKNOWN + + +async def test_missing_attribute( + hass: HomeAssistant, config_entry: MockConfigEntry, setup_component: ComponentSetup +) -> None: + """Test for missing attribute.""" + await setup_component( + { + "attribute": "missing", + }, + ) + + for val in [1, 2]: + hass.states.async_set("sensor.test_state", "State", {"attr": val}) + await hass.async_block_till_done() + + assert (state := hass.states.get("binary_sensor.test_trend_sensor")) + assert state.state == STATE_UNKNOWN + + +async def test_invalid_name_does_not_create(hass: HomeAssistant) -> None: + """Test for invalid name.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "trend", + "sensors": { + "test INVALID sensor": {"entity_id": "sensor.test_state"} + }, + } + }, + ) + assert hass.states.async_all("binary_sensor") == [] + + +async def test_invalid_sensor_does_not_create(hass: HomeAssistant) -> None: + """Test invalid sensor.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "trend", + "sensors": { + "test_trend_sensor": {"not_entity_id": "sensor.test_state"} + }, + } + }, + ) + assert hass.states.async_all("binary_sensor") == [] + + +async def test_no_sensors_does_not_create(hass: HomeAssistant) -> None: + """Test no sensors.""" + with assert_setup_component(0): + assert await setup.async_setup_component( + hass, "binary_sensor", {"binary_sensor": {"platform": "trend"}} + ) + assert hass.states.async_all("binary_sensor") == [] @pytest.mark.parametrize( @@ -423,21 +287,65 @@ async def test_reload(hass: HomeAssistant) -> None: [("on", "on"), ("off", "off"), ("unknown", "unknown")], ) async def test_restore_state( - hass: HomeAssistant, saved_state: str, restored_state: str + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + setup_component: ComponentSetup, + saved_state: str, + restored_state: str, ) -> None: """Test we restore the trend state.""" mock_restore_cache(hass, (State("binary_sensor.test_trend_sensor", saved_state),)) - assert await setup.async_setup_component( - hass, - "binary_sensor", + await setup_component( { - "binary_sensor": { - "platform": "trend", - "sensors": {"test_trend_sensor": {"entity_id": "sensor.test_state"}}, - } + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, }, ) - await hass.async_block_till_done() + # restored sensor should match saved one assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state + + # add not enough samples to trigger calculation + for val in [10, 20, 30, 40]: + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + # state should match restored state as no calculation happened + assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state + + # add more samples to trigger calculation + for val in [50, 60, 70, 80]: + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + # sensor should detect an upwards trend and turn on + assert hass.states.get("binary_sensor.test_trend_sensor").state == "on" + + +async def test_invalid_min_sample( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test if error is logged when min_sample is larger than max_samples.""" + with caplog.at_level(logging.ERROR): + await _setup_legacy_component( + hass, + { + "entity_id": "sensor.test_state", + "max_samples": 25, + "min_samples": 30, + }, + ) + + record = caplog.records[0] + assert record.levelname == "ERROR" + assert ( + "Invalid config for 'binary_sensor' from integration 'trend': min_samples must " + "be smaller than or equal to max_samples" in record.message + ) diff --git a/tests/components/trend/test_config_flow.py b/tests/components/trend/test_config_flow.py new file mode 100644 index 00000000000..e81d57ef9e1 --- /dev/null +++ b/tests/components/trend/test_config_flow.py @@ -0,0 +1,80 @@ +"""Test the Trend config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.trend import async_setup_entry +from homeassistant.components.trend.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"name": "CPU Temperature rising", "entity_id": "sensor.cpu_temp"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + + # test step 2 of config flow: settings of trend sensor + with patch( + "homeassistant.components.trend.async_setup_entry", wraps=async_setup_entry + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "invert": False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "CPU Temperature rising" + assert result["data"] == {} + assert result["options"] == { + "entity_id": "sensor.cpu_temp", + "invert": False, + "name": "CPU Temperature rising", + } + + +async def test_options(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Test options flow.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + { + "min_samples": 30, + "max_samples": 50, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "min_samples": 30, + "max_samples": 50, + "entity_id": "sensor.cpu_temp", + "invert": False, + "min_gradient": 0.0, + "name": "My trend", + "sample_duration": 0.0, + } diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py new file mode 100644 index 00000000000..47bcab2214d --- /dev/null +++ b/tests/components/trend/test_init.py @@ -0,0 +1,50 @@ +"""Test the Trend integration.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.trend.conftest import ComponentSetup + + +async def test_setup_and_remove_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test setting up and removing a config entry.""" + registry = er.async_get(hass) + trend_entity_id = "binary_sensor.my_trend" + + # Set up the config entry + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the entity is registered in the entity registry + assert registry.async_get(trend_entity_id) is not None + + # Remove the config entry + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the state and entity registry entry are removed + assert hass.states.get(trend_entity_id) is None + assert registry.async_get(trend_entity_id) is None + + +async def test_reload_config_entry( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_component: ComponentSetup, +) -> None: + """Test config entry reload.""" + await setup_component({}) + + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.config_entries.async_update_entry( + config_entry, data={**config_entry.data, "max_samples": 4.0} + ) + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.data == {**config_entry.data, "max_samples": 4.0} diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 71be6b3bb11..990d8d273ed 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -4,6 +4,7 @@ from http import HTTPStatus from typing import Any from unittest.mock import MagicMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components import ffmpeg, tts @@ -78,6 +79,7 @@ async def test_config_entry_unload( hass: HomeAssistant, hass_client: ClientSessionGenerator, mock_tts_entity: MockTTSEntity, + freezer: FrozenDateTimeFactory, ) -> None: """Test we can unload config entry.""" entity_id = f"{tts.DOMAIN}.{TEST_DOMAIN}" @@ -93,26 +95,24 @@ async def test_config_entry_unload( calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) now = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): - await hass.services.async_call( - tts.DOMAIN, - "speak", - { - ATTR_ENTITY_ID: entity_id, - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is someone at the door.", - }, - blocking=True, - ) - assert len(calls) == 1 + freezer.move_to(now) + await hass.services.async_call( + tts.DOMAIN, + "speak", + { + ATTR_ENTITY_ID: entity_id, + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is someone at the door.", + }, + blocking=True, + ) + assert len(calls) == 1 - assert ( - await retrieve_media( - hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID] - ) - == HTTPStatus.OK - ) - await hass.async_block_till_done() + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state is not None @@ -150,7 +150,7 @@ async def test_restore_state( async def test_setup_component(hass: HomeAssistant, setup: str) -> None: """Set up a TTS platform with defaults.""" assert hass.services.has_service(tts.DOMAIN, "clear_cache") - assert f"{tts.DOMAIN}.test" in hass.config.components + assert f"test.{tts.DOMAIN}" in hass.config.components @pytest.mark.parametrize("init_tts_cache_dir_side_effect", [OSError(2, "No access")]) diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py index 0630114da90..f8345683d4a 100644 --- a/tests/components/tuya/test_config_flow.py +++ b/tests/components/tuya/test_config_flow.py @@ -13,15 +13,13 @@ from homeassistant.components.tuya.const import ( CONF_ACCESS_SECRET, CONF_APP_TYPE, CONF_AUTH_TYPE, - CONF_COUNTRY_CODE, CONF_ENDPOINT, - CONF_PASSWORD, - CONF_USERNAME, DOMAIN, SMARTLIFE_APP, TUYA_COUNTRIES, TUYA_SMART_APP, ) +from homeassistant.const import CONF_COUNTRY_CODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant MOCK_SMART_HOME_PROJECT_TYPE = 0 diff --git a/tests/components/twentemilieu/snapshots/test_config_flow.ambr b/tests/components/twentemilieu/snapshots/test_config_flow.ambr index 7acb466d997..00b96062052 100644 --- a/tests/components/twentemilieu/snapshots/test_config_flow.ambr +++ b/tests/components/twentemilieu/snapshots/test_config_flow.ambr @@ -15,6 +15,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'twentemilieu', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -27,6 +28,7 @@ 'disabled_by': None, 'domain': 'twentemilieu', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -57,6 +59,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'twentemilieu', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -69,6 +72,7 @@ 'disabled_by': None, 'domain': 'twentemilieu', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/twinkly/__init__.py b/tests/components/twinkly/__init__.py index bd51ac5d7cd..4b1411e9223 100644 --- a/tests/components/twinkly/__init__.py +++ b/tests/components/twinkly/__init__.py @@ -1,4 +1,4 @@ -"""Constants and mock for the twkinly component tests.""" +"""Constants and mock for the twinkly component tests.""" from aiohttp.client_exceptions import ClientConnectionError diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index 7a7dc2557ef..2a10154c3da 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -30,6 +30,7 @@ 'disabled_by': None, 'domain': 'twinkly', 'entry_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/twinkly/test_config_flow.py b/tests/components/twinkly/test_config_flow.py index 2d335c69923..a65a2a2d963 100644 --- a/tests/components/twinkly/test_config_flow.py +++ b/tests/components/twinkly/test_config_flow.py @@ -3,13 +3,8 @@ from unittest.mock import patch from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.twinkly.const import ( - CONF_HOST, - CONF_ID, - CONF_NAME, - DOMAIN as TWINKLY_DOMAIN, -) -from homeassistant.const import CONF_MODEL +from homeassistant.components.twinkly.const import DOMAIN as TWINKLY_DOMAIN +from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from . import TEST_MODEL, TEST_NAME, ClientMock diff --git a/tests/components/twinkly/test_init.py b/tests/components/twinkly/test_init.py index f2049f9b513..33f24a31d8f 100644 --- a/tests/components/twinkly/test_init.py +++ b/tests/components/twinkly/test_init.py @@ -3,14 +3,9 @@ from unittest.mock import patch from uuid import uuid4 -from homeassistant.components.twinkly.const import ( - CONF_HOST, - CONF_ID, - CONF_NAME, - DOMAIN as TWINKLY_DOMAIN, -) +from homeassistant.components.twinkly.const import DOMAIN as TWINKLY_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_MODEL +from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from . import TEST_HOST, TEST_MODEL, TEST_NAME_ORIGINAL, ClientMock diff --git a/tests/components/twinkly/test_light.py b/tests/components/twinkly/test_light.py index bcb40f22d08..e3b8b499c8e 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -4,13 +4,8 @@ from __future__ import annotations from unittest.mock import patch from homeassistant.components.light import ATTR_BRIGHTNESS, LightEntityFeature -from homeassistant.components.twinkly.const import ( - CONF_HOST, - CONF_ID, - CONF_NAME, - DOMAIN as TWINKLY_DOMAIN, -) -from homeassistant.const import CONF_MODEL +from homeassistant.components.twinkly.const import DOMAIN as TWINKLY_DOMAIN +from homeassistant.const import CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index abe12a1e243..34d43129a94 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -1,9 +1,8 @@ """The tests for the UniFi Network device tracker platform.""" from datetime import timedelta -from unittest.mock import patch from aiounifi.models.message import MessageKey -from freezegun.api import FrozenDateTimeFactory +from freezegun.api import FrozenDateTimeFactory, freeze_time from homeassistant import config_entries from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -72,7 +71,7 @@ async def test_tracked_wireless_clients( # Change time to mark client as away new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -293,6 +292,7 @@ async def test_tracked_wireless_clients_event_source( async def test_tracked_devices( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, mock_unifi_websocket, mock_device_registry, ) -> None: @@ -351,9 +351,9 @@ async def test_tracked_devices( # Change of time can mark device not_home outside of expected reporting interval new_time = dt_util.utcnow() + timedelta(seconds=90) - with patch("homeassistant.util.dt.utcnow", return_value=new_time): - async_fire_time_changed(hass, new_time) - await hass.async_block_till_done() + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() assert hass.states.get("device_tracker.device_1").state == STATE_NOT_HOME assert hass.states.get("device_tracker.device_2").state == STATE_HOME @@ -712,7 +712,7 @@ async def test_option_ssid_filter( await hass.async_block_till_done() new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -740,7 +740,7 @@ async def test_option_ssid_filter( # Time pass to mark client as away new_time += controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -759,7 +759,7 @@ async def test_option_ssid_filter( await hass.async_block_till_done() new_time += controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -808,7 +808,7 @@ async def test_wireless_client_go_wired_issue( # Pass time new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -877,7 +877,7 @@ async def test_option_ignore_wired_bug( # pass time new_time = dt_util.utcnow() + controller.option_detection_time - with patch("homeassistant.util.dt.utcnow", return_value=new_time): + with freeze_time(new_time): async_fire_time_changed(hass, new_time) await hass.async_block_till_done() @@ -930,6 +930,7 @@ async def test_restoring_client( config_entry = config_entries.ConfigEntry( version=1, + minor_version=1, domain=UNIFI_DOMAIN, title="Mock Title", data=ENTRY_CONFIG, diff --git a/tests/components/unifi/test_diagnostics.py b/tests/components/unifi/test_diagnostics.py index 638e79ae649..127b9b79c2b 100644 --- a/tests/components/unifi/test_diagnostics.py +++ b/tests/components/unifi/test_diagnostics.py @@ -129,6 +129,7 @@ async def test_entry_diagnostics( "disabled_by": None, "domain": "unifi", "entry_id": "1", + "minor_version": 1, "options": { "allow_bandwidth_sensors": True, "allow_uptime_sensors": True, diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index f4366b98fc3..6eb6c05209c 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -3,7 +3,9 @@ from copy import deepcopy from datetime import datetime, timedelta from unittest.mock import patch +from aiounifi.models.device import DeviceState from aiounifi.models.message import MessageKey +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN @@ -19,6 +21,7 @@ from homeassistant.components.unifi.const import ( CONF_ALLOW_UPTIME_SENSORS, CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, + DEVICE_STATES, ) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, EntityCategory @@ -429,6 +432,7 @@ async def test_bandwidth_sensors( async def test_uptime_sensors( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, mock_unifi_websocket, entity_registry_enabled_by_default: None, initial_uptime, @@ -450,13 +454,13 @@ async def test_uptime_sensors( } now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.now", return_value=now): - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options=options, - clients_response=[uptime_client], - ) + freezer.move_to(now) + config_entry = await setup_unifi_integration( + hass, + aioclient_mock, + options=options, + clients_response=[uptime_client], + ) assert len(hass.states.async_all()) == 2 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 @@ -582,7 +586,7 @@ async def test_poe_port_switches( ) -> None: """Test the update_items function with some clients.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[DEVICE_1]) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 ent_reg = er.async_get(hass) ent_reg_entry = ent_reg.async_get("sensor.mock_name_port_1_poe_power") @@ -805,8 +809,8 @@ async def test_outlet_power_readings( """Test the outlet power reporting on PDU devices.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[PDU_DEVICE_1]) - assert len(hass.states.async_all()) == 10 - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 + assert len(hass.states.async_all()) == 11 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 5 ent_reg = er.async_get(hass) ent_reg_entry = ent_reg.async_get(f"sensor.{entity_id}") @@ -854,7 +858,7 @@ async def test_device_uptime( now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 assert hass.states.get("sensor.device_uptime").state == "2021-01-01T01:00:00+00:00" ent_reg = er.async_get(hass) @@ -910,7 +914,7 @@ async def test_device_temperature( } await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 assert hass.states.get("sensor.device_temperature").state == "30" ent_reg = er.async_get(hass) @@ -923,3 +927,43 @@ async def test_device_temperature( device["general_temperature"] = 60 mock_unifi_websocket(message=MessageKey.DEVICE, data=device) assert hass.states.get("sensor.device_temperature").state == "60" + + +async def test_device_state( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket +) -> None: + """Verify that state sensors are working as expected.""" + device = { + "board_rev": 3, + "device_id": "mock-id", + "general_temperature": 30, + "has_fan": True, + "has_temperature": True, + "fan_level": 0, + "ip": "10.0.1.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "Device", + "next_interval": 20, + "overheating": True, + "state": 1, + "type": "usw", + "upgradable": True, + "uptime": 60, + "version": "4.0.42.10433", + } + + await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 + + ent_reg = er.async_get(hass) + assert ( + ent_reg.async_get("sensor.device_state").entity_category + is EntityCategory.DIAGNOSTIC + ) + + for i in list(map(int, DeviceState)): + device["state"] = i + mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + assert hass.states.get("sensor.device_state").state == DEVICE_STATES[i] diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 00ebcd0e683..6a9e58b6f76 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -1628,6 +1628,7 @@ async def test_updating_unique_id( config_entry = config_entries.ConfigEntry( version=1, + minor_version=1, domain=UNIFI_DOMAIN, title="Mock Title", data=ENTRY_CONFIG, diff --git a/tests/components/unifi/test_update.py b/tests/components/unifi/test_update.py index 4f7a3dfe11d..a9fe3fdae7c 100644 --- a/tests/components/unifi/test_update.py +++ b/tests/components/unifi/test_update.py @@ -117,7 +117,7 @@ async def test_device_updates( # Simulate update finished - device_1["state"] = "0" + device_1["state"] = 0 device_1["version"] = "4.3.17.11279" device_1["upgradable"] = False del device_1["upgrade_to_firmware"] diff --git a/tests/components/unifi_direct/__init__.py b/tests/components/unifi_direct/__init__.py deleted file mode 100644 index 7f8d0fa29f7..00000000000 --- a/tests/components/unifi_direct/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the unifi_direct component.""" diff --git a/tests/components/unifi_direct/fixtures/data.txt b/tests/components/unifi_direct/fixtures/data.txt deleted file mode 100644 index fcb58070fcc..00000000000 --- a/tests/components/unifi_direct/fixtures/data.txt +++ /dev/null @@ -1 +0,0 @@ -b'mca-dump | tr -d "\r\n> "\r\n{ "board_rev": 16, "bootrom_version": "unifi-v1.6.7.249-gb74e0282", "cfgversion": "63b505a1c328fd9c", "country_code": 840, "default": false, "discovery_response": true, "fw_caps": 855, "guest_token": "E6BAE04FD72C", "has_eth1": false, "has_speaker": false, "hostname": "UBNT", "if_table": [ { "full_duplex": true, "ip": "0.0.0.0", "mac": "80:2a:a8:56:34:12", "name": "eth0", "netmask": "0.0.0.0", "num_port": 1, "rx_bytes": 3879332085, "rx_dropped": 0, "rx_errors": 0, "rx_multicast": 0, "rx_packets": 4093520, "speed": 1000, "tx_bytes": 1745140940, "tx_dropped": 0, "tx_errors": 0, "tx_packets": 3105586, "up": true } ], "inform_url": "?", "ip": "192.168.1.2", "isolated": false, "last_error": "", "locating": false, "mac": "80:2a:a8:56:34:12", "model": "U7LR", "model_display": "UAP-AC-LR", "netmask": "255.255.255.0", "port_table": [ { "media": "GE", "poe_caps": 0, "port_idx": 0, "port_poe": false } ], "radio_table": [ { "athstats": { "ast_ath_reset": 0, "ast_be_xmit": 1098121, "ast_cst": 225, "ast_deadqueue_reset": 0, "ast_fullqueue_stop": 0, "ast_txto": 151, "cu_self_rx": 8, "cu_self_tx": 4, "cu_total": 12, "n_rx_aggr": 3915695, "n_rx_pkts": 6518082, "n_tx_bawadv": 1205430, "n_tx_bawretries": 70257, "n_tx_pkts": 1813368, "n_tx_queue": 1024366, "n_tx_retries": 70273, "n_tx_xretries": 897, "n_txaggr_compgood": 616173, "n_txaggr_compretries": 71170, "n_txaggr_compxretry": 0, "n_txaggr_prepends": 21240, "name": "wifi0" }, "builtin_ant_gain": 0, "builtin_antenna": true, "max_txpower": 24, "min_txpower": 6, "name": "wifi0", "nss": 3, "radio": "ng", "scan_table": [ { "age": 2, "bssid": "28:56:5a:34:23:12", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "someones_wifi", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 8, "rssi_age": 2, "security": "secured" }, { "age": 37, "bssid": "00:60:0f:45:34:12", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 10, "rssi_age": 37, "security": "secured" }, { "age": 29, "bssid": "b0:93:5b:7a:35:23", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "ARRIS-CB55", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 10, "rssi_age": 29, "security": "secured" }, { "age": 0, "bssid": "e0:46:9a:e1:ea:7d", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "Darjeeling", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 9, "rssi_age": 0, "security": "secured" }, { "age": 1, "bssid": "00:60:0f:e1:ea:7e", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 10, "rssi_age": 1, "security": "secured" }, { "age": 0, "bssid": "7c:d1:c3:cd:e5:f4", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "Chris\'s Wi-Fi Network", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 17, "rssi_age": 0, "security": "secured" } ] }, { "athstats": { "ast_ath_reset": 14, "ast_be_xmit": 1097310, "ast_cst": 0, "ast_deadqueue_reset": 41, "ast_fullqueue_stop": 0, "ast_txto": 0, "cu_self_rx": 0, "cu_self_tx": 0, "cu_total": 0, "n_rx_aggr": 106804, "n_rx_pkts": 2453041, "n_tx_bawadv": 557298, "n_tx_bawretries": 0, "n_tx_pkts": 1080, "n_tx_queue": 0, "n_tx_retries": 1, "n_tx_xretries": 44046, "n_txaggr_compgood": 0, "n_txaggr_compretries": 0, "n_txaggr_compxretry": 0, "n_txaggr_prepends": 0, "name": "wifi1" }, "builtin_ant_gain": 0, "builtin_antenna": true, "has_dfs": true, "has_fccdfs": true, "is_11ac": true, "max_txpower": 22, "min_txpower": 4, "name": "wifi1", "nss": 2, "radio": "na", "scan_table": [] } ], "required_version": "3.4.1", "selfrun_beacon": false, "serial": "802AA896363C", "spectrum_scanning": false, "ssh_session_table": [], "state": 0, "stream_token": "", "sys_stats": { "loadavg_1": "0.03", "loadavg_15": "0.06", "loadavg_5": "0.06", "mem_buffer": 0, "mem_total": 129310720, "mem_used": 75800576 }, "system-stats": { "cpu": "8.4", "mem": "58.6", "uptime": "112391" }, "time": 1508795154, "uplink": "eth0", "uptime": 112391, "vap_table": [ { "bssid": "80:2a:a8:97:36:3c", "ccq": 914, "channel": 11, "essid": "220", "id": "55b19c7e50e4e11e798e84c7", "name": "ath0", "num_sta": 20, "radio": "ng", "rx_bytes": 1155345354, "rx_crypts": 5491, "rx_dropped": 5540, "rx_errors": 5540, "rx_frags": 0, "rx_nwids": 647001, "rx_packets": 1840967, "sta_table": [ { "auth_time": 4294967206, "authorized": true, "ccq": 991, "dhcpend_time": 660, "dhcpstart_time": 660, "hostname": "amazon-device", "idletime": 0, "ip": "192.168.1.45", "is_11n": true, "mac": "44:65:0d:12:34:56", "noise": -114, "rssi": 59, "rx_bytes": 1176121, "rx_mcast": 0, "rx_packets": 20927, "rx_rate": 24000, "rx_retries": 0, "signal": -55, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 364495, "tx_packets": 2183, "tx_power": 48, "tx_rate": 72222, "tx_retries": 589, "uptime": 7031, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 991, "dhcpend_time": 290, "dhcpstart_time": 290, "hostname": "iPhone", "idletime": 9, "ip": "192.168.1.209", "is_11n": true, "mac": "98:00:c6:56:34:12", "noise": -114, "rssi": 40, "rx_bytes": 5862172, "rx_mcast": 0, "rx_packets": 30977, "rx_rate": 24000, "rx_retries": 0, "signal": -74, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 31707361, "tx_packets": 27775, "tx_power": 48, "tx_rate": 140637, "tx_retries": 1213, "uptime": 15556, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 991, "dhcpend_time": 630, "dhcpstart_time": 630, "hostname": "android", "idletime": 0, "ip": "192.168.1.10", "is_11n": true, "mac": "b4:79:a7:45:34:12", "noise": -114, "rssi": 60, "rx_bytes": 13694423, "rx_mcast": 0, "rx_packets": 110909, "rx_rate": 1000, "rx_retries": 0, "signal": -54, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 7988429, "tx_packets": 28863, "tx_power": 48, "tx_rate": 72222, "tx_retries": 1254, "uptime": 19052, "vlan_id": 0 }, { "auth_time": 4294967176, "authorized": true, "ccq": 991, "dhcpend_time": 4480, "dhcpstart_time": 4480, "hostname": "wink", "idletime": 0, "ip": "192.168.1.3", "is_11n": true, "mac": "b4:79:a7:56:34:12", "noise": -114, "rssi": 38, "rx_bytes": 18705870, "rx_mcast": 0, "rx_packets": 78794, "rx_rate": 72109, "rx_retries": 0, "signal": -76, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 4416534, "tx_packets": 58304, "tx_power": 48, "tx_rate": 72222, "tx_retries": 1978, "uptime": 51648, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 981, "dhcpend_time": 1530, "dhcpstart_time": 1530, "hostname": "Chromecast", "idletime": 0, "ip": "192.168.1.30", "is_11n": true, "mac": "80:d2:1d:56:34:12", "noise": -114, "rssi": 37, "rx_bytes": 29377621, "rx_mcast": 0, "rx_packets": 105806, "rx_rate": 72109, "rx_retries": 0, "signal": -77, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 122681792, "tx_packets": 145339, "tx_power": 48, "tx_rate": 72222, "tx_retries": 2980, "uptime": 53658, "vlan_id": 0 }, { "auth_time": 4294967176, "authorized": true, "ccq": 991, "dhcpend_time": 370, "dhcpstart_time": 360, "idletime": 2, "ip": "192.168.1.51", "is_11n": false, "mac": "48:02:2d:56:34:12", "noise": -114, "rssi": 56, "rx_bytes": 48148926, "rx_mcast": 0, "rx_packets": 59462, "rx_rate": 1000, "rx_retries": 0, "signal": -58, "state": 16391, "state_ht": false, "state_pwrmgt": false, "tx_bytes": 7075470, "tx_packets": 33047, "tx_power": 48, "tx_rate": 54000, "tx_retries": 2833, "uptime": 63850, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 971, "dhcpend_time": 30, "dhcpstart_time": 30, "hostname": "ESP_1C2F8D", "idletime": 0, "ip": "192.168.1.54", "is_11n": true, "mac": "a0:20:a6:45:35:12", "noise": -114, "rssi": 51, "rx_bytes": 4684699, "rx_mcast": 0, "rx_packets": 137798, "rx_rate": 2000, "rx_retries": 0, "signal": -63, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 355735, "tx_packets": 6977, "tx_power": 48, "tx_rate": 72222, "tx_retries": 590, "uptime": 78427, "vlan_id": 0 }, { "auth_time": 4294967176, "authorized": true, "ccq": 991, "dhcpend_time": 220, "dhcpstart_time": 220, "hostname": "HF-LPB100-ZJ200", "idletime": 2, "ip": "192.168.1.53", "is_11n": true, "mac": "f0:fe:6b:56:34:12", "noise": -114, "rssi": 29, "rx_bytes": 1415840, "rx_mcast": 0, "rx_packets": 22821, "rx_rate": 1000, "rx_retries": 0, "signal": -85, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 402439, "tx_packets": 7779, "tx_power": 48, "tx_rate": 72222, "tx_retries": 891, "uptime": 111944, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 991, "dhcpend_time": 1620, "dhcpstart_time": 1620, "idletime": 0, "ip": "192.168.1.33", "is_11n": false, "mac": "94:10:3e:45:34:12", "noise": -114, "rssi": 48, "rx_bytes": 47843953, "rx_mcast": 0, "rx_packets": 79456, "rx_rate": 54000, "rx_retries": 0, "signal": -66, "state": 16391, "state_ht": false, "state_pwrmgt": false, "tx_bytes": 4357955, "tx_packets": 60958, "tx_power": 48, "tx_rate": 54000, "tx_retries": 4598, "uptime": 112316, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 991, "dhcpend_time": 540, "dhcpstart_time": 540, "hostname": "amazon-device", "idletime": 0, "ip": "192.168.1.46", "is_11n": true, "mac": "ac:63:be:56:34:12", "noise": -114, "rssi": 30, "rx_bytes": 14607810, "rx_mcast": 0, "rx_packets": 326158, "rx_rate": 24000, "rx_retries": 0, "signal": -84, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 3238319, "tx_packets": 25605, "tx_power": 48, "tx_rate": 72222, "tx_retries": 2465, "uptime": 112364, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 941, "dhcpend_time": 1060, "dhcpstart_time": 1060, "hostname": "Broadlink_RMMINI-56-34-12", "idletime": 12, "ip": "192.168.1.52", "is_11n": true, "mac": "34:ea:34:56:34:12", "noise": -114, "rssi": 43, "rx_bytes": 625268, "rx_mcast": 0, "rx_packets": 4711, "rx_rate": 65000, "rx_retries": 0, "signal": -71, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 420763, "tx_packets": 4620, "tx_power": 48, "tx_rate": 65000, "tx_retries": 783, "uptime": 112368, "vlan_id": 0 }, { "auth_time": 4294967256, "authorized": true, "ccq": 930, "dhcpend_time": 3360, "dhcpstart_time": 3360, "hostname": "garage", "idletime": 2, "ip": "192.168.1.28", "is_11n": true, "mac": "00:13:ef:45:34:12", "noise": -114, "rssi": 28, "rx_bytes": 11639474, "rx_mcast": 0, "rx_packets": 102103, "rx_rate": 24000, "rx_retries": 0, "signal": -86, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 6282728, "tx_packets": 85279, "tx_power": 48, "tx_rate": 58500, "tx_retries": 21185, "uptime": 112369, "vlan_id": 0 }, { "auth_time": 4294967286, "authorized": true, "ccq": 991, "dhcpend_time": 30, "dhcpstart_time": 30, "hostname": "keurig", "idletime": 0, "ip": "192.168.1.48", "is_11n": true, "mac": "18:fe:34:56:34:12", "noise": -114, "rssi": 52, "rx_bytes": 17781940, "rx_mcast": 0, "rx_packets": 432172, "rx_rate": 6000, "rx_retries": 0, "signal": -62, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 4143184, "tx_packets": 53751, "tx_power": 48, "tx_rate": 72222, "tx_retries": 3781, "uptime": 112369, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 940, "dhcpend_time": 50, "dhcpstart_time": 50, "hostname": "freezer", "idletime": 0, "ip": "192.168.1.26", "is_11n": true, "mac": "5c:cf:7f:07:5a:a4", "noise": -114, "rssi": 47, "rx_bytes": 13613265, "rx_mcast": 0, "rx_packets": 411785, "rx_rate": 2000, "rx_retries": 0, "signal": -67, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 1411127, "tx_packets": 17492, "tx_power": 48, "tx_rate": 65000, "tx_retries": 5869, "uptime": 112370, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 778, "dhcpend_time": 50, "dhcpstart_time": 50, "hostname": "fan", "idletime": 0, "ip": "192.168.1.34", "is_11n": true, "mac": "5c:cf:7f:02:09:4e", "noise": -114, "rssi": 45, "rx_bytes": 15377230, "rx_mcast": 0, "rx_packets": 417435, "rx_rate": 6000, "rx_retries": 0, "signal": -69, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 2974258, "tx_packets": 36175, "tx_power": 48, "tx_rate": 58500, "tx_retries": 18552, "uptime": 112372, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 991, "dhcpend_time": 1070, "dhcpstart_time": 1070, "hostname": "Broadlink_RMPROPLUS-45-34-12", "idletime": 1, "ip": "192.168.1.9", "is_11n": true, "mac": "b4:43:0d:45:56:56", "noise": -114, "rssi": 57, "rx_bytes": 1792908, "rx_mcast": 0, "rx_packets": 8528, "rx_rate": 72109, "rx_retries": 0, "signal": -57, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 770834, "tx_packets": 8443, "tx_power": 48, "tx_rate": 65000, "tx_retries": 5258, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 991, "dhcpend_time": 210, "dhcpstart_time": 210, "idletime": 49, "ip": "192.168.1.40", "is_11n": true, "mac": "0c:2a:69:02:3e:3b", "noise": -114, "rssi": 36, "rx_bytes": 427418, "rx_mcast": 0, "rx_packets": 2824, "rx_rate": 65000, "rx_retries": 0, "signal": -78, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 176039, "tx_packets": 2872, "tx_power": 48, "tx_rate": 65000, "tx_retries": 87, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 991, "dhcpend_time": 5030, "dhcpstart_time": 5030, "hostname": "HP2C27D78D9F3E", "idletime": 268, "ip": "192.168.1.44", "is_11n": true, "mac": "2c:27:d7:8d:9f:3e", "noise": -114, "rssi": 41, "rx_bytes": 172927, "rx_mcast": 0, "rx_packets": 781, "rx_rate": 72109, "rx_retries": 0, "signal": -73, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 41924, "tx_packets": 453, "tx_power": 48, "tx_rate": 66610, "tx_retries": 66, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 991, "dhcpend_time": 110, "dhcpstart_time": 110, "idletime": 4, "ip": "192.168.1.55", "is_11n": true, "mac": "0c:2a:69:04:e6:ac", "noise": -114, "rssi": 51, "rx_bytes": 300741, "rx_mcast": 0, "rx_packets": 2443, "rx_rate": 65000, "rx_retries": 0, "signal": -63, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 159980, "tx_packets": 2526, "tx_power": 48, "tx_rate": 65000, "tx_retries": 47, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294967256, "authorized": true, "ccq": 991, "dhcpend_time": 1570, "dhcpstart_time": 1560, "idletime": 1, "ip": "192.168.1.37", "is_11n": true, "mac": "0c:2a:69:03:df:37", "noise": -114, "rssi": 42, "rx_bytes": 304567, "rx_mcast": 0, "rx_packets": 2468, "rx_rate": 65000, "rx_retries": 0, "signal": -72, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 164382, "tx_packets": 2553, "tx_power": 48, "tx_rate": 65000, "tx_retries": 48, "uptime": 112373, "vlan_id": 0 } ], "state": "RUN", "tx_bytes": 1190129336, "tx_dropped": 7, "tx_errors": 0, "tx_packets": 1907093, "tx_power": 24, "tx_retries": 29927, "up": true, "usage": "user" }, { "bssid": "ff:ff:ff:ff:ff:ff", "ccq": 914, "channel": 157, "essid": "", "extchannel": 1, "id": "user", "name": "ath1", "num_sta": 0, "radio": "na", "rx_bytes": 0, "rx_crypts": 0, "rx_dropped": 0, "rx_errors": 0, "rx_frags": 0, "rx_nwids": 0, "rx_packets": 0, "sta_table": [], "state": "INIT", "tx_bytes": 0, "tx_dropped": 0, "tx_errors": 0, "tx_packets": 0, "tx_power": 22, "tx_retries": 0, "up": false, "usage": "uplink" }, { "bssid": "82:2a:a8:98:36:3c", "ccq": 482, "channel": 157, "essid": "220 5ghz", "extchannel": 1, "id": "55b19c7e50e4e11e798e84c7", "name": "ath2", "num_sta": 3, "radio": "na", "rx_bytes": 250435644, "rx_crypts": 4071, "rx_dropped": 4071, "rx_errors": 4071, "rx_frags": 0, "rx_nwids": 6660, "rx_packets": 1123263, "sta_table": [ { "auth_time": 4294967246, "authorized": true, "ccq": 631, "dhcpend_time": 190, "dhcpstart_time": 190, "hostname": "android-f4aaefc31d5d2f78", "idletime": 26, "ip": "192.168.1.15", "is_11a": true, "is_11ac": true, "is_11n": true, "mac": "c0:ee:fb:24:ef:a0", "noise": -105, "rssi": 16, "rx_bytes": 3188995, "rx_mcast": 0, "rx_packets": 37243, "rx_rate": 81000, "rx_retries": 0, "signal": -89, "state": 11, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 89051905, "tx_packets": 64756, "tx_power": 44, "tx_rate": 108000, "tx_retries": 0, "uptime": 5494, "vlan_id": 0 }, { "auth_time": 4294967286, "authorized": true, "ccq": 333, "dhcpend_time": 10, "dhcpstart_time": 10, "hostname": "mac_book_air", "idletime": 1, "ip": "192.168.1.12", "is_11a": true, "is_11n": true, "mac": "00:88:65:56:34:12", "noise": -105, "rssi": 52, "rx_bytes": 106902966, "rx_mcast": 0, "rx_packets": 270845, "rx_rate": 300000, "rx_retries": 0, "signal": -53, "state": 11, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 289588466, "tx_packets": 339466, "tx_power": 44, "tx_rate": 300000, "tx_retries": 0, "uptime": 15312, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 333, "dhcpend_time": 160, "dhcpstart_time": 160, "hostname": "Chromecast", "idletime": 0, "ip": "192.168.1.29", "is_11a": true, "is_11ac": true, "is_11n": true, "mac": "f4:f5:d8:11:57:6a", "noise": -105, "rssi": 40, "rx_bytes": 50958412, "rx_mcast": 0, "rx_packets": 339563, "rx_rate": 200000, "rx_retries": 0, "signal": -65, "state": 11, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 1186178689, "tx_packets": 890384, "tx_power": 44, "tx_rate": 150000, "tx_retries": 0, "uptime": 56493, "vlan_id": 0 } ], "state": "RUN", "tx_bytes": 2766849222, "tx_dropped": 119, "tx_errors": 23508, "tx_packets": 2247859, "tx_power": 22, "tx_retries": 0, "up": true, "usage": "user" } ], "version": "3.7.58.6385", "wifi_caps": 1909}' \ No newline at end of file diff --git a/tests/components/unifi_direct/test_device_tracker.py b/tests/components/unifi_direct/test_device_tracker.py deleted file mode 100644 index cfb1c7e92bc..00000000000 --- a/tests/components/unifi_direct/test_device_tracker.py +++ /dev/null @@ -1,178 +0,0 @@ -"""The tests for the Unifi direct device tracker platform.""" -from datetime import timedelta -import os -from unittest.mock import MagicMock, call, patch - -import pytest -import voluptuous as vol - -from homeassistant.components.device_tracker import ( - CONF_CONSIDER_HOME, - CONF_NEW_DEVICE_DEFAULTS, - CONF_TRACK_NEW, -) -from homeassistant.components.device_tracker.legacy import YAML_DEVICES -from homeassistant.components.unifi_direct.device_tracker import ( - CONF_PORT, - DOMAIN, - PLATFORM_SCHEMA, - UnifiDeviceScanner, - _response_to_json, - get_scanner, -) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from tests.common import assert_setup_component, load_fixture, mock_component - -scanner_path = "homeassistant.components.unifi_direct.device_tracker.UnifiDeviceScanner" - - -@pytest.fixture(autouse=True) -def setup_comp(hass): - """Initialize components.""" - mock_component(hass, "zone") - yaml_devices = hass.config.path(YAML_DEVICES) - yield - if os.path.isfile(yaml_devices): - os.remove(yaml_devices) - - -@patch(scanner_path, return_value=MagicMock(spec=UnifiDeviceScanner)) -async def test_get_scanner(unifi_mock, hass: HomeAssistant) -> None: - """Test creating an Unifi direct scanner with a password.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: "unifi_direct", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - CONF_NEW_DEVICE_DEFAULTS: {CONF_TRACK_NEW: True}, - } - } - - with assert_setup_component(1, DOMAIN): - assert await async_setup_component(hass, DOMAIN, conf_dict) - - conf_dict[DOMAIN][CONF_PORT] = 22 - assert unifi_mock.call_args == call(conf_dict[DOMAIN]) - - -@patch("pexpect.pxssh.pxssh") -async def test_get_device_name(mock_ssh, hass: HomeAssistant) -> None: - """Testing MAC matching.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: "unifi_direct", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - } - } - mock_ssh.return_value.before = load_fixture("data.txt", "unifi_direct") - scanner = get_scanner(hass, conf_dict) - devices = scanner.scan_devices() - assert len(devices) == 23 - assert scanner.get_device_name("98:00:c6:56:34:12") == "iPhone" - assert scanner.get_device_name("98:00:C6:56:34:12") == "iPhone" - - -@patch("pexpect.pxssh.pxssh.logout") -@patch("pexpect.pxssh.pxssh.login") -async def test_failed_to_log_in(mock_login, mock_logout, hass: HomeAssistant) -> None: - """Testing exception at login results in False.""" - from pexpect import exceptions - - conf_dict = { - DOMAIN: { - CONF_PLATFORM: "unifi_direct", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - } - } - - mock_login.side_effect = exceptions.EOF("Test") - scanner = get_scanner(hass, conf_dict) - assert not scanner - - -@patch("pexpect.pxssh.pxssh.logout") -@patch("pexpect.pxssh.pxssh.login", autospec=True) -@patch("pexpect.pxssh.pxssh.prompt") -@patch("pexpect.pxssh.pxssh.sendline") -async def test_to_get_update( - mock_sendline, mock_prompt, mock_login, mock_logout, hass: HomeAssistant -) -> None: - """Testing exception in get_update matching.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: "unifi_direct", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PASSWORD: "fake_pass", - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - } - } - - scanner = get_scanner(hass, conf_dict) - # mock_sendline.side_effect = AssertionError("Test") - mock_prompt.side_effect = AssertionError("Test") - devices = scanner._get_update() - assert devices is None - - -def test_good_response_parses(hass: HomeAssistant) -> None: - """Test that the response form the AP parses to JSON correctly.""" - response = _response_to_json(load_fixture("data.txt", "unifi_direct")) - assert response != {} - - -def test_bad_response_returns_none(hass: HomeAssistant) -> None: - """Test that a bad response form the AP parses to JSON correctly.""" - assert _response_to_json("{(}") == {} - - -def test_config_error() -> None: - """Test for configuration errors.""" - with pytest.raises(vol.Invalid): - PLATFORM_SCHEMA( - { - # no username - CONF_PASSWORD: "password", - CONF_PLATFORM: DOMAIN, - CONF_HOST: "myhost", - "port": 123, - } - ) - with pytest.raises(vol.Invalid): - PLATFORM_SCHEMA( - { - # no password - CONF_USERNAME: "foo", - CONF_PLATFORM: DOMAIN, - CONF_HOST: "myhost", - "port": 123, - } - ) - with pytest.raises(vol.Invalid): - PLATFORM_SCHEMA( - { - CONF_PLATFORM: DOMAIN, - CONF_USERNAME: "foo", - CONF_PASSWORD: "password", - CONF_HOST: "myhost", - "port": "foo", # bad port! - } - ) diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index b8932b99e2c..17db53d05ec 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -38,6 +38,7 @@ CAMERA_SWITCHES_BASIC = [ and d.name != "Detections: License Plate" and d.name != "Detections: Smoke/CO" and d.name != "SSH Enabled" + and d.name != "Color Night Vision" ] CAMERA_SWITCHES_NO_EXTRA = [ d for d in CAMERA_SWITCHES_BASIC if d.name not in ("High FPS", "Privacy Mode") diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index e31cab59358..60196e6fe24 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -159,7 +159,7 @@ class MockMediaPlayer(media_player.MediaPlayerEntity): @property def supported_features(self): """Flag media player features that are supported.""" - return self._supported_features + return MediaPlayerEntityFeature(self._supported_features) @property def media_image_url(self): diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 73f98c9e2db..92e63af4b6f 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -128,6 +128,7 @@ async def test_update(hass: HomeAssistant) -> None: update.entity_description = UpdateEntityDescription(key="F5 - Its very refreshing") assert update.device_class is None assert update.entity_category is EntityCategory.CONFIG + del update.device_class update.entity_description = UpdateEntityDescription( key="F5 - Its very refreshing", device_class=UpdateDeviceClass.FIRMWARE, @@ -864,3 +865,23 @@ async def test_name(hass: HomeAssistant) -> None: state = hass.states.get(entity4.entity_id) assert state assert expected.items() <= state.attributes.items() + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockUpdateEntity(UpdateEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockUpdateEntity() + assert entity.supported_features_compat is UpdateEntityFeature(1) + assert "MockUpdateEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "UpdateEntityFeature.INSTALL" in caplog.text + caplog.clear() + assert entity.supported_features_compat is UpdateEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/uptime/snapshots/test_config_flow.ambr b/tests/components/uptime/snapshots/test_config_flow.ambr index ac4b7396839..3e5b492f871 100644 --- a/tests/components/uptime/snapshots/test_config_flow.ambr +++ b/tests/components/uptime/snapshots/test_config_flow.ambr @@ -10,6 +10,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'uptime', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -18,6 +19,7 @@ 'disabled_by': None, 'domain': 'uptime', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index 5c8d8d4253c..0ac8140c52d 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -2,8 +2,8 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.components.select import ( @@ -95,7 +95,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -116,7 +116,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 4, @@ -144,7 +144,7 @@ async def test_services(hass: HomeAssistant, meter) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 5, @@ -221,7 +221,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -242,7 +242,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 4, @@ -270,7 +270,7 @@ async def test_services_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() now += timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 5, diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 2c64338c4f3..d77c2db356a 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1,6 +1,5 @@ """The tests for the utility_meter sensor platform.""" from datetime import timedelta -from unittest.mock import patch from freezegun import freeze_time import pytest @@ -132,7 +131,7 @@ async def test_state(hass: HomeAssistant, yaml_config, config_entry_config) -> N assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -166,7 +165,7 @@ async def test_state(hass: HomeAssistant, yaml_config, config_entry_config) -> N await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=20) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 6, @@ -729,7 +728,7 @@ async def test_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1, @@ -803,7 +802,7 @@ async def test_non_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1, @@ -813,7 +812,7 @@ async def test_non_net_consumption( await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, None, @@ -1148,7 +1147,7 @@ async def test_non_periodically_resetting_meter_with_tariffs( assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 3, @@ -1186,7 +1185,7 @@ async def test_non_periodically_resetting_meter_with_tariffs( assert state.attributes.get("status") == COLLECTING now = dt_util.utcnow() + timedelta(seconds=20) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 6, diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 7c5c0de1674..0b44476989b 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -5,7 +5,11 @@ from collections.abc import Generator import pytest -from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN, VacuumEntity +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + VacuumEntity, + VacuumEntityFeature, +) from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir @@ -89,7 +93,7 @@ async def test_deprecated_base_class( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up test stt platform via config entry.""" + """Set up test vacuum platform via config entry.""" async_add_entities([entity1]) mock_platform( @@ -121,3 +125,23 @@ async def test_deprecated_base_class( issue.translation_placeholders == {"platform": "test"} | translation_placeholders_extra ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockVacuumEntity(VacuumEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockVacuumEntity() + assert entity.supported_features_compat is VacuumEntityFeature(1) + assert "MockVacuumEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "VacuumEntityFeature.TURN_ON" in caplog.text + caplog.clear() + assert entity.supported_features_compat is VacuumEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/vacuum/test_significant_change.py b/tests/components/vacuum/test_significant_change.py new file mode 100644 index 00000000000..5f46080fb8d --- /dev/null +++ b/tests/components/vacuum/test_significant_change.py @@ -0,0 +1,51 @@ +"""Test the Vacuum significant change platform.""" +import pytest + +from homeassistant.components.vacuum import ( + ATTR_BATTERY_ICON, + ATTR_BATTERY_LEVEL, + ATTR_FAN_SPEED, +) +from homeassistant.components.vacuum.significant_change import ( + async_check_significant_change, +) + + +async def test_significant_state_change() -> None: + """Detect Vacuum significant state changes.""" + attrs = {} + assert not async_check_significant_change(None, "on", attrs, "on", attrs) + assert async_check_significant_change(None, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + ({ATTR_FAN_SPEED: "old_value"}, {ATTR_FAN_SPEED: "old_value"}, False), + ({ATTR_FAN_SPEED: "old_value"}, {ATTR_FAN_SPEED: "new_value"}, True), + # multiple attributes + ( + {ATTR_FAN_SPEED: "old_value", ATTR_BATTERY_LEVEL: 10.0}, + {ATTR_FAN_SPEED: "new_value", ATTR_BATTERY_LEVEL: 10.0}, + True, + ), + # float attributes + ({ATTR_BATTERY_LEVEL: 10.0}, {ATTR_BATTERY_LEVEL: 11.0}, True), + ({ATTR_BATTERY_LEVEL: 10.0}, {ATTR_BATTERY_LEVEL: 10.9}, False), + ({ATTR_BATTERY_LEVEL: "invalid"}, {ATTR_BATTERY_LEVEL: 10.0}, True), + ({ATTR_BATTERY_LEVEL: 10.0}, {ATTR_BATTERY_LEVEL: "invalid"}, False), + # insignificant attributes + ({ATTR_BATTERY_ICON: "old_value"}, {ATTR_BATTERY_ICON: "new_value"}, False), + ({ATTR_BATTERY_ICON: "old_value"}, {ATTR_BATTERY_ICON: "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + ({"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Vacuum significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/valve/__init__.py b/tests/components/valve/__init__.py new file mode 100644 index 00000000000..c39ec8220af --- /dev/null +++ b/tests/components/valve/__init__.py @@ -0,0 +1 @@ +"""Tests for the valve component.""" diff --git a/tests/components/valve/test_init.py b/tests/components/valve/test_init.py new file mode 100644 index 00000000000..08b0771da8e --- /dev/null +++ b/tests/components/valve/test_init.py @@ -0,0 +1,355 @@ +"""The tests for Valve.""" +from collections.abc import Generator + +import pytest + +from homeassistant.components.valve import ( + DOMAIN, + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_SET_VALVE_POSITION, + SERVICE_TOGGLE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNAVAILABLE, + Platform, +) +from homeassistant.core import HomeAssistant, callback +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.""" + + +class MockValveEntity(ValveEntity): + """Mock valve device to use in tests.""" + + _attr_should_poll = False + _target_valve_position: int + + def __init__( + self, + unique_id: str = "mock_valve", + name: str = "Valve", + features: ValveEntityFeature = ValveEntityFeature(0), + current_position: int = None, + device_class: ValveDeviceClass = None, + reports_position: bool = True, + ) -> None: + """Initialize the valve.""" + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_supported_features = features + self._attr_current_valve_position = current_position + if reports_position is not None: + self._attr_reports_position = reports_position + if device_class is not None: + self._attr_device_class = device_class + + def set_valve_position(self, position: int) -> None: + """Set the valve to opening or closing towards a target percentage.""" + if position > self._attr_current_valve_position: + self._attr_is_closing = False + self._attr_is_opening = True + else: + self._attr_is_closing = True + self._attr_is_opening = False + self._target_valve_position = position + self.schedule_update_ha_state() + + def stop_valve(self) -> None: + """Stop the valve.""" + self._attr_is_closing = False + self._attr_is_opening = False + self._target_valve_position = None + self._attr_is_closed = self._attr_current_valve_position == 0 + self.schedule_update_ha_state() + + @callback + def finish_movement(self): + """Set the value to the saved target and removes intermediate states.""" + self._attr_current_valve_position = self._target_valve_position + self._attr_is_closing = False + self._attr_is_opening = False + self.async_write_ha_state() + + +class MockBinaryValveEntity(ValveEntity): + """Mock valve device to use in tests.""" + + def __init__( + self, + unique_id: str = "mock_valve_2", + name: str = "Valve", + features: ValveEntityFeature = ValveEntityFeature(0), + is_closed: bool = None, + ) -> None: + """Initialize the valve.""" + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_supported_features = features + self._attr_is_closed = is_closed + self._attr_reports_position = False + + def open_valve(self) -> None: + """Open the valve.""" + self._attr_is_closed = False + + def close_valve(self) -> None: + """Mock implementantion for sync close function.""" + self._attr_is_closed = True + + +@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 + + +@pytest.fixture +def mock_config_entry(hass) -> tuple[MockConfigEntry, list[ValveEntity]]: + """Mock a config entry which sets up a couple of valve entities.""" + entities = [ + MockBinaryValveEntity( + is_closed=False, + features=ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, + ), + MockValveEntity( + current_position=50, + features=ValveEntityFeature.OPEN + | ValveEntityFeature.CLOSE + | ValveEntityFeature.STOP + | ValveEntityFeature.SET_POSITION, + ), + ] + + 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, Platform.VALVE + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload up test config entry.""" + await hass.config_entries.async_unload_platforms(config_entry, [Platform.VALVE]) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test platform via config entry.""" + async_add_entities(entities) + + 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) + + return (config_entry, entities) + + +async def test_valve_setup( + hass: HomeAssistant, mock_config_entry: tuple[MockConfigEntry, list[ValveEntity]] +) -> None: + """Test setup and tear down of valve platform and entity.""" + config_entry = mock_config_entry[0] + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + entity_id = mock_config_entry[1][0].entity_id + + assert config_entry.state == ConfigEntryState.LOADED + assert hass.states.get(entity_id) + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + entity_state = hass.states.get(entity_id) + + assert entity_state + assert entity_state.state == STATE_UNAVAILABLE + + +async def test_services( + hass: HomeAssistant, mock_config_entry: tuple[MockConfigEntry, list[ValveEntity]] +) -> None: + """Test the provided services.""" + config_entry = mock_config_entry[0] + ent1, ent2 = mock_config_entry[1] + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Test init all valves should be open + assert is_open(hass, ent1) + assert is_open(hass, ent2) + + # call basic toggle services + await call_service(hass, SERVICE_TOGGLE, ent1) + await call_service(hass, SERVICE_TOGGLE, ent2) + + # entities without stop should be closed and with stop should be closing + assert is_closed(hass, ent1) + assert is_closing(hass, ent2) + ent2.finish_movement() + assert is_closed(hass, ent2) + + # call basic toggle services and set different valve position states + await call_service(hass, SERVICE_TOGGLE, ent1) + await call_service(hass, SERVICE_TOGGLE, ent2) + await hass.async_block_till_done() + + # entities should be in correct state depending on the SUPPORT_STOP feature and valve position + assert is_open(hass, ent1) + assert is_opening(hass, ent2) + + # call basic toggle services + await call_service(hass, SERVICE_TOGGLE, ent1) + await call_service(hass, SERVICE_TOGGLE, ent2) + + # entities should be in correct state depending on the SUPPORT_STOP feature and valve position + assert is_closed(hass, ent1) + assert not is_opening(hass, ent2) + assert not is_closing(hass, ent2) + assert is_closed(hass, ent2) + + await call_service(hass, SERVICE_SET_VALVE_POSITION, ent2, 50) + assert is_opening(hass, ent2) + + +async def test_valve_device_class(hass: HomeAssistant) -> None: + """Test valve entity with defaults.""" + default_valve = MockValveEntity() + default_valve.hass = hass + + assert default_valve.device_class is None + + entity_description = ValveEntityDescription( + key="test", + device_class=ValveDeviceClass.GAS, + ) + default_valve.entity_description = entity_description + assert default_valve.device_class is ValveDeviceClass.GAS + + water_valve = MockValveEntity(device_class=ValveDeviceClass.WATER) + water_valve.hass = hass + + assert water_valve.device_class is ValveDeviceClass.WATER + + +async def test_valve_report_position(hass: HomeAssistant) -> None: + """Test valve entity with defaults.""" + default_valve = MockValveEntity(reports_position=None) + default_valve.hass = hass + + with pytest.raises(ValueError): + default_valve.reports_position + + second_valve = MockValveEntity(reports_position=True) + second_valve.hass = hass + + assert second_valve.reports_position is True + + entity_description = ValveEntityDescription(key="test", reports_position=True) + third_valve = MockValveEntity(reports_position=None) + third_valve.entity_description = entity_description + assert third_valve.reports_position is True + + +async def test_none_state(hass: HomeAssistant) -> None: + """Test different criteria for closeness.""" + binary_valve_with_none_is_closed_attr = MockBinaryValveEntity(is_closed=None) + binary_valve_with_none_is_closed_attr.hass = hass + + assert binary_valve_with_none_is_closed_attr.state is None + + pos_valve_with_none_is_closed_attr = MockValveEntity() + pos_valve_with_none_is_closed_attr.hass = hass + + assert pos_valve_with_none_is_closed_attr.state is None + + +async def test_supported_features(hass: HomeAssistant) -> None: + """Test valve entity with defaults.""" + valve = MockValveEntity(features=None) + valve.hass = hass + + assert valve.supported_features is None + + +def call_service(hass, service, ent, position=None): + """Call any service on entity.""" + params = {ATTR_ENTITY_ID: ent.entity_id} + if position is not None: + params["position"] = position + return hass.services.async_call(DOMAIN, service, params, blocking=True) + + +def set_valve_position(ent, position) -> None: + """Set a position value to a valve.""" + ent._values["current_valve_position"] = position + + +def is_open(hass, ent): + """Return if the valve is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_OPEN) + + +def is_opening(hass, ent): + """Return if the valve is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_OPENING) + + +def is_closed(hass, ent): + """Return if the valve is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_CLOSED) + + +def is_closing(hass, ent): + """Return if the valve is closed based on the statemachine.""" + return hass.states.is_state(ent.entity_id, STATE_CLOSING) diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index dc1b217948f..dfc29d46cc2 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4705,6 +4705,7 @@ 'disabled_by': None, 'domain': 'vicare', 'entry_id': '1234', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/water_heater/test_init.py b/tests/components/water_heater/test_init.py index bc996ab6fa4..0d33f3a9e93 100644 --- a/tests/components/water_heater/test_init.py +++ b/tests/components/water_heater/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest import voluptuous as vol +from homeassistant.components import water_heater from homeassistant.components.water_heater import ( SET_TEMPERATURE_SCHEMA, WaterHeaterEntity, @@ -13,7 +14,7 @@ from homeassistant.components.water_heater import ( ) from homeassistant.core import HomeAssistant -from tests.common import async_mock_service +from tests.common import async_mock_service, import_and_test_deprecated_constant_enum async def test_set_temp_schema_no_req( @@ -96,3 +97,41 @@ async def test_sync_turn_off(hass: HomeAssistant) -> None: await water_heater.async_turn_off() assert water_heater.async_turn_off.call_count == 1 + + +@pytest.mark.parametrize( + ("enum"), + [ + WaterHeaterEntityFeature.TARGET_TEMPERATURE, + WaterHeaterEntityFeature.OPERATION_MODE, + WaterHeaterEntityFeature.AWAY_MODE, + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: WaterHeaterEntityFeature, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, water_heater, enum, "SUPPORT_", "2025.1" + ) + + +def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: + """Test deprecated supported features ints.""" + + class MockWaterHeaterEntity(WaterHeaterEntity): + @property + def supported_features(self) -> int: + """Return supported features.""" + return 1 + + entity = MockWaterHeaterEntity() + assert entity.supported_features_compat is WaterHeaterEntityFeature(1) + assert "MockWaterHeaterEntity" in caplog.text + assert "is using deprecated supported features values" in caplog.text + assert "Instead it should use" in caplog.text + assert "WaterHeaterEntityFeature.TARGET_TEMPERATURE" in caplog.text + caplog.clear() + assert entity.supported_features_compat is WaterHeaterEntityFeature(1) + assert "is using deprecated supported features values" not in caplog.text diff --git a/tests/components/water_heater/test_significant_change.py b/tests/components/water_heater/test_significant_change.py new file mode 100644 index 00000000000..40803eea09a --- /dev/null +++ b/tests/components/water_heater/test_significant_change.py @@ -0,0 +1,96 @@ +"""Test the Water Heater significant change platform.""" +import pytest + +from homeassistant.components.water_heater import ( + ATTR_AWAY_MODE, + ATTR_CURRENT_TEMPERATURE, + ATTR_OPERATION_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ATTR_TEMPERATURE, +) +from homeassistant.components.water_heater.significant_change import ( + async_check_significant_change, +) +from homeassistant.core import HomeAssistant +from homeassistant.util.unit_system import ( + METRIC_SYSTEM as METRIC, + US_CUSTOMARY_SYSTEM as IMPERIAL, + UnitSystem, +) + + +async def test_significant_state_change(hass: HomeAssistant) -> None: + """Detect Water Heater significant state changes.""" + attrs = {} + assert not async_check_significant_change(hass, "on", attrs, "on", attrs) + assert async_check_significant_change(hass, "on", attrs, "off", attrs) + + +@pytest.mark.parametrize( + ("unit_system", "old_attrs", "new_attrs", "expected_result"), + [ + (METRIC, {ATTR_AWAY_MODE: "old_value"}, {ATTR_AWAY_MODE: "old_value"}, False), + (METRIC, {ATTR_AWAY_MODE: "old_value"}, {ATTR_AWAY_MODE: "new_value"}, True), + ( + METRIC, + {ATTR_OPERATION_MODE: "old_value"}, + {ATTR_OPERATION_MODE: "new_value"}, + True, + ), + # multiple attributes + ( + METRIC, + {ATTR_AWAY_MODE: "old_value", ATTR_OPERATION_MODE: "old_value"}, + {ATTR_AWAY_MODE: "new_value", ATTR_OPERATION_MODE: "old_value"}, + True, + ), + # float attributes + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 50.0}, + {ATTR_CURRENT_TEMPERATURE: 50.5}, + True, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 50.0}, + {ATTR_CURRENT_TEMPERATURE: 50.4}, + False, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: "invalid"}, + {ATTR_CURRENT_TEMPERATURE: 10.0}, + True, + ), + ( + METRIC, + {ATTR_CURRENT_TEMPERATURE: 10.0}, + {ATTR_CURRENT_TEMPERATURE: "invalid"}, + False, + ), + (IMPERIAL, {ATTR_TEMPERATURE: 160.0}, {ATTR_TEMPERATURE: 161}, True), + (IMPERIAL, {ATTR_TEMPERATURE: 160.0}, {ATTR_TEMPERATURE: 160.9}, False), + (METRIC, {ATTR_TARGET_TEMP_HIGH: 80.0}, {ATTR_TARGET_TEMP_HIGH: 80.5}, True), + (METRIC, {ATTR_TARGET_TEMP_HIGH: 80.0}, {ATTR_TARGET_TEMP_HIGH: 80.4}, False), + (METRIC, {ATTR_TARGET_TEMP_LOW: 30.0}, {ATTR_TARGET_TEMP_LOW: 30.5}, True), + (METRIC, {ATTR_TARGET_TEMP_LOW: 30.0}, {ATTR_TARGET_TEMP_LOW: 30.4}, False), + # insignificant attributes + (METRIC, {"unknown_attr": "old_value"}, {"unknown_attr": "old_value"}, False), + (METRIC, {"unknown_attr": "old_value"}, {"unknown_attr": "new_value"}, False), + ], +) +async def test_significant_atributes_change( + hass: HomeAssistant, + unit_system: UnitSystem, + old_attrs: dict, + new_attrs: dict, + expected_result: bool, +) -> None: + """Detect Water Heater significant attribute changes.""" + hass.config.units = unit_system + assert ( + async_check_significant_change(hass, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/watttime/snapshots/test_diagnostics.ambr b/tests/components/watttime/snapshots/test_diagnostics.ambr index e1cf4a8a42f..2ed35c19ad1 100644 --- a/tests/components/watttime/snapshots/test_diagnostics.ambr +++ b/tests/components/watttime/snapshots/test_diagnostics.ambr @@ -19,6 +19,7 @@ }), 'disabled_by': None, 'domain': 'watttime', + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/watttime/test_config_flow.py b/tests/components/watttime/test_config_flow.py index dbe1e5444d7..ce9284924f5 100644 --- a/tests/components/watttime/test_config_flow.py +++ b/tests/components/watttime/test_config_flow.py @@ -12,13 +12,13 @@ from homeassistant.components.watttime.config_flow import ( from homeassistant.components.watttime.const import ( CONF_BALANCING_AUTHORITY, CONF_BALANCING_AUTHORITY_ABBREV, - CONF_SHOW_ON_MAP, DOMAIN, ) from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_PASSWORD, + CONF_SHOW_ON_MAP, CONF_USERNAME, ) from homeassistant.core import HomeAssistant diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 3890d6a28d1..b982ab610ec 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -1286,7 +1286,7 @@ async def test_issue_forecast_deprecated_no_logging( assert weather_entity.state == ATTR_CONDITION_SUNNY - assert "Setting up weather.test" in caplog.text + assert "Setting up test.weather" in caplog.text assert ( "custom_components.test_weather.weather::weather.test is using a forecast attribute on an instance of WeatherEntity" not in caplog.text diff --git a/tests/components/weather/test_significant_change.py b/tests/components/weather/test_significant_change.py new file mode 100644 index 00000000000..93e5830a0ac --- /dev/null +++ b/tests/components/weather/test_significant_change.py @@ -0,0 +1,347 @@ +"""Test the Weather significant change platform.""" + +import pytest + +from homeassistant.components.weather.const import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRECIPITATION_UNIT, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY_UNIT, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, +) +from homeassistant.components.weather.significant_change import ( + async_check_significant_change, +) +from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature + + +async def test_significant_state_change() -> None: + """Detect Weather significant state changes.""" + assert not async_check_significant_change( + None, "clear-night", {}, "clear-night", {} + ) + assert async_check_significant_change(None, "clear-night", {}, "cloudy", {}) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # insignificant attributes + ( + {ATTR_WEATHER_PRECIPITATION_UNIT: "a"}, + {ATTR_WEATHER_PRECIPITATION_UNIT: "b"}, + False, + ), + ({ATTR_WEATHER_PRESSURE_UNIT: "a"}, {ATTR_WEATHER_PRESSURE_UNIT: "b"}, False), + ( + {ATTR_WEATHER_TEMPERATURE_UNIT: "a"}, + {ATTR_WEATHER_TEMPERATURE_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_VISIBILITY_UNIT: "a"}, + {ATTR_WEATHER_VISIBILITY_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + {ATTR_WEATHER_WIND_SPEED_UNIT: "b"}, + False, + ), + ( + {ATTR_WEATHER_PRECIPITATION_UNIT: "a", ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + {ATTR_WEATHER_PRECIPITATION_UNIT: "b", ATTR_WEATHER_WIND_SPEED_UNIT: "a"}, + False, + ), + # significant attributes, close to but not significant change + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20.4}, + False, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 68}, + { + ATTR_WEATHER_APPARENT_TEMPERATURE: 68.9, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + False, + ), + ( + {ATTR_WEATHER_DEW_POINT: 20}, + {ATTR_WEATHER_DEW_POINT: 20.4}, + False, + ), + ( + {ATTR_WEATHER_TEMPERATURE: 20}, + {ATTR_WEATHER_TEMPERATURE: 20.4}, + False, + ), + ( + {ATTR_WEATHER_CLOUD_COVERAGE: 80}, + {ATTR_WEATHER_CLOUD_COVERAGE: 80.9}, + False, + ), + ( + {ATTR_WEATHER_HUMIDITY: 90}, + {ATTR_WEATHER_HUMIDITY: 89.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, # W = 270° + {ATTR_WEATHER_WIND_BEARING: 269.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, + {ATTR_WEATHER_WIND_BEARING: "W"}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: 270}, + {ATTR_WEATHER_WIND_BEARING: 269.1}, + False, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.9, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.4, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.9, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + False, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.4, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + False, + ), + ( + {ATTR_WEATHER_UV_INDEX: 1}, + {ATTR_WEATHER_UV_INDEX: 1.09}, + False, + ), + ( + {ATTR_WEATHER_OZONE: 20}, + {ATTR_WEATHER_OZONE: 20.9}, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 1000}, + {ATTR_WEATHER_PRESSURE: 1000.9}, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 750.06}, + { + ATTR_WEATHER_PRESSURE: 750.74, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.MMHG, + }, + False, + ), + ( + {ATTR_WEATHER_PRESSURE: 29.5}, + { + ATTR_WEATHER_PRESSURE: 29.54, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.INHG, + }, + False, + ), + # significant attributes with significant change + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 20.5}, + True, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 68}, + { + ATTR_WEATHER_APPARENT_TEMPERATURE: 69, + ATTR_WEATHER_TEMPERATURE_UNIT: UnitOfTemperature.FAHRENHEIT, + }, + True, + ), + ( + {ATTR_WEATHER_DEW_POINT: 20}, + {ATTR_WEATHER_DEW_POINT: 20.5}, + True, + ), + ( + {ATTR_WEATHER_TEMPERATURE: 20}, + {ATTR_WEATHER_TEMPERATURE: 20.5}, + True, + ), + ( + {ATTR_WEATHER_CLOUD_COVERAGE: 80}, + {ATTR_WEATHER_CLOUD_COVERAGE: 81}, + True, + ), + ( + {ATTR_WEATHER_HUMIDITY: 90}, + {ATTR_WEATHER_HUMIDITY: 89}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, # W = 270° + {ATTR_WEATHER_WIND_BEARING: 269}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "W"}, + {ATTR_WEATHER_WIND_BEARING: "NW"}, # NW = 315° + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: 270}, + {ATTR_WEATHER_WIND_BEARING: 269}, + True, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 6, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_GUST_SPEED: 5}, + { + ATTR_WEATHER_WIND_GUST_SPEED: 5.5, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 6, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.KILOMETERS_PER_HOUR, + }, + True, + ), + ( + {ATTR_WEATHER_WIND_SPEED: 5}, + { + ATTR_WEATHER_WIND_SPEED: 5.5, + ATTR_WEATHER_WIND_SPEED_UNIT: UnitOfSpeed.METERS_PER_SECOND, + }, + True, + ), + ( + {ATTR_WEATHER_UV_INDEX: 1}, + {ATTR_WEATHER_UV_INDEX: 1.1}, + True, + ), + ( + {ATTR_WEATHER_OZONE: 20}, + {ATTR_WEATHER_OZONE: 21}, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 1000}, + {ATTR_WEATHER_PRESSURE: 1001}, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 750}, + { + ATTR_WEATHER_PRESSURE: 749, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.MMHG, + }, + True, + ), + ( + {ATTR_WEATHER_PRESSURE: 29.5}, + { + ATTR_WEATHER_PRESSURE: 29.55, + ATTR_WEATHER_PRESSURE_UNIT: UnitOfPressure.INHG, + }, + True, + ), + ], +) +async def test_significant_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Weather significant attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) + + +@pytest.mark.parametrize( + ("old_attrs", "new_attrs", "expected_result"), + [ + # invalid new values + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: "invalid"}, + False, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: None}, + False, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "NNW"}, + {ATTR_WEATHER_WIND_BEARING: "invalid"}, + False, + ), + # invalid old values + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: "invalid"}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + True, + ), + ( + {ATTR_WEATHER_APPARENT_TEMPERATURE: None}, + {ATTR_WEATHER_APPARENT_TEMPERATURE: 30}, + True, + ), + ( + {ATTR_WEATHER_WIND_BEARING: "invalid"}, + {ATTR_WEATHER_WIND_BEARING: "NNW"}, + True, + ), + ], +) +async def test_invalid_atributes_change( + old_attrs: dict, new_attrs: dict, expected_result: bool +) -> None: + """Detect Weather invalid attribute changes.""" + assert ( + async_check_significant_change(None, "state", old_attrs, "state", new_attrs) + == expected_result + ) diff --git a/tests/components/weatherkit/test_coordinator.py b/tests/components/weatherkit/test_coordinator.py index f619ace237a..7113e1d4d51 100644 --- a/tests/components/weatherkit/test_coordinator.py +++ b/tests/components/weatherkit/test_coordinator.py @@ -23,7 +23,7 @@ async def test_failed_updates(hass: HomeAssistant) -> None: ): async_fire_time_changed( hass, - utcnow() + timedelta(minutes=15), + utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/webostv/test_diagnostics.py b/tests/components/webostv/test_diagnostics.py index f0f411fb278..b7d1646c6b6 100644 --- a/tests/components/webostv/test_diagnostics.py +++ b/tests/components/webostv/test_diagnostics.py @@ -44,6 +44,7 @@ async def test_diagnostics( "entry": { "entry_id": entry.entry_id, "version": 1, + "minor_version": 1, "domain": "webostv", "title": "fake_webos", "data": { diff --git a/tests/components/webostv/test_trigger.py b/tests/components/webostv/test_trigger.py index 9cbf8768dd5..74573e2185b 100644 --- a/tests/components/webostv/test_trigger.py +++ b/tests/components/webostv/test_trigger.py @@ -60,7 +60,7 @@ async def test_webostv_turn_on_trigger_device_id( assert calls[0].data["some"] == device.id assert calls[0].data["id"] == 0 - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) calls.clear() diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index fe2f9f17504..8607a49b42c 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -43,6 +43,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from . import init_integration @@ -337,7 +338,7 @@ async def test_service_calls( mock_instance.set_fanspeed.reset_mock() # FAN_MIDDLE is not supported - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, diff --git a/tests/components/whois/snapshots/test_config_flow.ambr b/tests/components/whois/snapshots/test_config_flow.ambr index 6eec94d42a5..08f3861dcd2 100644 --- a/tests/components/whois/snapshots/test_config_flow.ambr +++ b/tests/components/whois/snapshots/test_config_flow.ambr @@ -12,6 +12,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -21,6 +22,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -48,6 +50,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -57,6 +60,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -84,6 +88,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -93,6 +98,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -120,6 +126,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -129,6 +136,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -156,6 +164,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'whois', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -165,6 +174,7 @@ 'disabled_by': None, 'domain': 'whois', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 57a7046546e..fb0d78365e8 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -9,7 +9,6 @@ import pytest from homeassistant import config_entries from homeassistant.components.workday.const import ( CONF_ADD_HOLIDAYS, - CONF_COUNTRY, CONF_EXCLUDES, CONF_OFFSET, CONF_REMOVE_HOLIDAYS, @@ -19,7 +18,7 @@ from homeassistant.components.workday.const import ( DEFAULT_WORKDAYS, DOMAIN, ) -from homeassistant.const import CONF_LANGUAGE, CONF_NAME +from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.util.dt import UTC diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py index d1920b7dc26..fc7bfeb1b0e 100644 --- a/tests/components/workday/test_repairs.py +++ b/tests/components/workday/test_repairs.py @@ -7,7 +7,7 @@ from homeassistant.components.repairs.websocket_api import ( RepairsFlowIndexView, RepairsFlowResourceView, ) -from homeassistant.components.workday.const import DOMAIN +from homeassistant.components.workday.const import CONF_REMOVE_HOLIDAYS, DOMAIN from homeassistant.const import CONF_COUNTRY from homeassistant.core import HomeAssistant from homeassistant.helpers.issue_registry import async_create_issue @@ -16,6 +16,7 @@ from homeassistant.setup import async_setup_component from . import ( TEST_CONFIG_INCORRECT_COUNTRY, TEST_CONFIG_INCORRECT_PROVINCE, + TEST_CONFIG_REMOVE_NAMED, init_integration, ) @@ -324,6 +325,83 @@ async def test_bad_province_none( assert not issue +async def test_bad_named_holiday( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test fixing bad province selecting none.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await init_integration(hass, TEST_CONFIG_REMOVE_NAMED) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + ws_client = await hass_ws_client(hass) + client = await hass_client() + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == "bad_named_holiday-1-not_a_holiday": + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post( + url, + json={"handler": DOMAIN, "issue_id": "bad_named_holiday-1-not_a_holiday"}, + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"] == { + CONF_COUNTRY: "US", + CONF_REMOVE_HOLIDAYS: "Not a Holiday", + "title": entry.title, + } + assert data["step_id"] == "named_holiday" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post( + url, json={"remove_holidays": ["Christmas", "Not exist 2"]} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["errors"] == { + CONF_REMOVE_HOLIDAYS: "remove_holiday_error", + } + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post( + url, json={"remove_holidays": ["Christmas", "Thanksgiving"]} + ) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.workday_sensor") + assert state + + await ws_client.send_json({"id": 2, "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"] == "bad_named_holiday-1-not_a_holiday": + issue = i + assert not issue + + async def test_other_fixable_issues( hass: HomeAssistant, hass_client: ClientSessionGenerator, diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index 899eda7ec1a..268ebef1d06 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -1,5 +1,6 @@ """Tests for the Wyoming integration.""" import asyncio +from unittest.mock import patch from wyoming.event import Event from wyoming.info import ( @@ -15,6 +16,10 @@ from wyoming.info import ( WakeProgram, ) +from homeassistant.components.wyoming import DOMAIN +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.core import HomeAssistant + TEST_ATTR = Attribution(name="Test", url="http://www.test.com") STT_INFO = Info( asr=[ @@ -124,3 +129,19 @@ class MockAsyncTcpClient: self.host = host self.port = port return self + + +async def reload_satellite( + hass: HomeAssistant, config_entry_id: str +) -> SatelliteDevice: + """Reload config entry with satellite info and returns new device.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), patch( + "homeassistant.components.wyoming.satellite.WyomingSatellite.run" + ) as _run_mock: + # _run_mock: satellite task does not actually run + await hass.config_entries.async_reload(config_entry_id) + + return hass.data[DOMAIN][config_entry_id].satellite.device diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index a30c1048eb6..f22ec7e9e16 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -16,6 +16,12 @@ from . import SATELLITE_INFO, STT_INFO, TTS_INFO, WAKE_WORD_INFO from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + @pytest.fixture(autouse=True) async def init_components(hass: HomeAssistant): """Set up required components.""" diff --git a/tests/components/wyoming/snapshots/test_config_flow.ambr b/tests/components/wyoming/snapshots/test_config_flow.ambr index 99f411027f5..a0e0c7c5011 100644 --- a/tests/components/wyoming/snapshots/test_config_flow.ambr +++ b/tests/components/wyoming/snapshots/test_config_flow.ambr @@ -55,6 +55,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'wyoming', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -65,6 +66,7 @@ 'disabled_by': None, 'domain': 'wyoming', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -97,6 +99,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'wyoming', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -107,6 +110,7 @@ 'disabled_by': None, 'domain': 'wyoming', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, @@ -139,6 +143,7 @@ 'description_placeholders': None, 'flow_id': , 'handler': 'wyoming', + 'minor_version': 1, 'options': dict({ }), 'result': ConfigEntrySnapshot({ @@ -149,6 +154,7 @@ 'disabled_by': None, 'domain': 'wyoming', 'entry_id': , + 'minor_version': 1, 'options': dict({ }), 'pref_disable_new_entities': False, diff --git a/tests/components/wyoming/test_binary_sensor.py b/tests/components/wyoming/test_binary_sensor.py index 27294186a90..fba181a63ca 100644 --- a/tests/components/wyoming/test_binary_sensor.py +++ b/tests/components/wyoming/test_binary_sensor.py @@ -4,6 +4,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from . import reload_satellite + async def test_assist_in_progress( hass: HomeAssistant, @@ -26,7 +28,8 @@ async def test_assist_in_progress( assert state.state == STATE_ON assert satellite_device.is_active - satellite_device.set_is_active(False) + # test restore does *not* happen + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) state = hass.states.get(assist_in_progress_id) assert state is not None diff --git a/tests/components/wyoming/test_number.py b/tests/components/wyoming/test_number.py new file mode 100644 index 00000000000..084021d61a7 --- /dev/null +++ b/tests/components/wyoming/test_number.py @@ -0,0 +1,102 @@ +"""Test Wyoming number.""" +from unittest.mock import patch + +from homeassistant.components.wyoming.devices import SatelliteDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import reload_satellite + + +async def test_auto_gain_number( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test automatic gain control number.""" + agc_entity_id = satellite_device.get_auto_gain_entity_id(hass) + assert agc_entity_id + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 0 + assert satellite_device.auto_gain == 0 + + # Change setting + with patch.object(satellite_device, "set_auto_gain") as mock_agc_changed: + await hass.services.async_call( + "number", + "set_value", + {"entity_id": agc_entity_id, "value": 31}, + blocking=True, + ) + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 31 + + # set function should have been called + mock_agc_changed.assert_called_once_with(31) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(agc_entity_id) + assert state is not None + assert int(state.state) == 31 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": agc_entity_id, "value": 15}, + blocking=True, + ) + + assert satellite_device.auto_gain == 15 + + +async def test_volume_multiplier_number( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test volume multiplier number.""" + vm_entity_id = satellite_device.get_volume_multiplier_entity_id(hass) + assert vm_entity_id + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 1.0 + assert satellite_device.volume_multiplier == 1.0 + + # Change setting + with patch.object(satellite_device, "set_volume_multiplier") as mock_vm_changed: + await hass.services.async_call( + "number", + "set_value", + {"entity_id": vm_entity_id, "value": 2.0}, + blocking=True, + ) + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 2.0 + + # set function should have been called + mock_vm_changed.assert_called_once_with(2.0) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(vm_entity_id) + assert state is not None + assert float(state.state) == 2.0 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": vm_entity_id, "value": 0.5}, + blocking=True, + ) + + assert float(satellite_device.volume_multiplier) == 0.5 diff --git a/tests/components/wyoming/test_select.py b/tests/components/wyoming/test_select.py index cab699336fb..128aab57a1a 100644 --- a/tests/components/wyoming/test_select.py +++ b/tests/components/wyoming/test_select.py @@ -9,6 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from . import reload_satellite + async def test_pipeline_select( hass: HomeAssistant, @@ -61,9 +63,16 @@ async def test_pipeline_select( assert state is not None assert state.state == "Test 1" - # async_pipeline_changed should have been called + # set function should have been called mock_pipeline_changed.assert_called_once_with("Test 1") + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(pipeline_entity_id) + assert state is not None + assert state.state == "Test 1" + # Change back and check update listener pipeline_listener = Mock() satellite_device.set_pipeline_listener(pipeline_listener) @@ -81,3 +90,52 @@ async def test_pipeline_select( # listener should have been called pipeline_listener.assert_called_once() + + +async def test_noise_suppression_level_select( + hass: HomeAssistant, + satellite_config_entry: ConfigEntry, + satellite_device: SatelliteDevice, +) -> None: + """Test noise suppression level select.""" + nsl_entity_id = satellite_device.get_noise_suppression_level_entity_id(hass) + assert nsl_entity_id + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "off" + assert satellite_device.noise_suppression_level == 0 + + # Change setting + with patch.object( + satellite_device, "set_noise_suppression_level" + ) as mock_nsl_changed: + await hass.services.async_call( + "select", + "select_option", + {"entity_id": nsl_entity_id, "option": "max"}, + blocking=True, + ) + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "max" + + # set function should have been called + mock_nsl_changed.assert_called_once_with(4) + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(nsl_entity_id) + assert state is not None + assert state.state == "max" + + await hass.services.async_call( + "select", + "select_option", + {"entity_id": nsl_entity_id, "option": "medium"}, + blocking=True, + ) + + assert satellite_device.noise_suppression_level == 2 diff --git a/tests/components/wyoming/test_switch.py b/tests/components/wyoming/test_switch.py index fc5a8689ceb..6246ba95003 100644 --- a/tests/components/wyoming/test_switch.py +++ b/tests/components/wyoming/test_switch.py @@ -4,6 +4,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from . import reload_satellite + async def test_muted( hass: HomeAssistant, @@ -30,3 +32,10 @@ async def test_muted( assert state is not None assert state.state == STATE_ON assert satellite_device.is_muted + + # test restore + satellite_device = await reload_satellite(hass, satellite_config_entry.entry_id) + + state = hass.states.get(muted_id) + assert state is not None + assert state.state == STATE_ON diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 2f2a25558e4..301074e8ffb 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -16,12 +16,6 @@ from homeassistant.helpers.entity_component import DATA_INSTANCES from . import MockAsyncTcpClient -@pytest.fixture(autouse=True) -def mock_tts_cache_dir_autouse(mock_tts_cache_dir): - """Mock the TTS cache dir with empty dir.""" - return mock_tts_cache_dir - - async def test_support(hass: HomeAssistant, init_wyoming_tts) -> None: """Test supported properties.""" state = hass.states.get("tts.test_tts") diff --git a/tests/components/xiaomi_ble/test_binary_sensor.py b/tests/components/xiaomi_ble/test_binary_sensor.py index 32d1fea7f62..14ea3e44af8 100644 --- a/tests/components/xiaomi_ble/test_binary_sensor.py +++ b/tests/components/xiaomi_ble/test_binary_sensor.py @@ -2,7 +2,6 @@ from datetime import timedelta import time -from unittest.mock import patch from homeassistant.components.bluetooth import ( FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, @@ -23,6 +22,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info_bleak, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -294,9 +294,8 @@ async def test_unavailable(hass: HomeAssistant) -> None: # 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, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -347,9 +346,8 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: # 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, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -400,9 +398,8 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: # 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, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index b0ddd99a7c2..ceca08a68ee 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -1,7 +1,6 @@ """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, @@ -28,6 +27,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info_bleak, patch_all_discovered_devices, + patch_bluetooth_time, ) @@ -692,9 +692,8 @@ async def test_unavailable(hass: HomeAssistant) -> None: # 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, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -739,9 +738,8 @@ async def test_sleepy_device(hass: HomeAssistant) -> None: # 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, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, @@ -788,9 +786,8 @@ async def test_sleepy_device_restore_state(hass: HomeAssistant) -> None: # 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, + with patch_bluetooth_time( + monotonic_now, ), patch_all_discovered_devices([]): async_fire_time_changed( hass, diff --git a/tests/components/xiaomi_miio/test_button.py b/tests/components/xiaomi_miio/test_button.py index 995c5ae034c..552b302aafe 100644 --- a/tests/components/xiaomi_miio/test_button.py +++ b/tests/components/xiaomi_miio/test_button.py @@ -5,15 +5,15 @@ import pytest from homeassistant.components.button import DOMAIN, SERVICE_PRESS from homeassistant.components.xiaomi_miio.const import ( - CONF_DEVICE, CONF_FLOW_TYPE, - CONF_MAC, DOMAIN as XIAOMI_DOMAIN, MODELS_VACUUM, ) from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_DEVICE, CONF_HOST, + CONF_MAC, CONF_MODEL, CONF_TOKEN, Platform, diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index a436908b44f..b36924764fe 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -10,7 +10,7 @@ import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf from homeassistant.components.xiaomi_miio import const -from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TOKEN +from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import HomeAssistant from . import TEST_MAC @@ -172,7 +172,7 @@ async def test_config_flow_gateway_success(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -205,7 +205,7 @@ async def test_config_flow_gateway_cloud_success(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -251,7 +251,7 @@ async def test_config_flow_gateway_cloud_multiple_success(hass: HomeAssistant) - CONF_HOST: TEST_HOST2, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC2, + CONF_MAC: TEST_MAC2, } @@ -460,7 +460,7 @@ async def test_zeroconf_gateway_success(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -685,14 +685,14 @@ async def test_config_flow_step_device_manual_model_succes(hass: HomeAssistant) assert result["type"] == "create_entry" assert result["title"] == overwrite_model assert result["data"] == { - const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_FLOW_TYPE: CONF_DEVICE, const.CONF_CLOUD_USERNAME: None, const.CONF_CLOUD_PASSWORD: None, const.CONF_CLOUD_COUNTRY: None, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: overwrite_model, - const.CONF_MAC: None, + CONF_MAC: None, } @@ -729,14 +729,14 @@ async def config_flow_device_success(hass, model_to_test): assert result["type"] == "create_entry" assert result["title"] == model_to_test assert result["data"] == { - const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_FLOW_TYPE: CONF_DEVICE, const.CONF_CLOUD_USERNAME: None, const.CONF_CLOUD_PASSWORD: None, const.CONF_CLOUD_COUNTRY: None, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: model_to_test, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -775,14 +775,14 @@ async def config_flow_generic_roborock(hass): assert result["type"] == "create_entry" assert result["title"] == dummy_model assert result["data"] == { - const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_FLOW_TYPE: CONF_DEVICE, const.CONF_CLOUD_USERNAME: None, const.CONF_CLOUD_PASSWORD: None, const.CONF_CLOUD_COUNTRY: None, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: dummy_model, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -829,14 +829,14 @@ async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test): assert result["type"] == "create_entry" assert result["title"] == model_to_test assert result["data"] == { - const.CONF_FLOW_TYPE: const.CONF_DEVICE, + const.CONF_FLOW_TYPE: CONF_DEVICE, const.CONF_CLOUD_USERNAME: None, const.CONF_CLOUD_PASSWORD: None, const.CONF_CLOUD_COUNTRY: None, CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: model_to_test, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } @@ -879,7 +879,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, }, title=TEST_NAME, ) @@ -919,7 +919,7 @@ async def test_options_flow_incomplete(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, }, title=TEST_NAME, ) @@ -957,7 +957,7 @@ async def test_reauth(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, }, title=TEST_NAME, ) @@ -1005,5 +1005,5 @@ async def test_reauth(hass: HomeAssistant) -> None: CONF_HOST: TEST_HOST, CONF_TOKEN: TEST_TOKEN, CONF_MODEL: TEST_MODEL, - const.CONF_MAC: TEST_MAC, + CONF_MAC: TEST_MAC, } diff --git a/tests/components/xiaomi_miio/test_select.py b/tests/components/xiaomi_miio/test_select.py index 48b8216bffc..794fbb090e0 100644 --- a/tests/components/xiaomi_miio/test_select.py +++ b/tests/components/xiaomi_miio/test_select.py @@ -17,20 +17,21 @@ from homeassistant.components.select import ( ) from homeassistant.components.xiaomi_miio import UPDATE_INTERVAL from homeassistant.components.xiaomi_miio.const import ( - CONF_DEVICE, CONF_FLOW_TYPE, - CONF_MAC, DOMAIN as XIAOMI_DOMAIN, MODEL_AIRFRESH_T2017, ) from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_DEVICE, CONF_HOST, + CONF_MAC, CONF_MODEL, CONF_TOKEN, Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from . import TEST_MAC @@ -77,7 +78,7 @@ async def test_select_bad_attr(hass: HomeAssistant) -> None: assert state assert state.state == "forward" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( "select", SERVICE_SELECT_OPTION, diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py index 422a52b44ac..9e823035dd9 100644 --- a/tests/components/xiaomi_miio/test_vacuum.py +++ b/tests/components/xiaomi_miio/test_vacuum.py @@ -23,15 +23,14 @@ from homeassistant.components.vacuum import ( STATE_ERROR, ) from homeassistant.components.xiaomi_miio.const import ( - CONF_DEVICE, CONF_FLOW_TYPE, - CONF_MAC, DOMAIN as XIAOMI_DOMAIN, MODELS_VACUUM, ) from homeassistant.components.xiaomi_miio.vacuum import ( ATTR_ERROR, ATTR_TIMERS, + CONF_DEVICE, SERVICE_CLEAN_SEGMENT, SERVICE_CLEAN_ZONE, SERVICE_GOTO, @@ -44,6 +43,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_HOST, + CONF_MAC, CONF_MODEL, CONF_TOKEN, STATE_UNAVAILABLE, diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py index af393339eba..6fc3259a4c0 100644 --- a/tests/components/yamaha/test_media_player.py +++ b/tests/components/yamaha/test_media_player.py @@ -16,6 +16,7 @@ CONFIG = {"media_player": {"platform": "yamaha", "host": "127.0.0.1"}} def _create_zone_mock(name, url): zone = MagicMock() zone.ctrl_url = url + zone.surround_programs = [] zone.zone = name return zone diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 54406bb1b4d..c94b2d66465 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -34,7 +34,7 @@ HOMEKIT_STATUS_UNPAIRED = b"1" HOMEKIT_STATUS_PAIRED = b"0" -def service_update_mock(ipv6, zeroconf, services, handlers, *, limit_service=None): +def service_update_mock(zeroconf, services, handlers, *, limit_service=None): """Call service update handler.""" for service in services: if limit_service is not None and service != limit_service: @@ -165,7 +165,7 @@ async def test_setup(hass: HomeAssistant, mock_async_zeroconf: None) -> None: ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_service_info_mock, @@ -196,7 +196,7 @@ async def test_setup_with_overly_long_url_and_name( ) -> None: """Test we still setup with long urls and names.""" with patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.get_url", return_value=( @@ -235,7 +235,7 @@ async def test_setup_with_defaults( ) -> None: """Test default interface config.""" with patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_service_info_mock, @@ -254,7 +254,7 @@ async def test_zeroconf_match_macaddress( ) -> None: """Test configured options for a device are loaded via config entry.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -278,7 +278,7 @@ async def test_zeroconf_match_macaddress( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), @@ -298,7 +298,7 @@ async def test_zeroconf_match_manufacturer( ) -> None: """Test configured options for a device are loaded via config entry.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -318,7 +318,7 @@ async def test_zeroconf_match_manufacturer( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_manufacturer("Samsung Electronics"), @@ -337,7 +337,7 @@ async def test_zeroconf_match_model( ) -> None: """Test matching a specific model in zeroconf.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -357,7 +357,7 @@ async def test_zeroconf_match_model( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_model("appletv"), @@ -376,7 +376,7 @@ async def test_zeroconf_match_manufacturer_not_present( ) -> None: """Test matchers reject when a property is missing.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -396,7 +396,7 @@ async def test_zeroconf_match_manufacturer_not_present( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("aabbccddeeff"), @@ -414,7 +414,7 @@ async def test_zeroconf_no_match( ) -> None: """Test configured options for a device are loaded via config entry.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -430,7 +430,7 @@ async def test_zeroconf_no_match( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), @@ -448,7 +448,7 @@ async def test_zeroconf_no_match_manufacturer( ) -> None: """Test configured options for a device are loaded via config entry.""" - def http_only_service_update_mock(ipv6, zeroconf, services, handlers): + def http_only_service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -468,7 +468,7 @@ async def test_zeroconf_no_match_manufacturer( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=http_only_service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=http_only_service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock_manufacturer("Not Samsung Electronics"), @@ -497,7 +497,7 @@ async def test_homekit_match_partial_space( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -535,7 +535,7 @@ async def test_device_with_invalid_name( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -568,7 +568,7 @@ async def test_homekit_match_partial_dash( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), @@ -601,7 +601,7 @@ async def test_homekit_match_partial_fnmatch( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -634,7 +634,7 @@ async def test_homekit_match_full( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), @@ -670,7 +670,7 @@ async def test_homekit_already_paired( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -704,7 +704,7 @@ async def test_homekit_invalid_paring_status( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._tcp.local." ), @@ -732,7 +732,7 @@ async def test_homekit_not_paired( ), patch.object( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_homekit_info_mock( @@ -770,7 +770,7 @@ async def test_homekit_controller_still_discovered_unpaired_for_cloud( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), @@ -810,7 +810,7 @@ async def test_homekit_controller_still_discovered_unpaired_for_polling( hass.config_entries.flow, "async_init" ) as mock_config_flow, patch.object( zeroconf, - "HaAsyncServiceBrowser", + "AsyncServiceBrowser", side_effect=lambda *args, **kwargs: service_update_mock( *args, **kwargs, limit_service="_hap._udp.local." ), @@ -940,7 +940,7 @@ async def test_get_instance(hass: HomeAssistant, mock_async_zeroconf: None) -> N async def test_removed_ignored(hass: HomeAssistant, mock_async_zeroconf: None) -> None: """Test we remove it when a zeroconf entry is removed.""" - def service_update_mock(ipv6, zeroconf, services, handlers): + def service_update_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -962,7 +962,7 @@ async def test_removed_ignored(hass: HomeAssistant, mock_async_zeroconf: None) - ) with patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_service_info_mock, @@ -995,7 +995,7 @@ async def test_async_detect_interfaces_setting_non_loopback_route( with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, patch.object( hass.config_entries.flow, "async_init" ), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED, @@ -1079,7 +1079,7 @@ async def test_async_detect_interfaces_setting_empty_route_linux( with patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch( "homeassistant.components.zeroconf.HaZeroconf" ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, @@ -1109,7 +1109,7 @@ async def test_async_detect_interfaces_setting_empty_route_freebsd( with patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch( "homeassistant.components.zeroconf.HaZeroconf" ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, @@ -1156,7 +1156,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_linux( with patch("homeassistant.components.zeroconf.sys.platform", "linux"), patch( "homeassistant.components.zeroconf.HaZeroconf" ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, @@ -1181,7 +1181,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( with patch("homeassistant.components.zeroconf.sys.platform", "freebsd"), patch( "homeassistant.components.zeroconf.HaZeroconf" ) as mock_zc, patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch( "homeassistant.components.zeroconf.network.async_get_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, @@ -1217,7 +1217,7 @@ async def test_setup_with_disallowed_characters_in_local_name( ) -> None: """Test we still setup with disallowed characters in the location name.""" with patch.object(hass.config_entries.flow, "async_init"), patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=service_update_mock + zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock ), patch.object( hass.config, "location_name", @@ -1248,7 +1248,7 @@ async def test_start_with_frontend( async def test_zeroconf_removed(hass: HomeAssistant, mock_async_zeroconf: None) -> None: """Test we dismiss flows when a PTR record is removed.""" - def _device_removed_mock(ipv6, zeroconf, services, handlers): + def _device_removed_mock(zeroconf, services, handlers): """Call service update handler.""" handlers[0]( zeroconf, @@ -1275,7 +1275,7 @@ async def test_zeroconf_removed(hass: HomeAssistant, mock_async_zeroconf: None) ) as mock_async_progress_by_init_data_type, patch.object( hass.config_entries.flow, "async_abort" ) as mock_async_abort, patch.object( - zeroconf, "HaAsyncServiceBrowser", side_effect=_device_removed_mock + zeroconf, "AsyncServiceBrowser", side_effect=_device_removed_mock ) as mock_service_browser, patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_zeroconf_info_mock("FFAADDCC11DD"), diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index 145aba799ca..b693c034199 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -52,7 +52,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .common import async_enable_traffic, find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE @@ -860,12 +860,13 @@ async def test_preset_setting_invalid( state = hass.states.get(entity_id) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "invalid_preset"}, - blocking=True, - ) + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "invalid_preset"}, + blocking=True, + ) state = hass.states.get(entity_id) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE @@ -1251,13 +1252,14 @@ async def test_set_fan_mode_not_supported( entity_id = find_entity_id(Platform.CLIMATE, device_climate_fan, hass) fan_cluster = device_climate_fan.device.endpoints[1].fan - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_LOW}, - blocking=True, - ) - assert fan_cluster.write_attributes.await_count == 0 + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_LOW}, + blocking=True, + ) + assert fan_cluster.write_attributes.await_count == 0 async def test_set_fan_mode(hass: HomeAssistant, device_climate_fan) -> None: diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index 24162296cd5..39f201e668e 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Callable import logging import math +from types import NoneType from unittest import mock from unittest.mock import AsyncMock, patch @@ -11,12 +12,17 @@ import zigpy.device import zigpy.endpoint from zigpy.endpoint import Endpoint as ZigpyEndpoint import zigpy.profiles.zha +import zigpy.quirks as zigpy_quirks import zigpy.types as t from zigpy.zcl import foundation import zigpy.zcl.clusters +from zigpy.zcl.clusters import CLUSTERS_BY_ID import zigpy.zdo.types as zdo_t import homeassistant.components.zha.core.cluster_handlers as cluster_handlers +from homeassistant.components.zha.core.cluster_handlers.lighting import ( + ColorClusterHandler, +) import homeassistant.components.zha.core.const as zha_const from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.endpoint import Endpoint @@ -97,7 +103,9 @@ def poll_control_ch(endpoint, zigpy_device_mock): ) cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id] - cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get(cluster_id) + cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( + cluster_id + ).get(None) return cluster_handler_class(cluster, endpoint) @@ -258,8 +266,8 @@ async def test_in_cluster_handler_config( cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id] cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( - cluster_id, cluster_handlers.ClusterHandler - ) + cluster_id, {None, cluster_handlers.ClusterHandler} + ).get(None) cluster_handler = cluster_handler_class(cluster, endpoint) await cluster_handler.async_configure() @@ -322,8 +330,8 @@ async def test_out_cluster_handler_config( cluster = zigpy_dev.endpoints[1].out_clusters[cluster_id] cluster.bind_only = True cluster_handler_class = registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.get( - cluster_id, cluster_handlers.ClusterHandler - ) + cluster_id, {None: cluster_handlers.ClusterHandler} + ).get(None) cluster_handler = cluster_handler_class(cluster, endpoint) await cluster_handler.async_configure() @@ -334,13 +342,46 @@ async def test_out_cluster_handler_config( def test_cluster_handler_registry() -> None: """Test ZIGBEE cluster handler Registry.""" + + # get all quirk ID from zigpy quirks registry + all_quirk_ids = {} + for cluster_id in CLUSTERS_BY_ID: + all_quirk_ids[cluster_id] = {None} + for manufacturer in zigpy_quirks._DEVICE_REGISTRY.registry.values(): + for model_quirk_list in manufacturer.values(): + for quirk in model_quirk_list: + quirk_id = getattr(quirk, zha_const.ATTR_QUIRK_ID, None) + device_description = getattr(quirk, "replacement", None) or getattr( + quirk, "signature", None + ) + + for endpoint in device_description["endpoints"].values(): + cluster_ids = set() + if "input_clusters" in endpoint: + cluster_ids.update(endpoint["input_clusters"]) + if "output_clusters" in endpoint: + cluster_ids.update(endpoint["output_clusters"]) + for cluster_id in cluster_ids: + if not isinstance(cluster_id, int): + cluster_id = cluster_id.cluster_id + if cluster_id not in all_quirk_ids: + all_quirk_ids[cluster_id] = {None} + all_quirk_ids[cluster_id].add(quirk_id) + + del quirk, model_quirk_list, manufacturer + for ( cluster_id, - cluster_handler, + cluster_handler_classes, ) in registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY.items(): assert isinstance(cluster_id, int) assert 0 <= cluster_id <= 0xFFFF - assert issubclass(cluster_handler, cluster_handlers.ClusterHandler) + assert cluster_id in all_quirk_ids + assert isinstance(cluster_handler_classes, dict) + for quirk_id, cluster_handler in cluster_handler_classes.items(): + assert isinstance(quirk_id, NoneType) or isinstance(quirk_id, str) + assert issubclass(cluster_handler, cluster_handlers.ClusterHandler) + assert quirk_id in all_quirk_ids[cluster_id] def test_epch_unclaimed_cluster_handlers(cluster_handler) -> None: @@ -731,7 +772,7 @@ async def test_zll_device_groups( mock.MagicMock(), ) async def test_cluster_no_ep_attribute( - zha_device_mock: Callable[..., ZHADevice] + zha_device_mock: Callable[..., ZHADevice], ) -> None: """Test cluster handlers for clusters without ep_attribute.""" @@ -818,7 +859,8 @@ async def test_invalid_cluster_handler(hass: HomeAssistant, caplog) -> None: ], ) - mock_zha_device = mock.AsyncMock(spec_set=ZHADevice) + mock_zha_device = mock.AsyncMock(spec=ZHADevice) + mock_zha_device.quirk_id = None zha_endpoint = Endpoint(zigpy_ep, mock_zha_device) # The cluster handler throws an error when matching this cluster @@ -827,14 +869,84 @@ async def test_invalid_cluster_handler(hass: HomeAssistant, caplog) -> None: # And one is also logged at runtime with patch.dict( - registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY, - {cluster.cluster_id: TestZigbeeClusterHandler}, + registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY[cluster.cluster_id], + {None: TestZigbeeClusterHandler}, ), caplog.at_level(logging.WARNING): zha_endpoint.add_all_cluster_handlers() assert "missing_attr" in caplog.text +async def test_standard_cluster_handler(hass: HomeAssistant, caplog) -> None: + """Test setting up a cluster handler that matches a standard cluster.""" + + class TestZigbeeClusterHandler(ColorClusterHandler): + pass + + mock_device = mock.AsyncMock(spec_set=zigpy.device.Device) + zigpy_ep = zigpy.endpoint.Endpoint(mock_device, endpoint_id=1) + + cluster = zigpy_ep.add_input_cluster(zigpy.zcl.clusters.lighting.Color.cluster_id) + cluster.configure_reporting_multiple = AsyncMock( + spec_set=cluster.configure_reporting_multiple, + return_value=[ + foundation.ConfigureReportingResponseRecord( + status=foundation.Status.SUCCESS + ) + ], + ) + + mock_zha_device = mock.AsyncMock(spec=ZHADevice) + mock_zha_device.quirk_id = None + zha_endpoint = Endpoint(zigpy_ep, mock_zha_device) + + with patch.dict( + registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY[cluster.cluster_id], + {"__test_quirk_id": TestZigbeeClusterHandler}, + ): + zha_endpoint.add_all_cluster_handlers() + + assert len(zha_endpoint.all_cluster_handlers) == 1 + assert isinstance( + list(zha_endpoint.all_cluster_handlers.values())[0], ColorClusterHandler + ) + + +async def test_quirk_id_cluster_handler(hass: HomeAssistant, caplog) -> None: + """Test setting up a cluster handler that matches a standard cluster.""" + + class TestZigbeeClusterHandler(ColorClusterHandler): + pass + + mock_device = mock.AsyncMock(spec_set=zigpy.device.Device) + zigpy_ep = zigpy.endpoint.Endpoint(mock_device, endpoint_id=1) + + cluster = zigpy_ep.add_input_cluster(zigpy.zcl.clusters.lighting.Color.cluster_id) + cluster.configure_reporting_multiple = AsyncMock( + spec_set=cluster.configure_reporting_multiple, + return_value=[ + foundation.ConfigureReportingResponseRecord( + status=foundation.Status.SUCCESS + ) + ], + ) + + mock_zha_device = mock.AsyncMock(spec=ZHADevice) + mock_zha_device.quirk_id = "__test_quirk_id" + zha_endpoint = Endpoint(zigpy_ep, mock_zha_device) + + with patch.dict( + registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY[cluster.cluster_id], + {"__test_quirk_id": TestZigbeeClusterHandler}, + ): + zha_endpoint.add_all_cluster_handlers() + + assert len(zha_endpoint.all_cluster_handlers) == 1 + assert isinstance( + list(zha_endpoint.all_cluster_handlers.values())[0], TestZigbeeClusterHandler + ) + + # parametrize side effects: @pytest.mark.parametrize( ("side_effect", "expected_error"), diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 1d9042daa4a..4f520920704 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -1,8 +1,9 @@ """Test ZHA Gateway.""" import asyncio -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest +from zigpy.application import ControllerApplication import zigpy.profiles.zha as zha import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.lighting as lighting @@ -222,6 +223,48 @@ async def test_gateway_create_group_with_id( assert zha_group.group_id == 0x1234 +@patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_devices", + MagicMock(), +) +@patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_load_groups", + MagicMock(), +) +@pytest.mark.parametrize( + ("device_path", "thread_state", "config_override"), + [ + ("/dev/ttyUSB0", True, {}), + ("socket://192.168.1.123:9999", False, {}), + ("socket://192.168.1.123:9999", True, {"use_thread": True}), + ], +) +async def test_gateway_initialize_bellows_thread( + device_path: str, + thread_state: bool, + config_override: dict, + hass: HomeAssistant, + zigpy_app_controller: ControllerApplication, + config_entry: MockConfigEntry, +) -> None: + """Test ZHA disabling the UART thread when connecting to a TCP coordinator.""" + config_entry.data = dict(config_entry.data) + config_entry.data["device"]["path"] = device_path + config_entry.add_to_hass(hass) + + zha_gateway = ZHAGateway(hass, {"zigpy_config": config_override}, config_entry) + + with patch( + "bellows.zigbee.application.ControllerApplication.new", + return_value=zigpy_app_controller, + ) as mock_new: + await zha_gateway.async_initialize() + + mock_new.mock_calls[-1].kwargs["config"]["use_thread"] is thread_state + + await zha_gateway.shutdown() + + @pytest.mark.parametrize( ("device_path", "config_override", "expected_channel"), [ diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 68ff116adea..80845cf9866 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -585,18 +585,18 @@ def test_quirk_classes() -> None: def test_entity_names() -> None: """Make sure that all handlers expose entities with valid names.""" - for _, entities in iter_all_rules(): - for entity in entities: - if hasattr(entity, "_attr_name"): + for _, entity_classes in iter_all_rules(): + for entity_class in entity_classes: + if hasattr(entity_class, "__attr_name"): # The entity has a name - assert isinstance(entity._attr_name, str) and entity._attr_name - elif hasattr(entity, "_attr_translation_key"): + assert (name := entity_class.__attr_name) and isinstance(name, str) + elif hasattr(entity_class, "__attr_translation_key"): assert ( - isinstance(entity._attr_translation_key, str) - and entity._attr_translation_key + isinstance(entity_class.__attr_translation_key, str) + and entity_class.__attr_translation_key ) - elif hasattr(entity, "_attr_device_class"): - assert entity._attr_device_class + elif hasattr(entity_class, "__attr_device_class"): + assert entity_class.__attr_device_class else: # The only exception (for now) is IASZone - assert entity is IASZone + assert entity_class is IASZone diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 7c11077c55d..d9a61b12357 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -1,4 +1,5 @@ """Test ZHA sensor.""" +from datetime import timedelta import math from unittest.mock import MagicMock, patch @@ -47,7 +48,10 @@ from .common import ( ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE -from tests.common import async_mock_load_restore_state_from_storage +from tests.common import ( + async_fire_time_changed, + async_mock_load_restore_state_from_storage, +) ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_{}" @@ -260,11 +264,12 @@ async def async_test_powerconfiguration2(hass, cluster, entity_id): """Test powerconfiguration/battery sensor.""" await send_attributes_report(hass, cluster, {33: -1}) assert_state(hass, entity_id, STATE_UNKNOWN, "%") - assert hass.states.get(entity_id).attributes["battery_voltage"] == 2.9 - assert hass.states.get(entity_id).attributes["battery_quantity"] == 3 - assert hass.states.get(entity_id).attributes["battery_size"] == "AAA" - await send_attributes_report(hass, cluster, {32: 20}) - assert hass.states.get(entity_id).attributes["battery_voltage"] == 2.0 + + await send_attributes_report(hass, cluster, {33: 255}) + assert_state(hass, entity_id, STATE_UNKNOWN, "%") + + await send_attributes_report(hass, cluster, {33: 98}) + assert_state(hass, entity_id, "49", "%") async def async_test_device_temperature(hass, cluster, entity_id): @@ -920,6 +925,44 @@ async def test_elec_measurement_sensor_type( assert state.attributes["measurement_type"] == expected_type +async def test_elec_measurement_sensor_polling( + hass: HomeAssistant, + elec_measurement_zigpy_dev, + zha_device_joined_restored, +) -> None: + """Test ZHA electrical measurement sensor polling.""" + + entity_id = ENTITY_ID_PREFIX.format("power") + zigpy_dev = elec_measurement_zigpy_dev + zigpy_dev.endpoints[1].electrical_measurement.PLUGGED_ATTR_READS[ + "active_power" + ] = 20 + + await zha_device_joined_restored(zigpy_dev) + + # test that the sensor has an initial state of 2.0 + state = hass.states.get(entity_id) + assert state.state == "2.0" + + # update the value for the power reading + zigpy_dev.endpoints[1].electrical_measurement.PLUGGED_ATTR_READS[ + "active_power" + ] = 60 + + # ensure the state is still 2.0 + state = hass.states.get(entity_id) + assert state.state == "2.0" + + # let the polling happen + future = dt_util.utcnow() + timedelta(seconds=90) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + # ensure the state has been updated to 6.0 + state = hass.states.get(entity_id) + assert state.state == "6.0" + + @pytest.mark.parametrize( "supported_attributes", ( diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index b07b34763d1..0db9b7dd18e 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -197,6 +197,17 @@ async def test_switch( tsn=None, ) + await async_setup_component(hass, "homeassistant", {}) + + cluster.read_attributes.reset_mock() + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert len(cluster.read_attributes.mock_calls) == 1 + assert cluster.read_attributes.call_args == call( + ["on_off"], allow_cache=False, only_cache=False, manufacturer=None + ) + # test joining a new switch to the network and HA await async_test_rejoin(hass, zigpy_device, [cluster], (1,)) diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index d5619ff014c..e4550b7f961 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -40,6 +40,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from .common import ( @@ -278,7 +279,7 @@ async def test_thermostat_v2( client.async_send_command.reset_mock() # Test setting invalid fan mode - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, @@ -692,7 +693,7 @@ async def test_preset_and_no_setpoint( assert state.attributes[ATTR_TEMPERATURE] is None assert state.attributes[ATTR_PRESET_MODE] == "Full power" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): # Test setting invalid preset mode await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index bf015a70676..75a7397cc4e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1650,6 +1650,7 @@ async def test_factory_reset_node( hass: HomeAssistant, client, multisensor_6, multisensor_6_state, integration ) -> None: """Test when a node is removed because it was reset.""" + dev_reg = dr.async_get(hass) # One config entry scenario remove_event = Event( type="node removed", @@ -1670,15 +1671,25 @@ async def test_factory_reset_node( assert notifications[msg_id]["message"].startswith("`Multisensor 6`") assert "with the home ID" not in notifications[msg_id]["message"] async_dismiss(hass, msg_id) + await hass.async_block_till_done() + assert not dev_reg.async_get_device(identifiers={dev_id}) # Add mock config entry to simulate having multiple entries new_entry = MockConfigEntry(domain=DOMAIN) new_entry.add_to_hass(hass) # Re-add the node then remove it again - client.driver.controller.nodes[multisensor_6_state["nodeId"]] = Node( - client, deepcopy(multisensor_6_state) + add_event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(multisensor_6_state), + "result": {}, + }, ) + client.driver.controller.receive_event(add_event) + await hass.async_block_till_done() remove_event.data["node"] = deepcopy(multisensor_6_state) client.driver.controller.receive_event(remove_event) # Test case where config entry title and home ID don't match @@ -1686,16 +1697,24 @@ async def test_factory_reset_node( assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert ( - "network `Mock Title`, with the home ID `3245146787`." + "network `Mock Title`, with the home ID `3245146787`" in notifications[msg_id]["message"] ) async_dismiss(hass, msg_id) # Test case where config entry title and home ID do match hass.config_entries.async_update_entry(integration, title="3245146787") - client.driver.controller.nodes[multisensor_6_state["nodeId"]] = Node( - client, deepcopy(multisensor_6_state) + add_event = Event( + type="node added", + data={ + "source": "controller", + "event": "node added", + "node": deepcopy(multisensor_6_state), + "result": {}, + }, ) + client.driver.controller.receive_event(add_event) + await hass.async_block_till_done() remove_event.data["node"] = deepcopy(multisensor_6_state) client.driver.controller.receive_event(remove_event) notifications = async_get_persistent_notifications(hass) diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 25553489b4e..26b9459cfc2 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -272,7 +272,7 @@ async def test_zwave_js_value_updated( clear_events() - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) @@ -834,7 +834,7 @@ async def test_zwave_js_event( clear_events() - with patch("homeassistant.config.load_yaml", return_value={}): + with patch("homeassistant.config.load_yaml_dict", return_value={}): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) diff --git a/tests/conftest.py b/tests/conftest.py index 4050c1cdb6a..ea4ddd23d28 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,7 +40,6 @@ from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.models import Credentials from homeassistant.auth.providers import homeassistant, legacy_api_password from homeassistant.components.device_tracker.legacy import Device -from homeassistant.components.network.models import Adapter, IPv4ConfiguredAddress from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_OK, @@ -383,7 +382,7 @@ def reset_hass_threading_local_object() -> Generator[None, None, None]: ha._hass.__dict__.clear() -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def bcrypt_cost() -> Generator[None, None, None]: """Run with reduced rounds during tests, to speed up uses.""" import bcrypt @@ -488,6 +487,8 @@ def aiohttp_client( if isinstance(__param, Application): server_kwargs = server_kwargs or {} server = TestServer(__param, loop=loop, **server_kwargs) + # Registering a view after starting the server should still work. + server.app._router.freeze = lambda: None client = CoalescingClient(server, loop=loop, **kwargs) elif isinstance(__param, BaseTestServer): client = TestClient(__param, loop=loop, **kwargs) @@ -972,7 +973,7 @@ async def _mqtt_mock_entry( mock_mqtt_instance = None async def _setup_mqtt_entry( - setup_entry: Callable[[HomeAssistant, ConfigEntry], Coroutine[Any, Any, bool]] + setup_entry: Callable[[HomeAssistant, ConfigEntry], Coroutine[Any, Any, bool]], ) -> MagicMock: """Set up the MQTT config entry.""" assert await setup_entry(hass, entry) @@ -1096,21 +1097,18 @@ async def mqtt_mock_entry( yield _setup_mqtt_entry -@pytest.fixture(autouse=True) +@pytest.fixture(autouse=True, scope="session") def mock_network() -> Generator[None, None, None]: """Mock network.""" - mock_adapter = Adapter( - name="eth0", - index=0, - enabled=True, - auto=True, - default=True, - ipv4=[IPv4ConfiguredAddress(address="10.10.10.10", network_prefix=24)], - ipv6=[], - ) with patch( - "homeassistant.components.network.network.async_load_adapters", - return_value=[mock_adapter], + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[ + Mock( + nice_name="eth0", + ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)], + index=0, + ) + ], ): yield @@ -1133,7 +1131,7 @@ def mock_zeroconf() -> Generator[None, None, None]: with patch( "homeassistant.components.zeroconf.HaZeroconf", autospec=True ) as mock_zc, patch( - "homeassistant.components.zeroconf.HaAsyncServiceBrowser", autospec=True + "homeassistant.components.zeroconf.AsyncServiceBrowser", autospec=True ): zc = mock_zc.return_value # DNSCache has strong Cython type checks, and MagicMock does not work @@ -1544,10 +1542,10 @@ async def mock_enable_bluetooth( await hass.async_block_till_done() -@pytest.fixture +@pytest.fixture(scope="session") def mock_bluetooth_adapters() -> Generator[None, None, None]: """Fixture to mock bluetooth adapters.""" - with patch( + with patch("bluetooth_auto_recovery.recover_adapter"), patch( "bluetooth_adapters.systems.platform.system", return_value="Linux" ), patch("bluetooth_adapters.systems.linux.LinuxAdapters.refresh"), patch( "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", @@ -1574,14 +1572,14 @@ def mock_bleak_scanner_start() -> Generator[MagicMock, None, None]: # Late imports to avoid loading bleak unless we need it # pylint: disable-next=import-outside-toplevel - from homeassistant.components.bluetooth import scanner as bluetooth_scanner + from habluetooth import scanner as bluetooth_scanner # We need to drop the stop method from the object since we patched # out start and this fixture will expire before the stop method is called # when EVENT_HOMEASSISTANT_STOP is fired. bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() # type: ignore[assignment] with patch( - "homeassistant.components.bluetooth.scanner.OriginalBleakScanner.start", + "habluetooth.scanner.OriginalBleakScanner.start", ) as mock_bleak_scanner_start: yield mock_bleak_scanner_start diff --git a/tests/fixtures/core/config/component_validation/basic/configuration.yaml b/tests/fixtures/core/config/component_validation/basic/configuration.yaml index 9c3d1eb190b..49db89f45ba 100644 --- a/tests/fixtures/core/config/component_validation/basic/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic/configuration.yaml @@ -56,3 +56,8 @@ custom_validator_bad_1: # This always raises ValueError custom_validator_bad_2: + +# Invalid domains +"iot_domain ": +"": +5: diff --git a/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml index 5744e3005fa..8e1c75c3511 100644 --- a/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/basic_include/configuration.yaml @@ -8,3 +8,6 @@ custom_validator_ok_1: !include integrations/custom_validator_ok_1.yaml custom_validator_ok_2: !include integrations/custom_validator_ok_2.yaml custom_validator_bad_1: !include integrations/custom_validator_bad_1.yaml custom_validator_bad_2: !include integrations/custom_validator_bad_2.yaml +"iot_domain ": !include integrations/iot_domain .yaml +"": !include integrations/.yaml +5: !include integrations/5.yaml diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/5.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain .yaml b/tests/fixtures/core/config/component_validation/basic_include/integrations/iot_domain .yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/5.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/iot_domain .yaml b/tests/fixtures/core/config/component_validation/include_dir_list/invalid_domains/iot_domain .yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/core/config/component_validation/packages/configuration.yaml b/tests/fixtures/core/config/component_validation/packages/configuration.yaml index b8116b5988e..25d734b126a 100644 --- a/tests/fixtures/core/config/component_validation/packages/configuration.yaml +++ b/tests/fixtures/core/config/component_validation/packages/configuration.yaml @@ -68,3 +68,10 @@ homeassistant: pack_custom_validator_bad_2: # This always raises ValueError custom_validator_bad_2: + # Invalid domains + pack_iot_domain_space: + "iot_domain ": + pack_empty: + "": + pack_5: + 5: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml new file mode 100644 index 00000000000..70bf80a6b64 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_5.yaml @@ -0,0 +1 @@ +5: diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml new file mode 100644 index 00000000000..510d4682445 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_empty.yaml @@ -0,0 +1 @@ +"": diff --git a/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml new file mode 100644 index 00000000000..49b5720a536 --- /dev/null +++ b/tests/fixtures/core/config/component_validation/packages_include_dir_named/integrations/pack_iot_domain_space.yaml @@ -0,0 +1 @@ +"iot_domain ": diff --git a/tests/fixtures/feedreader6.xml b/tests/fixtures/feedreader6.xml new file mode 100644 index 00000000000..621c89787e8 --- /dev/null +++ b/tests/fixtures/feedreader6.xml @@ -0,0 +1,27 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +0000 + Mon, 30 Apr 2018 15:00:00 +0000 + 1800 + + + Title 1 + Description 1 + http://www.example.com/link/1 + GUID 1 + Mon, 30 Apr 2018 15:10:00 +0000 + + + Title 2 + Description 2 + http://www.example.com/link/2 + GUID 2 + Mon, 30 Apr 2018 15:10:00 +0000 + + + + diff --git a/tests/helpers/snapshots/test_entity.ambr b/tests/helpers/snapshots/test_entity.ambr new file mode 100644 index 00000000000..1031134d2ad --- /dev/null +++ b/tests/helpers/snapshots/test_entity.ambr @@ -0,0 +1,135 @@ +# serializer version: 1 +# name: test_entity_description_as_dataclass + dict({ + 'device_class': 'test', + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': , + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_description_as_dataclass.1 + "EntityDescription(key='blah', device_class='test', entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name=, translation_key=None, unit_of_measurement=None)" +# --- +# name: test_extending_entity_description + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.1 + "test_extending_entity_description..FrozenEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.10 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.11 + "test_extending_entity_description..CustomInitEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None)" +# --- +# name: test_extending_entity_description.2 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.3 + "test_extending_entity_description..ThawedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.4 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extension': 'ext', + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.5 + "test_extending_entity_description..MyExtendedEntityDescription(key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extension='ext', extra='foo')" +# --- +# name: test_extending_entity_description.6 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.7 + "test_extending_entity_description..ComplexEntityDescription1(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- +# name: test_extending_entity_description.8 + dict({ + 'device_class': None, + 'entity_category': None, + 'entity_registry_enabled_default': True, + 'entity_registry_visible_default': True, + 'extra': 'foo', + 'force_update': False, + 'has_entity_name': False, + 'icon': None, + 'key': 'blah', + 'mixin': 'mixin', + 'name': 'name', + 'translation_key': None, + 'unit_of_measurement': None, + }) +# --- +# name: test_extending_entity_description.9 + "test_extending_entity_description..ComplexEntityDescription2(mixin='mixin', key='blah', device_class=None, entity_category=None, entity_registry_enabled_default=True, entity_registry_visible_default=True, force_update=False, icon=None, has_entity_name=False, name='name', translation_key=None, unit_of_measurement=None, extra='foo')" +# --- diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index b65f09aeaf9..de57fa0a8f3 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -227,7 +227,12 @@ async def test_platform_not_found(hass: HomeAssistant) -> None: assert res["light"] == [] warning = CheckConfigError( - "Platform error light.beer - Integration 'beer' not found.", None, None + ( + "Platform error 'light' from integration 'beer' - " + "Integration 'beer' not found." + ), + None, + None, ) _assert_warnings_errors(res, [warning], []) @@ -361,7 +366,7 @@ async def test_platform_import_error(hass: HomeAssistant) -> None: assert res.keys() == {"homeassistant", "light"} warning = CheckConfigError( - "Platform error light.demo - blablabla", + "Platform error 'light' from integration 'demo' - blablabla", None, None, ) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 3b8217028cc..bcb6f4fa971 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta from typing import Any from unittest.mock import AsyncMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -1137,7 +1138,7 @@ async def test_state_for(hass: HomeAssistant) -> None: assert not test(hass) now = dt_util.utcnow() + timedelta(seconds=5) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): assert test(hass) @@ -1163,7 +1164,7 @@ async def test_state_for_template(hass: HomeAssistant) -> None: assert not test(hass) now = dt_util.utcnow() + timedelta(seconds=5) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): assert test(hass) @@ -2235,7 +2236,7 @@ async def test_if_action_before_sunrise_no_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise + 1s -> 'before sunrise' not true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2247,7 +2248,7 @@ async def test_if_action_before_sunrise_no_offset( # now = sunrise -> 'before sunrise' true now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2259,7 +2260,7 @@ async def test_if_action_before_sunrise_no_offset( # now = local midnight -> 'before sunrise' true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2271,7 +2272,7 @@ async def test_if_action_before_sunrise_no_offset( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2306,7 +2307,7 @@ async def test_if_action_after_sunrise_no_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'after sunrise' not true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2318,7 +2319,7 @@ async def test_if_action_after_sunrise_no_offset( # now = sunrise + 1s -> 'after sunrise' true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2330,7 +2331,7 @@ async def test_if_action_after_sunrise_no_offset( # now = local midnight -> 'after sunrise' not true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2342,7 +2343,7 @@ async def test_if_action_after_sunrise_no_offset( # now = local midnight - 1s -> 'after sunrise' true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2381,7 +2382,7 @@ async def test_if_action_before_sunrise_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise + 1s + 1h -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 16, 14, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2393,7 +2394,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunrise + 1h -> 'before sunrise' with offset +1h true now = datetime(2015, 9, 16, 14, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2405,7 +2406,7 @@ async def test_if_action_before_sunrise_with_offset( # now = UTC midnight -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2417,7 +2418,7 @@ async def test_if_action_before_sunrise_with_offset( # now = UTC midnight - 1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2429,7 +2430,7 @@ async def test_if_action_before_sunrise_with_offset( # now = local midnight -> 'before sunrise' with offset +1h true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2441,7 +2442,7 @@ async def test_if_action_before_sunrise_with_offset( # now = local midnight - 1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2453,7 +2454,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunset -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2465,7 +2466,7 @@ async def test_if_action_before_sunrise_with_offset( # now = sunset -1s -> 'before sunrise' with offset +1h not true now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2504,7 +2505,7 @@ async def test_if_action_before_sunset_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = local midnight -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2516,7 +2517,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunset + 1s + 1h -> 'before sunset' with offset +1h not true now = datetime(2015, 9, 17, 2, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2528,7 +2529,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunset + 1h -> 'before sunset' with offset +1h true now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2540,7 +2541,7 @@ async def test_if_action_before_sunset_with_offset( # now = UTC midnight -> 'before sunset' with offset +1h true now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2552,7 +2553,7 @@ async def test_if_action_before_sunset_with_offset( # now = UTC midnight - 1s -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -2564,7 +2565,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunrise -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 13, 33, 18, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 5 @@ -2576,7 +2577,7 @@ async def test_if_action_before_sunset_with_offset( # now = sunrise -1s -> 'before sunset' with offset +1h true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2588,7 +2589,7 @@ async def test_if_action_before_sunset_with_offset( # now = local midnight-1s -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2627,7 +2628,7 @@ async def test_if_action_after_sunrise_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s + 1h -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 14, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2639,7 +2640,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunrise + 1h -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 14, 33, 58, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2651,7 +2652,7 @@ async def test_if_action_after_sunrise_with_offset( # now = UTC noon -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 12, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2663,7 +2664,7 @@ async def test_if_action_after_sunrise_with_offset( # now = UTC noon - 1s -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 16, 11, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2675,7 +2676,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local noon -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 19, 1, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2687,7 +2688,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local noon - 1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 16, 18, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2699,7 +2700,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunset -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -2711,7 +2712,7 @@ async def test_if_action_after_sunrise_with_offset( # now = sunset + 1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 1, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 5 @@ -2723,7 +2724,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local midnight-1s -> 'after sunrise' with offset +1h true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2735,7 +2736,7 @@ async def test_if_action_after_sunrise_with_offset( # now = local midnight -> 'after sunrise' with offset +1h not true now = datetime(2015, 9, 17, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 6 @@ -2774,7 +2775,7 @@ async def test_if_action_after_sunset_with_offset( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunset - 1s + 1h -> 'after sunset' with offset +1h not true now = datetime(2015, 9, 17, 2, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2786,7 +2787,7 @@ async def test_if_action_after_sunset_with_offset( # now = sunset + 1h -> 'after sunset' with offset +1h true now = datetime(2015, 9, 17, 2, 53, 45, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2798,7 +2799,7 @@ async def test_if_action_after_sunset_with_offset( # now = midnight-1s -> 'after sunset' with offset +1h true now = datetime(2015, 9, 16, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2810,7 +2811,7 @@ async def test_if_action_after_sunset_with_offset( # now = midnight -> 'after sunset' with offset +1h not true now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2849,7 +2850,7 @@ async def test_if_action_after_and_before_during( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'after sunrise' + 'before sunset' not true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2865,7 +2866,7 @@ async def test_if_action_after_and_before_during( # now = sunset + 1s -> 'after sunrise' + 'before sunset' not true now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -2877,7 +2878,7 @@ async def test_if_action_after_and_before_during( # now = sunrise + 1s -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2893,7 +2894,7 @@ async def test_if_action_after_and_before_during( # now = sunset - 1s -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2909,7 +2910,7 @@ async def test_if_action_after_and_before_during( # now = 9AM local -> 'after sunrise' + 'before sunset' true now = datetime(2015, 9, 16, 16, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -2952,7 +2953,7 @@ async def test_if_action_before_or_after_during( # sunrise: 2015-09-16 13:33:18 UTC, sunset: 2015-09-17 01:53:45 UTC # now = sunrise - 1s -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 16, 13, 33, 17, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -2968,7 +2969,7 @@ async def test_if_action_before_or_after_during( # now = sunset + 1s -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 17, 1, 53, 46, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -2984,7 +2985,7 @@ async def test_if_action_before_or_after_during( # now = sunrise + 1s -> 'before sunrise' | 'after sunset' false now = datetime(2015, 9, 16, 13, 33, 19, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3000,7 +3001,7 @@ async def test_if_action_before_or_after_during( # now = sunset - 1s -> 'before sunrise' | 'after sunset' false now = datetime(2015, 9, 17, 1, 53, 44, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3016,7 +3017,7 @@ async def test_if_action_before_or_after_during( # now = midnight + 1s local -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 16, 7, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 3 @@ -3032,7 +3033,7 @@ async def test_if_action_before_or_after_during( # now = midnight - 1s local -> 'before sunrise' | 'after sunset' true now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 4 @@ -3077,7 +3078,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunrise + 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 15, 21, 13, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -3089,7 +3090,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = sunrise - 1h -> 'before sunrise' true now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3101,7 +3102,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = local midnight -> 'before sunrise' true now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3113,7 +3114,7 @@ async def test_if_action_before_sunrise_no_offset_kotzebue( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3154,7 +3155,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunrise -> 'after sunrise' true now = datetime(2015, 7, 24, 15, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3166,7 +3167,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = sunrise - 1h -> 'after sunrise' not true now = datetime(2015, 7, 24, 14, 21, 12, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3178,7 +3179,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = local midnight -> 'after sunrise' not true now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3190,7 +3191,7 @@ async def test_if_action_after_sunrise_no_offset_kotzebue( # now = local midnight - 1s -> 'after sunrise' true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3231,7 +3232,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunset + 1s -> 'before sunset' not true now = datetime(2015, 7, 25, 11, 13, 34, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 0 @@ -3243,7 +3244,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = sunset - 1h-> 'before sunset' true now = datetime(2015, 7, 25, 10, 13, 33, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3255,7 +3256,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = local midnight -> 'before sunrise' true now = datetime(2015, 7, 24, 8, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3267,7 +3268,7 @@ async def test_if_action_before_sunset_no_offset_kotzebue( # now = local midnight - 1s -> 'before sunrise' not true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 @@ -3308,7 +3309,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # sunrise: 2015-07-24 15:21:12 UTC, sunset: 2015-07-25 11:13:33 UTC # now = sunset -> 'after sunset' true now = datetime(2015, 7, 25, 11, 13, 33, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3320,7 +3321,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = sunset - 1s -> 'after sunset' not true now = datetime(2015, 7, 25, 11, 13, 32, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3332,7 +3333,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = local midnight -> 'after sunset' not true now = datetime(2015, 7, 24, 8, 0, 1, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 1 @@ -3344,7 +3345,7 @@ async def test_if_action_after_sunset_no_offset_kotzebue( # now = local midnight - 1s -> 'after sunset' true now = datetime(2015, 7, 24, 7, 59, 59, tzinfo=dt_util.UTC) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.bus.async_fire("test_event") await hass.async_block_till_done() assert len(calls) == 2 diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 6d1945f2d5f..f997e3a6c10 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -539,6 +539,13 @@ def test_string(hass: HomeAssistant) -> None: for value in (True, 1, "hello"): schema(value) + # Test subclasses of str are returned + class MyString(str): + pass + + my_string = MyString("hello") + assert schema(my_string) is my_string + # Test template support for text, native in ( ("[1, 2]", [1, 2]), @@ -1624,3 +1631,19 @@ def test_platform_only_schema( cv.platform_only_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) assert expected_message in caplog.text assert issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, expected_issue) + + +def test_domain() -> None: + """Test domain.""" + with pytest.raises(vol.Invalid): + cv.domain_key(5) + with pytest.raises(vol.Invalid): + cv.domain_key("") + with pytest.raises(vol.Invalid): + cv.domain_key("hue ") + with pytest.raises(vol.Invalid): + cv.domain_key("hue ") + assert cv.domain_key("hue") == "hue" + assert cv.domain_key("hue1") == "hue1" + assert cv.domain_key("hue 1") == "hue" + assert cv.domain_key("hue 1") == "hue" diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 1216bd6e293..bd3546afb12 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -1,15 +1,24 @@ """Test deprecation helpers.""" +from enum import StrEnum +import logging +import sys +from typing import Any from unittest.mock import MagicMock, Mock, patch import pytest from homeassistant.core import HomeAssistant from homeassistant.helpers.deprecation import ( + DeprecatedConstant, + DeprecatedConstantEnum, + check_if_deprecated_constant, deprecated_class, deprecated_function, deprecated_substitute, + dir_with_deprecated_constants, get_deprecated, ) +from homeassistant.helpers.frame import MissingIntegrationFrame from tests.common import MockModule, mock_integration @@ -119,32 +128,52 @@ def test_deprecated_class(mock_get_logger) -> None: assert len(mock_logger.warning.mock_calls) == 1 -def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> None: +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) +def test_deprecated_function( + caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, +) -> None: """Test deprecated_function decorator. This tests the behavior when the calling integration is not known. """ - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass mock_deprecated_function() assert ( - "mock_deprecated_function is a deprecated function. Use new_function instead" - in caplog.text - ) + f"mock_deprecated_function is a deprecated function{extra_msg}. " + "Use new_function instead" + ) in caplog.text +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) def test_deprecated_function_called_from_built_in_integration( caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, ) -> None: """Test deprecated_function decorator. This tests the behavior when the calling integration is built-in. """ - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass @@ -170,14 +199,24 @@ def test_deprecated_function_called_from_built_in_integration( ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, this is a deprecated function. " - "Use new_function instead" in caplog.text - ) + "mock_deprecated_function was called from hue, " + f"this is a deprecated function{extra_msg}. " + "Use new_function instead" + ) in caplog.text +@pytest.mark.parametrize( + ("breaks_in_ha_version", "extra_msg"), + [ + (None, ""), + ("2099.1", " which will be removed in HA Core 2099.1"), + ], +) def test_deprecated_function_called_from_custom_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, + breaks_in_ha_version: str | None, + extra_msg: str, ) -> None: """Test deprecated_function decorator. @@ -186,7 +225,7 @@ def test_deprecated_function_called_from_custom_integration( mock_integration(hass, MockModule("hue"), built_in=False) - @deprecated_function("new_function") + @deprecated_function("new_function", breaks_in_ha_version=breaks_in_ha_version) def mock_deprecated_function(): pass @@ -212,7 +251,228 @@ def test_deprecated_function_called_from_custom_integration( ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, this is a deprecated function. " - "Use new_function instead, please report it to the author of the 'hue' custom " - "integration" in caplog.text + "mock_deprecated_function was called from hue, " + f"this is a deprecated function{extra_msg}. " + "Use new_function instead, please report it to the author of the " + "'hue' custom integration" + ) in caplog.text + + +class TestDeprecatedConstantEnum(StrEnum): + """Test deprecated constant enum.""" + + __test__ = False # prevent test collection of class by pytest + + TEST = "value" + + +def _get_value(obj: DeprecatedConstant | DeprecatedConstantEnum | tuple) -> Any: + if isinstance(obj, tuple): + if len(obj) == 2: + return obj[0].value + + return obj[0] + + if isinstance(obj, DeprecatedConstant): + return obj.value + + if isinstance(obj, DeprecatedConstantEnum): + return obj.enum.value + + +@pytest.mark.parametrize( + ("deprecated_constant", "extra_msg"), + [ + ( + DeprecatedConstant("value", "NEW_CONSTANT", None), + ". Use NEW_CONSTANT instead", + ), + ( + DeprecatedConstant(1, "NEW_CONSTANT", "2099.1"), + " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", + ), + ( + DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, None), + ". Use TestDeprecatedConstantEnum.TEST instead", + ), + ( + DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, "2099.1"), + " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", + ), + ( + ("value", "NEW_CONSTANT", None), + ". Use NEW_CONSTANT instead", + ), + ( + (1, "NEW_CONSTANT", "2099.1"), + " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", + ), + ( + (TestDeprecatedConstantEnum.TEST, None), + ". Use TestDeprecatedConstantEnum.TEST instead", + ), + ( + (TestDeprecatedConstantEnum.TEST, "2099.1"), + " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", + ), + ], +) +@pytest.mark.parametrize( + ("module_name", "extra_extra_msg"), + [ + ("homeassistant.components.hue.light", ""), # builtin integration + ( + "config.custom_components.hue.light", + ", please report it to the author of the 'hue' custom integration", + ), # custom component integration + ], +) +def test_check_if_deprecated_constant( + caplog: pytest.LogCaptureFixture, + deprecated_constant: DeprecatedConstant | DeprecatedConstantEnum | tuple, + extra_msg: str, + module_name: str, + extra_extra_msg: str, +) -> None: + """Test check_if_deprecated_constant.""" + module_globals = { + "__name__": module_name, + "_DEPRECATED_TEST_CONSTANT": deprecated_constant, + } + filename = f"/home/paulus/{module_name.replace('.', '/')}.py" + + # mock sys.modules for homeassistant/helpers/frame.py#get_integration_frame + with patch.dict(sys.modules, {module_name: Mock(__file__=filename)}), patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename=filename, + lineno="23", + line="await session.close()", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + value = check_if_deprecated_constant("TEST_CONSTANT", module_globals) + assert value == _get_value(deprecated_constant) + + assert ( + module_name, + logging.WARNING, + f"TEST_CONSTANT was used from hue, this is a deprecated constant{extra_msg}{extra_extra_msg}", + ) in caplog.record_tuples + + +@pytest.mark.parametrize( + ("deprecated_constant", "extra_msg"), + [ + ( + DeprecatedConstant("value", "NEW_CONSTANT", None), + ". Use NEW_CONSTANT instead", + ), + ( + DeprecatedConstant(1, "NEW_CONSTANT", "2099.1"), + " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", + ), + ( + DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, None), + ". Use TestDeprecatedConstantEnum.TEST instead", + ), + ( + DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, "2099.1"), + " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", + ), + ( + ("value", "NEW_CONSTANT", None), + ". Use NEW_CONSTANT instead", + ), + ( + (1, "NEW_CONSTANT", "2099.1"), + " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", + ), + ( + (TestDeprecatedConstantEnum.TEST, None), + ". Use TestDeprecatedConstantEnum.TEST instead", + ), + ( + (TestDeprecatedConstantEnum.TEST, "2099.1"), + " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", + ), + ], +) +@pytest.mark.parametrize( + ("module_name"), + [ + "homeassistant.components.hue.light", # builtin integration + "config.custom_components.hue.light", # custom component integration + ], +) +def test_check_if_deprecated_constant_integration_not_found( + caplog: pytest.LogCaptureFixture, + deprecated_constant: DeprecatedConstant | DeprecatedConstantEnum | tuple, + extra_msg: str, + module_name: str, +) -> None: + """Test check_if_deprecated_constant.""" + module_globals = { + "__name__": module_name, + "_DEPRECATED_TEST_CONSTANT": deprecated_constant, + } + + with patch( + "homeassistant.helpers.frame.extract_stack", side_effect=MissingIntegrationFrame + ): + value = check_if_deprecated_constant("TEST_CONSTANT", module_globals) + assert value == _get_value(deprecated_constant) + + assert ( + module_name, + logging.WARNING, + f"TEST_CONSTANT is a deprecated constant{extra_msg}", + ) not in caplog.record_tuples + + +def test_test_check_if_deprecated_constant_invalid( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test check_if_deprecated_constant will raise an attribute error and create an log entry on an invalid deprecation type.""" + module_name = "homeassistant.components.hue.light" + module_globals = {"__name__": module_name, "_DEPRECATED_TEST_CONSTANT": 1} + name = "TEST_CONSTANT" + + excepted_msg = ( + f"Value of _DEPRECATED_{name} is an instance of " + "but an instance of DeprecatedConstant or DeprecatedConstantEnum is required" ) + + with pytest.raises(AttributeError, match=excepted_msg): + check_if_deprecated_constant(name, module_globals) + + assert (module_name, logging.DEBUG, excepted_msg) in caplog.record_tuples + + +@pytest.mark.parametrize( + ("module_global", "expected"), + [ + ({"CONSTANT": 1}, ["CONSTANT"]), + ({"_DEPRECATED_CONSTANT": 1}, ["_DEPRECATED_CONSTANT", "CONSTANT"]), + ( + {"_DEPRECATED_CONSTANT": 1, "SOMETHING": 2}, + ["_DEPRECATED_CONSTANT", "SOMETHING", "CONSTANT"], + ), + ], +) +def test_dir_with_deprecated_constants( + module_global: dict[str, Any], expected: list[str] +) -> None: + """Test dir() with deprecated constants.""" + assert dir_with_deprecated_constants(module_global) == expected diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 657d8871e66..43540a52f7d 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -17,7 +17,11 @@ from homeassistant.helpers import ( entity_registry as er, ) -from tests.common import MockConfigEntry, flush_store +from tests.common import ( + MockConfigEntry, + flush_store, + import_and_test_deprecated_constant_enum, +) @pytest.fixture @@ -2012,3 +2016,12 @@ async def test_loading_invalid_configuration_url_from_storage( identifiers={("serial", "123456ABCDEF")}, ) assert entry.configuration_url == "invalid" + + +@pytest.mark.parametrize(("enum"), list(dr.DeviceEntryDisabler)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: dr.DeviceEntryDisabler, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, dr, enum, "DISABLED_", "2025.1") diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py index a251b20b0f4..89d23fb4533 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -144,8 +144,6 @@ async def test_callback_exception_gets_logged( # wrap in partial to test message logging. async_dispatcher_connect(hass, "test", partial(bad_handler)) async_dispatcher_send(hass, "test", "bad") - await hass.async_block_till_done() - await hass.async_block_till_done() assert ( f"Exception in functools.partial({bad_handler}) when dispatching 'test': ('bad',)" @@ -153,6 +151,25 @@ async def test_callback_exception_gets_logged( ) +@pytest.mark.no_fail_on_log_exception +async def test_coro_exception_gets_logged( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test exception raised by signal handler.""" + + async def bad_async_handler(*args): + """Record calls.""" + raise Exception("This is a bad message in a coro") + + # wrap in partial to test message logging. + async_dispatcher_connect(hass, "test", bad_async_handler) + async_dispatcher_send(hass, "test", "bad") + await hass.async_block_till_done() + + assert "bad_async_handler" in caplog.text + assert "when dispatching 'test': ('bad',)" in caplog.text + + async def test_dispatcher_add_dispatcher(hass: HomeAssistant) -> None: """Test adding a dispatcher from a dispatcher.""" calls = [] diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 4076afcfad0..a18d8963947 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -3,14 +3,18 @@ import asyncio from collections.abc import Iterable import dataclasses from datetime import timedelta +from enum import IntFlag import logging import threading from typing import Any from unittest.mock import MagicMock, PropertyMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, @@ -29,7 +33,6 @@ from tests.common import ( MockEntityPlatform, MockModule, MockPlatform, - get_test_home_assistant, mock_integration, mock_registry, ) @@ -59,6 +62,17 @@ def test_generate_entity_id_given_keys() -> None: ) +async def test_generate_entity_id_given_hass(hass: HomeAssistant) -> None: + """Test generating an entity id given hass object.""" + hass.states.async_set("test.overwrite_hidden_true", "test") + + fmt = "test.{}" + assert ( + entity.generate_entity_id(fmt, "overwrite hidden true", hass=hass) + == "test.overwrite_hidden_true_2" + ) + + async def test_async_update_support(hass: HomeAssistant) -> None: """Test async update getting called.""" sync_update = [] @@ -93,40 +107,19 @@ async def test_async_update_support(hass: HomeAssistant) -> None: assert len(async_update) == 1 -class TestHelpersEntity: - """Test homeassistant.helpers.entity module.""" +async def test_device_class(hass: HomeAssistant) -> None: + """Test device class attribute.""" + ent = entity.Entity() + ent.entity_id = "test.overwrite_hidden_true" + ent.hass = hass + ent.async_write_ha_state() + state = hass.states.get(ent.entity_id) + assert state.attributes.get(ATTR_DEVICE_CLASS) is None - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.entity = entity.Entity() - self.entity.entity_id = "test.overwrite_hidden_true" - self.hass = self.entity.hass = get_test_home_assistant() - self.entity.schedule_update_ha_state() - self.hass.block_till_done() - - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - - def test_generate_entity_id_given_hass(self): - """Test generating an entity id given hass object.""" - fmt = "test.{}" - assert ( - entity.generate_entity_id(fmt, "overwrite hidden true", hass=self.hass) - == "test.overwrite_hidden_true_2" - ) - - def test_device_class(self): - """Test device class attribute.""" - state = self.hass.states.get(self.entity.entity_id) - assert state.attributes.get(ATTR_DEVICE_CLASS) is None - with patch( - "homeassistant.helpers.entity.Entity.device_class", new="test_class" - ): - self.entity.schedule_update_ha_state() - self.hass.block_till_done() - state = self.hass.states.get(self.entity.entity_id) - assert state.attributes.get(ATTR_DEVICE_CLASS) == "test_class" + ent._attr_device_class = "test_class" + ent.async_write_ha_state() + state = hass.states.get(ent.entity_id) + assert state.attributes.get(ATTR_DEVICE_CLASS) == "test_class" async def test_warn_slow_update( @@ -662,10 +655,9 @@ async def test_set_context_expired(hass: HomeAssistant) -> None: """Test setting context.""" context = Context() - with patch.object( - entity.Entity, "context_recent_time", new_callable=PropertyMock - ) as recent: - recent.return_value = timedelta(seconds=-5) + with patch( + "homeassistant.helpers.entity.CONTEXT_RECENT_TIME", timedelta(seconds=-5) + ): ent = entity.Entity() ent.hass = hass ent.entity_id = "hello.world" @@ -966,7 +958,7 @@ async def test_entity_description_fallback() -> None: ent_with_description = entity.Entity() ent_with_description.entity_description = entity.EntityDescription(key="test") - for field in dataclasses.fields(entity.EntityDescription): + for field in dataclasses.fields(entity.EntityDescription._dataclass): if field.name == "key": continue @@ -1400,8 +1392,8 @@ async def test_translation_key(hass: HomeAssistant) -> None: assert mock_entity2.translation_key == "from_entity_description" -async def test_repr_using_stringify_state() -> None: - """Test that repr uses stringify state.""" +async def test_repr(hass) -> None: + """Test Entity.__repr__.""" class MyEntity(MockEntity): """Mock entity.""" @@ -1411,8 +1403,19 @@ async def test_repr_using_stringify_state() -> None: """Return the state.""" raise ValueError("Boom") - entity = MyEntity(entity_id="test.test", available=False) - assert str(entity) == "" + platform = MockEntityPlatform(hass, domain="hello") + my_entity = MyEntity(entity_id="test.test", available=False) + + # Not yet added + assert str(my_entity) == "" + + # Added + await platform.async_add_entities([my_entity]) + assert str(my_entity) == "" + + # Removed + await platform.async_remove_entity(my_entity.entity_id) + assert str(my_entity) == "" async def test_warn_using_async_update_ha_state( @@ -1657,3 +1660,405 @@ async def test_change_entity_id( assert len(result) == 2 assert len(ent.added_calls) == 3 assert len(ent.remove_calls) == 2 + + +def test_entity_description_as_dataclass(snapshot: SnapshotAssertion): + """Test EntityDescription behaves like a dataclass.""" + + obj = entity.EntityDescription("blah", device_class="test") + with pytest.raises(dataclasses.FrozenInstanceError): + obj.name = "mutate" + with pytest.raises(dataclasses.FrozenInstanceError): + delattr(obj, "name") + + assert dataclasses.is_dataclass(obj) + assert obj == snapshot + assert obj == entity.EntityDescription("blah", device_class="test") + assert repr(obj) == snapshot + + +def test_extending_entity_description(snapshot: SnapshotAssertion): + """Test extending entity descriptions.""" + + @dataclasses.dataclass(frozen=True) + class FrozenEntityDescription(entity.EntityDescription): + extra: str = None + + obj = FrozenEntityDescription("blah", extra="foo", name="name") + assert obj == snapshot + assert obj == FrozenEntityDescription("blah", extra="foo", name="name") + assert repr(obj) == snapshot + + # Try mutating + with pytest.raises(dataclasses.FrozenInstanceError): + obj.name = "mutate" + with pytest.raises(dataclasses.FrozenInstanceError): + delattr(obj, "name") + + @dataclasses.dataclass + class ThawedEntityDescription(entity.EntityDescription): + extra: str = None + + obj = ThawedEntityDescription("blah", extra="foo", name="name") + assert obj == snapshot + assert obj == ThawedEntityDescription("blah", extra="foo", name="name") + assert repr(obj) == snapshot + + # Try mutating + obj.name = "mutate" + assert obj.name == "mutate" + delattr(obj, "key") + assert not hasattr(obj, "key") + + # Try multiple levels of FrozenOrThawed + class ExtendedEntityDescription(entity.EntityDescription, frozen_or_thawed=True): + extension: str = None + + @dataclasses.dataclass(frozen=True) + class MyExtendedEntityDescription(ExtendedEntityDescription): + extra: str = None + + obj = MyExtendedEntityDescription("blah", extension="ext", extra="foo", name="name") + assert obj == snapshot + assert obj == MyExtendedEntityDescription( + "blah", extension="ext", extra="foo", name="name" + ) + assert repr(obj) == snapshot + + # Try multiple direct parents + @dataclasses.dataclass(frozen=True) + class MyMixin: + mixin: str = None + + @dataclasses.dataclass(frozen=True, kw_only=True) + class ComplexEntityDescription1(MyMixin, entity.EntityDescription): + extra: str = None + + obj = ComplexEntityDescription1(key="blah", extra="foo", mixin="mixin", name="name") + assert obj == snapshot + assert obj == ComplexEntityDescription1( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + @dataclasses.dataclass(frozen=True, kw_only=True) + class ComplexEntityDescription2(entity.EntityDescription, MyMixin): + extra: str = None + + obj = ComplexEntityDescription2(key="blah", extra="foo", mixin="mixin", name="name") + assert obj == snapshot + assert obj == ComplexEntityDescription2( + key="blah", extra="foo", mixin="mixin", name="name" + ) + assert repr(obj) == snapshot + + # Try inheriting with custom init + @dataclasses.dataclass + class CustomInitEntityDescription(entity.EntityDescription): + def __init__(self, extra, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.extra: str = extra + + obj = CustomInitEntityDescription(key="blah", extra="foo", name="name") + assert obj == snapshot + assert obj == CustomInitEntityDescription(key="blah", extra="foo", name="name") + assert repr(obj) == snapshot + + +async def test_update_capabilities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity capabilities are updated automatically.""" + platform = MockEntityPlatform(hass) + + ent = MockEntity(unique_id="qwer") + await platform.async_add_entities([ent]) + + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.device_class is None + assert entry.supported_features == 0 + + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = 127 + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == 127 + + ent._values["capability_attributes"] = None + ent._values["device_class"] = None + ent._values["supported_features"] = None + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.original_device_class is None + assert entry.supported_features == 0 + + # Device class can be overridden by user, make sure that does not break the + # automatic updating. + entity_registry.async_update_entity(ent.entity_id, device_class="set_by_user") + await hass.async_block_till_done() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.original_device_class is None + assert entry.supported_features == 0 + + # This will not trigger a state change because the device class is shadowed + # by the entity registry + ent._values["device_class"] = "some_class" + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.original_device_class == "some_class" + assert entry.supported_features == 0 + + +async def test_update_capabilities_no_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity capabilities are updated automatically.""" + platform = MockEntityPlatform(hass) + + ent = MockEntity() + await platform.async_add_entities([ent]) + + assert entity_registry.async_get(ent.entity_id) is None + + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["supported_features"] = 127 + ent.async_write_ha_state() + assert entity_registry.async_get(ent.entity_id) is None + + +async def test_update_capabilities_too_often( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry: er.EntityRegistry, +) -> None: + """Test entity capabilities are updated automatically.""" + capabilities_too_often_warning = "is updating its capabilities too often" + platform = MockEntityPlatform(hass) + + ent = MockEntity(unique_id="qwer") + await platform.async_add_entities([ent]) + + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.device_class is None + assert entry.supported_features == 0 + + for supported_features in range(1, entity.CAPABILITIES_UPDATE_LIMIT + 1): + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = supported_features + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == supported_features + + assert capabilities_too_often_warning not in caplog.text + + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = supported_features + 1 + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == supported_features + 1 + + assert capabilities_too_often_warning in caplog.text + + +async def test_update_capabilities_too_often_cooldown( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test entity capabilities are updated automatically.""" + capabilities_too_often_warning = "is updating its capabilities too often" + platform = MockEntityPlatform(hass) + + ent = MockEntity(unique_id="qwer") + await platform.async_add_entities([ent]) + + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities is None + assert entry.device_class is None + assert entry.supported_features == 0 + + for supported_features in range(1, entity.CAPABILITIES_UPDATE_LIMIT + 1): + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = supported_features + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == supported_features + + assert capabilities_too_often_warning not in caplog.text + + freezer.tick(timedelta(minutes=60) + timedelta(seconds=1)) + + ent._values["capability_attributes"] = {"bla": "blu"} + ent._values["device_class"] = "some_class" + ent._values["supported_features"] = supported_features + 1 + ent.async_write_ha_state() + entry = entity_registry.async_get(ent.entity_id) + assert entry.capabilities == {"bla": "blu"} + assert entry.original_device_class == "some_class" + assert entry.supported_features == supported_features + 1 + + assert capabilities_too_often_warning not in caplog.text + + +@pytest.mark.parametrize( + ("property", "default_value", "values"), [("attribution", None, ["abcd", "efgh"])] +) +async def test_cached_entity_properties( + hass: HomeAssistant, property: str, default_value: Any, values: Any +) -> None: + """Test entity properties are cached.""" + ent1 = entity.Entity() + ent2 = entity.Entity() + assert getattr(ent1, property) == default_value + assert getattr(ent2, property) == default_value + + # Test set + setattr(ent1, f"_attr_{property}", values[0]) + assert getattr(ent1, property) == values[0] + assert getattr(ent2, property) == default_value + + # Test update + setattr(ent1, f"_attr_{property}", values[1]) + assert getattr(ent1, property) == values[1] + assert getattr(ent2, property) == default_value + + # Test delete + delattr(ent1, f"_attr_{property}") + assert getattr(ent1, property) == default_value + assert getattr(ent2, property) == default_value + + +async def test_cached_entity_property_delete_attr(hass: HomeAssistant) -> None: + """Test deleting an _attr corresponding to a cached property.""" + property = "has_entity_name" + + ent = entity.Entity() + assert not hasattr(ent, f"_attr_{property}") + with pytest.raises(AttributeError): + delattr(ent, f"_attr_{property}") + assert getattr(ent, property) is False + + with pytest.raises(AttributeError): + delattr(ent, f"_attr_{property}") + assert not hasattr(ent, f"_attr_{property}") + assert getattr(ent, property) is False + + setattr(ent, f"_attr_{property}", True) + assert getattr(ent, property) is True + + delattr(ent, f"_attr_{property}") + assert not hasattr(ent, f"_attr_{property}") + assert getattr(ent, property) is False + + +async def test_cached_entity_property_class_attribute(hass: HomeAssistant) -> None: + """Test entity properties on class level work in derived classes.""" + property = "attribution" + values = ["abcd", "efgh"] + + class EntityWithClassAttribute1(entity.Entity): + """A derived class which overrides an _attr_ from a parent.""" + + _attr_attribution = values[0] + + class EntityWithClassAttribute2(entity.Entity, cached_properties={property}): + """A derived class which overrides an _attr_ from a parent. + + This class also redundantly marks the overridden _attr_ as cached. + """ + + _attr_attribution = values[0] + + class EntityWithClassAttribute3(entity.Entity, cached_properties={property}): + """A derived class which overrides an _attr_ from a parent. + + This class overrides the attribute property. + """ + + def __init__(self): + self._attr_attribution = values[0] + + @cached_property + def attribution(self) -> str | None: + """Return the attribution.""" + return self._attr_attribution + + class EntityWithClassAttribute4(entity.Entity, cached_properties={property}): + """A derived class which overrides an _attr_ from a parent. + + This class overrides the attribute property and the _attr_. + """ + + _attr_attribution = values[0] + + @cached_property + def attribution(self) -> str | None: + """Return the attribution.""" + return self._attr_attribution + + classes = ( + EntityWithClassAttribute1, + EntityWithClassAttribute2, + EntityWithClassAttribute3, + EntityWithClassAttribute4, + ) + + entities: list[tuple[entity.Entity, entity.Entity]] = [] + for cls in classes: + entities.append((cls(), cls())) + + for ent in entities: + assert getattr(ent[0], property) == values[0] + assert getattr(ent[1], property) == values[0] + + # Test update + for ent in entities: + setattr(ent[0], f"_attr_{property}", values[1]) + for ent in entities: + assert getattr(ent[0], property) == values[1] + assert getattr(ent[1], property) == values[0] + + +async def test_entity_report_deprecated_supported_features_values( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test reporting deprecated supported feature values only happens once.""" + ent = entity.Entity() + + class MockEntityFeatures(IntFlag): + VALUE1 = 1 + VALUE2 = 2 + + ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) + assert ( + "is using deprecated supported features values which will be removed" + in caplog.text + ) + assert "MockEntityFeatures.VALUE2" in caplog.text + + caplog.clear() + ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) + assert ( + "is using deprecated supported features values which will be removed" + not in caplog.text + ) diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 40e25633992..60d0774b549 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -215,7 +215,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: await component.async_setup({DOMAIN: {"platform": "mod1"}}) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 1 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components # Should not trigger attempt 2 async_fire_time_changed(hass, utcnow + timedelta(seconds=29)) @@ -226,7 +226,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow + timedelta(seconds=30)) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 2 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components # This should not trigger attempt 3 async_fire_time_changed(hass, utcnow + timedelta(seconds=59)) @@ -237,7 +237,7 @@ async def test_platform_not_ready(hass: HomeAssistant) -> None: async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 3 - assert "test_domain.mod1" in hass.config.components + assert "mod1.test_domain" in hass.config.components async def test_extract_from_service_fails_if_no_entity_id(hass: HomeAssistant) -> None: @@ -317,7 +317,7 @@ async def test_setup_dependencies_platform(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert "test_component" in hass.config.components assert "test_component2" in hass.config.components - assert "test_domain.test_component" in hass.config.components + assert "test_component.test_domain" in hass.config.components async def test_setup_entry(hass: HomeAssistant) -> None: @@ -680,7 +680,7 @@ async def test_platforms_shutdown_on_stop(hass: HomeAssistant) -> None: await component.async_setup({DOMAIN: {"platform": "mod1"}}) await hass.async_block_till_done() assert len(platform1_setup.mock_calls) == 1 - assert "test_domain.mod1" not in hass.config.components + assert "mod1.test_domain" not in hass.config.components with patch.object( component._platforms[DOMAIN], "async_shutdown" diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 721114c1a7b..dfaec4577aa 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -268,7 +268,7 @@ async def test_platform_error_slow_setup( await component.async_setup({DOMAIN: {"platform": "test_platform"}}) await hass.async_block_till_done() assert len(called) == 1 - assert "test_domain.test_platform" not in hass.config.components + assert "test_platform.test_domain" not in hass.config.components assert "test_platform is taking longer than 0 seconds" in caplog.text # Cleanup lingering (setup_platform) task after test is done @@ -833,7 +833,7 @@ async def test_setup_entry( assert await entity_platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{entity_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{entity_platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 1 assert len(entity_registry.entities) == 1 @@ -856,7 +856,7 @@ async def test_setup_entry_platform_not_ready( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 assert "Platform test not ready yet" in caplog.text @@ -877,7 +877,7 @@ async def test_setup_entry_platform_not_ready_with_message( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 @@ -904,7 +904,7 @@ async def test_setup_entry_platform_not_ready_from_exception( with patch.object(entity_platform, "async_call_later") as mock_call_later: assert not await ent_platform.async_setup_entry(config_entry) - full_name = f"{ent_platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{ent_platform.domain}" assert full_name not in hass.config.components assert len(async_setup_entry.mock_calls) == 1 @@ -1669,7 +1669,7 @@ async def test_setup_entry_with_entities_that_block_forever( ): assert await platform.async_setup_entry(config_entry) await hass.async_block_till_done() - full_name = f"{platform.domain}.{config_entry.domain}" + full_name = f"{config_entry.domain}.{platform.domain}" assert full_name in hass.config.components assert len(hass.states.async_entity_ids()) == 0 assert len(entity_registry.entities) == 1 diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 00ad580693e..245354a09a0 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -923,7 +923,9 @@ async def test_track_template_error_can_recover( async def test_track_template_time_change( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test tracking template with time change.""" template_error = Template("{{ utcnow().minute % 2 == 0 }}", hass) @@ -935,17 +937,15 @@ async def test_track_template_time_change( start_time = dt_util.utcnow() + timedelta(hours=24) time_that_will_not_match_right_away = start_time.replace(minute=1, second=0) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_template(hass, template_error, error_callback) - await hass.async_block_till_done() - assert not calls + freezer.move_to(time_that_will_not_match_right_away) + unsub = async_track_template(hass, template_error, error_callback) + await hass.async_block_till_done() + assert not calls first_time = start_time.replace(minute=2, second=0) - with patch("homeassistant.util.dt.utcnow", return_value=first_time): - async_fire_time_changed(hass, first_time) - await hass.async_block_till_done() + freezer.move_to(first_time) + async_fire_time_changed(hass, first_time) + await hass.async_block_till_done() assert len(calls) == 1 assert calls[0] == (None, None, None) @@ -3312,84 +3312,89 @@ async def test_track_template_with_time_default(hass: HomeAssistant) -> None: info.async_remove() -async def test_track_template_with_time_that_leaves_scope(hass: HomeAssistant) -> None: +async def test_track_template_with_time_that_leaves_scope( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test tracking template with time.""" now = dt_util.utcnow() test_time = datetime(now.year + 1, 5, 24, 11, 59, 1, 500000, tzinfo=dt_util.UTC) + freezer.move_to(test_time) - with patch("homeassistant.util.dt.utcnow", return_value=test_time): - hass.states.async_set("binary_sensor.washing_machine", "on") - specific_runs = [] - template_complex = Template( - """ - {% if states.binary_sensor.washing_machine.state == "on" %} - {{ now() }} - {% else %} - {{ states.binary_sensor.washing_machine.last_updated }} - {% endif %} - """, - hass, - ) + hass.states.async_set("binary_sensor.washing_machine", "on") + specific_runs = [] + template_complex = Template( + """ + {% if states.binary_sensor.washing_machine.state == "on" %} + {{ now() }} + {% else %} + {{ states.binary_sensor.washing_machine.last_updated }} + {% endif %} + """, + hass, + ) - def specific_run_callback( - event: EventType[EventStateChangedData] | None, - updates: list[TrackTemplateResult], - ) -> None: - specific_runs.append(updates.pop().result) + def specific_run_callback( + event: EventType[EventStateChangedData] | None, + updates: list[TrackTemplateResult], + ) -> None: + specific_runs.append(updates.pop().result) - info = async_track_template_result( - hass, [TrackTemplate(template_complex, None)], specific_run_callback - ) - await hass.async_block_till_done() + info = async_track_template_result( + hass, [TrackTemplate(template_complex, None)], specific_run_callback + ) + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": True, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": True, + } - hass.states.async_set("binary_sensor.washing_machine", "off") - await hass.async_block_till_done() + hass.states.async_set("binary_sensor.washing_machine", "off") + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": False, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": False, + } - hass.states.async_set("binary_sensor.washing_machine", "on") - await hass.async_block_till_done() + hass.states.async_set("binary_sensor.washing_machine", "on") + await hass.async_block_till_done() - assert info.listeners == { - "all": False, - "domains": set(), - "entities": {"binary_sensor.washing_machine"}, - "time": True, - } + assert info.listeners == { + "all": False, + "domains": set(), + "entities": {"binary_sensor.washing_machine"}, + "time": True, + } - # Verify we do not update before the minute rolls over - callback_count_before_time_change = len(specific_runs) - async_fire_time_changed(hass, test_time) - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + # Verify we do not update before the minute rolls over + callback_count_before_time_change = len(specific_runs) + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change - async_fire_time_changed(hass, test_time + timedelta(seconds=58)) - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + new_time = test_time + timedelta(seconds=58) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change - # Verify we do update on the next change of minute - async_fire_time_changed(hass, test_time + timedelta(seconds=59)) - - await hass.async_block_till_done() - assert len(specific_runs) == callback_count_before_time_change + 1 + # Verify we do update on the next change of minute + new_time = test_time + timedelta(seconds=59) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) + await hass.async_block_till_done() + assert len(specific_runs) == callback_count_before_time_change + 1 info.async_remove() async def test_async_track_template_result_multiple_templates_mixing_listeners( - hass: HomeAssistant, + hass: HomeAssistant, freezer: FrozenDateTimeFactory ) -> None: """Test tracking multiple templates with mixing listener types.""" @@ -3410,18 +3415,16 @@ async def test_async_track_template_result_multiple_templates_mixing_listeners( time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - info = async_track_template_result( - hass, - [ - TrackTemplate(template_1, None), - TrackTemplate(template_2, None), - ], - refresh_listener, - ) + info = async_track_template_result( + hass, + [ + TrackTemplate(template_1, None), + TrackTemplate(template_2, None), + ], + refresh_listener, + ) assert info.listeners == { "all": False, @@ -3450,9 +3453,9 @@ async def test_async_track_template_result_multiple_templates_mixing_listeners( refresh_runs = [] next_time = time_that_will_not_match_right_away + timedelta(hours=25) - with patch("homeassistant.util.dt.utcnow", return_value=next_time): - async_fire_time_changed(hass, next_time) - await hass.async_block_till_done() + freezer.move_to(next_time) + async_fire_time_changed(hass, next_time) + await hass.async_block_till_done() assert refresh_runs == [ [ @@ -3787,7 +3790,10 @@ async def test_track_sunset(hass: HomeAssistant) -> None: assert len(offset_runs) == 1 -async def test_async_track_time_change(hass: HomeAssistant) -> None: +async def test_async_track_time_change( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test tracking time change.""" none_runs = [] wildcard_runs = [] @@ -3798,21 +3804,19 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_time_change(hass, callback(lambda x: none_runs.append(x))) - unsub_utc = async_track_utc_time_change( - hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] - ) - unsub_wildcard = async_track_time_change( - hass, - callback(lambda x: wildcard_runs.append(x)), - second="*", - minute="*", - hour="*", - ) + unsub = async_track_time_change(hass, callback(lambda x: none_runs.append(x))) + unsub_utc = async_track_utc_time_change( + hass, callback(lambda x: specific_runs.append(x)), second=[0, 30] + ) + unsub_wildcard = async_track_time_change( + hass, + callback(lambda x: wildcard_runs.append(x)), + second="*", + minute="*", + hour="*", + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3851,7 +3855,10 @@ async def test_async_track_time_change(hass: HomeAssistant) -> None: assert len(none_runs) == 3 -async def test_periodic_task_minute(hass: HomeAssistant) -> None: +async def test_periodic_task_minute( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks per minute.""" specific_runs = [] @@ -3860,13 +3867,11 @@ async def test_periodic_task_minute(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, callback(lambda x: specific_runs.append(x)), minute="/5", second=0 - ) + unsub = async_track_utc_time_change( + hass, callback(lambda x: specific_runs.append(x)), minute="/5", second=0 + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3895,7 +3900,10 @@ async def test_periodic_task_minute(hass: HomeAssistant) -> None: assert len(specific_runs) == 2 -async def test_periodic_task_hour(hass: HomeAssistant) -> None: +async def test_periodic_task_hour( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks per hour.""" specific_runs = [] @@ -3904,17 +3912,15 @@ async def test_periodic_task_hour(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) @@ -3973,71 +3979,77 @@ async def test_periodic_task_wrong_input(hass: HomeAssistant) -> None: assert len(specific_runs) == 0 -async def test_periodic_task_clock_rollback(hass: HomeAssistant) -> None: +async def test_periodic_task_clock_rollback( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test periodic tasks with the time rolling backwards.""" specific_runs = [] now = dt_util.utcnow() - time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) - - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, ) + + new_time = datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 24, 23, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 1 + new_time = datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) async_fire_time_changed( hass, - datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC), + new_time, fire_all=True, ) await hass.async_block_till_done() assert len(specific_runs) == 1 + new_time = datetime(now.year + 1, 5, 24, 0, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) async_fire_time_changed( hass, - datetime(now.year + 1, 5, 24, 0, 0, 0, 999999, tzinfo=dt_util.UTC), + new_time, fire_all=True, ) await hass.async_block_till_done() assert len(specific_runs) == 1 - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 2 unsub() - async_fire_time_changed( - hass, datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) - ) + new_time = datetime(now.year + 1, 5, 25, 2, 0, 0, 999999, tzinfo=dt_util.UTC) + freezer.move_to(new_time) + async_fire_time_changed(hass, new_time) await hass.async_block_till_done() assert len(specific_runs) == 2 -async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: +async def test_periodic_task_duplicate_time( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: """Test periodic tasks not triggering on duplicate time.""" specific_runs = [] @@ -4046,17 +4058,15 @@ async def test_periodic_task_duplicate_time(hass: HomeAssistant) -> None: time_that_will_not_match_right_away = datetime( now.year + 1, 5, 24, 21, 59, 55, tzinfo=dt_util.UTC ) + freezer.move_to(time_that_will_not_match_right_away) - with patch( - "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away - ): - unsub = async_track_utc_time_change( - hass, - callback(lambda x: specific_runs.append(x)), - hour="/2", - minute=0, - second=0, - ) + unsub = async_track_utc_time_change( + hass, + callback(lambda x: specific_runs.append(x)), + hour="/2", + minute=0, + second=0, + ) async_fire_time_changed( hass, datetime(now.year + 1, 5, 24, 22, 0, 0, 999999, tzinfo=dt_util.UTC) diff --git a/tests/helpers/test_init.py b/tests/helpers/test_init.py index c567c6bc7bc..39b387000ca 100644 --- a/tests/helpers/test_init.py +++ b/tests/helpers/test_init.py @@ -2,10 +2,12 @@ from collections import OrderedDict +import pytest + from homeassistant import helpers -def test_extract_domain_configs() -> None: +def test_extract_domain_configs(caplog: pytest.LogCaptureFixture) -> None: """Test the extraction of domain configuration.""" config = { "zone": None, @@ -19,8 +21,13 @@ def test_extract_domain_configs() -> None: helpers.extract_domain_configs(config, "zone") ) + assert ( + "helpers.extract_domain_configs is a deprecated function which will be removed " + "in HA Core 2024.6. Use config.extract_domain_configs instead" in caplog.text + ) -def test_config_per_platform() -> None: + +def test_config_per_platform(caplog: pytest.LogCaptureFixture) -> None: """Test config per platform method.""" config = OrderedDict( [ @@ -36,3 +43,8 @@ def test_config_per_platform() -> None: (None, 1), ("hello 2", config["zone Hallo"][1]), ] == list(helpers.config_per_platform(config, "zone")) + + assert ( + "helpers.config_per_platform is a deprecated function which will be removed " + "in HA Core 2024.6. Use config.config_per_platform instead" in caplog.text + ) diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py index 586dbc19eb8..4425ce00ce1 100644 --- a/tests/helpers/test_reload.py +++ b/tests/helpers/test_reload.py @@ -53,7 +53,7 @@ async def test_reload_platform(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) @@ -93,7 +93,7 @@ async def test_setup_reload_service(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) @@ -134,7 +134,7 @@ async def test_setup_reload_service_when_async_process_component_config_fails( await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) @@ -186,7 +186,7 @@ async def test_setup_reload_service_with_platform_that_provides_async_reset_plat await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 await async_setup_reload_service(hass, PLATFORM, [DOMAIN]) diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index f01718d6af6..d69996e5d29 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -508,7 +508,7 @@ async def test_restore_entity_end_to_end( await hass.async_block_till_done() assert component_setup.called - assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert f"{PLATFORM}.{DOMAIN}" in hass.config.components assert len(setup_called) == 1 platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 7e655a69c0a..1ea602f7cda 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -9,6 +9,7 @@ from types import MappingProxyType from unittest import mock from unittest.mock import AsyncMock, MagicMock, patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -385,7 +386,10 @@ async def test_calling_service_response_data( "target": {}, }, "running_script": False, - } + }, + "variables": { + "my_response": {"data": "value-12345"}, + }, } ], "1": [ @@ -398,10 +402,7 @@ async def test_calling_service_response_data( "target": {}, }, "running_script": False, - }, - "variables": { - "my_response": {"data": "value-12345"}, - }, + } } ], } @@ -1162,13 +1163,13 @@ async def test_wait_template_not_schedule(hass: HomeAssistant) -> None: assert_action_trace( { "0": [{"result": {"event": "test_event", "event_data": {}}}], - "1": [{"result": {"wait": {"completed": True, "remaining": None}}}], - "2": [ + "1": [ { - "result": {"event": "test_event", "event_data": {}}, + "result": {"wait": {"completed": True, "remaining": None}}, "variables": {"wait": {"completed": True, "remaining": None}}, } ], + "2": [{"result": {"event": "test_event", "event_data": {}}}], } ) @@ -1229,13 +1230,13 @@ async def test_wait_timeout( else: variable_wait = {"wait": {"trigger": None, "remaining": 0.0}} expected_trace = { - "0": [{"result": variable_wait}], - "1": [ + "0": [ { - "result": {"event": "test_event", "event_data": {}}, + "result": variable_wait, "variables": variable_wait, } ], + "1": [{"result": {"event": "test_event", "event_data": {}}}], } assert_action_trace(expected_trace) @@ -1290,19 +1291,14 @@ async def test_wait_continue_on_timeout( else: variable_wait = {"wait": {"trigger": None, "remaining": 0.0}} expected_trace = { - "0": [{"result": variable_wait}], + "0": [{"result": variable_wait, "variables": variable_wait}], } if continue_on_timeout is False: expected_trace["0"][0]["result"]["timeout"] = True expected_trace["0"][0]["error_type"] = asyncio.TimeoutError expected_script_execution = "aborted" else: - expected_trace["1"] = [ - { - "result": {"event": "test_event", "event_data": {}}, - "variables": variable_wait, - } - ] + expected_trace["1"] = [{"result": {"event": "test_event", "event_data": {}}}] expected_script_execution = "finished" assert_action_trace(expected_trace, expected_script_execution) @@ -1346,13 +1342,13 @@ async def test_wait_template_with_utcnow(hass: HomeAssistant) -> None: try: non_matching_time = start_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=non_matching_time): + with freeze_time(non_matching_time): hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) assert script_obj.is_running match_time = start_time.replace(hour=12) - with patch("homeassistant.util.dt.utcnow", return_value=match_time): + with freeze_time(match_time): async_fire_time_changed(hass, match_time) except (AssertionError, asyncio.TimeoutError): await script_obj.async_stop() @@ -1378,15 +1374,13 @@ async def test_wait_template_with_utcnow_no_match(hass: HomeAssistant) -> None: try: non_matching_time = start_time.replace(hour=3) - with patch("homeassistant.util.dt.utcnow", return_value=non_matching_time): + with freeze_time(non_matching_time): hass.async_create_task(script_obj.async_run(context=Context())) await asyncio.wait_for(wait_started_flag.wait(), 1) assert script_obj.is_running second_non_matching_time = start_time.replace(hour=4) - with patch( - "homeassistant.util.dt.utcnow", return_value=second_non_matching_time - ): + with freeze_time(second_non_matching_time): async_fire_time_changed(hass, second_non_matching_time) async with asyncio.timeout(0.1): @@ -3270,12 +3264,12 @@ async def test_parallel(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - "description": "state of switch.trigger", }, } - } + }, + "variables": {"wait": {"remaining": None}}, } ], "0/parallel/1/sequence/0": [ { - "variables": {}, "result": { "event": "test_event", "event_data": {"hello": "from action 2", "what": "world"}, @@ -3284,7 +3278,6 @@ async def test_parallel(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - ], "0/parallel/0/sequence/1": [ { - "variables": {"wait": {"remaining": None}}, "result": { "event": "test_event", "event_data": {"hello": "from action 1", "what": "world"}, @@ -4463,7 +4456,7 @@ async def test_set_variable( assert f"Executing step {alias}" in caplog.text expected_trace = { - "0": [{}], + "0": [{"variables": {"variable": "value"}}], "1": [ { "result": { @@ -4475,7 +4468,6 @@ async def test_set_variable( }, "running_script": False, }, - "variables": {"variable": "value"}, } ], } @@ -4505,7 +4497,7 @@ async def test_set_redefines_variable( assert mock_calls[1].data["value"] == 2 expected_trace = { - "0": [{}], + "0": [{"variables": {"variable": "1"}}], "1": [ { "result": { @@ -4516,11 +4508,10 @@ async def test_set_redefines_variable( "target": {}, }, "running_script": False, - }, - "variables": {"variable": "1"}, + } } ], - "2": [{}], + "2": [{"variables": {"variable": 2}}], "3": [ { "result": { @@ -4531,8 +4522,7 @@ async def test_set_redefines_variable( "target": {}, }, "running_script": False, - }, - "variables": {"variable": 2}, + } } ], } diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index c4ad244620b..e925b425f96 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -396,6 +396,7 @@ def test_assist_pipeline_selector_schema( ({"min": 10, "max": 1000, "mode": "slider", "step": 0.5}, (), ()), ({"mode": "box"}, (10,), ()), ({"mode": "box", "step": "any"}, (), ()), + ({"mode": "slider", "min": 0, "max": 1, "step": "any"}, (), ()), ), ) def test_number_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -408,12 +409,6 @@ def test_number_selector_schema(schema, valid_selections, invalid_selections) -> ( {}, # Must have mandatory fields {"mode": "slider"}, # Must have min+max in slider mode - { - "mode": "slider", - "min": 0, - "max": 1, - "step": "any", # Can't combine slider with step any - }, ), ) def test_number_selector_schema_error(schema) -> None: diff --git a/tests/helpers/test_significant_change.py b/tests/helpers/test_significant_change.py index c0d9f1b3a4a..6444781aa85 100644 --- a/tests/helpers/test_significant_change.py +++ b/tests/helpers/test_significant_change.py @@ -72,3 +72,14 @@ async def test_significant_change_extra(hass: HomeAssistant, checker) -> None: State(ent_id, "200", attrs), extra_arg=1 ) assert checker.async_is_significant_change(State(ent_id, "200", attrs), extra_arg=2) + + +async def test_check_valid_float(hass: HomeAssistant) -> None: + """Test extra significant checker works.""" + assert significant_change.check_valid_float("1") + assert significant_change.check_valid_float("1.0") + assert significant_change.check_valid_float(1) + assert significant_change.check_valid_float(1.0) + assert not significant_change.check_valid_float("") + assert not significant_change.check_valid_float("invalid") + assert not significant_change.check_valid_float("1.1.1") diff --git a/tests/helpers/test_sun.py b/tests/helpers/test_sun.py index e030958ab82..b6dc1616a48 100644 --- a/tests/helpers/test_sun.py +++ b/tests/helpers/test_sun.py @@ -1,8 +1,8 @@ """The tests for the Sun helpers.""" from datetime import datetime, timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET @@ -77,7 +77,7 @@ def test_next_events(hass: HomeAssistant) -> None: break mod += 1 - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert next_dawn == sun.get_astral_event_next(hass, "dawn") assert next_dusk == sun.get_astral_event_next(hass, "dusk") assert next_midnight == sun.get_astral_event_next(hass, "midnight") @@ -132,7 +132,7 @@ def test_date_events_default_date(hass: HomeAssistant) -> None: sunrise = astral.sun.sunrise(location.observer, date=utc_today) sunset = astral.sun.sunset(location.observer, date=utc_today) - with patch("homeassistant.util.dt.now", return_value=utc_now): + with freeze_time(utc_now): assert dawn == sun.get_astral_event_date(hass, "dawn", utc_today) assert dusk == sun.get_astral_event_date(hass, "dusk", utc_today) assert midnight == sun.get_astral_event_date(hass, "midnight", utc_today) @@ -171,11 +171,11 @@ def test_date_events_accepts_datetime(hass: HomeAssistant) -> None: def test_is_up(hass: HomeAssistant) -> None: """Test retrieving next sun events.""" utc_now = datetime(2016, 11, 1, 12, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert not sun.is_up(hass) utc_now = datetime(2016, 11, 1, 18, 0, 0, tzinfo=dt_util.UTC) - with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=utc_now): + with freeze_time(utc_now): assert sun.is_up(hass) diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index ce9fd0c036c..b70c9479abb 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -22,13 +22,13 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_UNAVAILABLE, - VOLUME_LITERS, UnitOfLength, UnitOfMass, UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfVolume, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError @@ -60,7 +60,7 @@ def _set_up_units(hass: HomeAssistant) -> None: mass=UnitOfMass.GRAMS, pressure=UnitOfPressure.PA, temperature=UnitOfTemperature.CELSIUS, - volume=VOLUME_LITERS, + volume=UnitOfVolume.LITERS, wind_speed=UnitOfSpeed.KILOMETERS_PER_HOUR, ) @@ -1318,6 +1318,88 @@ def test_average(hass: HomeAssistant) -> None: template.Template("{{ average([]) }}", hass).async_render() +def test_median(hass: HomeAssistant) -> None: + """Test the median filter.""" + assert template.Template("{{ [1, 3, 2] | median }}", hass).async_render() == 2 + assert template.Template("{{ median([1, 3, 2, 4]) }}", hass).async_render() == 2.5 + assert template.Template("{{ median(1, 3, 2) }}", hass).async_render() == 2 + assert template.Template("{{ median('cdeba') }}", hass).async_render() == "c" + + # Testing of default values + assert template.Template("{{ median([1, 2, 3], -1) }}", hass).async_render() == 2 + assert template.Template("{{ median([], -1) }}", hass).async_render() == -1 + assert template.Template("{{ median([], default=-1) }}", hass).async_render() == -1 + assert template.Template("{{ median('abcd', -1) }}", hass).async_render() == -1 + assert ( + template.Template("{{ median([], 5, default=-1) }}", hass).async_render() == -1 + ) + assert ( + template.Template("{{ median(1, 'a', 3, default=-1) }}", hass).async_render() + == -1 + ) + + with pytest.raises(TemplateError): + template.Template("{{ 1 | median }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ median() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ median([]) }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ median('abcd') }}", hass).async_render() + + +def test_statistical_mode(hass: HomeAssistant) -> None: + """Test the mode filter.""" + assert ( + template.Template("{{ [1, 2, 2, 3] | statistical_mode }}", hass).async_render() + == 2 + ) + assert ( + template.Template("{{ statistical_mode([1, 2, 3]) }}", hass).async_render() == 1 + ) + assert ( + template.Template( + "{{ statistical_mode('hello', 'bye', 'hello') }}", hass + ).async_render() + == "hello" + ) + assert ( + template.Template("{{ statistical_mode('banana') }}", hass).async_render() + == "a" + ) + + # Testing of default values + assert ( + template.Template("{{ statistical_mode([1, 2, 3], -1) }}", hass).async_render() + == 1 + ) + assert ( + template.Template("{{ statistical_mode([], -1) }}", hass).async_render() == -1 + ) + assert ( + template.Template("{{ statistical_mode([], default=-1) }}", hass).async_render() + == -1 + ) + assert ( + template.Template( + "{{ statistical_mode([], 5, default=-1) }}", hass + ).async_render() + == -1 + ) + + with pytest.raises(TemplateError): + template.Template("{{ 1 | statistical_mode }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ statistical_mode() }}", hass).async_render() + + with pytest.raises(TemplateError): + template.Template("{{ statistical_mode([]) }}", hass).async_render() + + def test_min(hass: HomeAssistant) -> None: """Test the min filter.""" assert template.Template("{{ [1, 2, 3] | min }}", hass).async_render() == 1 @@ -1869,7 +1951,7 @@ def test_has_value(hass: HomeAssistant) -> None: def test_now(mock_is_safe, hass: HomeAssistant) -> None: """Test now method.""" now = dt_util.now() - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): info = template.Template("{{ now().isoformat() }}", hass).async_render_to_info() assert now.isoformat() == info.result() @@ -1883,7 +1965,7 @@ def test_now(mock_is_safe, hass: HomeAssistant) -> None: def test_utcnow(mock_is_safe, hass: HomeAssistant) -> None: """Test now method.""" utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow): + with freeze_time(utcnow): info = template.Template( "{{ utcnow().isoformat() }}", hass ).async_render_to_info() @@ -1970,7 +2052,7 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: relative_time_template = ( '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' ) - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): result = template.Template( relative_time_template, hass, @@ -2042,7 +2124,7 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: def test_timedelta(mock_is_safe, hass: HomeAssistant) -> None: """Test relative_time method.""" now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - with patch("homeassistant.util.dt.now", return_value=now): + with freeze_time(now): result = template.Template( "{{timedelta(seconds=120)}}", hass, diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index 6f5b4253218..62152299932 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -56,14 +56,14 @@ async def test_component_translation_path( ) assert path.normpath( - translation.component_translation_path("switch.test", "en", int_test) + translation.component_translation_path("test.switch", "en", int_test) ) == path.normpath( hass.config.path("custom_components", "test", "translations", "switch.en.json") ) assert path.normpath( translation.component_translation_path( - "switch.test_embedded", "en", int_test_embedded + "test_embedded.switch", "en", int_test_embedded ) ) == path.normpath( hass.config.path( @@ -255,7 +255,7 @@ async def test_translation_merging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test we merge translations of two integrations.""" - hass.config.components.add("sensor.moon") + hass.config.components.add("moon.sensor") hass.config.components.add("sensor") orig_load_translations = translation.load_translations_files @@ -263,7 +263,7 @@ async def test_translation_merging( def mock_load_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.moon"] = { + result["moon.sensor"] = { "state": {"moon__phase": {"first_quarter": "First Quarter"}} } return result @@ -276,13 +276,13 @@ async def test_translation_merging( assert "component.sensor.state.moon__phase.first_quarter" in translations - hass.config.components.add("sensor.season") + hass.config.components.add("season.sensor") # Patch in some bad translation data def mock_load_bad_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.season"] = {"state": "bad data"} + result["season.sensor"] = {"state": "bad data"} return result with patch( @@ -308,7 +308,7 @@ async def test_translation_merging_loaded_apart( def mock_load_translations_files(files): """Mock loading.""" result = orig_load_translations(files) - result["sensor.moon"] = { + result["moon.sensor"] = { "state": {"moon__phase": {"first_quarter": "First Quarter"}} } return result @@ -323,7 +323,7 @@ async def test_translation_merging_loaded_apart( assert "component.sensor.state.moon__phase.first_quarter" not in translations - hass.config.components.add("sensor.moon") + hass.config.components.add("moon.sensor") with patch( "homeassistant.helpers.translation.load_translations_files", diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 06dff1e0869..425ad561f50 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -78,7 +78,10 @@ def test_config_platform_valid( ( BASE_CONFIG + "light:\n platform: beer", {"homeassistant", "light"}, - "Platform error light.beer - Integration 'beer' not found.", + ( + "Platform error 'light' from integration 'beer' - " + "Integration 'beer' not found." + ), ), ], ) diff --git a/tests/snapshots/test_config.ambr b/tests/snapshots/test_config.ambr index 7438bda5cde..76d3f0c4666 100644 --- a/tests/snapshots/test_config.ambr +++ b/tests/snapshots/test_config.ambr @@ -1,24 +1,36 @@ # serializer version: 1 # name: test_component_config_validation_error[basic] list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid domain 'iot_domain ' at configuration.yaml, line 61", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '' at configuration.yaml, line 62", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '5' at configuration.yaml, line 1", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -57,24 +69,36 @@ # --- # name: test_component_config_validation_error[basic_include] list([ + dict({ + 'has_exc_info': False, + 'message': "Invalid domain 'iot_domain ' at configuration.yaml, line 11", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '' at configuration.yaml, line 12", + }), + dict({ + 'has_exc_info': False, + 'message': "Invalid domain '5' at configuration.yaml, line 1", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'iot_domain' at integrations/iot_domain.yaml, line 5: required key 'platform' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 8: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 11: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 17: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 17: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 18: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 19: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -119,18 +143,18 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_3.yaml, line 3: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_4.yaml, line 3: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 5: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 6: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_5.yaml, line 7: expected str for dictionary value 'option2', got 123 ''', }), ]) @@ -143,42 +167,54 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 3: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 6: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 12: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 13: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at iot_domain/iot_domain_2.yaml, line 14: expected str for dictionary value 'option2', got 123 ''', }), ]) # --- # name: test_component_config_validation_error[packages] list([ + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_iot_domain_space' at configuration.yaml, line 72 failed: Invalid domain 'iot_domain '", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_empty' at configuration.yaml, line 74 failed: Invalid domain ''", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_5' at configuration.yaml, line 76 failed: Invalid domain '5'", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'iot_domain' at configuration.yaml, line 11: required key 'platform' not provided", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 16: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 16: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 21: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 21: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 29: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 30: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 31: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 29: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 30: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 31: expected str for dictionary value 'option2', got 123 ''', }), dict({ @@ -217,6 +253,18 @@ # --- # name: test_component_config_validation_error[packages_include_dir_named] list([ + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_5' at integrations/pack_5.yaml, line 1 failed: Invalid domain '5'", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_empty' at integrations/pack_empty.yaml, line 1 failed: Invalid domain ''", + }), + dict({ + 'has_exc_info': False, + 'message': "Setup of package 'pack_iot_domain_space' at integrations/pack_iot_domain_space.yaml, line 1 failed: Invalid domain 'iot_domain '", + }), dict({ 'has_exc_info': False, 'message': "Invalid config for 'adr_0007_2' at integrations/adr_0007_2.yaml, line 2: required key 'host' not provided", @@ -255,31 +303,34 @@ }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 9: expected str for dictionary value 'option1', got 123", }), dict({ 'has_exc_info': False, - 'message': "Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option", + 'message': "Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option", }), dict({ 'has_exc_info': False, 'message': ''' - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 18: required key 'option1' not provided - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option - Invalid config for 'iot_domain.non_adr_0007' at integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 18: required key 'option1' not provided + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option + Invalid config for 'iot_domain' from integration 'non_adr_0007' at integrations/iot_domain.yaml, line 20: expected str for dictionary value 'option2', got 123 ''', }), ]) # --- # name: test_component_config_validation_error_with_docs[basic] list([ + "Invalid domain 'iot_domain ' at configuration.yaml, line 61", + "Invalid domain '' at configuration.yaml, line 62", + "Invalid domain '5' at configuration.yaml, line 1", "Invalid config for 'iot_domain' at configuration.yaml, line 6: required key 'platform' not provided, please check the docs at https://www.home-assistant.io/integrations/iot_domain", - "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", - "Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 9: expected str for dictionary value 'option1', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", + "Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 12: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007", ''' - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'iot_domain.non_adr_0007', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 - Invalid config for 'iot_domain.non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 18: required key 'option1' not provided, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 19: 'no_such_option' is an invalid option for 'non_adr_0007.iot_domain', check: no_such_option, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 + Invalid config for 'iot_domain' from integration 'non_adr_0007' at configuration.yaml, line 20: expected str for dictionary value 'option2', got 123, please check the docs at https://www.home-assistant.io/integrations/non_adr_0007 ''', "Invalid config for 'adr_0007_2' at configuration.yaml, line 27: required key 'host' not provided, please check the docs at https://www.home-assistant.io/integrations/adr_0007_2", "Invalid config for 'adr_0007_3' at configuration.yaml, line 32: expected int for dictionary value 'adr_0007_3->port', got 'foo', please check the docs at https://www.home-assistant.io/integrations/adr_0007_3", diff --git a/tests/snapshots/test_config_entries.ambr b/tests/snapshots/test_config_entries.ambr new file mode 100644 index 00000000000..bfb583ba8db --- /dev/null +++ b/tests/snapshots/test_config_entries.ambr @@ -0,0 +1,19 @@ +# serializer version: 1 +# name: test_as_dict + dict({ + 'data': dict({ + }), + 'disabled_by': None, + 'domain': 'test', + 'entry_id': 'mock-entry', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }) +# --- diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index b98d3d0311f..4c350168d4e 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -40,7 +40,7 @@ async def apply_stop_hass(stop_hass: None) -> None: """Make sure all hass are stopped.""" -@pytest.fixture(autouse=True) +@pytest.fixture(scope="session", autouse=True) def mock_http_start_stop() -> Generator[None, None, None]: """Mock HTTP start and stop.""" with patch( @@ -913,6 +913,7 @@ async def test_bootstrap_dependencies( """Mock the MQTT config flow.""" VERSION = 1 + MINOR_VERSION = 1 entry = MockConfigEntry(domain="mqtt", data={"broker": "test-broker"}) entry.add_to_hass(hass) diff --git a/tests/test_config.py b/tests/test_config.py index de5e7e0581d..0f6b36d90b5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1980,6 +1980,30 @@ async def test_core_config_schema_no_country(hass: HomeAssistant) -> None: assert issue +@pytest.mark.parametrize( + ("config", "expected_issue"), + [ + ({}, None), + ({"legacy_templates": True}, "legacy_templates_true"), + ({"legacy_templates": False}, "legacy_templates_false"), + ], +) +async def test_core_config_schema_legacy_template( + hass: HomeAssistant, config: dict[str, Any], expected_issue: str | None +) -> None: + """Test legacy_template core config schema.""" + await config_util.async_process_ha_core_config(hass, config) + + issue_registry = ir.async_get(hass) + for issue_id in {"legacy_templates_true", "legacy_templates_false"}: + issue = issue_registry.async_get_issue("homeassistant", issue_id) + assert issue if issue_id == expected_issue else not issue + + await config_util.async_process_ha_core_config(hass, {}) + for issue_id in {"legacy_templates_true", "legacy_templates_false"}: + assert not issue_registry.async_get_issue("homeassistant", issue_id) + + async def test_core_store_no_country( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: @@ -2043,7 +2067,7 @@ async def test_component_config_validation_error( for domain_with_label in config: integration = await async_get_integration( - hass, domain_with_label.partition(" ")[0] + hass, cv.domain_key(domain_with_label) ) await config_util.async_process_component_and_handle_errors( hass, @@ -2088,7 +2112,7 @@ async def test_component_config_validation_error_with_docs( for domain_with_label in config: integration = await async_get_integration( - hass, domain_with_label.partition(" ")[0] + hass, cv.domain_key(domain_with_label) ) await config_util.async_process_component_and_handle_errors( hass, @@ -2207,3 +2231,36 @@ async def test_yaml_error( if record.levelno == logging.ERROR ] assert error_records == snapshot + + +def test_extract_domain_configs() -> None: + """Test the extraction of domain configuration.""" + config = { + "zone": None, + "zoner": None, + "zone ": None, + "zone Hallo": None, + "zone 100": None, + } + + assert {"zone", "zone Hallo", "zone 100"} == set( + config_util.extract_domain_configs(config, "zone") + ) + + +def test_config_per_platform() -> None: + """Test config per platform method.""" + config = OrderedDict( + [ + ("zone", {"platform": "hello"}), + ("zoner", None), + ("zone Hallo", [1, {"platform": "hello 2"}]), + ("zone 100", None), + ] + ) + + assert [ + ("hello", config["zone"]), + (None, 1), + ("hello 2", config["zone Hallo"][1]), + ] == list(config_util.config_per_platform(config, "zone")) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index f63972c79e8..e9989b6839e 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -9,6 +9,7 @@ from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant import config_entries, data_entry_flow, loader from homeassistant.components import dhcp @@ -133,11 +134,15 @@ async def test_call_setup_entry_without_reload_support(hass: HomeAssistant) -> N assert not entry.supports_unload -async def test_call_async_migrate_entry(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (1, 2), (2, 2)]) +async def test_call_async_migrate_entry( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: """Test we call .async_migrate_entry when version mismatch.""" entry = MockConfigEntry(domain="comp") assert not entry.supports_unload - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) mock_migrate_entry = AsyncMock(return_value=True) @@ -163,10 +168,14 @@ async def test_call_async_migrate_entry(hass: HomeAssistant) -> None: assert entry.supports_unload -async def test_call_async_migrate_entry_failure_false(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (1, 2), (2, 2)]) +async def test_call_async_migrate_entry_failure_false( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: """Test migration fails if returns false.""" entry = MockConfigEntry(domain="comp") - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) assert not entry.supports_unload @@ -191,10 +200,14 @@ async def test_call_async_migrate_entry_failure_false(hass: HomeAssistant) -> No assert not entry.supports_unload -async def test_call_async_migrate_entry_failure_exception(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (1, 2), (2, 2)]) +async def test_call_async_migrate_entry_failure_exception( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: """Test migration fails if exception raised.""" entry = MockConfigEntry(domain="comp") - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) assert not entry.supports_unload @@ -219,10 +232,14 @@ async def test_call_async_migrate_entry_failure_exception(hass: HomeAssistant) - assert not entry.supports_unload -async def test_call_async_migrate_entry_failure_not_bool(hass: HomeAssistant) -> None: +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (1, 2), (2, 2)]) +async def test_call_async_migrate_entry_failure_not_bool( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: """Test migration fails if boolean not returned.""" entry = MockConfigEntry(domain="comp") - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) assert not entry.supports_unload @@ -247,12 +264,14 @@ async def test_call_async_migrate_entry_failure_not_bool(hass: HomeAssistant) -> assert not entry.supports_unload +@pytest.mark.parametrize(("major_version", "minor_version"), [(2, 1), (2, 2)]) async def test_call_async_migrate_entry_failure_not_supported( - hass: HomeAssistant, + hass: HomeAssistant, major_version: int, minor_version: int ) -> None: """Test migration fails if async_migrate_entry not implemented.""" entry = MockConfigEntry(domain="comp") - entry.version = 2 + entry.version = major_version + entry.minor_version = minor_version entry.add_to_hass(hass) assert not entry.supports_unload @@ -268,6 +287,29 @@ async def test_call_async_migrate_entry_failure_not_supported( assert not entry.supports_unload +@pytest.mark.parametrize(("major_version", "minor_version"), [(1, 2)]) +async def test_call_async_migrate_entry_not_supported_minor_version( + hass: HomeAssistant, major_version: int, minor_version: int +) -> None: + """Test migration without async_migrate_entry and minor version changed.""" + entry = MockConfigEntry(domain="comp") + entry.version = major_version + entry.minor_version = minor_version + entry.add_to_hass(hass) + assert not entry.supports_unload + + mock_setup_entry = AsyncMock(return_value=True) + + mock_integration(hass, MockModule("comp", async_setup_entry=mock_setup_entry)) + mock_platform(hass, "comp.config_flow", None) + + result = await async_setup_component(hass, "comp", {}) + assert result + assert len(mock_setup_entry.mock_calls) == 1 + assert entry.state is config_entries.ConfigEntryState.LOADED + assert not entry.supports_unload + + async def test_remove_entry( hass: HomeAssistant, manager: config_entries.ConfigEntries, @@ -667,14 +709,43 @@ async def test_saving_and_loading(hass: HomeAssistant) -> None: for orig, loaded in zip( hass.config_entries.async_entries(), manager.async_entries() ): - assert orig.version == loaded.version - assert orig.domain == loaded.domain - assert orig.title == loaded.title - assert orig.data == loaded.data - assert orig.source == loaded.source - assert orig.unique_id == loaded.unique_id - assert orig.pref_disable_new_entities == loaded.pref_disable_new_entities - assert orig.pref_disable_polling == loaded.pref_disable_polling + assert orig.as_dict() == loaded.as_dict() + + +async def test_as_dict(snapshot: SnapshotAssertion) -> None: + """Test ConfigEntry.as_dict.""" + + # Ensure as_dict is not overridden + assert MockConfigEntry.as_dict is config_entries.ConfigEntry.as_dict + + excluded_from_dict = { + "supports_unload", + "supports_remove_device", + "state", + "_setup_lock", + "update_listeners", + "reason", + "_async_cancel_retry_setup", + "_on_unload", + "reload_lock", + "_reauth_lock", + "_tasks", + "_background_tasks", + "_integration_for_domain", + "_tries", + "_setup_again_job", + } + + entry = MockConfigEntry(entry_id="mock-entry") + + # Make sure the expected keys are present + dict_repr = entry.as_dict() + for key in config_entries.ConfigEntry.__slots__: + assert key in dict_repr or key in excluded_from_dict + assert not (key in dict_repr and key in excluded_from_dict) + + # Make sure the dict representation is as expected + assert dict_repr == snapshot async def test_forward_entry_sets_up_component(hass: HomeAssistant) -> None: diff --git a/tests/test_const.py b/tests/test_const.py new file mode 100644 index 00000000000..fedf35ae6d1 --- /dev/null +++ b/tests/test_const.py @@ -0,0 +1,167 @@ +"""Test const module.""" + + +from enum import Enum + +import pytest + +from homeassistant import const +from homeassistant.components import sensor + +from tests.common import ( + import_and_test_deprecated_constant, + import_and_test_deprecated_constant_enum, +) + + +def _create_tuples( + value: Enum | list[Enum], constant_prefix: str +) -> list[tuple[Enum, str]]: + result = [] + for enum in value: + result.append((enum, constant_prefix)) + return result + + +@pytest.mark.parametrize( + ("enum", "constant_prefix"), + _create_tuples(const.EntityCategory, "ENTITY_CATEGORY_") + + _create_tuples( + [ + sensor.SensorDeviceClass.AQI, + sensor.SensorDeviceClass.BATTERY, + sensor.SensorDeviceClass.CO, + sensor.SensorDeviceClass.CO2, + sensor.SensorDeviceClass.CURRENT, + sensor.SensorDeviceClass.DATE, + sensor.SensorDeviceClass.ENERGY, + sensor.SensorDeviceClass.FREQUENCY, + sensor.SensorDeviceClass.GAS, + sensor.SensorDeviceClass.HUMIDITY, + sensor.SensorDeviceClass.ILLUMINANCE, + sensor.SensorDeviceClass.MONETARY, + sensor.SensorDeviceClass.NITROGEN_DIOXIDE, + sensor.SensorDeviceClass.NITROGEN_MONOXIDE, + sensor.SensorDeviceClass.NITROUS_OXIDE, + sensor.SensorDeviceClass.OZONE, + sensor.SensorDeviceClass.PM1, + sensor.SensorDeviceClass.PM10, + sensor.SensorDeviceClass.PM25, + sensor.SensorDeviceClass.POWER_FACTOR, + sensor.SensorDeviceClass.POWER, + sensor.SensorDeviceClass.PRESSURE, + sensor.SensorDeviceClass.SIGNAL_STRENGTH, + sensor.SensorDeviceClass.SULPHUR_DIOXIDE, + sensor.SensorDeviceClass.TEMPERATURE, + sensor.SensorDeviceClass.TIMESTAMP, + sensor.SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + sensor.SensorDeviceClass.VOLTAGE, + ], + "DEVICE_CLASS_", + ) + + _create_tuples(const.UnitOfApparentPower, "POWER_") + + _create_tuples(const.UnitOfPower, "POWER_") + + _create_tuples( + [ + const.UnitOfEnergy.KILO_WATT_HOUR, + const.UnitOfEnergy.MEGA_WATT_HOUR, + const.UnitOfEnergy.WATT_HOUR, + ], + "ENERGY_", + ) + + _create_tuples(const.UnitOfElectricCurrent, "ELECTRIC_CURRENT_") + + _create_tuples(const.UnitOfElectricPotential, "ELECTRIC_POTENTIAL_") + + _create_tuples(const.UnitOfTemperature, "TEMP_") + + _create_tuples(const.UnitOfTime, "TIME_") + + _create_tuples( + [ + const.UnitOfLength.MILLIMETERS, + const.UnitOfLength.CENTIMETERS, + const.UnitOfLength.METERS, + const.UnitOfLength.KILOMETERS, + const.UnitOfLength.INCHES, + const.UnitOfLength.FEET, + const.UnitOfLength.MILES, + ], + "LENGTH_", + ) + + _create_tuples(const.UnitOfFrequency, "FREQUENCY_") + + _create_tuples(const.UnitOfPressure, "PRESSURE_") + + _create_tuples( + [ + const.UnitOfVolume.CUBIC_FEET, + const.UnitOfVolume.CUBIC_METERS, + const.UnitOfVolume.LITERS, + const.UnitOfVolume.MILLILITERS, + const.UnitOfVolume.GALLONS, + ], + "VOLUME_", + ) + + _create_tuples(const.UnitOfVolumeFlowRate, "VOLUME_FLOW_RATE_") + + _create_tuples( + [ + const.UnitOfMass.GRAMS, + const.UnitOfMass.KILOGRAMS, + const.UnitOfMass.MILLIGRAMS, + const.UnitOfMass.MICROGRAMS, + const.UnitOfMass.OUNCES, + const.UnitOfMass.POUNDS, + ], + "MASS_", + ) + + _create_tuples(const.UnitOfIrradiance, "IRRADIATION_") + + _create_tuples( + [ + const.UnitOfPrecipitationDepth.INCHES, + const.UnitOfPrecipitationDepth.MILLIMETERS, + const.UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + const.UnitOfVolumetricFlux.INCHES_PER_HOUR, + ], + "PRECIPITATION_", + ) + + _create_tuples(const.UnitOfSpeed, "SPEED_") + + _create_tuples( + [ + const.UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, + const.UnitOfVolumetricFlux.INCHES_PER_DAY, + const.UnitOfVolumetricFlux.INCHES_PER_HOUR, + ], + "SPEED_", + ) + + _create_tuples(const.UnitOfInformation, "DATA_") + + _create_tuples(const.UnitOfDataRate, "DATA_RATE_"), +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: Enum, + constant_prefix: str, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, const, enum, constant_prefix, "2025.1" + ) + + +@pytest.mark.parametrize( + ("replacement", "constant_name"), + [ + (const.UnitOfLength.YARDS, "LENGTH_YARD"), + (const.UnitOfSoundPressure.DECIBEL, "SOUND_PRESSURE_DB"), + (const.UnitOfSoundPressure.WEIGHTED_DECIBEL_A, "SOUND_PRESSURE_WEIGHTED_DBA"), + (const.UnitOfVolume.FLUID_OUNCES, "VOLUME_FLUID_OUNCE"), + ], +) +def test_deprecated_constant_name_changes( + caplog: pytest.LogCaptureFixture, + replacement: Enum, + constant_name: str, +) -> None: + """Test deprecated constants, where the name is not the same as the enum value.""" + import_and_test_deprecated_constant( + caplog, + const, + constant_name, + f"{replacement.__class__.__name__}.{replacement.name}", + replacement, + "2025.1", + ) diff --git a/tests/test_core.py b/tests/test_core.py index 43291c032d7..90b87068a5d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -14,6 +14,7 @@ import time from typing import Any from unittest.mock import MagicMock, Mock, PropertyMock, patch +from freezegun import freeze_time import pytest from pytest_unordered import unordered import voluptuous as vol @@ -36,6 +37,7 @@ from homeassistant.const import ( ) import homeassistant.core as ha from homeassistant.core import ( + CoreState, HassJob, HomeAssistant, ServiceCall, @@ -56,7 +58,11 @@ import homeassistant.util.dt as dt_util from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.unit_system import METRIC_SYSTEM -from .common import async_capture_events, async_mock_service +from .common import ( + async_capture_events, + async_mock_service, + import_and_test_deprecated_constant_enum, +) PST = dt_util.get_time_zone("America/Los_Angeles") @@ -399,6 +405,32 @@ async def test_stage_shutdown(hass: HomeAssistant) -> None: assert len(test_all) == 2 +async def test_stage_shutdown_timeouts(hass: HomeAssistant) -> None: + """Simulate a shutdown, test timeouts at each step.""" + + with patch.object(hass.timeout, "async_timeout", side_effect=asyncio.TimeoutError): + await hass.async_stop() + + assert hass.state == CoreState.stopped + + +async def test_stage_shutdown_generic_error(hass: HomeAssistant, caplog) -> None: + """Simulate a shutdown, test that a generic error at the final stage doesn't prevent it.""" + + task = asyncio.Future() + hass._tasks.add(task) + + def fail_the_task(_): + task.set_exception(Exception("test_exception")) + + with patch.object(task, "cancel", side_effect=fail_the_task) as patched_call: + await hass.async_stop() + assert patched_call.called + + assert "test_exception" in caplog.text + assert hass.state == ha.CoreState.stopped + + async def test_stage_shutdown_with_exit_code(hass: HomeAssistant) -> None: """Simulate a shutdown, test calling stuff with exit code checks.""" test_stop = async_capture_events(hass, EVENT_HOMEASSISTANT_STOP) @@ -1102,7 +1134,7 @@ async def test_statemachine_last_changed_not_updated_on_same_state( future = dt_util.utcnow() + timedelta(hours=10) - with patch("homeassistant.util.dt.utcnow", return_value=future): + with freeze_time(future): hass.states.async_set("light.Bowl", "on", {"attr": "triggers_change"}) await hass.async_block_till_done() @@ -2566,3 +2598,49 @@ def test_hassjob_passing_job_type(): HassJob(not_callback_func, job_type=ha.HassJobType.Callback).job_type == ha.HassJobType.Callback ) + + +async def test_shutdown_job(hass: HomeAssistant) -> None: + """Test async_add_shutdown_job.""" + evt = asyncio.Event() + + async def shutdown_func() -> None: + # Sleep to ensure core is waiting for the task to finish + await asyncio.sleep(0.01) + # Set the event + evt.set() + + job = HassJob(shutdown_func, "shutdown_job") + hass.async_add_shutdown_job(job) + await hass.async_stop() + assert evt.is_set() + + +async def test_cancel_shutdown_job(hass: HomeAssistant) -> None: + """Test cancelling a job added to async_add_shutdown_job.""" + evt = asyncio.Event() + + async def shutdown_func() -> None: + evt.set() + + job = HassJob(shutdown_func, "shutdown_job") + cancel = hass.async_add_shutdown_job(job) + cancel() + await hass.async_stop() + assert not evt.is_set() + + +@pytest.mark.parametrize( + ("enum"), + [ + ha.ConfigSource.DISCOVERED, + ha.ConfigSource.YAML, + ha.ConfigSource.STORAGE, + ], +) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: ha.ConfigSource, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum(caplog, ha, enum, "SOURCE_", "2025.1") diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 98380890e41..eb507febe8a 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -10,7 +10,7 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.core import HomeAssistant from homeassistant.util.decorator import Registry -from .common import async_capture_events +from .common import async_capture_events, import_and_test_deprecated_constant_enum @pytest.fixture @@ -802,3 +802,14 @@ async def test_find_flows_by_init_data_type( ) assert len(wifi_flows) == 0 assert len(manager.async_progress()) == 0 + + +@pytest.mark.parametrize(("enum"), list(data_entry_flow.FlowResultType)) +def test_deprecated_constants( + caplog: pytest.LogCaptureFixture, + enum: data_entry_flow.FlowResultType, +) -> None: + """Test deprecated constants.""" + import_and_test_deprecated_constant_enum( + caplog, data_entry_flow, enum, "RESULT_TYPE_", "2025.1" + ) diff --git a/tests/test_runner.py b/tests/test_runner.py index 3b06e3b64dc..14728321721 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util import executor, thread # https://github.com/home-assistant/supervisor/blob/main/supervisor/docker/homeassistant.py -SUPERVISOR_HARD_TIMEOUT = 220 +SUPERVISOR_HARD_TIMEOUT = 240 TIMEOUT_SAFETY_MARGIN = 10 @@ -21,9 +21,10 @@ TIMEOUT_SAFETY_MARGIN = 10 async def test_cumulative_shutdown_timeout_less_than_supervisor() -> None: """Verify the cumulative shutdown timeout is at least 10s less than the supervisor.""" assert ( - core.STAGE_1_SHUTDOWN_TIMEOUT - + core.STAGE_2_SHUTDOWN_TIMEOUT - + core.STAGE_3_SHUTDOWN_TIMEOUT + core.STOPPING_STAGE_SHUTDOWN_TIMEOUT + + core.STOP_STAGE_SHUTDOWN_TIMEOUT + + core.FINAL_WRITE_STAGE_SHUTDOWN_TIMEOUT + + core.CLOSE_STAGE_SHUTDOWN_TIMEOUT + executor.EXECUTOR_SHUTDOWN_TIMEOUT + thread.THREADING_SHUTDOWN_TIMEOUT + TIMEOUT_SAFETY_MARGIN diff --git a/tests/test_setup.py b/tests/test_setup.py index 00bb3fa2a2d..14c56d39a5a 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -672,7 +672,7 @@ async def test_async_get_loaded_integrations(hass: HomeAssistant) -> None: hass.config.components.add("notbase.switch") hass.config.components.add("myintegration") hass.config.components.add("device_tracker") - hass.config.components.add("device_tracker.other") + hass.config.components.add("other.device_tracker") hass.config.components.add("myintegration.light") assert setup.async_get_loaded_integrations(hass) == { "other", @@ -729,9 +729,9 @@ async def test_async_start_setup(hass: HomeAssistant) -> None: async def test_async_start_setup_platforms(hass: HomeAssistant) -> None: """Test setup started context manager keeps track of setup times for platforms.""" - with setup.async_start_setup(hass, ["sensor.august"]): + with setup.async_start_setup(hass, ["august.sensor"]): assert isinstance( - hass.data[setup.DATA_SETUP_STARTED]["sensor.august"], datetime.datetime + hass.data[setup.DATA_SETUP_STARTED]["august.sensor"], datetime.datetime ) assert "august" not in hass.data[setup.DATA_SETUP_STARTED] diff --git a/tests/test_test_fixtures.py b/tests/test_test_fixtures.py index 1c0fe0a7eaa..eb2103d4272 100644 --- a/tests/test_test_fixtures.py +++ b/tests/test_test_fixtures.py @@ -1,10 +1,16 @@ """Test test fixture configuration.""" +from http import HTTPStatus import socket +from aiohttp import web import pytest import pytest_socket +from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.setup import async_setup_component + +from .typing import ClientSessionGenerator def test_sockets_disabled() -> None: @@ -27,3 +33,38 @@ async def test_hass_cv(hass: HomeAssistant) -> None: in the fixture and that async_get_hass() works correctly. """ assert async_get_hass() is hass + + +def register_view(hass: HomeAssistant) -> None: + """Register a view.""" + + class TestView(HomeAssistantView): + """Test view to serve the test.""" + + requires_auth = False + url = "/api/test" + name = "api:test" + + async def get(self, request: web.Request) -> web.Response: + """Return a test result.""" + return self.json({"test": True}) + + hass.http.register_view(TestView()) + + +async def test_aiohttp_client_frozen_router_view( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, +) -> None: + """Test aiohttp_client fixture patches frozen router for views.""" + assert await async_setup_component(hass, "http", {}) + await hass.async_block_till_done() + + # Registering the view after starting the server should still work. + client = await hass_client() + register_view(hass) + + response = await client.get("/api/test") + assert response.status == HTTPStatus.OK + result = await response.json() + assert result["test"] is True diff --git a/tests/testing_config/custom_components/test/alarm_control_panel.py b/tests/testing_config/custom_components/test/alarm_control_panel.py index b39c2c71eda..7490a7703a4 100644 --- a/tests/testing_config/custom_components/test/alarm_control_panel.py +++ b/tests/testing_config/custom_components/test/alarm_control_panel.py @@ -4,11 +4,7 @@ Call init before using it in your tests to ensure clean test data. """ from homeassistant.components.alarm_control_panel import AlarmControlPanelEntity from homeassistant.components.alarm_control_panel.const import ( - SUPPORT_ALARM_ARM_AWAY, - SUPPORT_ALARM_ARM_HOME, - SUPPORT_ALARM_ARM_NIGHT, - SUPPORT_ALARM_ARM_VACATION, - SUPPORT_ALARM_TRIGGER, + AlarmControlPanelEntityFeature, ) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, @@ -73,14 +69,14 @@ class MockAlarm(MockEntity, AlarmControlPanelEntity): return self._state @property - def supported_features(self) -> int: + def supported_features(self) -> AlarmControlPanelEntityFeature: """Return the list of supported features.""" return ( - SUPPORT_ALARM_ARM_HOME - | SUPPORT_ALARM_ARM_AWAY - | SUPPORT_ALARM_ARM_NIGHT - | SUPPORT_ALARM_TRIGGER - | SUPPORT_ALARM_ARM_VACATION + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.TRIGGER + | AlarmControlPanelEntityFeature.ARM_VACATION ) def alarm_arm_away(self, code=None): diff --git a/tests/testing_config/custom_components/test/cover.py b/tests/testing_config/custom_components/test/cover.py index 51a4a9dc83b..2a57412ea9e 100644 --- a/tests/testing_config/custom_components/test/cover.py +++ b/tests/testing_config/custom_components/test/cover.py @@ -2,17 +2,7 @@ Call init before using it in your tests to ensure clean test data. """ -from homeassistant.components.cover import ( - SUPPORT_CLOSE, - SUPPORT_CLOSE_TILT, - SUPPORT_OPEN, - SUPPORT_OPEN_TILT, - SUPPORT_SET_POSITION, - SUPPORT_SET_TILT_POSITION, - SUPPORT_STOP, - SUPPORT_STOP_TILT, - CoverEntity, -) +from homeassistant.components.cover import CoverEntity, CoverEntityFeature from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING from tests.common import MockEntity @@ -32,38 +22,38 @@ def init(empty=False): name="Simple cover", is_on=True, unique_id="unique_cover", - supported_features=SUPPORT_OPEN | SUPPORT_CLOSE, + supported_features=CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, ), MockCover( name="Set position cover", is_on=True, unique_id="unique_set_pos_cover", current_cover_position=50, - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_STOP - | SUPPORT_SET_POSITION, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION, ), MockCover( name="Simple tilt cover", is_on=True, unique_id="unique_tilt_cover", - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_OPEN_TILT - | SUPPORT_CLOSE_TILT, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT, ), MockCover( name="Set tilt position cover", is_on=True, unique_id="unique_set_pos_tilt_cover", current_cover_tilt_position=50, - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_OPEN_TILT - | SUPPORT_CLOSE_TILT - | SUPPORT_STOP_TILT - | SUPPORT_SET_TILT_POSITION, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, ), MockCover( name="All functions cover", @@ -71,14 +61,14 @@ def init(empty=False): unique_id="unique_all_functions_cover", current_cover_position=50, current_cover_tilt_position=50, - supported_features=SUPPORT_OPEN - | SUPPORT_CLOSE - | SUPPORT_STOP - | SUPPORT_SET_POSITION - | SUPPORT_OPEN_TILT - | SUPPORT_CLOSE_TILT - | SUPPORT_STOP_TILT - | SUPPORT_SET_TILT_POSITION, + supported_features=CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION, ), ] ) @@ -97,7 +87,7 @@ class MockCover(MockEntity, CoverEntity): @property def is_closed(self): """Return if the cover is closed or not.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: return self.current_cover_position == 0 if "state" in self._values: @@ -107,7 +97,7 @@ class MockCover(MockEntity, CoverEntity): @property def is_opening(self): """Return if the cover is opening or not.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: if "state" in self._values: return self._values["state"] == STATE_OPENING @@ -116,7 +106,7 @@ class MockCover(MockEntity, CoverEntity): @property def is_closing(self): """Return if the cover is closing or not.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: if "state" in self._values: return self._values["state"] == STATE_CLOSING @@ -124,14 +114,14 @@ class MockCover(MockEntity, CoverEntity): def open_cover(self, **kwargs) -> None: """Open cover.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: self._values["state"] = STATE_OPENING else: self._values["state"] = STATE_OPEN def close_cover(self, **kwargs) -> None: """Close cover.""" - if self.supported_features & SUPPORT_STOP: + if self.supported_features & CoverEntityFeature.STOP: self._values["state"] = STATE_CLOSING else: self._values["state"] = STATE_CLOSED diff --git a/tests/testing_config/custom_components/test/device_tracker.py b/tests/testing_config/custom_components/test/device_tracker.py index 31294a48e3d..11eb366f2fc 100644 --- a/tests/testing_config/custom_components/test/device_tracker.py +++ b/tests/testing_config/custom_components/test/device_tracker.py @@ -2,7 +2,7 @@ from homeassistant.components.device_tracker import DeviceScanner from homeassistant.components.device_tracker.config_entry import ScannerEntity -from homeassistant.components.device_tracker.const import SOURCE_TYPE_ROUTER +from homeassistant.components.device_tracker.const import SourceType async def async_get_scanner(hass, config): @@ -23,7 +23,7 @@ class MockScannerEntity(ScannerEntity): @property def source_type(self): """Return the source type, eg gps or router, of the device.""" - return SOURCE_TYPE_ROUTER + return SourceType.ROUTER @property def battery_level(self): diff --git a/tests/testing_config/custom_components/test/lock.py b/tests/testing_config/custom_components/test/lock.py index b48e8b1fad9..9cefa34363e 100644 --- a/tests/testing_config/custom_components/test/lock.py +++ b/tests/testing_config/custom_components/test/lock.py @@ -2,7 +2,7 @@ Call init before using it in your tests to ensure clean test data. """ -from homeassistant.components.lock import SUPPORT_OPEN, LockEntity +from homeassistant.components.lock import LockEntity, LockEntityFeature from tests.common import MockEntity @@ -20,7 +20,7 @@ def init(empty=False): "support_open": MockLock( name="Support open Lock", is_locked=True, - supported_features=SUPPORT_OPEN, + supported_features=LockEntityFeature.OPEN, unique_id="unique_support_open", ), "no_support_open": MockLock( diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index 4eae52fd4a5..d436a94e329 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -11,14 +11,14 @@ from homeassistant.components.sensor import ( from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, - FREQUENCY_GIGAHERTZ, LIGHT_LUX, PERCENTAGE, - POWER_VOLT_AMPERE, POWER_VOLT_AMPERE_REACTIVE, SIGNAL_STRENGTH_DECIBELS, - VOLUME_CUBIC_METERS, + UnitOfApparentPower, + UnitOfFrequency, UnitOfPressure, + UnitOfVolume, ) from tests.common import MockEntity @@ -26,7 +26,7 @@ from tests.common import MockEntity DEVICE_CLASSES.append("none") UNITS_OF_MEASUREMENT = { - SensorDeviceClass.APPARENT_POWER: POWER_VOLT_AMPERE, # apparent power (VA) + SensorDeviceClass.APPARENT_POWER: UnitOfApparentPower.VOLT_AMPERE, # apparent power (VA) SensorDeviceClass.BATTERY: PERCENTAGE, # % of battery that is left SensorDeviceClass.CO: CONCENTRATION_PARTS_PER_MILLION, # ppm of CO concentration SensorDeviceClass.CO2: CONCENTRATION_PARTS_PER_MILLION, # ppm of CO2 concentration @@ -47,12 +47,12 @@ UNITS_OF_MEASUREMENT = { SensorDeviceClass.POWER: "kW", # power (W/kW) SensorDeviceClass.CURRENT: "A", # current (A) SensorDeviceClass.ENERGY: "kWh", # energy (Wh/kWh/MWh) - SensorDeviceClass.FREQUENCY: FREQUENCY_GIGAHERTZ, # energy (Hz/kHz/MHz/GHz) + SensorDeviceClass.FREQUENCY: UnitOfFrequency.GIGAHERTZ, # energy (Hz/kHz/MHz/GHz) SensorDeviceClass.POWER_FACTOR: PERCENTAGE, # power factor (no unit, min: -1.0, max: 1.0) SensorDeviceClass.REACTIVE_POWER: POWER_VOLT_AMPERE_REACTIVE, # reactive power (var) SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, # µg/m³ of vocs SensorDeviceClass.VOLTAGE: "V", # voltage (V) - SensorDeviceClass.GAS: VOLUME_CUBIC_METERS, # gas (m³) + SensorDeviceClass.GAS: UnitOfVolume.CUBIC_METERS, # gas (m³) } ENTITIES = {} diff --git a/tests/testing_config/custom_components/test_constant_deprecation/__init__.py b/tests/testing_config/custom_components/test_constant_deprecation/__init__.py new file mode 100644 index 00000000000..4367cbed7b1 --- /dev/null +++ b/tests/testing_config/custom_components/test_constant_deprecation/__init__.py @@ -0,0 +1,9 @@ +"""Test deprecated constants custom integration.""" + +from types import ModuleType +from typing import Any + + +def import_deprecated_costant(module: ModuleType, constant_name: str) -> Any: + """Import and return deprecated constant.""" + return getattr(module, constant_name) diff --git a/tests/util/snapshots/test_color.ambr b/tests/util/snapshots/test_color.ambr new file mode 100644 index 00000000000..514502131fb --- /dev/null +++ b/tests/util/snapshots/test_color.ambr @@ -0,0 +1,519 @@ +# serializer version: 1 +# name: test_brightness_to_254_range + dict({ + 1: 0.996078431372549, + 2: 1.992156862745098, + 3: 2.988235294117647, + 4: 3.984313725490196, + 5: 4.980392156862745, + 6: 5.976470588235294, + 7: 6.972549019607843, + 8: 7.968627450980392, + 9: 8.964705882352941, + 10: 9.96078431372549, + 11: 10.95686274509804, + 12: 11.952941176470588, + 13: 12.949019607843137, + 14: 13.945098039215686, + 15: 14.941176470588236, + 16: 15.937254901960785, + 17: 16.933333333333334, + 18: 17.929411764705883, + 19: 18.92549019607843, + 20: 19.92156862745098, + 21: 20.91764705882353, + 22: 21.91372549019608, + 23: 22.909803921568628, + 24: 23.905882352941177, + 25: 24.901960784313726, + 26: 25.898039215686275, + 27: 26.894117647058824, + 28: 27.890196078431373, + 29: 28.886274509803922, + 30: 29.88235294117647, + 31: 30.87843137254902, + 32: 31.87450980392157, + 33: 32.870588235294115, + 34: 33.86666666666667, + 35: 34.86274509803921, + 36: 35.858823529411765, + 37: 36.85490196078431, + 38: 37.85098039215686, + 39: 38.84705882352941, + 40: 39.84313725490196, + 41: 40.83921568627451, + 42: 41.83529411764706, + 43: 42.831372549019605, + 44: 43.82745098039216, + 45: 44.8235294117647, + 46: 45.819607843137256, + 47: 46.8156862745098, + 48: 47.811764705882354, + 49: 48.8078431372549, + 50: 49.80392156862745, + 51: 50.8, + 52: 51.79607843137255, + 53: 52.792156862745095, + 54: 53.78823529411765, + 55: 54.78431372549019, + 56: 55.780392156862746, + 57: 56.77647058823529, + 58: 57.772549019607844, + 59: 58.76862745098039, + 60: 59.76470588235294, + 61: 60.76078431372549, + 62: 61.75686274509804, + 63: 62.752941176470586, + 64: 63.74901960784314, + 65: 64.74509803921569, + 66: 65.74117647058823, + 67: 66.73725490196078, + 68: 67.73333333333333, + 69: 68.72941176470589, + 70: 69.72549019607843, + 71: 70.72156862745098, + 72: 71.71764705882353, + 73: 72.71372549019608, + 74: 73.70980392156862, + 75: 74.70588235294117, + 76: 75.70196078431373, + 77: 76.69803921568628, + 78: 77.69411764705882, + 79: 78.69019607843137, + 80: 79.68627450980392, + 81: 80.68235294117648, + 82: 81.67843137254901, + 83: 82.67450980392157, + 84: 83.67058823529412, + 85: 84.66666666666667, + 86: 85.66274509803921, + 87: 86.65882352941176, + 88: 87.65490196078431, + 89: 88.65098039215687, + 90: 89.6470588235294, + 91: 90.64313725490196, + 92: 91.63921568627451, + 93: 92.63529411764706, + 94: 93.6313725490196, + 95: 94.62745098039215, + 96: 95.62352941176471, + 97: 96.61960784313726, + 98: 97.6156862745098, + 99: 98.61176470588235, + 100: 99.6078431372549, + 101: 100.60392156862746, + 102: 101.6, + 103: 102.59607843137255, + 104: 103.5921568627451, + 105: 104.58823529411765, + 106: 105.58431372549019, + 107: 106.58039215686274, + 108: 107.5764705882353, + 109: 108.57254901960785, + 110: 109.56862745098039, + 111: 110.56470588235294, + 112: 111.56078431372549, + 113: 112.55686274509804, + 114: 113.55294117647058, + 115: 114.54901960784314, + 116: 115.54509803921569, + 117: 116.54117647058824, + 118: 117.53725490196078, + 119: 118.53333333333333, + 120: 119.52941176470588, + 121: 120.52549019607844, + 122: 121.52156862745097, + 123: 122.51764705882353, + 124: 123.51372549019608, + 125: 124.50980392156863, + 126: 125.50588235294117, + 127: 126.50196078431372, + 128: 127.49803921568628, + 129: 128.49411764705883, + 130: 129.49019607843138, + 131: 130.48627450980393, + 132: 131.48235294117646, + 133: 132.478431372549, + 134: 133.47450980392156, + 135: 134.47058823529412, + 136: 135.46666666666667, + 137: 136.46274509803922, + 138: 137.45882352941177, + 139: 138.45490196078433, + 140: 139.45098039215685, + 141: 140.4470588235294, + 142: 141.44313725490196, + 143: 142.4392156862745, + 144: 143.43529411764706, + 145: 144.4313725490196, + 146: 145.42745098039217, + 147: 146.42352941176472, + 148: 147.41960784313724, + 149: 148.4156862745098, + 150: 149.41176470588235, + 151: 150.4078431372549, + 152: 151.40392156862745, + 153: 152.4, + 154: 153.39607843137256, + 155: 154.3921568627451, + 156: 155.38823529411764, + 157: 156.3843137254902, + 158: 157.38039215686274, + 159: 158.3764705882353, + 160: 159.37254901960785, + 161: 160.3686274509804, + 162: 161.36470588235295, + 163: 162.3607843137255, + 164: 163.35686274509803, + 165: 164.35294117647058, + 166: 165.34901960784313, + 167: 166.34509803921569, + 168: 167.34117647058824, + 169: 168.3372549019608, + 170: 169.33333333333334, + 171: 170.3294117647059, + 172: 171.32549019607842, + 173: 172.32156862745097, + 174: 173.31764705882352, + 175: 174.31372549019608, + 176: 175.30980392156863, + 177: 176.30588235294118, + 178: 177.30196078431374, + 179: 178.2980392156863, + 180: 179.2941176470588, + 181: 180.29019607843136, + 182: 181.28627450980392, + 183: 182.28235294117647, + 184: 183.27843137254902, + 185: 184.27450980392157, + 186: 185.27058823529413, + 187: 186.26666666666668, + 188: 187.2627450980392, + 189: 188.25882352941176, + 190: 189.2549019607843, + 191: 190.25098039215686, + 192: 191.24705882352941, + 193: 192.24313725490197, + 194: 193.23921568627452, + 195: 194.23529411764707, + 196: 195.2313725490196, + 197: 196.22745098039215, + 198: 197.2235294117647, + 199: 198.21960784313725, + 200: 199.2156862745098, + 201: 200.21176470588236, + 202: 201.2078431372549, + 203: 202.20392156862746, + 204: 203.2, + 205: 204.19607843137254, + 206: 205.1921568627451, + 207: 206.18823529411765, + 208: 207.1843137254902, + 209: 208.18039215686275, + 210: 209.1764705882353, + 211: 210.17254901960786, + 212: 211.16862745098038, + 213: 212.16470588235293, + 214: 213.1607843137255, + 215: 214.15686274509804, + 216: 215.1529411764706, + 217: 216.14901960784314, + 218: 217.1450980392157, + 219: 218.14117647058825, + 220: 219.13725490196077, + 221: 220.13333333333333, + 222: 221.12941176470588, + 223: 222.12549019607843, + 224: 223.12156862745098, + 225: 224.11764705882354, + 226: 225.1137254901961, + 227: 226.10980392156864, + 228: 227.10588235294117, + 229: 228.10196078431372, + 230: 229.09803921568627, + 231: 230.09411764705882, + 232: 231.09019607843138, + 233: 232.08627450980393, + 234: 233.08235294117648, + 235: 234.07843137254903, + 236: 235.07450980392156, + 237: 236.0705882352941, + 238: 237.06666666666666, + 239: 238.06274509803922, + 240: 239.05882352941177, + 241: 240.05490196078432, + 242: 241.05098039215687, + 243: 242.04705882352943, + 244: 243.04313725490195, + 245: 244.0392156862745, + 246: 245.03529411764706, + 247: 246.0313725490196, + 248: 247.02745098039216, + 249: 248.0235294117647, + 250: 249.01960784313727, + 251: 250.01568627450982, + 252: 251.01176470588234, + 253: 252.0078431372549, + 254: 253.00392156862745, + 255: 254.0, + }) +# --- +# name: test_brightness_to_254_range.1 + dict({ + 0.996078431372549: 1, + 1.992156862745098: 2, + 2.988235294117647: 3, + 3.984313725490196: 4, + 4.980392156862745: 5, + 5.976470588235294: 6, + 6.972549019607843: 7, + 7.968627450980392: 8, + 8.964705882352941: 9, + 9.96078431372549: 10, + 10.95686274509804: 11, + 11.952941176470588: 12, + 12.949019607843137: 13, + 13.945098039215686: 14, + 14.941176470588236: 15, + 15.937254901960785: 16, + 16.933333333333334: 17, + 17.929411764705883: 18, + 18.92549019607843: 19, + 19.92156862745098: 20, + 20.91764705882353: 21, + 21.91372549019608: 22, + 22.909803921568628: 23, + 23.905882352941177: 24, + 24.901960784313726: 25, + 25.898039215686275: 26, + 26.894117647058824: 27, + 27.890196078431373: 28, + 28.886274509803922: 29, + 29.88235294117647: 30, + 30.87843137254902: 31, + 31.87450980392157: 32, + 32.870588235294115: 33, + 33.86666666666667: 34, + 34.86274509803921: 35, + 35.858823529411765: 36, + 36.85490196078431: 37, + 37.85098039215686: 38, + 38.84705882352941: 39, + 39.84313725490196: 40, + 40.83921568627451: 41, + 41.83529411764706: 42, + 42.831372549019605: 43, + 43.82745098039216: 44, + 44.8235294117647: 45, + 45.819607843137256: 46, + 46.8156862745098: 47, + 47.811764705882354: 48, + 48.8078431372549: 49, + 49.80392156862745: 50, + 50.8: 51, + 51.79607843137255: 52, + 52.792156862745095: 53, + 53.78823529411765: 54, + 54.78431372549019: 55, + 55.780392156862746: 56, + 56.77647058823529: 57, + 57.772549019607844: 58, + 58.76862745098039: 59, + 59.76470588235294: 60, + 60.76078431372549: 61, + 61.75686274509804: 62, + 62.752941176470586: 63, + 63.74901960784314: 64, + 64.74509803921569: 65, + 65.74117647058823: 66, + 66.73725490196078: 67, + 67.73333333333333: 68, + 68.72941176470589: 69, + 69.72549019607843: 70, + 70.72156862745098: 71, + 71.71764705882353: 72, + 72.71372549019608: 73, + 73.70980392156862: 74, + 74.70588235294117: 75, + 75.70196078431373: 76, + 76.69803921568628: 77, + 77.69411764705882: 78, + 78.69019607843137: 79, + 79.68627450980392: 80, + 80.68235294117648: 81, + 81.67843137254901: 82, + 82.67450980392157: 83, + 83.67058823529412: 84, + 84.66666666666667: 85, + 85.66274509803921: 86, + 86.65882352941176: 87, + 87.65490196078431: 88, + 88.65098039215687: 89, + 89.6470588235294: 90, + 90.64313725490196: 91, + 91.63921568627451: 92, + 92.63529411764706: 93, + 93.6313725490196: 94, + 94.62745098039215: 95, + 95.62352941176471: 96, + 96.61960784313726: 97, + 97.6156862745098: 98, + 98.61176470588235: 99, + 99.6078431372549: 100, + 100.60392156862746: 101, + 101.6: 102, + 102.59607843137255: 103, + 103.5921568627451: 104, + 104.58823529411765: 105, + 105.58431372549019: 106, + 106.58039215686274: 107, + 107.5764705882353: 108, + 108.57254901960785: 109, + 109.56862745098039: 110, + 110.56470588235294: 111, + 111.56078431372549: 112, + 112.55686274509804: 113, + 113.55294117647058: 114, + 114.54901960784314: 115, + 115.54509803921569: 116, + 116.54117647058824: 117, + 117.53725490196078: 118, + 118.53333333333333: 119, + 119.52941176470588: 120, + 120.52549019607844: 121, + 121.52156862745097: 122, + 122.51764705882353: 123, + 123.51372549019608: 124, + 124.50980392156863: 125, + 125.50588235294117: 126, + 126.50196078431372: 127, + 127.49803921568628: 128, + 128.49411764705883: 129, + 129.49019607843138: 130, + 130.48627450980393: 131, + 131.48235294117646: 132, + 132.478431372549: 133, + 133.47450980392156: 134, + 134.47058823529412: 135, + 135.46666666666667: 136, + 136.46274509803922: 137, + 137.45882352941177: 138, + 138.45490196078433: 139, + 139.45098039215685: 140, + 140.4470588235294: 141, + 141.44313725490196: 142, + 142.4392156862745: 143, + 143.43529411764706: 144, + 144.4313725490196: 145, + 145.42745098039217: 146, + 146.42352941176472: 147, + 147.41960784313724: 148, + 148.4156862745098: 149, + 149.41176470588235: 150, + 150.4078431372549: 151, + 151.40392156862745: 152, + 152.4: 153, + 153.39607843137256: 154, + 154.3921568627451: 155, + 155.38823529411764: 156, + 156.3843137254902: 157, + 157.38039215686274: 158, + 158.3764705882353: 159, + 159.37254901960785: 160, + 160.3686274509804: 161, + 161.36470588235295: 162, + 162.3607843137255: 163, + 163.35686274509803: 164, + 164.35294117647058: 165, + 165.34901960784313: 166, + 166.34509803921569: 167, + 167.34117647058824: 168, + 168.3372549019608: 169, + 169.33333333333334: 170, + 170.3294117647059: 171, + 171.32549019607842: 172, + 172.32156862745097: 173, + 173.31764705882352: 174, + 174.31372549019608: 175, + 175.30980392156863: 176, + 176.30588235294118: 177, + 177.30196078431374: 178, + 178.2980392156863: 179, + 179.2941176470588: 180, + 180.29019607843136: 181, + 181.28627450980392: 182, + 182.28235294117647: 183, + 183.27843137254902: 184, + 184.27450980392157: 185, + 185.27058823529413: 186, + 186.26666666666668: 187, + 187.2627450980392: 188, + 188.25882352941176: 189, + 189.2549019607843: 190, + 190.25098039215686: 191, + 191.24705882352941: 192, + 192.24313725490197: 193, + 193.23921568627452: 194, + 194.23529411764707: 195, + 195.2313725490196: 196, + 196.22745098039215: 197, + 197.2235294117647: 198, + 198.21960784313725: 199, + 199.2156862745098: 200, + 200.21176470588236: 201, + 201.2078431372549: 202, + 202.20392156862746: 203, + 203.2: 204, + 204.19607843137254: 205, + 205.1921568627451: 206, + 206.18823529411765: 207, + 207.1843137254902: 208, + 208.18039215686275: 209, + 209.1764705882353: 210, + 210.17254901960786: 211, + 211.16862745098038: 212, + 212.16470588235293: 213, + 213.1607843137255: 214, + 214.15686274509804: 215, + 215.1529411764706: 216, + 216.14901960784314: 217, + 217.1450980392157: 218, + 218.14117647058825: 219, + 219.13725490196077: 220, + 220.13333333333333: 221, + 221.12941176470588: 222, + 222.12549019607843: 223, + 223.12156862745098: 224, + 224.11764705882354: 225, + 225.1137254901961: 226, + 226.10980392156864: 227, + 227.10588235294117: 228, + 228.10196078431372: 229, + 229.09803921568627: 230, + 230.09411764705882: 231, + 231.09019607843138: 232, + 232.08627450980393: 233, + 233.08235294117648: 234, + 234.07843137254903: 235, + 235.07450980392156: 236, + 236.0705882352941: 237, + 237.06666666666666: 238, + 238.06274509803922: 239, + 239.05882352941177: 240, + 240.05490196078432: 241, + 241.05098039215687: 242, + 242.04705882352943: 243, + 243.04313725490195: 244, + 244.0392156862745: 245, + 245.03529411764706: 246, + 246.0313725490196: 247, + 247.02745098039216: 248, + 248.0235294117647: 249, + 249.01960784313727: 250, + 250.01568627450982: 251, + 251.01176470588234: 252, + 252.0078431372549: 253, + 253.00392156862745: 254, + 254.0: 255, + }) +# --- diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 7c5e959aabc..5dd20d8d887 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -1,5 +1,8 @@ """Test Home Assistant color util methods.""" +import math + import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol import homeassistant.util.color as color_util @@ -270,6 +273,15 @@ def test_color_rgbw_to_rgb() -> None: assert color_util.color_rgbw_to_rgb(0, 0, 0, 127) == (127, 127, 127) +def test_color_xy_to_temperature() -> None: + """Test color_xy_to_temperature.""" + assert color_util.color_xy_to_temperature(0.5119, 0.4147) == 2136 + assert color_util.color_xy_to_temperature(0.368, 0.3686) == 4302 + assert color_util.color_xy_to_temperature(0.4448, 0.4066) == 2893 + assert color_util.color_xy_to_temperature(0.1, 0.8) == 8645 + assert color_util.color_xy_to_temperature(0.5, 0.4) == 2140 + + def test_color_rgb_to_hex() -> None: """Test color_rgb_to_hex.""" assert color_util.color_rgb_to_hex(255, 255, 255) == "ffffff" @@ -578,3 +590,137 @@ def test_white_levels_to_color_temperature() -> None: 2000, 0, ) + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (530, 255), # test min==255 clamp + (511, 255), + (255, 127), + (49, 24), + (1, 1), + (0, 1), # test max==1 clamp + ], +) +async def test_ranged_value_to_brightness_large(value: float, brightness: int) -> None: + """Test a large scale and clamping and convert a single value to a brightness.""" + scale = (1, 511) + + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("brightness", "value", "math_ceil"), + [ + (255, 511.0, 511), + (127, 254.49803921568628, 255), + (24, 48.09411764705882, 49), + ], +) +async def test_brightness_to_ranged_value_large( + brightness: int, value: float, math_ceil: int +) -> None: + """Test a large scale and convert a brightness to a single value.""" + scale = (1, 511) + + assert color_util.brightness_to_value(scale, brightness) == value + + assert math.ceil(color_util.brightness_to_value(scale, brightness)) == math_ceil + + +@pytest.mark.parametrize( + ("scale", "value", "brightness"), + [ + ((1, 4), 1, 64), + ((1, 4), 2, 128), + ((1, 4), 3, 191), + ((1, 4), 4, 255), + ((1, 6), 1, 42), + ((1, 6), 2, 85), + ((1, 6), 3, 128), + ((1, 6), 4, 170), + ((1, 6), 5, 212), + ((1, 6), 6, 255), + ], +) +async def test_ranged_value_to_brightness_small( + scale: tuple[float, float], value: float, brightness: int +) -> None: + """Test a small scale and convert a single value to a brightness.""" + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("scale", "brightness", "value"), + [ + ((1, 4), 63, 1), + ((1, 4), 127, 2), + ((1, 4), 191, 3), + ((1, 4), 255, 4), + ((1, 6), 42, 1), + ((1, 6), 85, 2), + ((1, 6), 127, 3), + ((1, 6), 170, 4), + ((1, 6), 212, 5), + ((1, 6), 255, 6), + ], +) +async def test_brightness_to_ranged_value_small( + scale: tuple[float, float], brightness: int, value: float +) -> None: + """Test a small scale and convert a brightness to a single value.""" + assert math.ceil(color_util.brightness_to_value(scale, brightness)) == value + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (101, 2), + (139, 64), + (178, 128), + (217, 192), + (255, 255), + ], +) +async def test_ranged_value_to_brightness_starting_high( + value: float, brightness: int +) -> None: + """Test a range that does not start with 1.""" + scale = (101, 255) + + assert color_util.value_to_brightness(scale, value) == brightness + + +@pytest.mark.parametrize( + ("value", "brightness"), + [ + (0, 64), + (1, 128), + (2, 191), + (3, 255), + ], +) +async def test_ranged_value_to_brightness_starting_zero( + value: float, brightness: int +) -> None: + """Test a range that starts with 0.""" + scale = (0, 3) + + assert color_util.value_to_brightness(scale, value) == brightness + + +async def test_brightness_to_254_range(snapshot: SnapshotAssertion) -> None: + """Test brightness scaling to a 254 range and back.""" + brightness_range = range(1, 256) # (1..255) + scale = (1, 254) + scaled_values = { + brightness: color_util.brightness_to_value(scale, brightness) + for brightness in brightness_range + } + assert scaled_values == snapshot + restored_values = {} + for expected_brightness, value in scaled_values.items(): + restored_values[value] = color_util.value_to_brightness(scale, value) + assert color_util.value_to_brightness(scale, value) == expected_brightness + assert restored_values == snapshot diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 28695a94400..a973135d831 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import UTC, datetime, timedelta -import time import pytest @@ -737,8 +736,3 @@ def test_find_next_time_expression_tenth_second_pattern_does_not_drift_entering_ assert (next_target - prev_target).total_seconds() == 60 assert next_target.second == 10 prev_target = next_target - - -def test_monotonic_time_coarse() -> None: - """Test monotonic time coarse.""" - assert abs(time.monotonic() - dt_util.monotonic_time_coarse()) < 1 diff --git a/tests/util/test_executor.py b/tests/util/test_executor.py index 076864c65c4..d7731a44b7d 100644 --- a/tests/util/test_executor.py +++ b/tests/util/test_executor.py @@ -88,6 +88,10 @@ async def test_overall_timeout_reached(caplog: pytest.LogCaptureFixture) -> None iexecutor.shutdown() finish = time.monotonic() - assert finish - start < 1.3 + # Idealy execution time (finish - start) should be < 1.2 sec. + # CI tests might not run in an ideal environment and timing might + # not be accurate, so we let this test pass + # if the duration is below 3 seconds. + assert finish - start < 3.0 iexecutor.shutdown() diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index a08311cca4f..350baa9d4c2 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -7,7 +7,12 @@ from unittest.mock import patch import pytest -from homeassistant.core import HomeAssistant, callback, is_callback +from homeassistant.core import ( + HomeAssistant, + callback, + is_callback, + is_callback_check_partial, +) import homeassistant.util.logging as logging_util @@ -93,7 +98,7 @@ def test_catch_log_exception() -> None: def callback_meth(): pass - assert is_callback( + assert is_callback_check_partial( logging_util.catch_log_exception(partial(callback_meth), lambda: None) ) @@ -104,3 +109,39 @@ def test_catch_log_exception() -> None: assert not is_callback(wrapped) assert not asyncio.iscoroutinefunction(wrapped) + + +@pytest.mark.no_fail_on_log_exception +async def test_catch_log_exception_catches_and_logs() -> None: + """Test it is still a callback after wrapping including partial.""" + saved_args = [] + + def save_args(*args): + saved_args.append(args) + + async def async_meth(): + raise ValueError("failure async") + + func = logging_util.catch_log_exception(async_meth, save_args) + await func("failure async passed") + + assert saved_args == [("failure async passed",)] + saved_args.clear() + + @callback + def callback_meth(): + raise ValueError("failure callback") + + func = logging_util.catch_log_exception(callback_meth, save_args) + func("failure callback passed") + + assert saved_args == [("failure callback passed",)] + saved_args.clear() + + def sync_meth(): + raise ValueError("failure sync") + + func = logging_util.catch_log_exception(sync_meth, save_args) + func("failure sync passed") + + assert saved_args == [("failure sync passed",)] diff --git a/tests/util/test_scaling.py b/tests/util/test_scaling.py new file mode 100644 index 00000000000..5fef6cf806b --- /dev/null +++ b/tests/util/test_scaling.py @@ -0,0 +1,249 @@ +"""Test Home Assistant scaling utils.""" + +import math + +import pytest + +from homeassistant.util.percentage import ( + scale_ranged_value_to_int_range, + scale_to_ranged_value, +) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (255, 100), + (127, 49), + (10, 3), + (1, 0), + ], +) +async def test_ranged_value_to_int_range_large( + input_val: float, output_val: int +) -> None: + """Test a large range of low and high values convert a single value to a percentage.""" + source_range = (1, 255) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val", "math_ceil"), + [ + (100, 255, 255), + (50, 127.5, 128), + (4, 10.2, 11), + ], +) +async def test_scale_to_ranged_value_large( + input_val: float, output_val: float, math_ceil: int +) -> None: + """Test a large range of low and high values convert an int to a single value.""" + source_range = (1, 100) + dest_range = (1, 255) + + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_val + + assert ( + math.ceil(scale_to_ranged_value(source_range, dest_range, input_val)) + == math_ceil + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (1, 16), + (2, 33), + (3, 50), + (4, 66), + (5, 83), + (6, 100), + ], +) +async def test_scale_ranged_value_to_int_range_small( + input_val: float, output_val: int +) -> None: + """Test a small range of low and high values convert a single value to a percentage.""" + source_range = (1, 6) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (16, 1), + (33, 2), + (50, 3), + (66, 4), + (83, 5), + (100, 6), + ], +) +async def test_scale_to_ranged_value_small(input_val: float, output_val: int) -> None: + """Test a small range of low and high values convert an int to a single value.""" + source_range = (1, 100) + dest_range = (1, 6) + + assert ( + math.ceil(scale_to_ranged_value(source_range, dest_range, input_val)) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (1, 25), + (2, 50), + (3, 75), + (4, 100), + ], +) +async def test_scale_ranged_value_to_int_range_starting_at_one( + input_val: float, output_val: int +) -> None: + """Test a range that starts with 1.""" + source_range = (1, 4) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (101, 0), + (139, 25), + (178, 50), + (217, 75), + (255, 100), + ], +) +async def test_scale_ranged_value_to_int_range_starting_high( + input_val: float, output_val: int +) -> None: + """Test a range that does not start with 1.""" + source_range = (101, 255) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_int", "output_float"), + [ + (0.0, 25, 25.0), + (1.0, 50, 50.0), + (2.0, 75, 75.0), + (3.0, 100, 100.0), + ], +) +async def test_scale_ranged_value_to_scaled_range_starting_zero( + input_val: float, output_int: int, output_float: float +) -> None: + """Test a range that starts with 0.""" + source_range = (0, 3) + dest_range = (1, 100) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_int + ) + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_float + assert scale_ranged_value_to_int_range( + dest_range, source_range, output_float + ) == int(input_val) + assert scale_to_ranged_value(dest_range, source_range, output_float) == input_val + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (101, 100), + (139, 125), + (178, 150), + (217, 175), + (255, 200), + ], +) +async def test_scale_ranged_value_to_int_range_starting_high_with_offset( + input_val: float, output_val: int +) -> None: + """Test a ranges that do not start with 1.""" + source_range = (101, 255) + dest_range = (101, 200) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_val"), + [ + (0, 125), + (1, 150), + (2, 175), + (3, 200), + ], +) +async def test_scale_ranged_value_to_int_range_starting_zero_with_offset( + input_val: float, output_val: int +) -> None: + """Test a range that starts with 0 and an other starting high.""" + source_range = (0, 3) + dest_range = (101, 200) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_val + ) + + +@pytest.mark.parametrize( + ("input_val", "output_int", "output_float"), + [ + (0.0, 1, 1.0), + (1.0, 3, 3.0), + (2.0, 5, 5.0), + (3.0, 7, 7.0), + ], +) +async def test_scale_ranged_value_to_int_range_starting_zero_with_zero_offset( + input_val: float, output_int: int, output_float: float +) -> None: + """Test a ranges that start with 0. + + In case a range starts with 0, this means value 0 is the first value, + and the values shift -1. + """ + source_range = (0, 3) + dest_range = (0, 7) + + assert ( + scale_ranged_value_to_int_range(source_range, dest_range, input_val) + == output_int + ) + assert scale_to_ranged_value(source_range, dest_range, input_val) == output_float + assert scale_ranged_value_to_int_range(dest_range, source_range, output_int) == int( + input_val + ) + assert scale_to_ranged_value(dest_range, source_range, output_float) == input_val diff --git a/tests/util/test_ssl.py b/tests/util/test_ssl.py index 4d43859cc44..4a88e061cbc 100644 --- a/tests/util/test_ssl.py +++ b/tests/util/test_ssl.py @@ -51,3 +51,12 @@ def test_no_verify_ssl_context(mock_sslcontext) -> None: mock_sslcontext.set_ciphers.assert_called_with( SSL_CIPHER_LISTS[SSLCipherList.INTERMEDIATE] ) + + +def test_ssl_context_caching() -> None: + """Test that SSLContext instances are cached correctly.""" + + assert client_context() is client_context(SSLCipherList.PYTHON_DEFAULT) + assert create_no_verify_ssl_context() is create_no_verify_ssl_context( + SSLCipherList.PYTHON_DEFAULT + ) diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index c4e5c58e235..1e31d8c6955 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -134,6 +134,7 @@ def test_include_yaml( [ ({"/test/one.yaml": "one", "/test/two.yaml": "two"}, ["one", "two"]), ({"/test/one.yaml": "1", "/test/two.yaml": "2"}, [1, 2]), + ({"/test/one.yaml": "1", "/test/two.yaml": None}, [1]), ], ) def test_include_dir_list( @@ -190,6 +191,10 @@ def test_include_dir_list_recursive( {"/test/first.yaml": "1", "/test/second.yaml": "2"}, {"first": 1, "second": 2}, ), + ( + {"/test/first.yaml": "1", "/test/second.yaml": None}, + {"first": 1}, + ), ], ) def test_include_dir_named( @@ -249,6 +254,10 @@ def test_include_dir_named_recursive( {"/test/first.yaml": "- 1", "/test/second.yaml": "- 2\n- 3"}, [1, 2, 3], ), + ( + {"/test/first.yaml": "- 1", "/test/second.yaml": None}, + [1], + ), ], ) def test_include_dir_merge_list( @@ -311,6 +320,13 @@ def test_include_dir_merge_list_recursive( }, {"key1": 1, "key2": 2, "key3": 3}, ), + ( + { + "/test/first.yaml": "key1: 1", + "/test/second.yaml": None, + }, + {"key1": 1}, + ), ], ) def test_include_dir_merge_named( @@ -686,3 +702,20 @@ def test_string_used_as_vol_schema(try_both_loaders) -> None: schema({"key_1": "value_1", "key_2": "value_2"}) with pytest.raises(vol.Invalid): schema({"key_1": "value_2", "key_2": "value_1"}) + + +@pytest.mark.parametrize( + ("hass_config_yaml", "expected_data"), [("", {}), ("bla:", {"bla": None})] +) +def test_load_yaml_dict( + try_both_loaders, mock_hass_config_yaml: None, expected_data: Any +) -> None: + """Test item without a key.""" + assert yaml.load_yaml_dict(YAML_CONFIG_FILE) == expected_data + + +@pytest.mark.parametrize("hass_config_yaml", ["abc", "123", "[]"]) +def test_load_yaml_dict_fail(try_both_loaders, mock_hass_config_yaml: None) -> None: + """Test item without a key.""" + with pytest.raises(yaml_loader.YamlTypeError): + yaml_loader.load_yaml_dict(YAML_CONFIG_FILE)